rename to core
This commit is contained in:
228
core/notifications/domain/entities/Notification.ts
Normal file
228
core/notifications/domain/entities/Notification.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* Domain Entity: Notification
|
||||
*
|
||||
* Represents a notification sent to a user.
|
||||
* Immutable entity with factory methods and domain validation.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { NotificationId } from '../value-objects/NotificationId';
|
||||
|
||||
import type { NotificationType, NotificationChannel } 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 implements IEntity<string> {
|
||||
private constructor(private readonly props: NotificationProps) {}
|
||||
|
||||
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 id(): string { return this.props.id.value; }
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
209
core/notifications/domain/entities/NotificationPreference.ts
Normal file
209
core/notifications/domain/entities/NotificationPreference.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Domain Entity: NotificationPreference
|
||||
*
|
||||
* Represents a user's notification preferences for different channels and types.
|
||||
*/
|
||||
|
||||
import type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { QuietHours } from '../value-objects/QuietHours';
|
||||
|
||||
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 {
|
||||
/** Aggregate ID for this preference (usually same as driverId) */
|
||||
id: string;
|
||||
/** 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 implements IEntity<string> {
|
||||
private constructor(private readonly props: NotificationPreferenceProps) {}
|
||||
|
||||
static create(
|
||||
props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date },
|
||||
): NotificationPreference {
|
||||
if (!props.id) throw new NotificationDomainError('Preference ID is required');
|
||||
if (!props.driverId) throw new NotificationDomainError('Driver ID is required');
|
||||
if (!props.channels) throw new NotificationDomainError('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({
|
||||
id: driverId,
|
||||
driverId,
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
email: { enabled: false },
|
||||
discord: { enabled: false },
|
||||
push: { enabled: false },
|
||||
},
|
||||
digestMode: false,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
get id(): string { return this.props.id; }
|
||||
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; }
|
||||
|
||||
get quietHours(): QuietHours | undefined {
|
||||
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return QuietHours.create(this.props.quietHoursStart, this.props.quietHoursEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const quietHours = this.quietHours;
|
||||
if (!quietHours) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
return quietHours.containsHour(now.getHours());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
const props = this.toJSON();
|
||||
|
||||
if (start === undefined || end === undefined) {
|
||||
delete props.quietHoursStart;
|
||||
delete props.quietHoursEnd;
|
||||
} else {
|
||||
const validated = QuietHours.create(start, end);
|
||||
props.quietHoursStart = validated.props.startHour;
|
||||
props.quietHoursEnd = validated.props.endHour;
|
||||
}
|
||||
|
||||
props.updatedAt = new Date();
|
||||
return NotificationPreference.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle digest mode
|
||||
*/
|
||||
setDigestMode(enabled: boolean, frequencyHours?: number): NotificationPreference {
|
||||
const props = this.toJSON();
|
||||
props.digestMode = enabled;
|
||||
if (frequencyHours !== undefined) {
|
||||
props.digestFrequencyHours = frequencyHours;
|
||||
}
|
||||
props.updatedAt = new Date();
|
||||
return NotificationPreference.create(props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to plain object for serialization
|
||||
*/
|
||||
toJSON(): NotificationPreferenceProps {
|
||||
return { ...this.props };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user