wip
This commit is contained in:
135
packages/notifications/domain/entities/Notification.ts
Normal file
135
packages/notifications/domain/entities/Notification.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
193
packages/notifications/domain/entities/NotificationPreference.ts
Normal file
193
packages/notifications/domain/entities/NotificationPreference.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Repository Interface: INotificationPreferenceRepository
|
||||
*
|
||||
* Defines the contract for persisting and retrieving NotificationPreference entities.
|
||||
*/
|
||||
|
||||
import type { NotificationPreference } from '../entities/NotificationPreference';
|
||||
|
||||
export interface INotificationPreferenceRepository {
|
||||
/**
|
||||
* Find preferences for a driver
|
||||
*/
|
||||
findByDriverId(driverId: string): Promise<NotificationPreference | null>;
|
||||
|
||||
/**
|
||||
* Save preferences (create or update)
|
||||
*/
|
||||
save(preference: NotificationPreference): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete preferences for a driver
|
||||
*/
|
||||
delete(driverId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Get or create default preferences for a driver
|
||||
*/
|
||||
getOrCreateDefault(driverId: string): Promise<NotificationPreference>;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Repository Interface: INotificationRepository
|
||||
*
|
||||
* Defines the contract for persisting and retrieving Notification entities.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../entities/Notification';
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
|
||||
export interface INotificationRepository {
|
||||
/**
|
||||
* Find a notification by ID
|
||||
*/
|
||||
findById(id: string): Promise<Notification | null>;
|
||||
|
||||
/**
|
||||
* Find all notifications for a recipient
|
||||
*/
|
||||
findByRecipientId(recipientId: string): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Find unread notifications for a recipient
|
||||
*/
|
||||
findUnreadByRecipientId(recipientId: string): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Find notifications by type for a recipient
|
||||
*/
|
||||
findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]>;
|
||||
|
||||
/**
|
||||
* Count unread notifications for a recipient
|
||||
*/
|
||||
countUnreadByRecipientId(recipientId: string): Promise<number>;
|
||||
|
||||
/**
|
||||
* Save a new notification
|
||||
*/
|
||||
create(notification: Notification): Promise<void>;
|
||||
|
||||
/**
|
||||
* Update an existing notification
|
||||
*/
|
||||
update(notification: Notification): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete a notification
|
||||
*/
|
||||
delete(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Delete all notifications for a recipient
|
||||
*/
|
||||
deleteAllByRecipientId(recipientId: string): Promise<void>;
|
||||
|
||||
/**
|
||||
* Mark all notifications as read for a recipient
|
||||
*/
|
||||
markAllAsReadByRecipientId(recipientId: string): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Value Object: NotificationChannel
|
||||
*
|
||||
* Defines the delivery channels for notifications.
|
||||
*/
|
||||
|
||||
export type NotificationChannel =
|
||||
| 'in_app' // In-app notification (stored in database, shown in UI)
|
||||
| 'email' // Email notification
|
||||
| 'discord' // Discord webhook notification
|
||||
| 'push'; // Push notification (future: mobile/browser)
|
||||
|
||||
/**
|
||||
* Get human-readable name for channel
|
||||
*/
|
||||
export function getChannelDisplayName(channel: NotificationChannel): string {
|
||||
const names: Record<NotificationChannel, string> = {
|
||||
in_app: 'In-App',
|
||||
email: 'Email',
|
||||
discord: 'Discord',
|
||||
push: 'Push Notification',
|
||||
};
|
||||
return names[channel];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if channel requires external integration
|
||||
*/
|
||||
export function isExternalChannel(channel: NotificationChannel): boolean {
|
||||
return channel !== 'in_app';
|
||||
}
|
||||
|
||||
/**
|
||||
* Default channels that are always enabled
|
||||
*/
|
||||
export const DEFAULT_ENABLED_CHANNELS: NotificationChannel[] = ['in_app'];
|
||||
|
||||
/**
|
||||
* All available channels
|
||||
*/
|
||||
export const ALL_CHANNELS: NotificationChannel[] = ['in_app', 'email', 'discord', 'push'];
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Value Object: NotificationType
|
||||
*
|
||||
* Defines the types of notifications that can be sent in the system.
|
||||
*/
|
||||
|
||||
export type NotificationType =
|
||||
// Protest-related
|
||||
| 'protest_filed' // A protest was filed against you
|
||||
| 'protest_defense_requested' // Steward requests your defense
|
||||
| 'protest_defense_submitted' // Accused submitted their defense
|
||||
| 'protest_comment_added' // New comment on a protest you're involved in
|
||||
| 'protest_vote_required' // You need to vote on a protest
|
||||
| 'protest_vote_cast' // Someone voted on a protest
|
||||
| 'protest_resolved' // Protest has been resolved
|
||||
// Penalty-related
|
||||
| 'penalty_issued' // A penalty was issued to you
|
||||
| 'penalty_appealed' // Penalty appeal submitted
|
||||
| 'penalty_appeal_resolved' // Appeal was resolved
|
||||
// Race-related
|
||||
| 'race_registration_open' // Race registration is now open
|
||||
| 'race_reminder' // Race starting soon reminder
|
||||
| 'race_results_posted' // Race results are available
|
||||
// League-related
|
||||
| 'league_invite' // You were invited to a league
|
||||
| 'league_join_request' // Someone requested to join your league
|
||||
| 'league_join_approved' // Your join request was approved
|
||||
| 'league_join_rejected' // Your join request was rejected
|
||||
| 'league_role_changed' // Your role in a league changed
|
||||
// Team-related
|
||||
| 'team_invite' // You were invited to a team
|
||||
| 'team_join_request' // Someone requested to join your team
|
||||
| 'team_join_approved' // Your team join request was approved
|
||||
// System
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
|
||||
/**
|
||||
* Get human-readable title for notification type
|
||||
*/
|
||||
export function getNotificationTypeTitle(type: NotificationType): string {
|
||||
const titles: Record<NotificationType, string> = {
|
||||
protest_filed: 'Protest Filed',
|
||||
protest_defense_requested: 'Defense Requested',
|
||||
protest_defense_submitted: 'Defense Submitted',
|
||||
protest_comment_added: 'New Comment',
|
||||
protest_vote_required: 'Vote Required',
|
||||
protest_vote_cast: 'Vote Cast',
|
||||
protest_resolved: 'Protest Resolved',
|
||||
penalty_issued: 'Penalty Issued',
|
||||
penalty_appealed: 'Penalty Appealed',
|
||||
penalty_appeal_resolved: 'Appeal Resolved',
|
||||
race_registration_open: 'Registration Open',
|
||||
race_reminder: 'Race Reminder',
|
||||
race_results_posted: 'Results Posted',
|
||||
league_invite: 'League Invitation',
|
||||
league_join_request: 'Join Request',
|
||||
league_join_approved: 'Request Approved',
|
||||
league_join_rejected: 'Request Rejected',
|
||||
league_role_changed: 'Role Changed',
|
||||
team_invite: 'Team Invitation',
|
||||
team_join_request: 'Team Join Request',
|
||||
team_join_approved: 'Team Request Approved',
|
||||
system_announcement: 'Announcement',
|
||||
};
|
||||
return titles[type];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get priority level for notification type (higher = more urgent)
|
||||
*/
|
||||
export function getNotificationTypePriority(type: NotificationType): number {
|
||||
const priorities: Record<NotificationType, number> = {
|
||||
protest_filed: 8,
|
||||
protest_defense_requested: 9,
|
||||
protest_defense_submitted: 6,
|
||||
protest_comment_added: 4,
|
||||
protest_vote_required: 8,
|
||||
protest_vote_cast: 3,
|
||||
protest_resolved: 7,
|
||||
penalty_issued: 9,
|
||||
penalty_appealed: 7,
|
||||
penalty_appeal_resolved: 7,
|
||||
race_registration_open: 5,
|
||||
race_reminder: 8,
|
||||
race_results_posted: 5,
|
||||
league_invite: 6,
|
||||
league_join_request: 5,
|
||||
league_join_approved: 7,
|
||||
league_join_rejected: 7,
|
||||
league_role_changed: 6,
|
||||
team_invite: 5,
|
||||
team_join_request: 4,
|
||||
team_join_approved: 6,
|
||||
system_announcement: 10,
|
||||
};
|
||||
return priorities[type];
|
||||
}
|
||||
Reference in New Issue
Block a user