161 lines
5.9 KiB
TypeScript
161 lines
5.9 KiB
TypeScript
/**
|
|
* Application Use Case: SendNotificationUseCase
|
|
*
|
|
* Sends a notification to a recipient through the appropriate channels
|
|
* based on their preferences.
|
|
*/
|
|
|
|
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
|
import { Result } from '@core/shared/application/Result';
|
|
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
|
import { v4 as uuid } from 'uuid';
|
|
import type { NotificationData } from '../../domain/entities/Notification';
|
|
import { Notification } from '../../domain/entities/Notification';
|
|
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
|
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
|
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
|
|
import type { NotificationDeliveryResult, NotificationGatewayRegistry } from '../ports/NotificationGateway';
|
|
|
|
export interface SendNotificationCommand {
|
|
recipientId: string;
|
|
type: NotificationType;
|
|
title: string;
|
|
body: string;
|
|
data?: NotificationData;
|
|
actionUrl?: string;
|
|
/** How urgently to display this notification (default: 'silent') */
|
|
urgency?: 'silent' | 'toast' | 'modal';
|
|
/** Whether this notification requires a response before dismissal */
|
|
requiresResponse?: boolean;
|
|
/** Action buttons for modal notifications */
|
|
actions?: Array<{
|
|
label: string;
|
|
type: 'primary' | 'secondary' | 'danger';
|
|
href?: string;
|
|
actionId?: string;
|
|
}>;
|
|
/** Override channels (skip preference check) */
|
|
forceChannels?: NotificationChannel[];
|
|
}
|
|
|
|
export interface SendNotificationResult {
|
|
/** The created notification */
|
|
notification: Notification;
|
|
/** Delivery results for each channel */
|
|
deliveryResults: NotificationDeliveryResult[];
|
|
}
|
|
|
|
export type SendNotificationErrorCode = 'REPOSITORY_ERROR';
|
|
|
|
export class SendNotificationUseCase {
|
|
constructor(
|
|
private readonly notificationRepository: INotificationRepository,
|
|
private readonly preferenceRepository: INotificationPreferenceRepository,
|
|
private readonly gatewayRegistry: NotificationGatewayRegistry,
|
|
private readonly output: UseCaseOutputPort<SendNotificationResult>,
|
|
private readonly logger: Logger,
|
|
) {
|
|
this.logger.debug('SendNotificationUseCase initialized.');
|
|
}
|
|
|
|
async execute(
|
|
command: SendNotificationCommand,
|
|
): Promise<Result<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>> {
|
|
this.logger.debug('Executing SendNotificationUseCase', { command });
|
|
try {
|
|
// Get recipient's preferences
|
|
this.logger.debug('Checking notification preferences.', { type: command.type, recipientId: command.recipientId });
|
|
const preferences = await this.preferenceRepository.getOrCreateDefault(command.recipientId);
|
|
|
|
// Check if this notification type is enabled
|
|
if (!preferences.isTypeEnabled(command.type)) {
|
|
// User has disabled this type - create but don't deliver
|
|
const notification = Notification.create({
|
|
id: uuid(),
|
|
recipientId: command.recipientId,
|
|
type: command.type,
|
|
title: command.title,
|
|
body: command.body,
|
|
channel: 'in_app',
|
|
status: 'dismissed', // Auto-dismiss since user doesn't want these
|
|
...(command.data ? { data: command.data } : {}),
|
|
...(command.actionUrl ? { actionUrl: command.actionUrl } : {}),
|
|
});
|
|
|
|
await this.notificationRepository.create(notification);
|
|
|
|
this.output.present({
|
|
notification,
|
|
deliveryResults: [],
|
|
});
|
|
|
|
return Result.ok(undefined);
|
|
}
|
|
|
|
// Determine which channels to use
|
|
const channels =
|
|
command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
|
|
|
|
// Check quiet hours (skip external channels during quiet hours)
|
|
const effectiveChannels = preferences.isInQuietHours()
|
|
? channels.filter(ch => ch === 'in_app')
|
|
: channels;
|
|
|
|
// Ensure at least in_app is used
|
|
if (!effectiveChannels.includes('in_app')) {
|
|
effectiveChannels.unshift('in_app');
|
|
}
|
|
|
|
const deliveryResults: NotificationDeliveryResult[] = [];
|
|
let primaryNotification: Notification | null = null;
|
|
|
|
// Send through each channel
|
|
for (const channel of effectiveChannels) {
|
|
const notification = Notification.create({
|
|
id: uuid(),
|
|
recipientId: command.recipientId,
|
|
type: command.type,
|
|
title: command.title,
|
|
body: command.body,
|
|
channel,
|
|
...(command.urgency ? { urgency: command.urgency } : {}),
|
|
...(command.data ? { data: command.data } : {}),
|
|
...(command.actionUrl ? { actionUrl: command.actionUrl } : {}),
|
|
...(command.actions ? { actions: command.actions } : {}),
|
|
...(command.requiresResponse !== undefined
|
|
? { requiresResponse: command.requiresResponse }
|
|
: {}),
|
|
});
|
|
|
|
// Save to repository (in_app channel) or attempt delivery (external channels)
|
|
if (channel === 'in_app') {
|
|
await this.notificationRepository.create(notification);
|
|
primaryNotification = notification;
|
|
deliveryResults.push({
|
|
success: true,
|
|
channel,
|
|
attemptedAt: new Date(),
|
|
});
|
|
} else {
|
|
// Attempt external delivery
|
|
const result = await this.gatewayRegistry.send(notification);
|
|
deliveryResults.push(result);
|
|
}
|
|
}
|
|
|
|
this.output.present({
|
|
notification: primaryNotification!,
|
|
deliveryResults,
|
|
});
|
|
|
|
return Result.ok(undefined);
|
|
} catch (error) {
|
|
const err = error instanceof Error ? error : new Error(String(error));
|
|
this.logger.error('Error sending notification', err);
|
|
return Result.err({
|
|
code: 'REPOSITORY_ERROR',
|
|
details: { message: err.message },
|
|
});
|
|
}
|
|
}
|
|
} |