This commit is contained in:
2025-12-09 22:22:06 +01:00
parent e34a11ae7c
commit 3adf2e5e94
62 changed files with 6079 additions and 998 deletions

View File

@@ -0,0 +1,26 @@
/**
* 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/GetUnreadNotificationsQuery';
export * from './use-cases/NotificationPreferencesUseCases';
// Ports
export * from './ports/INotificationGateway';
// Re-export domain types for convenience
export type { Notification, NotificationProps, NotificationStatus, NotificationData } 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';
// Re-export repository interfaces
export type { INotificationRepository } from '../domain/repositories/INotificationRepository';
export type { INotificationPreferenceRepository } from '../domain/repositories/INotificationPreferenceRepository';

View 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/value-objects/NotificationChannel';
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>;
}

View File

@@ -0,0 +1,89 @@
/**
* Application Query: GetUnreadNotificationsQuery
*
* Retrieves unread notifications for a recipient.
*/
import type { Notification } from '../../domain/entities/Notification';
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
export interface UnreadNotificationsResult {
notifications: Notification[];
totalCount: number;
}
export class GetUnreadNotificationsQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string): Promise<UnreadNotificationsResult> {
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId);
return {
notifications,
totalCount: notifications.length,
};
}
}
/**
* Application Query: GetNotificationsQuery
*
* Retrieves all notifications for a recipient with optional filtering.
*/
export interface GetNotificationsOptions {
includeRead?: boolean;
includeDismissed?: boolean;
limit?: number;
offset?: number;
}
export class GetNotificationsQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string, options: GetNotificationsOptions = {}): Promise<Notification[]> {
const allNotifications = await this.notificationRepository.findByRecipientId(recipientId);
let filtered = allNotifications;
// Filter by status
if (!options.includeRead && !options.includeDismissed) {
filtered = filtered.filter(n => n.isUnread());
} else if (!options.includeDismissed) {
filtered = filtered.filter(n => !n.isDismissed());
} else if (!options.includeRead) {
filtered = filtered.filter(n => n.isUnread() || n.isDismissed());
}
// Sort by creation date (newest first)
filtered.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
// Apply pagination
if (options.offset !== undefined) {
filtered = filtered.slice(options.offset);
}
if (options.limit !== undefined) {
filtered = filtered.slice(0, options.limit);
}
return filtered;
}
}
/**
* Application Query: GetUnreadCountQuery
*
* Gets the count of unread notifications for a recipient.
*/
export class GetUnreadCountQuery {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(recipientId: string): Promise<number> {
return this.notificationRepository.countUnreadByRecipientId(recipientId);
}
}

View File

