This commit is contained in:
2025-12-09 22:22:06 +01:00
parent e34a11ae7c
commit 3adf2e5e94
62 changed files with 6079 additions and 998 deletions

View File

@@ -0,0 +1,135 @@
/**
* Domain Entity: Notification
*
* Represents a notification sent to a user.
* Immutable entity with factory methods and domain validation.
*/
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
export type NotificationStatus = 'unread' | 'read' | 'dismissed';
export interface NotificationData {
/** Reference to related protest */
protestId?: string;
/** Reference to related race */
raceId?: string;
/** Reference to related league */
leagueId?: string;
/** Reference to related driver (e.g., who filed protest) */
actorDriverId?: string;
/** Reference to related penalty */
penaltyId?: string;
/** Reference to related team */
teamId?: string;
/** Deadline for action (e.g., defense deadline) */
deadline?: Date;
/** Any additional context data */
[key: string]: unknown;
}
export interface NotificationProps {
id: string;
/** Driver who receives this notification */
recipientId: string;
/** Type of notification */
type: NotificationType;
/** Human-readable title */
title: string;
/** Notification body/message */
body: string;
/** Channel this notification was/will be sent through */
channel: NotificationChannel;
/** Current status */
status: NotificationStatus;
/** Structured data for linking/context */
data?: NotificationData;
/** Optional action URL */
actionUrl?: string;
/** When the notification was created */
createdAt: Date;
/** When the notification was read (if applicable) */
readAt?: Date;
}
export class Notification {
private constructor(private readonly props: NotificationProps) {}
static create(props: Omit<NotificationProps, 'status' | 'createdAt'> & {
status?: NotificationStatus;
createdAt?: Date;
}): Notification {
if (!props.id) throw new Error('Notification ID is required');
if (!props.recipientId) throw new Error('Recipient ID is required');
if (!props.type) throw new Error('Notification type is required');
if (!props.title?.trim()) throw new Error('Notification title is required');
if (!props.body?.trim()) throw new Error('Notification body is required');
if (!props.channel) throw new Error('Notification channel is required');
return new Notification({
...props,
status: props.status ?? 'unread',
createdAt: props.createdAt ?? new Date(),
});
}
get id(): string { return this.props.id; }
get recipientId(): string { return this.props.recipientId; }
get type(): NotificationType { return this.props.type; }
get title(): string { return this.props.title; }
get body(): string { return this.props.body; }
get channel(): NotificationChannel { return this.props.channel; }
get status(): NotificationStatus { return this.props.status; }
get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; }
get actionUrl(): string | undefined { return this.props.actionUrl; }
get createdAt(): Date { return this.props.createdAt; }
get readAt(): Date | undefined { return this.props.readAt; }
isUnread(): boolean {
return this.props.status === 'unread';
}
isRead(): boolean {
return this.props.status === 'read';
}
isDismissed(): boolean {
return this.props.status === 'dismissed';
}
/**
* Mark the notification as read
*/
markAsRead(): Notification {
if (this.props.status !== 'unread') {
return this; // Already read or dismissed, no change
}
return new Notification({
...this.props,
status: 'read',
readAt: new Date(),
});
}
/**
* Dismiss the notification
*/
dismiss(): Notification {
if (this.props.status === 'dismissed') {
return this; // Already dismissed
}
return new Notification({
...this.props,
status: 'dismissed',
readAt: this.props.readAt ?? new Date(),
});
}
/**
* Convert to plain object for serialization
*/
toJSON(): NotificationProps {
return { ...this.props };
}
}

View File

