rename to core
This commit is contained in:
31
core/notifications/application/index.ts
Normal file
31
core/notifications/application/index.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Notifications Application Layer
|
||||
*
|
||||
* Exports all use cases, queries, and ports.
|
||||
*/
|
||||
|
||||
// Use Cases
|
||||
export * from './use-cases/SendNotificationUseCase';
|
||||
export * from './use-cases/MarkNotificationReadUseCase';
|
||||
export * from './use-cases/GetUnreadNotificationsUseCase';
|
||||
export * from './use-cases/NotificationPreferencesUseCases';
|
||||
|
||||
// Ports
|
||||
export * from './ports/INotificationGateway';
|
||||
|
||||
// Re-export domain types for convenience
|
||||
export type {
|
||||
Notification,
|
||||
NotificationProps,
|
||||
NotificationStatus,
|
||||
NotificationData,
|
||||
NotificationUrgency,
|
||||
NotificationAction,
|
||||
} from '../domain/entities/Notification';
|
||||
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
|
||||
export type { NotificationType, NotificationChannel } from '../domain/types/NotificationTypes';
|
||||
export { getNotificationTypeTitle, getNotificationTypePriority, getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/types/NotificationTypes';
|
||||
|
||||
// Re-export repository interfaces
|
||||
export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
|
||||
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';
|
||||
68
core/notifications/application/ports/INotificationGateway.ts
Normal file
68
core/notifications/application/ports/INotificationGateway.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Application Port: INotificationGateway
|
||||
*
|
||||
* Defines the contract for sending notifications through external channels.
|
||||
* Implementations (adapters) handle the actual delivery mechanism.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface NotificationDeliveryResult {
|
||||
success: boolean;
|
||||
channel: NotificationChannel;
|
||||
/** External message ID (e.g., Discord message ID, email ID) */
|
||||
externalId?: string;
|
||||
/** Error message if delivery failed */
|
||||
error?: string;
|
||||
/** Timestamp of delivery attempt */
|
||||
attemptedAt: Date;
|
||||
}
|
||||
|
||||
export interface INotificationGateway {
|
||||
/**
|
||||
* Send a notification through this gateway's channel
|
||||
*/
|
||||
send(notification: Notification): Promise<NotificationDeliveryResult>;
|
||||
|
||||
/**
|
||||
* Check if this gateway supports the given channel
|
||||
*/
|
||||
supportsChannel(channel: NotificationChannel): boolean;
|
||||
|
||||
/**
|
||||
* Check if the gateway is configured and ready to send
|
||||
*/
|
||||
isConfigured(): boolean;
|
||||
|
||||
/**
|
||||
* Get the channel this gateway handles
|
||||
*/
|
||||
getChannel(): NotificationChannel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for notification gateways
|
||||
* Allows routing notifications to the appropriate gateway based on channel
|
||||
*/
|
||||
export interface INotificationGatewayRegistry {
|
||||
/**
|
||||
* Register a gateway for a channel
|
||||
*/
|
||||
register(gateway: INotificationGateway): void;
|
||||
|
||||
/**
|
||||
* Get gateway for a specific channel
|
||||
*/
|
||||
getGateway(channel: NotificationChannel): INotificationGateway | null;
|
||||
|
||||
/**
|
||||
* Get all registered gateways
|
||||
*/
|
||||
getAllGateways(): INotificationGateway[];
|
||||
|
||||
/**
|
||||
* Send notification through appropriate gateway
|
||||
*/
|
||||
send(notification: Notification): Promise<NotificationDeliveryResult>;
|
||||
}
|
||||
41
core/notifications/application/ports/INotificationService.ts
Normal file
41
core/notifications/application/ports/INotificationService.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { NotificationType } from '../../domain/types/NotificationTypes';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface NotificationData {
|
||||
raceEventId?: string;
|
||||
sessionId?: string;
|
||||
leagueId?: string;
|
||||
position?: number | 'DNF';
|
||||
positionChange?: number;
|
||||
incidents?: number;
|
||||
provisionalRatingChange?: number;
|
||||
finalRatingChange?: number;
|
||||
hadPenaltiesApplied?: boolean;
|
||||
deadline?: Date;
|
||||
protestId?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface NotificationAction {
|
||||
label: string;
|
||||
type: 'primary' | 'secondary' | 'danger';
|
||||
href?: string;
|
||||
actionId?: string;
|
||||
}
|
||||
|
||||
export interface SendNotificationCommand {
|
||||
recipientId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
channel: NotificationChannel;
|
||||
urgency: 'silent' | 'toast' | 'modal';
|
||||
data?: NotificationData;
|
||||
actionUrl?: string;
|
||||
actions?: NotificationAction[];
|
||||
requiresResponse?: boolean;
|
||||
}
|
||||
|
||||
export interface INotificationService {
|
||||
sendNotification(command: SendNotificationCommand): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Application Use Case: GetUnreadNotificationsUseCase
|
||||
*
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
|
||||
export interface UnreadNotificationsResult {
|
||||
notifications: Notification[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<UnreadNotificationsResult> {
|
||||
this.logger.debug(`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`);
|
||||
try {
|
||||
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId);
|
||||
this.logger.info(`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`);
|
||||
|
||||
if (notifications.length === 0) {
|
||||
this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
};
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to retrieve unread notifications for recipient ID: ${recipientId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Additional notification query/use case types (e.g., listing or counting notifications)
|
||||
* can be added here in the future as needed.
|
||||
*/
|
||||
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Application Use Case: MarkNotificationReadUseCase
|
||||
*
|
||||
* Marks a notification as read.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
|
||||
export interface MarkNotificationReadCommand {
|
||||
notificationId: string;
|
||||
recipientId: string; // For validation
|
||||
}
|
||||
|
||||
export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(command: MarkNotificationReadCommand): Promise<void> {
|
||||
this.logger.debug(`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`);
|
||||
try {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
this.logger.warn(`Notification not found for ID: ${command.notificationId}`);
|
||||
throw new NotificationDomainError('Notification not found');
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
this.logger.warn(`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`);
|
||||
throw new NotificationDomainError('Cannot mark another user\'s notification as read');
|
||||
}
|
||||
|
||||
if (!notification.isUnread()) {
|
||||
this.logger.info(`Notification ${command.notificationId} is already read. Skipping update.`);
|
||||
return; // Already read, nothing to do
|
||||
}
|
||||
|
||||
const updatedNotification = notification.markAsRead();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
this.logger.info(`Notification ${command.notificationId} successfully marked as read.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to mark notification ${command.notificationId} as read: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Use Case: MarkAllNotificationsReadUseCase
|
||||
*
|
||||
* Marks all notifications as read for a recipient.
|
||||
*/
|
||||
export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<void> {
|
||||
await this.notificationRepository.markAllAsReadByRecipientId(recipientId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Application Use Case: DismissNotificationUseCase
|
||||
*
|
||||
* Dismisses a notification.
|
||||
*/
|
||||
export interface DismissNotificationCommand {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DismissNotificationCommand): Promise<void> {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
throw new NotificationDomainError('Notification not found');
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
throw new NotificationDomainError('Cannot dismiss another user\'s notification');
|
||||
}
|
||||
|
||||
if (notification.isDismissed()) {
|
||||
return; // Already dismissed
|
||||
}
|
||||
|
||||
const updatedNotification = notification.dismiss();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Application Use Cases: Notification Preferences
|
||||
*
|
||||
* Manages user notification preferences.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
/**
|
||||
* Query: GetNotificationPreferencesQuery
|
||||
*/
|
||||
export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(driverId: string): Promise<NotificationPreference> {
|
||||
this.logger.debug(`Fetching notification preferences for driver: ${driverId}`);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(driverId);
|
||||
this.logger.info(`Successfully fetched preferences for driver: ${driverId}`);
|
||||
return preferences;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: UpdateChannelPreferenceUseCase
|
||||
*/
|
||||
export interface UpdateChannelPreferenceCommand {
|
||||
driverId: string;
|
||||
channel: NotificationChannel;
|
||||
preference: ChannelPreference;
|
||||
}
|
||||
|
||||
export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateChannelPreferenceCommand): Promise<void> {
|
||||
this.logger.debug(`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${command.preference}`);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateChannel(command.channel, command.preference);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated channel preference for driver: ${command.driverId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: UpdateTypePreferenceUseCase
|
||||
*/
|
||||
export interface UpdateTypePreferenceCommand {
|
||||
driverId: string;
|
||||
type: NotificationType;
|
||||
preference: TypePreference;
|
||||
}
|
||||
|
||||
export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateTypePreferenceCommand): Promise<void> {
|
||||
this.logger.debug(`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${command.preference}`);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateTypePreference(command.type, command.preference);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated type preference for driver: ${command.driverId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: UpdateQuietHoursUseCase
|
||||
*/
|
||||
export interface UpdateQuietHoursCommand {
|
||||
driverId: string;
|
||||
startHour: number | undefined;
|
||||
endHour: number | undefined;
|
||||
}
|
||||
|
||||
export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly logger: ILogger,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateQuietHoursCommand): Promise<void> {
|
||||
this.logger.debug(`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`);
|
||||
try {
|
||||
// Validate hours if provided
|
||||
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
|
||||
this.logger.warn(`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`);
|
||||
throw new NotificationDomainError('Start hour must be between 0 and 23');
|
||||
}
|
||||
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
|
||||
this.logger.warn(`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`);
|
||||
throw new NotificationDomainError('End hour must be between 0 and 23');
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateQuietHours(command.startHour, command.endHour);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Use Case: SetDigestModeUseCase
|
||||
*/
|
||||
export interface SetDigestModeCommand {
|
||||
driverId: string;
|
||||
enabled: boolean;
|
||||
frequencyHours?: number;
|
||||
}
|
||||
|
||||
export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: SetDigestModeCommand): Promise<void> {
|
||||
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
|
||||
throw new NotificationDomainError('Digest frequency must be at least 1 hour');
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.setDigestMode(command.enabled, command.frequencyHours);
|
||||
await this.preferenceRepository.save(updated);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Application Use Case: SendNotificationUseCase
|
||||
*
|
||||
* Sends a notification to a recipient through the appropriate channels
|
||||
* based on their preferences.
|
||||
*/
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { ILogger } from '../../../shared/src/logging/ILogger';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationData } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { INotificationGatewayRegistry, NotificationDeliveryResult } from '../ports/INotificationGateway';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface SendNotificationCommand {
|
||||
recipientId: string;
|
||||
type: NotificationType;
|
||||
title: string;
|
||||
body: string;
|
||||
data?: NotificationData;
|
||||
actionUrl?: string;
|
||||
/** How urgently to display this notification (default: 'silent') */
|
||||
urgency?: 'silent' | 'toast' | 'modal';
|
||||
/** Whether this notification requires a response before dismissal */
|
||||
requiresResponse?: boolean;
|
||||
/** Action buttons for modal notifications */
|
||||
actions?: Array<{
|
||||
label: string;
|
||||
type: 'primary' | 'secondary' | 'danger';
|
||||
href?: string;
|
||||
actionId?: string;
|
||||
}>;
|
||||
/** Override channels (skip preference check) */
|
||||
forceChannels?: NotificationChannel[];
|
||||
}
|
||||
|
||||
export interface SendNotificationResult {
|
||||
/** The created notification */
|
||||
notification: Notification;
|
||||
/** Delivery results for each channel */
|
||||
deliveryResults: NotificationDeliveryResult[];
|
||||
}
|
||||
|
||||
export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly gatewayRegistry: INotificationGatewayRegistry,
|
||||
private readonly logger: ILogger,
|
||||
) {
|
||||
this.logger.debug('SendNotificationUseCase initialized.');
|
||||
}
|
||||
|
||||
async execute(command: SendNotificationCommand): Promise<SendNotificationResult> {
|
||||
this.logger.debug('Executing SendNotificationUseCase', { command });
|
||||
try {
|
||||
// Get recipient's preferences
|
||||
this.logger.debug('Checking notification preferences.', { type: command.type, recipientId: command.recipientId });
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.recipientId);
|
||||
|
||||
// Check if this notification type is enabled
|
||||
if (!preferences.isTypeEnabled(command.type)) {
|
||||
// User has disabled this type - create but don't deliver
|
||||
const notification = Notification.create({
|
||||
id: uuid(),
|
||||
recipientId: command.recipientId,
|
||||
type: command.type,
|
||||
title: command.title,
|
||||
body: command.body,
|
||||
channel: 'in_app',
|
||||
status: 'dismissed', // Auto-dismiss since user doesn't want these
|
||||
...(command.data ? { data: command.data } : {}),
|
||||
...(command.actionUrl ? { actionUrl: command.actionUrl } : {}),
|
||||
});
|
||||
|
||||
await this.notificationRepository.create(notification);
|
||||
|
||||
return {
|
||||
notification,
|
||||
deliveryResults: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Determine which channels to use
|
||||
const channels = command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
|
||||
|
||||
// Check quiet hours (skip external channels during quiet hours)
|
||||
const effectiveChannels = preferences.isInQuietHours()
|
||||
? channels.filter(ch => ch === 'in_app')
|
||||
: channels;
|
||||
|
||||
// Ensure at least in_app is used
|
||||
if (!effectiveChannels.includes('in_app')) {
|
||||
effectiveChannels.unshift('in_app');
|
||||
}
|
||||
|
||||
const deliveryResults: NotificationDeliveryResult[] = [];
|
||||
let primaryNotification: Notification | null = null;
|
||||
|
||||
// Send through each channel
|
||||
for (const channel of effectiveChannels) {
|
||||
const notification = Notification.create({
|
||||
id: uuid(),
|
||||
recipientId: command.recipientId,
|
||||
type: command.type,
|
||||
title: command.title,
|
||||
body: command.body,
|
||||
channel,
|
||||
...(command.urgency ? { urgency: command.urgency } : {}),
|
||||
...(command.data ? { data: command.data } : {}),
|
||||
...(command.actionUrl ? { actionUrl: command.actionUrl } : {}),
|
||||
...(command.actions ? { actions: command.actions } : {}),
|
||||
...(command.requiresResponse !== undefined
|
||||
? { requiresResponse: command.requiresResponse }
|
||||
: {}),
|
||||
});
|
||||
|
||||
// Save to repository (in_app channel) or attempt delivery (external channels)
|
||||
if (channel === 'in_app') {
|
||||
await this.notificationRepository.create(notification);
|
||||
primaryNotification = notification;
|
||||
deliveryResults.push({
|
||||
success: true,
|
||||
channel,
|
||||
attemptedAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
// Attempt external delivery
|
||||
const result = await this.gatewayRegistry.send(notification);
|
||||
deliveryResults.push(result);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
notification: primaryNotification!,
|
||||
deliveryResults,
|
||||
};
|
||||
}
|
||||
}
|
||||
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 };
|
||||
}
|
||||
}
|
||||
19
core/notifications/domain/errors/NotificationDomainError.ts
Normal file
19
core/notifications/domain/errors/NotificationDomainError.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
|
||||
|
||||
/**
|
||||
* Domain Error: NotificationDomainError
|
||||
*
|
||||
* Implements the shared IDomainError contract for notification domain failures.
|
||||
*/
|
||||
export class NotificationDomainError extends Error implements IDomainError<CommonDomainErrorKind> {
|
||||
readonly name = 'NotificationDomainError';
|
||||
readonly type = 'domain' as const;
|
||||
readonly context = 'notifications';
|
||||
readonly kind: CommonDomainErrorKind;
|
||||
|
||||
constructor(message: string, kind: CommonDomainErrorKind = 'validation') {
|
||||
super(message);
|
||||
this.kind = kind;
|
||||
Object.setPrototypeOf(this, new.target.prototype);
|
||||
}
|
||||
}
|
||||
@@ -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 '../types/NotificationTypes';
|
||||
|
||||
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>;
|
||||
}
|
||||
165
core/notifications/domain/types/NotificationTypes.ts
Normal file
165
core/notifications/domain/types/NotificationTypes.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* Domain Types: NotificationChannel, NotificationType and helpers
|
||||
*
|
||||
* These are pure type-level/value helpers and intentionally live under domain/types
|
||||
* rather than domain/value-objects, which is reserved for class-based value objects.
|
||||
*/
|
||||
|
||||
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'];
|
||||
|
||||
/**
|
||||
* Domain Type: 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
|
||||
| 'race_performance_summary' // Immediate performance summary after main race
|
||||
| 'race_final_results' // Final results after stewarding closes
|
||||
// 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
|
||||
// Sponsorship-related
|
||||
| 'sponsorship_request_received' // A sponsor wants to sponsor you/your entity
|
||||
| 'sponsorship_request_accepted' // Your sponsorship request was accepted
|
||||
| 'sponsorship_request_rejected' // Your sponsorship request was rejected
|
||||
| 'sponsorship_request_withdrawn' // A sponsor withdrew their request
|
||||
| 'sponsorship_activated' // Sponsorship is now active
|
||||
| 'sponsorship_payment_received' // Payment received for sponsorship
|
||||
// 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',
|
||||
race_performance_summary: 'Performance Summary',
|
||||
race_final_results: 'Final Results',
|
||||
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',
|
||||
sponsorship_request_received: 'Sponsorship Request',
|
||||
sponsorship_request_accepted: 'Sponsorship Accepted',
|
||||
sponsorship_request_rejected: 'Sponsorship Rejected',
|
||||
sponsorship_request_withdrawn: 'Sponsorship Withdrawn',
|
||||
sponsorship_activated: 'Sponsorship Active',
|
||||
sponsorship_payment_received: 'Payment Received',
|
||||
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,
|
||||
race_performance_summary: 9, // High priority - immediate race feedback
|
||||
race_final_results: 7, // Medium-high priority - final standings
|
||||
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,
|
||||
sponsorship_request_received: 7,
|
||||
sponsorship_request_accepted: 8,
|
||||
sponsorship_request_rejected: 6,
|
||||
sponsorship_request_withdrawn: 5,
|
||||
sponsorship_activated: 7,
|
||||
sponsorship_payment_received: 8,
|
||||
system_announcement: 10,
|
||||
};
|
||||
return priorities[type];
|
||||
}
|
||||
43
core/notifications/domain/value-objects/NotificationId.ts
Normal file
43
core/notifications/domain/value-objects/NotificationId.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
|
||||
export interface NotificationIdProps {
|
||||
value: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: NotificationId
|
||||
*
|
||||
* Encapsulates the unique identifier for a notification and
|
||||
* enforces basic invariants (non-empty trimmed string).
|
||||
*/
|
||||
export class NotificationId implements IValueObject<NotificationIdProps> {
|
||||
public readonly props: NotificationIdProps;
|
||||
|
||||
private constructor(value: string) {
|
||||
this.props = { value };
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory with validation.
|
||||
* - Trims input.
|
||||
* - Requires a non-empty value.
|
||||
*/
|
||||
static create(raw: string): NotificationId {
|
||||
const value = raw.trim();
|
||||
|
||||
if (!value) {
|
||||
throw new NotificationDomainError('Notification ID must be a non-empty string', 'validation');
|
||||
}
|
||||
|
||||
return new NotificationId(value);
|
||||
}
|
||||
|
||||
get value(): string {
|
||||
return this.props.value;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<NotificationIdProps>): boolean {
|
||||
return this.props.value === other.props.value;
|
||||
}
|
||||
}
|
||||
72
core/notifications/domain/value-objects/QuietHours.ts
Normal file
72
core/notifications/domain/value-objects/QuietHours.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { IValueObject } from '@gridpilot/shared/domain';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
|
||||
export interface QuietHoursProps {
|
||||
startHour: number;
|
||||
endHour: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value Object: QuietHours
|
||||
*
|
||||
* Encapsulates a daily quiet-hours window using 0-23 hour indices and
|
||||
* provides logic to determine whether a given hour falls within the window.
|
||||
*
|
||||
* Supports both normal ranges (start < end) and overnight ranges (start > end).
|
||||
*/
|
||||
export class QuietHours implements IValueObject<QuietHoursProps> {
|
||||
public readonly props: QuietHoursProps;
|
||||
|
||||
private constructor(startHour: number, endHour: number) {
|
||||
this.props = { startHour, endHour };
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory with validation.
|
||||
* - Hours must be integers between 0 and 23.
|
||||
* - Start and end cannot be equal (would mean a 0-length window).
|
||||
*/
|
||||
static create(startHour: number, endHour: number): QuietHours {
|
||||
QuietHours.assertValidHour(startHour, 'Start hour');
|
||||
QuietHours.assertValidHour(endHour, 'End hour');
|
||||
|
||||
if (startHour === endHour) {
|
||||
throw new NotificationDomainError('Quiet hours start and end cannot be the same', 'validation');
|
||||
}
|
||||
|
||||
return new QuietHours(startHour, endHour);
|
||||
}
|
||||
|
||||
private static assertValidHour(value: number, label: string): void {
|
||||
if (!Number.isInteger(value)) {
|
||||
throw new NotificationDomainError(`${label} must be an integer between 0 and 23`, 'validation');
|
||||
}
|
||||
if (value < 0 || value > 23) {
|
||||
throw new NotificationDomainError(`${label} must be between 0 and 23`, 'validation');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given hour (0-23) lies within the quiet window.
|
||||
*/
|
||||
containsHour(hour: number): boolean {
|
||||
QuietHours.assertValidHour(hour, 'Hour');
|
||||
|
||||
const { startHour, endHour } = this.props;
|
||||
|
||||
if (startHour < endHour) {
|
||||
// Normal range (e.g., 22:00 to 23:59 is NOT this case, but 1:00 to 7:00 is)
|
||||
return hour >= startHour && hour < endHour;
|
||||
}
|
||||
|
||||
// Overnight range (e.g., 22:00 to 07:00)
|
||||
return hour >= startHour || hour < endHour;
|
||||
}
|
||||
|
||||
equals(other: IValueObject<QuietHoursProps>): boolean {
|
||||
return (
|
||||
this.props.startHour === other.props.startHour &&
|
||||
this.props.endHour === other.props.endHour
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Infrastructure Adapter: DiscordNotificationAdapter (Stub)
|
||||
*
|
||||
* Handles Discord webhook notifications.
|
||||
* Currently a stub - to be implemented when Discord integration is needed.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface DiscordAdapterConfig {
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
export class DiscordNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'discord';
|
||||
private webhookUrl: string | undefined;
|
||||
|
||||
constructor(config: DiscordAdapterConfig = {}) {
|
||||
this.webhookUrl = config.webhookUrl;
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.channel,
|
||||
error: 'Discord webhook URL not configured',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual Discord webhook call
|
||||
// For now, this is a stub that logs and returns success
|
||||
console.log(`[Discord Stub] Would send notification to ${this.webhookUrl}:`, {
|
||||
title: notification.title,
|
||||
body: notification.body,
|
||||
type: notification.type,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.channel,
|
||||
externalId: `discord-stub-${notification.id}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
supportsChannel(channel: NotificationChannel): boolean {
|
||||
return channel === this.channel;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!this.webhookUrl;
|
||||
}
|
||||
|
||||
getChannel(): NotificationChannel {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the webhook URL
|
||||
*/
|
||||
setWebhookUrl(url: string): void {
|
||||
this.webhookUrl = url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Infrastructure Adapter: EmailNotificationAdapter (Stub)
|
||||
*
|
||||
* Handles email notifications.
|
||||
* Currently a stub - to be implemented when email integration is needed.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface EmailAdapterConfig {
|
||||
smtpHost?: string;
|
||||
smtpPort?: number;
|
||||
smtpUser?: string;
|
||||
smtpPassword?: string;
|
||||
fromAddress?: string;
|
||||
}
|
||||
|
||||
export class EmailNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'email';
|
||||
private config: EmailAdapterConfig;
|
||||
|
||||
constructor(config: EmailAdapterConfig = {}) {
|
||||
this.config = config;
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
if (!this.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: this.channel,
|
||||
error: 'Email SMTP not configured',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: Implement actual email sending
|
||||
// For now, this is a stub that logs and returns success
|
||||
console.log(`[Email Stub] Would send email:`, {
|
||||
to: notification.recipientId, // Would need to resolve to actual email
|
||||
subject: notification.title,
|
||||
body: notification.body,
|
||||
type: notification.type,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
channel: this.channel,
|
||||
externalId: `email-stub-${notification.id}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
supportsChannel(channel: NotificationChannel): boolean {
|
||||
return channel === this.channel;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return !!(this.config.smtpHost && this.config.fromAddress);
|
||||
}
|
||||
|
||||
getChannel(): NotificationChannel {
|
||||
return this.channel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update SMTP configuration
|
||||
*/
|
||||
configure(config: EmailAdapterConfig): void {
|
||||
this.config = { ...this.config, ...config };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Infrastructure Adapter: InAppNotificationAdapter
|
||||
*
|
||||
* Handles in-app notifications (stored in database, shown in UI).
|
||||
* This is the primary/default notification channel.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export class InAppNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'in_app';
|
||||
|
||||
/**
|
||||
* For in_app, sending is essentially a no-op since the notification
|
||||
* is already persisted by the use case. This just confirms delivery.
|
||||
*/
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
// In-app notifications are stored directly in the repository
|
||||
// This adapter just confirms the "delivery" was successful
|
||||
return {
|
||||
success: true,
|
||||
channel: this.channel,
|
||||
externalId: notification.id,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
supportsChannel(channel: NotificationChannel): boolean {
|
||||
return channel === this.channel;
|
||||
}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return true; // Always configured
|
||||
}
|
||||
|
||||
getChannel(): NotificationChannel {
|
||||
return this.channel;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Infrastructure: NotificationGatewayRegistry
|
||||
*
|
||||
* Manages notification gateways and routes notifications to appropriate channels.
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
INotificationGatewayRegistry,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
|
||||
export class NotificationGatewayRegistry implements INotificationGatewayRegistry {
|
||||
private gateways: Map<NotificationChannel, INotificationGateway> = new Map();
|
||||
|
||||
constructor(initialGateways: INotificationGateway[] = []) {
|
||||
initialGateways.forEach(gateway => this.register(gateway));
|
||||
}
|
||||
|
||||
register(gateway: INotificationGateway): void {
|
||||
const channel = gateway.getChannel();
|
||||
this.gateways.set(channel, gateway);
|
||||
}
|
||||
|
||||
getGateway(channel: NotificationChannel): INotificationGateway | null {
|
||||
return this.gateways.get(channel) || null;
|
||||
}
|
||||
|
||||
getAllGateways(): INotificationGateway[] {
|
||||
return Array.from(this.gateways.values());
|
||||
}
|
||||
|
||||
async send(notification: Notification): Promise<NotificationDeliveryResult> {
|
||||
const gateway = this.gateways.get(notification.channel);
|
||||
|
||||
if (!gateway) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: `No gateway registered for channel: ${notification.channel}`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
if (!gateway.isConfigured()) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: `Gateway for channel ${notification.channel} is not configured`,
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return await gateway.send(notification);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
channel: notification.channel,
|
||||
error: error instanceof Error ? error.message : 'Unknown error during delivery',
|
||||
attemptedAt: new Date(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
11
core/notifications/infrastructure/index.ts
Normal file
11
core/notifications/infrastructure/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Infrastructure layer exports for notifications package
|
||||
*/
|
||||
|
||||
// Repositories
|
||||
export { InMemoryNotificationRepository } from './repositories/InMemoryNotificationRepository';
|
||||
export { InMemoryNotificationPreferenceRepository } from './repositories/InMemoryNotificationPreferenceRepository';
|
||||
|
||||
// Adapters
|
||||
export { InAppNotificationAdapter } from './adapters/InAppNotificationAdapter';
|
||||
export { NotificationGatewayRegistry } from './adapters/NotificationGatewayRegistry';
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* In-Memory Implementation: InMemoryNotificationPreferenceRepository
|
||||
*
|
||||
* Provides an in-memory storage implementation for notification preferences.
|
||||
*/
|
||||
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryNotificationPreferenceRepository implements INotificationPreferenceRepository {
|
||||
private preferences: Map<string, NotificationPreference> = new Map();
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, initialPreferences: NotificationPreference[] = []) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryNotificationPreferenceRepository initialized.');
|
||||
initialPreferences.forEach(pref => {
|
||||
this.preferences.set(pref.driverId, pref);
|
||||
this.logger.debug(`Seeded preference for driver: ${pref.driverId}`);
|
||||
});
|
||||
}
|
||||
|
||||
async findByDriverId(driverId: string): Promise<NotificationPreference | null> {
|
||||
this.logger.debug(`Finding notification preference for driver: ${driverId}`);
|
||||
try {
|
||||
const preference = this.preferences.get(driverId) || null;
|
||||
if (preference) {
|
||||
this.logger.info(`Found preference for driver: ${driverId}`);
|
||||
} else {
|
||||
this.logger.warn(`Preference not found for driver: ${driverId}`);
|
||||
}
|
||||
return preference;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding preference for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async save(preference: NotificationPreference): Promise<void> {
|
||||
this.logger.debug(`Saving notification preference for driver: ${preference.driverId}`);
|
||||
try {
|
||||
this.preferences.set(preference.driverId, preference);
|
||||
this.logger.info(`Preference for driver ${preference.driverId} saved successfully.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error saving preference for driver ${preference.driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(driverId: string): Promise<void> {
|
||||
this.logger.debug(`Deleting notification preference for driver: ${driverId}`);
|
||||
try {
|
||||
if (this.preferences.delete(driverId)) {
|
||||
this.logger.info(`Preference for driver ${driverId} deleted successfully.`);
|
||||
} else {
|
||||
this.logger.warn(`Preference for driver ${driverId} not found for deletion.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting preference for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getOrCreateDefault(driverId: string): Promise<NotificationPreference> {
|
||||
this.logger.debug(`Getting or creating default notification preference for driver: ${driverId}`);
|
||||
try {
|
||||
const existing = this.preferences.get(driverId);
|
||||
if (existing) {
|
||||
this.logger.debug(`Existing preference found for driver: ${driverId}.`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
this.logger.info(`Creating default preference for driver: ${driverId}.`);
|
||||
const defaultPreference = NotificationPreference.createDefault(driverId);
|
||||
this.preferences.set(driverId, defaultPreference);
|
||||
this.logger.info(`Default preference created and saved for driver: ${driverId}.`);
|
||||
return defaultPreference;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error getting or creating default preference for driver ${driverId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
/**
|
||||
* In-Memory Implementation: InMemoryNotificationRepository
|
||||
*
|
||||
* Provides an in-memory storage implementation for notifications.
|
||||
*/
|
||||
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { NotificationType } from '../../domain/types/NotificationTypes';
|
||||
import type { ILogger } from '@gridpilot/shared/logging/ILogger';
|
||||
|
||||
export class InMemoryNotificationRepository implements INotificationRepository {
|
||||
private notifications: Map<string, Notification> = new Map();
|
||||
private readonly logger: ILogger;
|
||||
|
||||
constructor(logger: ILogger, initialNotifications: Notification[] = []) {
|
||||
this.logger = logger;
|
||||
this.logger.info('InMemoryNotificationRepository initialized.');
|
||||
initialNotifications.forEach(notification => {
|
||||
this.notifications.set(notification.id, notification);
|
||||
this.logger.debug(`Seeded notification: ${notification.id}`);
|
||||
});
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<Notification | null> {
|
||||
this.logger.debug(`Finding notification by ID: ${id}`);
|
||||
try {
|
||||
const notification = this.notifications.get(id) || null;
|
||||
if (notification) {
|
||||
this.logger.info(`Found notification with ID: ${id}`);
|
||||
} else {
|
||||
this.logger.warn(`Notification with ID ${id} not found.`);
|
||||
}
|
||||
return notification;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding notification by ID ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByRecipientId(recipientId: string): Promise<Notification[]> {
|
||||
this.logger.debug(`Finding notifications for recipient ID: ${recipientId}`);
|
||||
try {
|
||||
const notifications = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
this.logger.info(`Found ${notifications.length} notifications for recipient ID: ${recipientId}.`);
|
||||
return notifications;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding notifications for recipient ID ${recipientId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findUnreadByRecipientId(recipientId: string): Promise<Notification[]> {
|
||||
this.logger.debug(`Finding unread notifications for recipient ID: ${recipientId}`);
|
||||
try {
|
||||
const notifications = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.isUnread())
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
this.logger.info(`Found ${notifications.length} unread notifications for recipient ID: ${recipientId}.`);
|
||||
return notifications;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding unread notifications for recipient ID ${recipientId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]> {
|
||||
this.logger.debug(`Finding notifications for recipient ID: ${recipientId}, type: ${type}`);
|
||||
try {
|
||||
const notifications = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.type === type)
|
||||
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
|
||||
this.logger.info(`Found ${notifications.length} notifications for recipient ID: ${recipientId}, type: ${type}.`);
|
||||
return notifications;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error finding notifications for recipient ID ${recipientId}, type ${type}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async countUnreadByRecipientId(recipientId: string): Promise<number> {
|
||||
this.logger.debug(`Counting unread notifications for recipient ID: ${recipientId}`);
|
||||
try {
|
||||
const count = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.isUnread())
|
||||
.length;
|
||||
this.logger.info(`Counted ${count} unread notifications for recipient ID: ${recipientId}.`);
|
||||
return count;
|
||||
} catch (error) {
|
||||
this.logger.error(`Error counting unread notifications for recipient ID ${recipientId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(notification: Notification): Promise<void> {
|
||||
this.logger.debug(`Creating notification: ${notification.id}`);
|
||||
try {
|
||||
if (this.notifications.has(notification.id)) {
|
||||
this.logger.warn(`Notification with ID ${notification.id} already exists. Throwing error.`);
|
||||
throw new Error(`Notification with ID ${notification.id} already exists`);
|
||||
}
|
||||
this.notifications.set(notification.id, notification);
|
||||
this.logger.info(`Notification ${notification.id} created successfully.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error creating notification ${notification.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(notification: Notification): Promise<void> {
|
||||
this.logger.debug(`Updating notification: ${notification.id}`);
|
||||
try {
|
||||
if (!this.notifications.has(notification.id)) {
|
||||
this.logger.warn(`Notification with ID ${notification.id} not found for update. Throwing error.`);
|
||||
throw new Error(`Notification with ID ${notification.id} not found`);
|
||||
}
|
||||
this.notifications.set(notification.id, notification);
|
||||
this.logger.info(`Notification ${notification.id} updated successfully.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error updating notification ${notification.id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
this.logger.debug(`Deleting notification: ${id}`);
|
||||
try {
|
||||
if (this.notifications.delete(id)) {
|
||||
this.logger.info(`Notification ${id} deleted successfully.`);
|
||||
} else {
|
||||
this.logger.warn(`Notification with ID ${id} not found for deletion.`);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting notification ${id}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async deleteAllByRecipientId(recipientId: string): Promise<void> {
|
||||
this.logger.debug(`Deleting all notifications for recipient ID: ${recipientId}`);
|
||||
try {
|
||||
const initialCount = this.notifications.size;
|
||||
const toDelete = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId)
|
||||
.map(n => n.id);
|
||||
|
||||
toDelete.forEach(id => this.notifications.delete(id));
|
||||
this.logger.info(`Deleted ${toDelete.length} notifications for recipient ID: ${recipientId}.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error deleting all notifications for recipient ID ${recipientId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async markAllAsReadByRecipientId(recipientId: string): Promise<void> {
|
||||
this.logger.debug(`Marking all notifications as read for recipient ID: ${recipientId}`);
|
||||
try {
|
||||
const toUpdate = Array.from(this.notifications.values())
|
||||
.filter(n => n.recipientId === recipientId && n.isUnread());
|
||||
|
||||
this.logger.info(`Found ${toUpdate.length} unread notifications to mark as read for recipient ID: ${recipientId}.`);
|
||||
|
||||
toUpdate.forEach(n => {
|
||||
const updated = n.markAsRead();
|
||||
this.notifications.set(updated.id, updated);
|
||||
});
|
||||
this.logger.info(`Marked ${toUpdate.length} notifications as read for recipient ID: ${recipientId}.`);
|
||||
} catch (error) {
|
||||
this.logger.error(`Error marking all notifications as read for recipient ID ${recipientId}:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
18
core/notifications/package.json
Normal file
18
core/notifications/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@gridpilot/notifications",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./domain/*": "./domain/*",
|
||||
"./application": "./application/index.ts",
|
||||
"./application/*": "./application/*",
|
||||
"./infrastructure": "./infrastructure/index.ts",
|
||||
"./infrastructure/*": "./infrastructure/*"
|
||||
},
|
||||
"dependencies": {
|
||||
"uuid": "^11.0.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/uuid": "^10.0.0"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user