@@ -0,0 +1,87 @@
/**
* Application Use Case: MarkNotificationReadUseCase
*
* Marks a notification as read.
*/
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
export interface MarkNotificationReadCommand {
notificationId: string;
recipientId: string; // For validation
}
export class MarkNotificationReadUseCase {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(command: MarkNotificationReadCommand): Promise<void> {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new Error('Notification not found');
}
if (notification.recipientId !== command.recipientId) {
throw new Error('Cannot mark another user\'s notification as read');
}
if (!notification.isUnread()) {
return; // Already read, nothing to do
}
const updatedNotification = notification.markAsRead();
await this.notificationRepository.update(updatedNotification);
}
}
/**
* Application Use Case: MarkAllNotificationsReadUseCase
*
* Marks all notifications as read for a recipient.
*/
export class MarkAllNotificationsReadUseCase {
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 {
constructor(
private readonly notificationRepository: INotificationRepository,
) {}
async execute(command: DismissNotificationCommand): Promise<void> {
const notification = await this.notificationRepository.findById(command.notificationId);
if (!notification) {
throw new Error('Notification not found');
}
if (notification.recipientId !== command.recipientId) {
throw new Error('Cannot dismiss another user\'s notification');
}
if (notification.isDismissed()) {
return; // Already dismissed
}
const updatedNotification = notification.dismiss();
await this.notificationRepository.update(updatedNotification);
}
}

View File

@@ -0,0 +1,120 @@
/**
* Application Use Cases: Notification Preferences
*
* Manages user notification preferences.
*/
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';
/**
* Query: GetNotificationPreferencesQuery
*/
export class GetNotificationPreferencesQuery {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(driverId: string): Promise<NotificationPreference> {
return this.preferenceRepository.getOrCreateDefault(driverId);
}
}
/**
* Use Case: UpdateChannelPreferenceUseCase
*/
export interface UpdateChannelPreferenceCommand {
driverId: string;
channel: NotificationChannel;
preference: ChannelPreference;
}
export class UpdateChannelPreferenceUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: UpdateChannelPreferenceCommand): Promise<void> {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.updateChannel(command.channel, command.preference);
await this.preferenceRepository.save(updated);
}
}
/**
* Use Case: UpdateTypePreferenceUseCase
*/
export interface UpdateTypePreferenceCommand {
driverId: string;
type: NotificationType;
preference: TypePreference;
}
export class UpdateTypePreferenceUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: UpdateTypePreferenceCommand): Promise<void> {
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
const updated = preferences.updateTypePreference(command.type, command.preference);
await this.preferenceRepository.save(updated);
}
}
/**
* Use Case: UpdateQuietHoursUseCase
*/
export interface UpdateQuietHoursCommand {
driverId: string;
startHour: number | undefined;
endHour: number | undefined;
}
export class UpdateQuietHoursUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: UpdateQuietHoursCommand): Promise<void> {
// Validate hours if provided
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
throw new Error('Start hour must be between 0 and 23');
}
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
throw new Error('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);
}
}
/**
* Use Case: SetDigestModeUseCase
*/
export interface SetDigestModeCommand {
driverId: string;
enabled: boolean;
frequencyHours?: number;
}
export class SetDigestModeUseCase {
constructor(
private readonly preferenceRepository: INotificationPreferenceRepository,
) {}
async execute(command: SetDigestModeCommand): Promise<void> {
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
throw new Error('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);
}
}

View File

@@ -0,0 +1,119 @@
/**
* 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 { 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';
export interface SendNotificationCommand {
recipientId: string;
type: NotificationType;
title: string;
body: string;
data?: NotificationData;
actionUrl?: 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 {
constructor(
private readonly notificationRepository: INotificationRepository,
private readonly preferenceRepository: INotificationPreferenceRepository,
private readonly gatewayRegistry: INotificationGatewayRegistry,
) {}
async execute(command: SendNotificationCommand): Promise<SendNotificationResult> {
// Get recipient's preferences
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',
data: command.data,
actionUrl: command.actionUrl,
status: 'dismissed', // Auto-dismiss since user doesn't want these
});
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,
data: command.data,
actionUrl: command.actionUrl,
});
// 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,
};
}
}

View File

@@ -0,0 +1,135 @@
/**
* Domain Entity: Notification
*
* Represents a notification sent to a user.
* Immutable entity with factory methods and domain validation.
*/
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
export type NotificationStatus = 'unread' | 'read' | 'dismissed';
export interface NotificationData {
/** Reference to related protest */
protestId?: string;
/** Reference to related race */
raceId?: string;
/** Reference to related league */
leagueId?: string;
/** Reference to related driver (e.g., who filed protest) */
actorDriverId?: string;
/** Reference to related penalty */
penaltyId?: string;
/** Reference to related team */
teamId?: string;
/** Deadline for action (e.g., defense deadline) */
deadline?: Date;
/** Any additional context data */
[key: string]: unknown;
}
export interface NotificationProps {
id: string;
/** Driver who receives this notification */
recipientId: string;
/** Type of notification */
type: NotificationType;
/** Human-readable title */
title: string;
/** Notification body/message */
body: string;
/** Channel this notification was/will be sent through */
channel: NotificationChannel;
/** Current status */
status: NotificationStatus;
/** Structured data for linking/context */
data?: NotificationData;
/** Optional action URL */
actionUrl?: string;
/** When the notification was created */
createdAt: Date;
/** When the notification was read (if applicable) */
readAt?: Date;
}
export class Notification {
private constructor(private readonly props: NotificationProps) {}
static create(props: Omit<NotificationProps, 'status' | 'createdAt'> & {
status?: NotificationStatus;
createdAt?: Date;
}): Notification {
if (!props.id) throw new Error('Notification ID is required');
if (!props.recipientId) throw new Error('Recipient ID is required');
if (!props.type) throw new Error('Notification type is required');
if (!props.title?.trim()) throw new Error('Notification title is required');
if (!props.body?.trim()) throw new Error('Notification body is required');
if (!props.channel) throw new Error('Notification channel is required');
return new Notification({
...props,
status: props.status ?? 'unread',
createdAt: props.createdAt ?? new Date(),
});
}
get id(): string { return this.props.id; }
get recipientId(): string { return this.props.recipientId; }
get type(): NotificationType { return this.props.type; }
get title(): string { return this.props.title; }
get body(): string { return this.props.body; }
get channel(): NotificationChannel { return this.props.channel; }
get status(): NotificationStatus { return this.props.status; }
get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; }
get actionUrl(): string | undefined { return this.props.actionUrl; }
get createdAt(): Date { return this.props.createdAt; }
get readAt(): Date | undefined { return this.props.readAt; }
isUnread(): boolean {
return this.props.status === 'unread';
}
isRead(): boolean {
return this.props.status === 'read';
}
isDismissed(): boolean {
return this.props.status === 'dismissed';
}
/**
* Mark the notification as read
*/
markAsRead(): Notification {
if (this.props.status !== 'unread') {
return this; // Already read or dismissed, no change
}
return new Notification({
...this.props,
status: 'read',
readAt: new Date(),
});
}
/**
* Dismiss the notification
*/
dismiss(): Notification {
if (this.props.status === 'dismissed') {
return this; // Already dismissed
}
return new Notification({
...this.props,
status: 'dismissed',
readAt: this.props.readAt ?? new Date(),
});
}
/**
* Convert to plain object for serialization
*/
toJSON(): NotificationProps {
return { ...this.props };
}
}