@@ -0,0 +1,193 @@
/**
* Domain Entity: NotificationPreference
*
* Represents a user's notification preferences for different channels and types.
*/
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
export interface ChannelPreference {
/** Whether this channel is enabled */
enabled: boolean;
/** Channel-specific settings (e.g., discord webhook URL, email address) */
settings?: Record<string, string>;
}
export interface TypePreference {
/** Whether notifications of this type are enabled */
enabled: boolean;
/** Which channels to use for this type (overrides global) */
channels?: NotificationChannel[];
}
export interface NotificationPreferenceProps {
/** Driver ID this preference belongs to */
driverId: string;
/** Global channel preferences */
channels: Record<NotificationChannel, ChannelPreference>;
/** Per-type preferences (optional overrides) */
typePreferences?: Partial<Record<NotificationType, TypePreference>>;
/** Whether to receive digest emails instead of individual notifications */
digestMode?: boolean;
/** Digest frequency in hours (e.g., 24 for daily) */
digestFrequencyHours?: number;
/** Quiet hours start (0-23) */
quietHoursStart?: number;
/** Quiet hours end (0-23) */
quietHoursEnd?: number;
/** Last updated timestamp */
updatedAt: Date;
}
export class NotificationPreference {
private constructor(private readonly props: NotificationPreferenceProps) {}
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
if (!props.driverId) throw new Error('Driver ID is required');
if (!props.channels) throw new Error('Channel preferences are required');
return new NotificationPreference({
...props,
updatedAt: props.updatedAt ?? new Date(),
});
}
/**
* Create default preferences for a new user
*/
static createDefault(driverId: string): NotificationPreference {
return new NotificationPreference({
driverId,
channels: {
in_app: { enabled: true },
email: { enabled: false },
discord: { enabled: false },
push: { enabled: false },
},
digestMode: false,
updatedAt: new Date(),
});
}
get driverId(): string { return this.props.driverId; }
get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; }
get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined {
return this.props.typePreferences ? { ...this.props.typePreferences } : undefined;
}
get digestMode(): boolean { return this.props.digestMode ?? false; }
get digestFrequencyHours(): number { return this.props.digestFrequencyHours ?? 24; }
get quietHoursStart(): number | undefined { return this.props.quietHoursStart; }
get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; }
get updatedAt(): Date { return this.props.updatedAt; }
/**
* Check if a specific channel is enabled
*/
isChannelEnabled(channel: NotificationChannel): boolean {
return this.props.channels[channel]?.enabled ?? false;
}
/**
* Check if a specific notification type is enabled
*/
isTypeEnabled(type: NotificationType): boolean {
const typePref = this.props.typePreferences?.[type];
return typePref?.enabled ?? true; // Default to enabled if not specified
}
/**
* Get enabled channels for a specific notification type
*/
getEnabledChannelsForType(type: NotificationType): NotificationChannel[] {
// Check type-specific channel overrides
const typePref = this.props.typePreferences?.[type];
if (typePref?.channels && typePref.channels.length > 0) {
return typePref.channels.filter(ch => this.isChannelEnabled(ch));
}
// Fall back to globally enabled channels
return (Object.keys(this.props.channels) as NotificationChannel[])
.filter(ch => this.isChannelEnabled(ch));
}
/**
* Check if current time is in quiet hours
*/
isInQuietHours(): boolean {
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
return false;
}
const now = new Date();
const currentHour = now.getHours();
if (this.props.quietHoursStart < this.props.quietHoursEnd) {
// Normal range (e.g., 22:00 to 07:00 next day is NOT this case)
return currentHour >= this.props.quietHoursStart && currentHour < this.props.quietHoursEnd;
} else {
// Overnight range (e.g., 22:00 to 07:00)
return currentHour >= this.props.quietHoursStart || currentHour < this.props.quietHoursEnd;
}
}
/**
* Update channel preference
*/
updateChannel(channel: NotificationChannel, preference: ChannelPreference): NotificationPreference {
return new NotificationPreference({
...this.props,
channels: {
...this.props.channels,
[channel]: preference,
},
updatedAt: new Date(),
});
}
/**
* Update type preference
*/
updateTypePreference(type: NotificationType, preference: TypePreference): NotificationPreference {
return new NotificationPreference({
...this.props,
typePreferences: {
...this.props.typePreferences,
[type]: preference,
},
updatedAt: new Date(),
});
}
/**
* Update quiet hours
*/
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
return new NotificationPreference({
...this.props,
quietHoursStart: start,
quietHoursEnd: end,
updatedAt: new Date(),
});
}
/**
* Toggle digest mode
*/
setDigestMode(enabled: boolean, frequencyHours?: number): NotificationPreference {
return new NotificationPreference({
...this.props,
digestMode: enabled,
digestFrequencyHours: frequencyHours ?? this.props.digestFrequencyHours,
updatedAt: new Date(),
});
}
/**
* Convert to plain object for serialization
*/
toJSON(): NotificationPreferenceProps {
return { ...this.props };
}
}