wip
This commit is contained in:
@@ -23,10 +23,8 @@ export type {
|
||||
NotificationAction,
|
||||
} from '../domain/entities/Notification';
|
||||
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
|
||||
export type { NotificationType } from '../domain/value-objects/NotificationType';
|
||||
export type { NotificationChannel } from '../domain/value-objects/NotificationChannel';
|
||||
export { getNotificationTypeTitle, getNotificationTypePriority } from '../domain/value-objects/NotificationType';
|
||||
export { getChannelDisplayName, isExternalChannel, DEFAULT_ENABLED_CHANNELS, ALL_CHANNELS } from '../domain/value-objects/NotificationChannel';
|
||||
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';
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface NotificationDeliveryResult {
|
||||
success: boolean;
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
|
||||
@@ -12,7 +13,7 @@ export interface UnreadNotificationsResult {
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetUnreadNotificationsUseCase {
|
||||
export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
* 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';
|
||||
|
||||
@@ -12,7 +13,7 @@ export interface MarkNotificationReadCommand {
|
||||
recipientId: string; // For validation
|
||||
}
|
||||
|
||||
export class MarkNotificationReadUseCase {
|
||||
export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
@@ -42,7 +43,7 @@ export class MarkNotificationReadUseCase {
|
||||
*
|
||||
* Marks all notifications as read for a recipient.
|
||||
*/
|
||||
export class MarkAllNotificationsReadUseCase {
|
||||
export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
@@ -62,7 +63,7 @@ export interface DismissNotificationCommand {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export class DismissNotificationUseCase {
|
||||
export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
) {}
|
||||
|
||||
@@ -4,16 +4,17 @@
|
||||
* Manages user notification preferences.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
/**
|
||||
* Query: GetNotificationPreferencesQuery
|
||||
*/
|
||||
export class GetNotificationPreferencesQuery {
|
||||
export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -32,7 +33,7 @@ export interface UpdateChannelPreferenceCommand {
|
||||
preference: ChannelPreference;
|
||||
}
|
||||
|
||||
export class UpdateChannelPreferenceUseCase {
|
||||
export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -53,7 +54,7 @@ export interface UpdateTypePreferenceCommand {
|
||||
preference: TypePreference;
|
||||
}
|
||||
|
||||
export class UpdateTypePreferenceUseCase {
|
||||
export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -74,7 +75,7 @@ export interface UpdateQuietHoursCommand {
|
||||
endHour: number | undefined;
|
||||
}
|
||||
|
||||
export class UpdateQuietHoursUseCase {
|
||||
export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
@@ -103,7 +104,7 @@ export interface SetDigestModeCommand {
|
||||
frequencyHours?: number;
|
||||
}
|
||||
|
||||
export class SetDigestModeUseCase {
|
||||
export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
) {}
|
||||
|
||||
@@ -6,13 +6,13 @@
|
||||
*/
|
||||
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import type { AsyncUseCase } from '@gridpilot/shared/application';
|
||||
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 } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationType, NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface SendNotificationCommand {
|
||||
recipientId: string;
|
||||
@@ -43,7 +43,7 @@ export interface SendNotificationResult {
|
||||
deliveryResults: NotificationDeliveryResult[];
|
||||
}
|
||||
|
||||
export class SendNotificationUseCase {
|
||||
export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
|
||||
@@ -5,10 +5,11 @@
|
||||
* 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 } from '../value-objects/NotificationType';
|
||||
import type { NotificationChannel } from '../value-objects/NotificationChannel';
|
||||
import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
|
||||
|
||||
export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required';
|
||||
|
||||
@@ -54,7 +55,7 @@ export interface NotificationAction {
|
||||
}
|
||||
|
||||
export interface NotificationProps {
|
||||
id: string;
|
||||
id: NotificationId;
|
||||
/** Driver who receives this notification */
|
||||
recipientId: string;
|
||||
/** Type of notification */
|
||||
@@ -85,15 +86,17 @@ export interface NotificationProps {
|
||||
respondedAt?: Date;
|
||||
}
|
||||
|
||||
export class Notification {
|
||||
export class Notification implements IEntity<string> {
|
||||
private constructor(private readonly props: NotificationProps) {}
|
||||
|
||||
static create(props: Omit<NotificationProps, 'status' | 'createdAt' | 'urgency'> & {
|
||||
static create(props: Omit<NotificationProps, 'id' | 'status' | 'createdAt' | 'urgency'> & {
|
||||
id: string;
|
||||
status?: NotificationStatus;
|
||||
createdAt?: Date;
|
||||
urgency?: NotificationUrgency;
|
||||
}): Notification {
|
||||
if (!props.id) throw new NotificationDomainError('Notification ID is required');
|
||||
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');
|
||||
@@ -105,13 +108,14 @@ export class Notification {
|
||||
|
||||
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; }
|
||||
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; }
|
||||
@@ -210,7 +214,10 @@ export class Notification {
|
||||
/**
|
||||
* Convert to plain object for serialization
|
||||
*/
|
||||
toJSON(): NotificationProps {
|
||||
return { ...this.props };
|
||||
toJSON(): Omit<NotificationProps, 'id'> & { id: string } {
|
||||
return {
|
||||
...this.props,
|
||||
id: this.props.id.value,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -4,10 +4,10 @@
|
||||
* 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 type { IEntity } from '@gridpilot/shared/domain';
|
||||
import type { NotificationType, NotificationChannel } from '../types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
|
||||
import { QuietHours } from '../value-objects/QuietHours';
|
||||
|
||||
export interface ChannelPreference {
|
||||
/** Whether this channel is enabled */
|
||||
@@ -24,6 +24,8 @@ export interface TypePreference {
|
||||
}
|
||||
|
||||
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 */
|
||||
@@ -42,10 +44,13 @@ export interface NotificationPreferenceProps {
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export class NotificationPreference {
|
||||
export class NotificationPreference implements IEntity<string> {
|
||||
private constructor(private readonly props: NotificationPreferenceProps) {}
|
||||
|
||||
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
|
||||
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');
|
||||
|
||||
@@ -60,6 +65,7 @@ export class NotificationPreference {
|
||||
*/
|
||||
static createDefault(driverId: string): NotificationPreference {
|
||||
return new NotificationPreference({
|
||||
id: driverId,
|
||||
driverId,
|
||||
channels: {
|
||||
in_app: { enabled: true },
|
||||
@@ -72,6 +78,7 @@ export class NotificationPreference {
|
||||
});
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -83,6 +90,13 @@ export class NotificationPreference {
|
||||
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
|
||||
*/
|
||||
@@ -117,20 +131,13 @@ export class NotificationPreference {
|
||||
* Check if current time is in quiet hours
|
||||
*/
|
||||
isInQuietHours(): boolean {
|
||||
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
|
||||
const quietHours = this.quietHours;
|
||||
if (!quietHours) {
|
||||
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;
|
||||
}
|
||||
return quietHours.containsHour(now.getHours());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -165,10 +172,12 @@ export class NotificationPreference {
|
||||
* Update quiet hours
|
||||
*/
|
||||
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
|
||||
const validated = start === undefined || end === undefined ? undefined : QuietHours.create(start, end);
|
||||
|
||||
return new NotificationPreference({
|
||||
...this.props,
|
||||
quietHoursStart: start,
|
||||
quietHoursEnd: end,
|
||||
quietHoursStart: validated?.props.startHour,
|
||||
quietHoursEnd: validated?.props.endHour,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,19 @@
|
||||
export class NotificationDomainError extends Error {
|
||||
readonly name: string = 'NotificationDomainError';
|
||||
import type { IDomainError, CommonDomainErrorKind } from '@gridpilot/shared/errors';
|
||||
|
||||
constructor(message: string) {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Notification } from '../entities/Notification';
|
||||
import type { NotificationType } from '../value-objects/NotificationType';
|
||||
import type { NotificationType } from '../types/NotificationTypes';
|
||||
|
||||
export interface INotificationRepository {
|
||||
/**
|
||||
|
||||
@@ -1,45 +1,88 @@
|
||||
/**
|
||||
* Value Object: NotificationType
|
||||
*
|
||||
* 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_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
|
||||
| '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_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_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
|
||||
| '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
|
||||
| '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_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
|
||||
| 'sponsorship_activated' // Sponsorship is now active
|
||||
| 'sponsorship_payment_received' // Payment received for sponsorship
|
||||
// System
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
| 'system_announcement'; // System-wide announcement
|
||||
|
||||
/**
|
||||
* Get human-readable title for notification type
|
||||
@@ -1,41 +0,0 @@
|
||||
/**
|
||||
* 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,38 @@
|
||||
import { NotificationId } from './NotificationId';
|
||||
import { NotificationDomainError } from '../errors/NotificationDomainError';
|
||||
|
||||
describe('NotificationId', () => {
|
||||
it('creates a valid NotificationId from a non-empty string', () => {
|
||||
const id = NotificationId.create('noti_123');
|
||||
|
||||
expect(id.value).toBe('noti_123');
|
||||
});
|
||||
|
||||
it('trims whitespace from the raw value', () => {
|
||||
const id = NotificationId.create(' noti_456 ');
|
||||
|
||||
expect(id.value).toBe('noti_456');
|
||||
});
|
||||
|
||||
it('throws NotificationDomainError for empty string', () => {
|
||||
expect(() => NotificationId.create('')).toThrow(NotificationDomainError);
|
||||
expect(() => NotificationId.create(' ')).toThrow(NotificationDomainError);
|
||||
|
||||
try {
|
||||
NotificationId.create(' ');
|
||||
} catch (error) {
|
||||
if (error instanceof NotificationDomainError) {
|
||||
expect(error.kind).toBe('validation');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('compares equality based on underlying value', () => {
|
||||
const a = NotificationId.create('noti_1');
|
||||
const b = NotificationId.create('noti_1');
|
||||
const c = NotificationId.create('noti_2');
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { QuietHours } from './QuietHours';
|
||||
|
||||
describe('QuietHours', () => {
|
||||
it('creates a valid normal-range window', () => {
|
||||
const qh = QuietHours.create(9, 17);
|
||||
expect(qh.props.startHour).toBe(9);
|
||||
expect(qh.props.endHour).toBe(17);
|
||||
});
|
||||
|
||||
it('creates a valid overnight window', () => {
|
||||
const qh = QuietHours.create(22, 7);
|
||||
expect(qh.props.startHour).toBe(22);
|
||||
expect(qh.props.endHour).toBe(7);
|
||||
});
|
||||
|
||||
it('throws when hours are out of range', () => {
|
||||
expect(() => QuietHours.create(-1, 10)).toThrow();
|
||||
expect(() => QuietHours.create(0, 24)).toThrow();
|
||||
});
|
||||
|
||||
it('throws when start and end are equal', () => {
|
||||
expect(() => QuietHours.create(10, 10)).toThrow();
|
||||
});
|
||||
|
||||
it('detects containment for normal range', () => {
|
||||
const qh = QuietHours.create(9, 17);
|
||||
expect(qh.containsHour(8)).toBe(false);
|
||||
expect(qh.containsHour(9)).toBe(true);
|
||||
expect(qh.containsHour(12)).toBe(true);
|
||||
expect(qh.containsHour(17)).toBe(false);
|
||||
});
|
||||
|
||||
it('detects containment for overnight range', () => {
|
||||
const qh = QuietHours.create(22, 7);
|
||||
expect(qh.containsHour(21)).toBe(false);
|
||||
expect(qh.containsHour(22)).toBe(true);
|
||||
expect(qh.containsHour(23)).toBe(true);
|
||||
expect(qh.containsHour(0)).toBe(true);
|
||||
expect(qh.containsHour(6)).toBe(true);
|
||||
expect(qh.containsHour(7)).toBe(false);
|
||||
});
|
||||
|
||||
it('implements value-based equality', () => {
|
||||
const a = QuietHours.create(22, 7);
|
||||
const b = QuietHours.create(22, 7);
|
||||
const c = QuietHours.create(9, 17);
|
||||
|
||||
expect(a.equals(b)).toBe(true);
|
||||
expect(a.equals(c)).toBe(false);
|
||||
});
|
||||
});
|
||||
72
packages/notifications/domain/value-objects/QuietHours.ts
Normal file
72
packages/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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface DiscordAdapterConfig {
|
||||
webhookUrl?: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export interface EmailAdapterConfig {
|
||||
smtpHost?: string;
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
INotificationGateway,
|
||||
NotificationDeliveryResult
|
||||
} from '../../application/ports/INotificationGateway';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export class InAppNotificationAdapter implements INotificationGateway {
|
||||
private readonly channel: NotificationChannel = 'in_app';
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { NotificationChannel } from '../../domain/value-objects/NotificationChannel';
|
||||
import type { NotificationChannel } from '../../domain/types/NotificationTypes';
|
||||
import type {
|
||||
INotificationGateway,
|
||||
INotificationGatewayRegistry,
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { NotificationType } from '../../domain/value-objects/NotificationType';
|
||||
import type { NotificationType } from '../../domain/types/NotificationTypes';
|
||||
|
||||
export class InMemoryNotificationRepository implements INotificationRepository {
|
||||
private notifications: Map<string, Notification> = new Map();
|
||||
|
||||
Reference in New Issue
Block a user