View File

@@ -0,0 +1,193 @@
/**
* Domain Entity: NotificationPreference
*
* Represents a user's notification preferences for different channels and types.
*/
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
import { DEFAULT_ENABLED_CHANNELS } from '../value-objects/NotificationChannel';
export interface ChannelPreference {
/** Whether this channel is enabled */
enabled: boolean;
/** Channel-specific settings (e.g., discord webhook URL, email address) */
settings?: Record<string, string>;
}
export interface TypePreference {
/** Whether notifications of this type are enabled */
enabled: boolean;
/** Which channels to use for this type (overrides global) */
channels?: NotificationChannel[];
}
export interface NotificationPreferenceProps {
/** Driver ID this preference belongs to */
driverId: string;
/** Global channel preferences */
channels: Record<NotificationChannel, ChannelPreference>;
/** Per-type preferences (optional overrides) */
typePreferences?: Partial<Record<NotificationType, TypePreference>>;
/** Whether to receive digest emails instead of individual notifications */
digestMode?: boolean;
/** Digest frequency in hours (e.g., 24 for daily) */
digestFrequencyHours?: number;
/** Quiet hours start (0-23) */
quietHoursStart?: number;
/** Quiet hours end (0-23) */
quietHoursEnd?: number;
/** Last updated timestamp */
updatedAt: Date;
}
export class NotificationPreference {
private constructor(private readonly props: NotificationPreferenceProps) {}
static create(props: Omit<NotificationPreferenceProps, 'updatedAt'> & { updatedAt?: Date }): NotificationPreference {
if (!props.driverId) throw new Error('Driver ID is required');
if (!props.channels) throw new Error('Channel preferences are required');
return new NotificationPreference({
...props,
updatedAt: props.updatedAt ?? new Date(),
});
}
/**
* Create default preferences for a new user
*/
static createDefault(driverId: string): NotificationPreference {
return new NotificationPreference({
driverId,
channels: {
in_app: { enabled: true },
email: { enabled: false },
discord: { enabled: false },
push: { enabled: false },
},
digestMode: false,
updatedAt: new Date(),
});
}
get driverId(): string { return this.props.driverId; }
get channels(): Record<NotificationChannel, ChannelPreference> { return { ...this.props.channels }; }
get typePreferences(): Partial<Record<NotificationType, TypePreference>> | undefined {
return this.props.typePreferences ? { ...this.props.typePreferences } : undefined;
}
get digestMode(): boolean { return this.props.digestMode ?? false; }
get digestFrequencyHours(): number { return this.props.digestFrequencyHours ?? 24; }
get quietHoursStart(): number | undefined { return this.props.quietHoursStart; }
get quietHoursEnd(): number | undefined { return this.props.quietHoursEnd; }
get updatedAt(): Date { return this.props.updatedAt; }
/**
* Check if a specific channel is enabled
*/
isChannelEnabled(channel: NotificationChannel): boolean {
return this.props.channels[channel]?.enabled ?? false;
}
/**
* Check if a specific notification type is enabled
*/
isTypeEnabled(type: NotificationType): boolean {
const typePref = this.props.typePreferences?.[type];
return typePref?.enabled ?? true; // Default to enabled if not specified
}
/**
* Get enabled channels for a specific notification type
*/
getEnabledChannelsForType(type: NotificationType): NotificationChannel[] {
// Check type-specific channel overrides
const typePref = this.props.typePreferences?.[type];
if (typePref?.channels && typePref.channels.length > 0) {
return typePref.channels.filter(ch => this.isChannelEnabled(ch));
}
// Fall back to globally enabled channels
return (Object.keys(this.props.channels) as NotificationChannel[])
.filter(ch => this.isChannelEnabled(ch));
}
/**
* Check if current time is in quiet hours
*/
isInQuietHours(): boolean {
if (this.props.quietHoursStart === undefined || this.props.quietHoursEnd === undefined) {
return false;
}
const now = new Date();
const currentHour = now.getHours();
if (this.props.quietHoursStart < this.props.quietHoursEnd) {
// Normal range (e.g., 22:00 to 07:00 next day is NOT this case)
return currentHour >= this.props.quietHoursStart && currentHour < this.props.quietHoursEnd;
} else {
// Overnight range (e.g., 22:00 to 07:00)
return currentHour >= this.props.quietHoursStart || currentHour < this.props.quietHoursEnd;
}
}
/**
* Update channel preference
*/
updateChannel(channel: NotificationChannel, preference: ChannelPreference): NotificationPreference {
return new NotificationPreference({
...this.props,
channels: {
...this.props.channels,
[channel]: preference,
},
updatedAt: new Date(),
});
}
/**
* Update type preference
*/
updateTypePreference(type: NotificationType, preference: TypePreference): NotificationPreference {
return new NotificationPreference({
...this.props,
typePreferences: {
...this.props.typePreferences,
[type]: preference,
},
updatedAt: new Date(),
});
}
/**
* Update quiet hours
*/
updateQuietHours(start: number | undefined, end: number | undefined): NotificationPreference {
return new NotificationPreference({
...this.props,
quietHoursStart: start,
quietHoursEnd: end,
updatedAt: new Date(),
});
}
/**
* Toggle digest mode
*/
setDigestMode(enabled: boolean, frequencyHours?: number): NotificationPreference {
return new NotificationPreference({
...this.props,
digestMode: enabled,
digestFrequencyHours: frequencyHours ?? this.props.digestFrequencyHours,
updatedAt: new Date(),
});
}
/**
* Convert to plain object for serialization
*/
toJSON(): NotificationPreferenceProps {
return { ...this.props };
}
}

