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