This commit is contained in:
2025-12-11 13:50:38 +01:00
parent e4c1be628d
commit c7e5de40d6
212 changed files with 2965 additions and 763 deletions

View File

@@ -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'];

View File

@@ -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);
});
});

View 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;
}
}

View File

@@ -1,116 +0,0 @@
/**
* Value Object: NotificationType
*
* Defines the types of notifications that can be sent in the system.
*/
export type NotificationType =
// Protest-related
| 'protest_filed' // A protest was filed against you
| 'protest_defense_requested' // Steward requests your defense
| 'protest_defense_submitted' // Accused submitted their defense
| 'protest_comment_added' // New comment on a protest you're involved in
| 'protest_vote_required' // You need to vote on a protest
| 'protest_vote_cast' // Someone voted on a protest
| 'protest_resolved' // Protest has been resolved
// Penalty-related
| 'penalty_issued' // A penalty was issued to you
| 'penalty_appealed' // Penalty appeal submitted
| 'penalty_appeal_resolved' // Appeal was resolved
// Race-related
| 'race_registration_open' // Race registration is now open
| 'race_reminder' // Race starting soon reminder
| 'race_results_posted' // Race results are available
// League-related
| 'league_invite' // You were invited to a league
| 'league_join_request' // Someone requested to join your league
| 'league_join_approved' // Your join request was approved
| 'league_join_rejected' // Your join request was rejected
| 'league_role_changed' // Your role in a league changed
// Team-related
| 'team_invite' // You were invited to a team
| 'team_join_request' // Someone requested to join your team
| 'team_join_approved' // Your team join request was approved
// 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',
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,
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];
}

View File

@@ -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);
});
});

View 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
);
}
}