View File

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

View File

@@ -0,0 +1,60 @@
/**
* Repository Interface: INotificationRepository
*
* Defines the contract for persisting and retrieving Notification entities.
*/
import type { Notification } from '../entities/Notification';
import type { NotificationType } from '../value-objects/NotificationType';
export interface INotificationRepository {
/**
* Find a notification by ID
*/
findById(id: string): Promise<Notification | null>;
/**
* Find all notifications for a recipient
*/
findByRecipientId(recipientId: string): Promise<Notification[]>;
/**
* Find unread notifications for a recipient
*/
findUnreadByRecipientId(recipientId: string): Promise<Notification[]>;
/**
* Find notifications by type for a recipient
*/
findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]>;
/**
* Count unread notifications for a recipient
*/
countUnreadByRecipientId(recipientId: string): Promise<number>;
/**
* Save a new notification
*/
create(notification: Notification): Promise<void>;
/**
* Update an existing notification
*/
update(notification: Notification): Promise<void>;
/**
* Delete a notification
*/
delete(id: string): Promise<void>;
/**
* Delete all notifications for a recipient
*/
deleteAllByRecipientId(recipientId: string): Promise<void>;
/**
* Mark all notifications as read for a recipient
*/
markAllAsReadByRecipientId(recipientId: string): Promise<void>;
}

View File

@@ -0,0 +1,41 @@
/**
* Value Object: NotificationChannel
*
* Defines the delivery channels for notifications.
*/
export type NotificationChannel =
| 'in_app' // In-app notification (stored in database, shown in UI)
| 'email' // Email notification
| 'discord' // Discord webhook notification
| 'push'; // Push notification (future: mobile/browser)
/**
* Get human-readable name for channel
*/
export function getChannelDisplayName(channel: NotificationChannel): string {
const names: Record<NotificationChannel, string> = {
in_app: 'In-App',
email: 'Email',
discord: 'Discord',
push: 'Push Notification',
};
return names[channel];
}
/**
* Check if channel requires external integration
*/
export function isExternalChannel(channel: NotificationChannel): boolean {
return channel !== 'in_app';
}
/**
* Default channels that are always enabled
*/
export const DEFAULT_ENABLED_CHANNELS: NotificationChannel[] = ['in_app'];
/**
* All available channels
*/
export const ALL_CHANNELS: NotificationChannel[] = ['in_app', 'email', 'discord', 'push'];

View File

