228 lines
7.2 KiB
TypeScript
228 lines
7.2 KiB
TypeScript
/**
|
|
* 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<NotificationId> {
|
|
private constructor(private readonly props: NotificationProps) {
|
|
super(props.id);}
|
|
|
|
static create(props: Omit<NotificationProps, 'id' | 'status' | 'createdAt' | 'urgency'> & {
|
|
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<NotificationProps, 'id'> & { id: string } {
|
|
return {
|
|
...this.props,
|
|
id: this.props.id.value,
|
|
};
|
|
}
|
|
} |