/** * Domain Entity: Notification * * Represents a notification sent to a user. * Immutable entity with factory methods and domain validation. */ import { Entity } from '@core/shared/domain/Entity'; import { NotificationDomainError } from '../errors/NotificationDomainError'; import { NotificationId } from '../value-objects/NotificationId'; import type { NotificationChannel, NotificationType } from '../types/NotificationTypes'; export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required'; /** * Notification urgency determines how the notification is displayed * - silent: Only appears in notification center (default) * - toast: Shows a temporary toast notification * - modal: Shows a blocking modal that requires user action (cannot be ignored) */ export type NotificationUrgency = 'silent' | 'toast' | 'modal'; 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; } /** * Configuration for action buttons shown on modal notifications */ export interface NotificationAction { /** Button label */ label: string; /** Action type - determines styling */ type: 'primary' | 'secondary' | 'danger'; /** URL to navigate to when clicked */ href?: string; /** Custom action identifier (for handling in code) */ actionId?: string; } export interface NotificationProps { id: NotificationId; /** 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; /** How urgently to display this notification */ urgency: NotificationUrgency; /** Structured data for linking/context */ data?: NotificationData; /** Optional action URL (for simple click-through) */ actionUrl?: string; /** Action buttons for modal notifications */ actions?: NotificationAction[]; /** Whether this notification requires a response before it can be dismissed */ requiresResponse?: boolean; /** When the notification was created */ createdAt: Date; /** When the notification was read (if applicable) */ readAt?: Date; /** When the notification was responded to (for action_required status) */ respondedAt?: Date; } export class Notification extends Entity { private constructor(private readonly props: NotificationProps) { super(props.id);} static create(props: Omit & { id: string; status?: NotificationStatus; createdAt?: Date; urgency?: NotificationUrgency; }): Notification { const id = NotificationId.create(props.id); if (!props.recipientId) throw new NotificationDomainError('Recipient ID is required'); if (!props.type) throw new NotificationDomainError('Notification type is required'); if (!props.title?.trim()) throw new NotificationDomainError('Notification title is required'); if (!props.body?.trim()) throw new NotificationDomainError('Notification body is required'); if (!props.channel) throw new NotificationDomainError('Notification channel is required'); // Modal notifications that require response start with action_required status const defaultStatus = props.requiresResponse ? 'action_required' : 'unread'; return new Notification({ ...props, id, status: props.status ?? defaultStatus, urgency: props.urgency ?? 'silent', createdAt: props.createdAt ?? new Date(), }); } 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 urgency(): NotificationUrgency { return this.props.urgency; } get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; } get actionUrl(): string | undefined { return this.props.actionUrl; } get actions(): NotificationAction[] | undefined { return this.props.actions ? [...this.props.actions] : undefined; } get requiresResponse(): boolean { return this.props.requiresResponse ?? false; } get createdAt(): Date { return this.props.createdAt; } get readAt(): Date | undefined { return this.props.readAt; } get respondedAt(): Date | undefined { return this.props.respondedAt; } isUnread(): boolean { return this.props.status === 'unread'; } isRead(): boolean { return this.props.status === 'read'; } isDismissed(): boolean { return this.props.status === 'dismissed'; } isActionRequired(): boolean { return this.props.status === 'action_required'; } isSilent(): boolean { return this.props.urgency === 'silent'; } isToast(): boolean { return this.props.urgency === 'toast'; } isModal(): boolean { return this.props.urgency === 'modal'; } /** * Check if this notification can be dismissed without responding */ canDismiss(): boolean { return !this.props.requiresResponse || this.props.status !== 'action_required'; } /** * 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(), }); } /** * Mark that the user has responded to an action_required notification */ markAsResponded(actionId?: string): Notification { const data = actionId !== undefined ? { ...(this.props.data ?? {}), responseActionId: actionId } : this.props.data; return new Notification({ ...this.props, status: 'read', readAt: this.props.readAt ?? new Date(), respondedAt: new Date(), ...(data !== undefined ? { data } : {}), }); } /** * Dismiss the notification */ dismiss(): Notification { if (this.props.status === 'dismissed') { return this; // Already dismissed } // Cannot dismiss action_required notifications without responding if (this.props.requiresResponse && this.props.status === 'action_required') { throw new NotificationDomainError('Cannot dismiss notification that requires response'); } return new Notification({ ...this.props, status: 'dismissed', readAt: this.props.readAt ?? new Date(), }); } /** * Convert to plain object for serialization */ toJSON(): Omit & { id: string } { return { ...this.props, id: this.props.id.value, }; } }