@@ -0,0 +1,97 @@
/**
* Value Object: NotificationType
*
* Defines the types of notifications that can be sent in the system.
*/
export type NotificationType =
// Protest-related
| 'protest_filed' // A protest was filed against you
| 'protest_defense_requested' // Steward requests your defense
| 'protest_defense_submitted' // Accused submitted their defense
| 'protest_comment_added' // New comment on a protest you're involved in
| 'protest_vote_required' // You need to vote on a protest
| 'protest_vote_cast' // Someone voted on a protest
| 'protest_resolved' // Protest has been resolved
// Penalty-related
| 'penalty_issued' // A penalty was issued to you
| 'penalty_appealed' // Penalty appeal submitted
| 'penalty_appeal_resolved' // Appeal was resolved
// Race-related
| 'race_registration_open' // Race registration is now open
| 'race_reminder' // Race starting soon reminder
| 'race_results_posted' // Race results are available
// League-related
| 'league_invite' // You were invited to a league
| 'league_join_request' // Someone requested to join your league
| 'league_join_approved' // Your join request was approved
| 'league_join_rejected' // Your join request was rejected
| 'league_role_changed' // Your role in a league changed
// Team-related
| 'team_invite' // You were invited to a team
| 'team_join_request' // Someone requested to join your team
| 'team_join_approved' // Your team join request was approved
// System
| 'system_announcement'; // System-wide announcement
/**
* Get human-readable title for notification type
*/
export function getNotificationTypeTitle(type: NotificationType): string {
const titles: Record<NotificationType, string> = {
protest_filed: 'Protest Filed',
protest_defense_requested: 'Defense Requested',
protest_defense_submitted: 'Defense Submitted',
protest_comment_added: 'New Comment',
protest_vote_required: 'Vote Required',
protest_vote_cast: 'Vote Cast',
protest_resolved: 'Protest Resolved',
penalty_issued: 'Penalty Issued',
penalty_appealed: 'Penalty Appealed',
penalty_appeal_resolved: 'Appeal Resolved',
race_registration_open: 'Registration Open',
race_reminder: 'Race Reminder',
race_results_posted: 'Results Posted',
league_invite: 'League Invitation',
league_join_request: 'Join Request',
league_join_approved: 'Request Approved',
league_join_rejected: 'Request Rejected',
league_role_changed: 'Role Changed',
team_invite: 'Team Invitation',
team_join_request: 'Team Join Request',
team_join_approved: 'Team Request Approved',
system_announcement: 'Announcement',
};
return titles[type];
}
/**
* Get priority level for notification type (higher = more urgent)
*/
export function getNotificationTypePriority(type: NotificationType): number {
const priorities: Record<NotificationType, number> = {
protest_filed: 8,
protest_defense_requested: 9,
protest_defense_submitted: 6,
protest_comment_added: 4,
protest_vote_required: 8,
protest_vote_cast: 3,
protest_resolved: 7,
penalty_issued: 9,
penalty_appealed: 7,
penalty_appeal_resolved: 7,
race_registration_open: 5,
race_reminder: 8,
race_results_posted: 5,
league_invite: 6,
league_join_request: 5,
league_join_approved: 7,
league_join_rejected: 7,
league_role_changed: 6,
team_invite: 5,
team_join_request: 4,
team_join_approved: 6,
system_announcement: 10,
};
return priorities[type];
}

View File

@@ -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/value-objects/NotificationChannel';
export interface DiscordAdapterConfig {
webhookUrl?: string;
}
export class DiscordNotificationAdapter implements INotificationGateway {
private readonly channel: NotificationChannel = 'discord';
private webhookUrl?: string;
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;
}
}

View File

@@ -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/value-objects/NotificationChannel';
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 };
}
}

View File

@@ -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/value-objects/NotificationChannel';
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;
}
}

View File

@@ -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/value-objects/NotificationChannel';
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(),
};
}
}
}

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

View File

