This commit is contained in:
2025-12-09 22:45:03 +01:00
parent 3adf2e5e94
commit 3659d25e52
20 changed files with 2537 additions and 85 deletions

View File

@@ -14,7 +14,14 @@ export * from './use-cases/NotificationPreferencesUseCases';
export * from './ports/INotificationGateway';
// Re-export domain types for convenience
export type { Notification, NotificationProps, NotificationStatus, NotificationData } from '../domain/entities/Notification';
export type {
Notification,
NotificationProps,
NotificationStatus,
NotificationData,
NotificationUrgency,
NotificationAction,
} from '../domain/entities/Notification';
export type { NotificationPreference, NotificationPreferenceProps, ChannelPreference, TypePreference } from '../domain/entities/NotificationPreference';
export type { NotificationType } from '../domain/value-objects/NotificationType';
export type { NotificationChannel } from '../domain/value-objects/NotificationChannel';

View File

@@ -21,6 +21,17 @@ export interface SendNotificationCommand {
body: string;
data?: NotificationData;
actionUrl?: string;
/** How urgently to display this notification (default: 'silent') */
urgency?: 'silent' | 'toast' | 'modal';
/** Whether this notification requires a response before dismissal */
requiresResponse?: boolean;
/** Action buttons for modal notifications */
actions?: Array<{
label: string;
type: 'primary' | 'secondary' | 'danger';
href?: string;
actionId?: string;
}>;
/** Override channels (skip preference check) */
forceChannels?: NotificationChannel[];
}
@@ -91,8 +102,11 @@ export class SendNotificationUseCase {
title: command.title,
body: command.body,
channel,
urgency: command.urgency,
data: command.data,
actionUrl: command.actionUrl,
actions: command.actions,
requiresResponse: command.requiresResponse,
});
// Save to repository (in_app channel) or attempt delivery (external channels)

View File