@@ -0,0 +1,41 @@
/**
* 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';
export class InMemoryNotificationPreferenceRepository implements INotificationPreferenceRepository {
private preferences: Map<string, NotificationPreference> = new Map();
constructor(initialPreferences: NotificationPreference[] = []) {
initialPreferences.forEach(pref => {
this.preferences.set(pref.driverId, pref);
});
}
async findByDriverId(driverId: string): Promise<NotificationPreference | null> {
return this.preferences.get(driverId) || null;
}
async save(preference: NotificationPreference): Promise<void> {
this.preferences.set(preference.driverId, preference);
}
async delete(driverId: string): Promise<void> {
this.preferences.delete(driverId);
}
async getOrCreateDefault(driverId: string): Promise<NotificationPreference> {
const existing = this.preferences.get(driverId);
if (existing) {
return existing;
}
const defaultPreference = NotificationPreference.createDefault(driverId);
this.preferences.set(driverId, defaultPreference);
return defaultPreference;
}
}

View File

@@ -0,0 +1,83 @@
/**
* 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/value-objects/NotificationType';
export class InMemoryNotificationRepository implements INotificationRepository {
private notifications: Map<string, Notification> = new Map();
constructor(initialNotifications: Notification[] = []) {
initialNotifications.forEach(notification => {
this.notifications.set(notification.id, notification);
});
}
async findById(id: string): Promise<Notification | null> {
return this.notifications.get(id) || null;
}
async findByRecipientId(recipientId: string): Promise<Notification[]> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async findUnreadByRecipientId(recipientId: string): Promise<Notification[]> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.isUnread())
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async findByRecipientIdAndType(recipientId: string, type: NotificationType): Promise<Notification[]> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.type === type)
.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime());
}
async countUnreadByRecipientId(recipientId: string): Promise<number> {
return Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.isUnread())
.length;
}
async create(notification: Notification): Promise<void> {
if (this.notifications.has(notification.id)) {
throw new Error(`Notification with ID ${notification.id} already exists`);
}
this.notifications.set(notification.id, notification);
}
async update(notification: Notification): Promise<void> {
if (!this.notifications.has(notification.id)) {
throw new Error(`Notification with ID ${notification.id} not found`);
}
this.notifications.set(notification.id, notification);
}
async delete(id: string): Promise<void> {
this.notifications.delete(id);
}
async deleteAllByRecipientId(recipientId: string): Promise<void> {
const toDelete = Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId)
.map(n => n.id);
toDelete.forEach(id => this.notifications.delete(id));
}
async markAllAsReadByRecipientId(recipientId: string): Promise<void> {
const toUpdate = Array.from(this.notifications.values())
.filter(n => n.recipientId === recipientId && n.isUnread());
toUpdate.forEach(n => {
const updated = n.markAsRead();
this.notifications.set(updated.id, updated);
});
}
}

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

View File

@@ -31,6 +31,8 @@ export * from './use-cases/ReviewProtestUseCase';
export * from './use-cases/ApplyPenaltyUseCase';
export * from './use-cases/GetRaceProtestsQuery';
export * from './use-cases/GetRacePenaltiesQuery';
export * from './use-cases/RequestProtestDefenseUseCase';
export * from './use-cases/SubmitProtestDefenseUseCase';
// Export ports
export * from './ports/DriverRatingProvider';

View File

@@ -0,0 +1,65 @@
/**
* Application Use Case: RequestProtestDefenseUseCase
*
* Allows a steward to request defense from the accused driver before making a decision.
* This will trigger a notification to the accused driver.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
import type { IRaceRepository } from '../../domain/repositories/IRaceRepository';
import type { ILeagueMembershipRepository } from '../../domain/repositories/ILeagueMembershipRepository';
import { isLeagueStewardOrHigherRole } from '../../domain/value-objects/LeagueRoles';
export interface RequestProtestDefenseCommand {
protestId: string;
stewardId: string;
}
export interface RequestProtestDefenseResult {
success: boolean;
accusedDriverId: string;
protestId: string;
}
export class RequestProtestDefenseUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
private readonly raceRepository: IRaceRepository,
private readonly membershipRepository: ILeagueMembershipRepository,
) {}
async execute(command: RequestProtestDefenseCommand): Promise<RequestProtestDefenseResult> {
// Get the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
}
// Get the race to find the league
const race = await this.raceRepository.findById(protest.raceId);
if (!race) {
throw new Error('Race not found');
}
// Verify the steward has permission
const membership = await this.membershipRepository.getMembership(race.leagueId, command.stewardId);
if (!membership || !isLeagueStewardOrHigherRole(membership.role)) {
throw new Error('Only stewards and admins can request defense');
}
// Check if defense can be requested
if (!protest.canRequestDefense()) {
throw new Error('Defense cannot be requested for this protest');
}
// Request defense
const updatedProtest = protest.requestDefense(command.stewardId);
await this.protestRepository.update(updatedProtest);
return {
success: true,
accusedDriverId: protest.accusedDriverId,
protestId: protest.id,
};
}
}

View File

@@ -0,0 +1,52 @@
/**
* Application Use Case: SubmitProtestDefenseUseCase
*
* Allows the accused driver to submit their defense statement for a protest.
*/
import type { IProtestRepository } from '../../domain/repositories/IProtestRepository';
export interface SubmitProtestDefenseCommand {
protestId: string;
driverId: string;
statement: string;
videoUrl?: string;
}
export interface SubmitProtestDefenseResult {
success: boolean;
protestId: string;
}
export class SubmitProtestDefenseUseCase {
constructor(
private readonly protestRepository: IProtestRepository,
) {}
async execute(command: SubmitProtestDefenseCommand): Promise<SubmitProtestDefenseResult> {
// Get the protest
const protest = await this.protestRepository.findById(command.protestId);
if (!protest) {
throw new Error('Protest not found');
}
// Verify the submitter is the accused driver
if (protest.accusedDriverId !== command.driverId) {
throw new Error('Only the accused driver can submit a defense');
}
// Check if defense can be submitted
if (!protest.canSubmitDefense()) {
throw new Error('Defense cannot be submitted for this protest');
}
// Submit defense
const updatedProtest = protest.submitDefense(command.statement, command.videoUrl);
await this.protestRepository.update(updatedProtest);
return {
success: true,
protestId: protest.id,
};
}
}

View File

@@ -0,0 +1,56 @@
import type {
ILeagueMembershipRepository,
} from '@gridpilot/racing/domain/repositories/ILeagueMembershipRepository';
import type { ILeagueRepository } from '@gridpilot/racing/domain/repositories/ILeagueRepository';
import type {
LeagueMembership,
MembershipRole,
} from '@gridpilot/racing/domain/entities/LeagueMembership';
export interface TransferLeagueOwnershipCommandDTO {
leagueId: string;
currentOwnerId: string;
newOwnerId: string;
}
export class TransferLeagueOwnershipUseCase {
constructor(
private readonly leagueRepository: ILeagueRepository,
private readonly membershipRepository: ILeagueMembershipRepository
) {}
async execute(command: TransferLeagueOwnershipCommandDTO): Promise<void> {
const { leagueId, currentOwnerId, newOwnerId } = command;
const league = await this.leagueRepository.findById(leagueId);
if (!league) {
throw new Error('League not found');
}
if (league.ownerId !== currentOwnerId) {
throw new Error('Only the current owner can transfer ownership');
}
const newOwnerMembership = await this.membershipRepository.getMembership(leagueId, newOwnerId);
if (!newOwnerMembership || newOwnerMembership.status !== 'active') {
throw new Error('New owner must be an active member of the league');
}
const currentOwnerMembership = await this.membershipRepository.getMembership(leagueId, currentOwnerId);
await this.membershipRepository.saveMembership({
...newOwnerMembership,
role: 'owner' as MembershipRole,
});
if (currentOwnerMembership) {
await this.membershipRepository.saveMembership({
...currentOwnerMembership,
role: 'admin' as MembershipRole,
});
}
const updatedLeague = league.update({ ownerId: newOwnerId });
await this.leagueRepository.update(updatedLeague);
}
}

View File