@@ -1,6 +1,6 @@
/**
* Domain Entity: Notification
*
*
* Represents a notification sent to a user.
* Immutable entity with factory methods and domain validation.
*/
@@ -8,7 +8,15 @@
import type { NotificationType } from '../value-objects/NotificationType';
import type { NotificationChannel } from '../value-objects/NotificationChannel';
export type NotificationStatus = 'unread' | 'read' | 'dismissed';
export type NotificationStatus = 'unread' | 'read' | 'dismissed' | 'action_required';
/**
* Notification urgency determines how the notification is displayed
* - silent: Only appears in notification center (default)
* - toast: Shows a temporary toast notification
* - modal: Shows a blocking modal that requires user action (cannot be ignored)
*/
export type NotificationUrgency = 'silent' | 'toast' | 'modal';
export interface NotificationData {
/** Reference to related protest */
@@ -29,6 +37,20 @@ export interface NotificationData {
[key: string]: unknown;
}
/**
* Configuration for action buttons shown on modal notifications
*/
export interface NotificationAction {
/** Button label */
label: string;
/** Action type - determines styling */
type: 'primary' | 'secondary' | 'danger';
/** URL to navigate to when clicked */
href?: string;
/** Custom action identifier (for handling in code) */
actionId?: string;
}
export interface NotificationProps {
id: string;
/** Driver who receives this notification */
@@ -43,22 +65,31 @@ export interface NotificationProps {
channel: NotificationChannel;
/** Current status */
status: NotificationStatus;
/** How urgently to display this notification */
urgency: NotificationUrgency;
/** Structured data for linking/context */
data?: NotificationData;
/** Optional action URL */
/** Optional action URL (for simple click-through) */
actionUrl?: string;
/** Action buttons for modal notifications */
actions?: NotificationAction[];
/** Whether this notification requires a response before it can be dismissed */
requiresResponse?: boolean;
/** When the notification was created */
createdAt: Date;
/** When the notification was read (if applicable) */
readAt?: Date;
/** When the notification was responded to (for action_required status) */
respondedAt?: Date;
}
export class Notification {
private constructor(private readonly props: NotificationProps) {}
static create(props: Omit<NotificationProps, 'status' | 'createdAt'> & {
static create(props: Omit<NotificationProps, 'status' | 'createdAt' | 'urgency'> & {
status?: NotificationStatus;
createdAt?: Date;
urgency?: NotificationUrgency;
}): Notification {
if (!props.id) throw new Error('Notification ID is required');
if (!props.recipientId) throw new Error('Recipient ID is required');
@@ -67,9 +98,13 @@ export class Notification {
if (!props.body?.trim()) throw new Error('Notification body is required');
if (!props.channel) throw new Error('Notification channel is required');
// Modal notifications that require response start with action_required status
const defaultStatus = props.requiresResponse ? 'action_required' : 'unread';
return new Notification({
...props,
status: props.status ?? 'unread',
status: props.status ?? defaultStatus,
urgency: props.urgency ?? 'silent',
createdAt: props.createdAt ?? new Date(),
});
}
@@ -81,10 +116,14 @@ export class Notification {
get body(): string { return this.props.body; }
get channel(): NotificationChannel { return this.props.channel; }
get status(): NotificationStatus { return this.props.status; }
get urgency(): NotificationUrgency { return this.props.urgency; }
get data(): NotificationData | undefined { return this.props.data ? { ...this.props.data } : undefined; }
get actionUrl(): string | undefined { return this.props.actionUrl; }
get actions(): NotificationAction[] | undefined { return this.props.actions ? [...this.props.actions] : undefined; }
get requiresResponse(): boolean { return this.props.requiresResponse ?? false; }
get createdAt(): Date { return this.props.createdAt; }
get readAt(): Date | undefined { return this.props.readAt; }
get respondedAt(): Date | undefined { return this.props.respondedAt; }
isUnread(): boolean {
return this.props.status === 'unread';
@@ -98,6 +137,29 @@ export class Notification {
return this.props.status === 'dismissed';
}
isActionRequired(): boolean {
return this.props.status === 'action_required';
}
isSilent(): boolean {
return this.props.urgency === 'silent';
}
isToast(): boolean {
return this.props.urgency === 'toast';
}
isModal(): boolean {
return this.props.urgency === 'modal';
}
/**
* Check if this notification can be dismissed without responding
*/
canDismiss(): boolean {
return !this.props.requiresResponse || this.props.status !== 'action_required';
}
/**
* Mark the notification as read
*/
@@ -112,6 +174,19 @@ export class Notification {
});
}
/**
* Mark that the user has responded to an action_required notification
*/
markAsResponded(actionId?: string): Notification {
return new Notification({
...this.props,
status: 'read',
readAt: this.props.readAt ?? new Date(),
respondedAt: new Date(),
data: actionId ? { ...this.props.data, responseActionId: actionId } : this.props.data,
});
}
/**
* Dismiss the notification
*/
@@ -119,6 +194,10 @@ export class Notification {
if (this.props.status === 'dismissed') {
return this; // Already dismissed
}
// Cannot dismiss action_required notifications without responding
if (this.props.requiresResponse && this.props.status === 'action_required') {
throw new Error('Cannot dismiss notification that requires response');
}
return new Notification({
...this.props,
status: 'dismissed',

View File

@@ -1,4 +1,5 @@
import type { LeagueVisibilityType } from '../../domain/value-objects/LeagueVisibility';
import type { StewardingDecisionMode } from '../../domain/entities/League';
export type LeagueStructureMode = 'solo' | 'fixedTeams';
@@ -57,6 +58,49 @@ export interface LeagueTimingsFormDTO {
monthlyWeekday?: import('../../domain/value-objects/Weekday').Weekday;
}
/**
* Stewarding configuration for protests and penalties.
*/
export interface LeagueStewardingFormDTO {
/**
* How protest decisions are made
*/
decisionMode: StewardingDecisionMode;
/**
* Number of votes required to uphold/reject a protest
* Used with steward_vote, member_vote, steward_veto, member_veto modes
*/
requiredVotes?: number;
/**
* Whether to require a defense from the accused before deciding
*/
requireDefense: boolean;
/**
* Time limit (hours) for accused to submit defense
*/
defenseTimeLimit: number;
/**
* Time limit (hours) for voting to complete
*/
voteTimeLimit: number;
/**
* Time limit (hours) after race ends when protests can be filed
*/
protestDeadlineHours: number;
/**
* Time limit (hours) after race ends when stewarding is closed
*/
stewardingClosesHours: number;
/**
* Whether to notify the accused when a protest is filed
*/
notifyAccusedOnProtest: boolean;
/**
* Whether to notify eligible voters when a vote is required
*/
notifyOnVoteRequired: boolean;
}
export interface LeagueConfigFormModel {
leagueId?: string; // present for admin, omitted for create
basics: {
@@ -80,6 +124,7 @@ export interface LeagueConfigFormModel {
scoring: LeagueScoringFormDTO;
dropPolicy: LeagueDropPolicyFormDTO;
timings: LeagueTimingsFormDTO;
stewarding: LeagueStewardingFormDTO;
}
/**

View File

@@ -77,4 +77,5 @@ export type {
LeagueDropPolicyFormDTO,
LeagueStructureMode,
LeagueTimingsFormDTO,
LeagueStewardingFormDTO,
} from './dto/LeagueConfigFormDTO';

View File

@@ -5,6 +5,56 @@
* Immutable entity with factory methods and domain validation.
*/
/**
* Stewarding decision mode for protests
*/
export type StewardingDecisionMode =
| 'admin_only' // Only admins can decide
| 'steward_vote' // X stewards must vote to uphold
| 'member_vote' // X members must vote to uphold
| 'steward_veto' // Upheld unless X stewards vote against
| 'member_veto'; // Upheld unless X members vote against
export interface StewardingSettings {
/**
* How protest decisions are made
*/
decisionMode: StewardingDecisionMode;
/**
* Number of votes required to uphold/reject a protest
* Used with steward_vote, member_vote, steward_veto, member_veto modes
*/
requiredVotes?: number;
/**
* Whether to require a defense from the accused before deciding
*/
requireDefense?: boolean;
/**
* Time limit (hours) for accused to submit defense
*/
defenseTimeLimit?: number;
/**
* Time limit (hours) for voting to complete
*/
voteTimeLimit?: number;
/**
* Time limit (hours) after race ends when protests can be filed
*/
protestDeadlineHours?: number;
/**
* Time limit (hours) after race ends when stewarding is closed (no more decisions)
*/
stewardingClosesHours?: number;
/**
* Whether to notify the accused when a protest is filed
*/
notifyAccusedOnProtest?: boolean;
/**
* Whether to notify eligible voters when a vote is required
*/
notifyOnVoteRequired?: boolean;
}
export interface LeagueSettings {
pointsSystem: 'f1-2024' | 'indycar' | 'custom';
sessionDuration?: number;
@@ -15,6 +65,10 @@ export interface LeagueSettings {
* Used for simple capacity display on the website.
*/
maxDrivers?: number;
/**
* Stewarding settings for protest handling
*/
stewarding?: StewardingSettings;
}
export interface LeagueSocialLinks {
@@ -64,11 +118,23 @@ export class League {
}): League {
this.validate(props);
const defaultStewardingSettings: StewardingSettings = {
decisionMode: 'admin_only',
requireDefense: false,
defenseTimeLimit: 48,
voteTimeLimit: 72,
protestDeadlineHours: 48,
stewardingClosesHours: 168, // 7 days
notifyAccusedOnProtest: true,
notifyOnVoteRequired: true,
};
const defaultSettings: LeagueSettings = {
pointsSystem: 'f1-2024',
sessionDuration: 60,
qualifyingFormat: 'open',
maxDrivers: 32,
stewarding: defaultStewardingSettings,
};
return new League({