@@ -118,6 +118,7 @@ export class League {
update(props: Partial<{
name: string;
description: string;
ownerId: string;
settings: LeagueSettings;
socialLinks: LeagueSocialLinks | undefined;
}>): League {
@@ -125,7 +126,7 @@ export class League {
id: this.id,
name: props.name ?? this.name,
description: props.description ?? this.description,
ownerId: this.ownerId,
ownerId: props.ownerId ?? this.ownerId,
settings: props.settings ?? this.settings,
createdAt: this.createdAt,
socialLinks: props.socialLinks ?? this.socialLinks,

View File

@@ -5,13 +5,16 @@
* Penalties can be applied as a result of an upheld protest or directly by stewards.
*/
export type PenaltyType =
export type PenaltyType =
| 'time_penalty' // Add time to race result (e.g., +5 seconds)
| 'grid_penalty' // Grid position penalty for next race
| 'points_deduction' // Deduct championship points
| 'disqualification' // DSQ from the race
| 'warning' // Official warning (no immediate consequence)
| 'license_points'; // Add penalty points to license (future feature)
| 'license_points' // Add penalty points to license (safety rating)
| 'probation' // Conditional penalty
| 'fine' // Monetary/points fine
| 'race_ban'; // Multi-race suspension
export type PenaltyStatus = 'pending' | 'applied' | 'appealed' | 'overturned';
@@ -52,7 +55,7 @@ export class Penalty {
if (!props.issuedBy) throw new Error('Penalty must be issued by a steward');
// Validate value based on type
if (['time_penalty', 'grid_penalty', 'points_deduction'].includes(props.type)) {
if (['time_penalty', 'grid_penalty', 'points_deduction', 'license_points', 'fine', 'race_ban'].includes(props.type)) {
if (props.value === undefined || props.value <= 0) {
throw new Error(`${props.type} requires a positive value`);
}
@@ -135,6 +138,12 @@ export class Penalty {
return 'Official warning';
case 'license_points':
return `${this.props.value} license penalty points`;
case 'probation':
return 'Probationary period';
case 'fine':
return `${this.props.value} points fine`;
case 'race_ban':
return `${this.props.value} race suspension`;
default:
return 'Penalty';
}

View File

@@ -1,10 +1,18 @@
/**
* Domain Entity: Protest
*
*
* Represents a protest filed by a driver against another driver for an incident during a race.
*
* Workflow states:
* - pending: Initial state when protest is filed
* - awaiting_defense: Defense has been requested from the accused driver
* - under_review: Steward is actively reviewing the protest
* - upheld: Protest was upheld (penalty will be applied)
* - dismissed: Protest was dismissed (no action taken)
* - withdrawn: Protesting driver withdrew the protest
*/
export type ProtestStatus = 'pending' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export type ProtestStatus = 'pending' | 'awaiting_defense' | 'under_review' | 'upheld' | 'dismissed' | 'withdrawn';
export interface ProtestIncident {
/** Lap number where the incident occurred */
@@ -15,6 +23,15 @@ export interface ProtestIncident {
description: string;
}
export interface ProtestDefense {
/** The accused driver's statement/defense */
statement: string;
/** URL to defense video clip (optional) */
videoUrl?: string;
/** Timestamp when defense was submitted */
submittedAt: Date;
}
export interface ProtestProps {
id: string;
raceId: string;
@@ -38,6 +55,12 @@ export interface ProtestProps {
filedAt: Date;
/** Timestamp when the protest was reviewed */
reviewedAt?: Date;
/** Defense from the accused driver (if requested and submitted) */
defense?: ProtestDefense;
/** Timestamp when defense was requested */
defenseRequestedAt?: Date;
/** ID of the steward who requested defense */
defenseRequestedBy?: string;
}
export class Protest {
@@ -71,11 +94,18 @@ export class Protest {
get decisionNotes(): string | undefined { return this.props.decisionNotes; }
get filedAt(): Date { return this.props.filedAt; }
get reviewedAt(): Date | undefined { return this.props.reviewedAt; }
get defense(): ProtestDefense | undefined { return this.props.defense ? { ...this.props.defense } : undefined; }
get defenseRequestedAt(): Date | undefined { return this.props.defenseRequestedAt; }
get defenseRequestedBy(): string | undefined { return this.props.defenseRequestedBy; }
isPending(): boolean {
return this.props.status === 'pending';
}
isAwaitingDefense(): boolean {
return this.props.status === 'awaiting_defense';
}
isUnderReview(): boolean {
return this.props.status === 'under_review';
}
@@ -84,12 +114,60 @@ export class Protest {
return ['upheld', 'dismissed', 'withdrawn'].includes(this.props.status);
}
hasDefense(): boolean {
return this.props.defense !== undefined;
}
canRequestDefense(): boolean {
return this.isPending() && !this.hasDefense() && !this.props.defenseRequestedAt;
}
canSubmitDefense(): boolean {
return this.isAwaitingDefense() && !this.hasDefense();
}
/**
* Start reviewing the protest
* Request defense from the accused driver
*/
requestDefense(stewardId: string): Protest {
if (!this.canRequestDefense()) {
throw new Error('Defense can only be requested for pending protests without existing defense');
}
return new Protest({
...this.props,
status: 'awaiting_defense',
defenseRequestedAt: new Date(),
defenseRequestedBy: stewardId,
});
}
/**
* Submit defense from the accused driver
*/
submitDefense(statement: string, videoUrl?: string): Protest {
if (!this.canSubmitDefense()) {
throw new Error('Defense can only be submitted when protest is awaiting defense');
}
if (!statement?.trim()) {
throw new Error('Defense statement is required');
}
return new Protest({
...this.props,
status: 'under_review',
defense: {
statement: statement.trim(),
videoUrl,
submittedAt: new Date(),
},
});
}
/**
* Start reviewing the protest (without requiring defense)
*/
startReview(stewardId: string): Protest {
if (!this.isPending()) {
throw new Error('Only pending protests can be put under review');
if (!this.isPending() && !this.isAwaitingDefense()) {
throw new Error('Only pending or awaiting-defense protests can be put under review');
}
return new Protest({
...this.props,
@@ -102,8 +180,8 @@ export class Protest {
* Uphold the protest (finding the accused guilty)
*/
uphold(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be upheld');
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be upheld');
}
return new Protest({
...this.props,
@@ -118,8 +196,8 @@ export class Protest {
* Dismiss the protest (finding no fault)
*/
dismiss(stewardId: string, decisionNotes: string): Protest {
if (!this.isPending() && !this.isUnderReview()) {
throw new Error('Only pending or under-review protests can be dismissed');
if (!this.isPending() && !this.isUnderReview() && !this.isAwaitingDefense()) {
throw new Error('Only pending, awaiting-defense, or under-review protests can be dismissed');
}
return new Protest({
...this.props,

View File

@@ -0,0 +1,73 @@
/**
* Domain Value Object: LeagueRoles
*
* Utility functions for working with league membership roles.
*/
import type { MembershipRole } from '../entities/LeagueMembership';
/**
* Role hierarchy (higher number = more authority)
*/
const ROLE_HIERARCHY: Record<MembershipRole, number> = {
member: 0,
steward: 1,
admin: 2,
owner: 3,
};
/**
* Check if a role is at least steward level
*/
export function isLeagueStewardOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.steward;
}
/**
* Check if a role is at least admin level
*/
export function isLeagueAdminOrHigherRole(role: MembershipRole): boolean {
return ROLE_HIERARCHY[role] >= ROLE_HIERARCHY.admin;
}
/**
* Check if a role is owner
*/
export function isLeagueOwnerRole(role: MembershipRole): boolean {
return role === 'owner';
}
/**
* Compare two roles
* Returns positive if role1 > role2, negative if role1 < role2, 0 if equal
*/
export function compareRoles(role1: MembershipRole, role2: MembershipRole): number {
return ROLE_HIERARCHY[role1] - ROLE_HIERARCHY[role2];
}
/**
* Get role display name
*/
export function getRoleDisplayName(role: MembershipRole): string {
const names: Record<MembershipRole, string> = {
member: 'Member',
steward: 'Steward',
admin: 'Admin',
owner: 'Owner',
};
return names[role];
}
/**
* Get all roles in order of hierarchy
*/
export function getAllRolesOrdered(): MembershipRole[] {
return ['member', 'steward', 'admin', 'owner'];
}
/**
* Get roles that can be assigned (excludes owner as it's transferred, not assigned)
*/
export function getAssignableRoles(): MembershipRole[] {
return ['member', 'steward', 'admin'];
}