refactor use cases
This commit is contained in:
@@ -1,16 +1,27 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { GetUnreadNotificationsUseCase } from './GetUnreadNotificationsUseCase';
|
||||
import {
|
||||
GetUnreadNotificationsUseCase,
|
||||
type GetUnreadNotificationsInput,
|
||||
type GetUnreadNotificationsResult,
|
||||
} from './GetUnreadNotificationsUseCase';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
|
||||
interface NotificationRepositoryMock {
|
||||
findUnreadByRecipientId: Mock;
|
||||
}
|
||||
|
||||
interface OutputPortMock extends UseCaseOutputPort<GetUnreadNotificationsResult> {
|
||||
present: Mock;
|
||||
}
|
||||
|
||||
describe('GetUnreadNotificationsUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let logger: Logger;
|
||||
let output: OutputPortMock;
|
||||
let useCase: GetUnreadNotificationsUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -25,8 +36,13 @@ describe('GetUnreadNotificationsUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as OutputPortMock;
|
||||
|
||||
useCase = new GetUnreadNotificationsUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
@@ -37,7 +53,7 @@ describe('GetUnreadNotificationsUseCase', () => {
|
||||
Notification.create({
|
||||
id: 'n1',
|
||||
recipientId,
|
||||
type: 'info',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
@@ -46,19 +62,33 @@ describe('GetUnreadNotificationsUseCase', () => {
|
||||
|
||||
notificationRepository.findUnreadByRecipientId.mockResolvedValue(notifications);
|
||||
|
||||
const result = await useCase.execute(recipientId);
|
||||
const input: GetUnreadNotificationsInput = { recipientId };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(notificationRepository.findUnreadByRecipientId).toHaveBeenCalledWith(recipientId);
|
||||
expect(result.notifications).toEqual(notifications);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
notifications,
|
||||
totalCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('handles repository errors by logging and rethrowing', async () => {
|
||||
it('handles repository errors by logging and returning error result', async () => {
|
||||
const recipientId = 'driver-1';
|
||||
const error = new Error('DB error');
|
||||
notificationRepository.findUnreadByRecipientId.mockRejectedValue(error);
|
||||
|
||||
await expect(useCase.execute(recipientId)).rejects.toThrow('DB error');
|
||||
const input: GetUnreadNotificationsInput = { recipientId };
|
||||
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'REPOSITORY_ERROR', { message: string }>;
|
||||
expect(err.code).toBe('REPOSITORY_ERROR');
|
||||
expect(err.details.message).toBe('DB error');
|
||||
expect((logger.error as unknown as Mock)).toHaveBeenCalled();
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,41 +1,72 @@
|
||||
/**
|
||||
* Application Use Case: GetUnreadNotificationsUseCase
|
||||
*
|
||||
*
|
||||
* Retrieves unread notifications for a recipient.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase , Logger } from '@core/shared/application';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { Notification } from '../../domain/entities/Notification';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
|
||||
export interface UnreadNotificationsResult {
|
||||
export type GetUnreadNotificationsInput = {
|
||||
recipientId: string;
|
||||
};
|
||||
|
||||
export interface GetUnreadNotificationsResult {
|
||||
notifications: Notification[];
|
||||
totalCount: number;
|
||||
}
|
||||
|
||||
export class GetUnreadNotificationsUseCase implements AsyncUseCase<string, UnreadNotificationsResult> {
|
||||
export type GetUnreadNotificationsErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class GetUnreadNotificationsUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<GetUnreadNotificationsResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<UnreadNotificationsResult> {
|
||||
this.logger.debug(`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`);
|
||||
async execute(
|
||||
input: GetUnreadNotificationsInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>> {
|
||||
const { recipientId } = input;
|
||||
this.logger.debug(
|
||||
`Attempting to retrieve unread notifications for recipient ID: ${recipientId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const notifications = await this.notificationRepository.findUnreadByRecipientId(recipientId);
|
||||
this.logger.info(`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`);
|
||||
|
||||
const notifications = await this.notificationRepository.findUnreadByRecipientId(
|
||||
recipientId,
|
||||
);
|
||||
this.logger.info(
|
||||
`Successfully retrieved ${notifications.length} unread notifications for recipient ID: ${recipientId}`,
|
||||
);
|
||||
|
||||
if (notifications.length === 0) {
|
||||
this.logger.warn(`No unread notifications found for recipient ID: ${recipientId}`);
|
||||
}
|
||||
|
||||
return {
|
||||
this.output.present({
|
||||
notifications,
|
||||
totalCount: notifications.length,
|
||||
};
|
||||
});
|
||||
|
||||
return Result.ok<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>(
|
||||
undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to retrieve unread notifications for recipient ID: ${recipientId}`, error instanceof Error ? error : new Error(String(error)));
|
||||
throw error;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to retrieve unread notifications for recipient ID: ${recipientId}`,
|
||||
err,
|
||||
);
|
||||
|
||||
return Result.err<void, ApplicationErrorCode<GetUnreadNotificationsErrorCode, { message: string }>>({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { describe, it, expect, vi, type Mock } from 'vitest';
|
||||
import { MarkNotificationReadUseCase } from './MarkNotificationReadUseCase';
|
||||
import {
|
||||
MarkNotificationReadUseCase,
|
||||
type MarkNotificationReadCommand,
|
||||
type MarkNotificationReadResult,
|
||||
} from './MarkNotificationReadUseCase';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { Notification } from '../../domain/entities/Notification';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
interface NotificationRepositoryMock {
|
||||
findById: Mock;
|
||||
@@ -11,9 +16,14 @@ interface NotificationRepositoryMock {
|
||||
markAllAsReadByRecipientId: Mock;
|
||||
}
|
||||
|
||||
interface OutputPortMock extends UseCaseOutputPort<MarkNotificationReadResult> {
|
||||
present: Mock;
|
||||
}
|
||||
|
||||
describe('MarkNotificationReadUseCase', () => {
|
||||
let notificationRepository: NotificationRepositoryMock;
|
||||
let logger: Logger;
|
||||
let output: OutputPortMock;
|
||||
let useCase: MarkNotificationReadUseCase;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -30,27 +40,39 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
|
||||
output = {
|
||||
present: vi.fn(),
|
||||
} as unknown as OutputPortMock;
|
||||
|
||||
useCase = new MarkNotificationReadUseCase(
|
||||
notificationRepository as unknown as INotificationRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
});
|
||||
|
||||
it('throws when notification is not found', async () => {
|
||||
it('returns NOTIFICATION_NOT_FOUND when notification is not found', async () => {
|
||||
notificationRepository.findById.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }),
|
||||
).rejects.toThrow(NotificationDomainError);
|
||||
const command: MarkNotificationReadCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
expect((logger.warn as unknown as Mock)).toHaveBeenCalled();
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'NOTIFICATION_NOT_FOUND', { message: string }>;
|
||||
expect(err.code).toBe('NOTIFICATION_NOT_FOUND');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws when recipientId does not match', async () => {
|
||||
it('returns RECIPIENT_MISMATCH when recipientId does not match', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-2',
|
||||
type: 'info',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
@@ -58,16 +80,24 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
|
||||
notificationRepository.findById.mockResolvedValue(notification);
|
||||
|
||||
await expect(
|
||||
useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' }),
|
||||
).rejects.toThrow(NotificationDomainError);
|
||||
const command: MarkNotificationReadCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'RECIPIENT_MISMATCH', { message: string }>;
|
||||
expect(err.code).toBe('RECIPIENT_MISMATCH');
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks notification as read when unread', async () => {
|
||||
it('marks notification as read when unread and presents result', async () => {
|
||||
const notification = Notification.create({
|
||||
id: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
type: 'info',
|
||||
type: 'system_announcement',
|
||||
title: 'Test',
|
||||
body: 'Body',
|
||||
channel: 'in_app',
|
||||
@@ -75,9 +105,19 @@ describe('MarkNotificationReadUseCase', () => {
|
||||
|
||||
notificationRepository.findById.mockResolvedValue(notification);
|
||||
|
||||
await useCase.execute({ notificationId: 'n1', recipientId: 'driver-1' });
|
||||
const command: MarkNotificationReadCommand = {
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(notificationRepository.update).toHaveBeenCalled();
|
||||
expect((logger.info as unknown as Mock)).toHaveBeenCalled();
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
notificationId: 'n1',
|
||||
recipientId: 'driver-1',
|
||||
wasAlreadyRead: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Marks a notification as read.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase , Logger } from '@core/shared/application';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { INotificationRepository } from '../../domain/repositories/INotificationRepository';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
@@ -13,38 +15,86 @@ export interface MarkNotificationReadCommand {
|
||||
recipientId: string; // For validation
|
||||
}
|
||||
|
||||
export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificationReadCommand, void> {
|
||||
export interface MarkNotificationReadResult {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
wasAlreadyRead: boolean;
|
||||
}
|
||||
|
||||
export type MarkNotificationReadErrorCode =
|
||||
| 'NOTIFICATION_NOT_FOUND'
|
||||
| 'RECIPIENT_MISMATCH'
|
||||
| 'REPOSITORY_ERROR';
|
||||
|
||||
export class MarkNotificationReadUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<MarkNotificationReadResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(command: MarkNotificationReadCommand): Promise<void> {
|
||||
this.logger.debug(`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`);
|
||||
async execute(
|
||||
command: MarkNotificationReadCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<MarkNotificationReadErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Attempting to mark notification ${command.notificationId} as read for recipient ${command.recipientId}`,
|
||||
);
|
||||
|
||||
try {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
|
||||
if (!notification) {
|
||||
this.logger.warn(`Notification not found for ID: ${command.notificationId}`);
|
||||
throw new NotificationDomainError('Notification not found');
|
||||
return Result.err({
|
||||
code: 'NOTIFICATION_NOT_FOUND',
|
||||
details: { message: 'Notification not found' },
|
||||
});
|
||||
}
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
this.logger.warn(`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`);
|
||||
throw new NotificationDomainError('Cannot mark another user\'s notification as read');
|
||||
this.logger.warn(
|
||||
`Unauthorized attempt to mark notification ${command.notificationId}. Recipient ID mismatch.`,
|
||||
);
|
||||
return Result.err({
|
||||
code: 'RECIPIENT_MISMATCH',
|
||||
details: { message: "Cannot mark another user's notification as read" },
|
||||
});
|
||||
}
|
||||
|
||||
if (!notification.isUnread()) {
|
||||
this.logger.info(`Notification ${command.notificationId} is already read. Skipping update.`);
|
||||
return; // Already read, nothing to do
|
||||
this.logger.info(
|
||||
`Notification ${command.notificationId} is already read. Skipping update.`,
|
||||
);
|
||||
this.output.present({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyRead: true,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
const updatedNotification = notification.markAsRead();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
this.logger.info(`Notification ${command.notificationId} successfully marked as read.`);
|
||||
this.logger.info(
|
||||
`Notification ${command.notificationId} successfully marked as read.`,
|
||||
);
|
||||
|
||||
this.output.present({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyRead: false,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to mark notification ${command.notificationId} as read: ${error instanceof Error ? error.message : String(error)}`);
|
||||
throw error;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to mark notification ${command.notificationId} as read: ${err.message}`,
|
||||
);
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -54,13 +104,36 @@ export class MarkNotificationReadUseCase implements AsyncUseCase<MarkNotificatio
|
||||
*
|
||||
* Marks all notifications as read for a recipient.
|
||||
*/
|
||||
export class MarkAllNotificationsReadUseCase implements AsyncUseCase<string, void> {
|
||||
export interface MarkAllNotificationsReadInput {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export interface MarkAllNotificationsReadResult {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export type MarkAllNotificationsReadErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class MarkAllNotificationsReadUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<MarkAllNotificationsReadResult>,
|
||||
) {}
|
||||
|
||||
async execute(recipientId: string): Promise<void> {
|
||||
await this.notificationRepository.markAllAsReadByRecipientId(recipientId);
|
||||
async execute(
|
||||
input: MarkAllNotificationsReadInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<MarkAllNotificationsReadErrorCode, { message: string }>>> {
|
||||
try {
|
||||
await this.notificationRepository.markAllAsReadByRecipientId(input.recipientId);
|
||||
this.output.present({ recipientId: input.recipientId });
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,27 +147,78 @@ export interface DismissNotificationCommand {
|
||||
recipientId: string;
|
||||
}
|
||||
|
||||
export class DismissNotificationUseCase implements AsyncUseCase<DismissNotificationCommand, void> {
|
||||
export interface DismissNotificationResult {
|
||||
notificationId: string;
|
||||
recipientId: string;
|
||||
wasAlreadyDismissed: boolean;
|
||||
}
|
||||
|
||||
export type DismissNotificationErrorCode =
|
||||
| 'NOTIFICATION_NOT_FOUND'
|
||||
| 'RECIPIENT_MISMATCH'
|
||||
| 'CANNOT_DISMISS_REQUIRING_RESPONSE'
|
||||
| 'REPOSITORY_ERROR';
|
||||
|
||||
export class DismissNotificationUseCase {
|
||||
constructor(
|
||||
private readonly notificationRepository: INotificationRepository,
|
||||
private readonly output: UseCaseOutputPort<DismissNotificationResult>,
|
||||
) {}
|
||||
|
||||
async execute(command: DismissNotificationCommand): Promise<void> {
|
||||
const notification = await this.notificationRepository.findById(command.notificationId);
|
||||
|
||||
if (!notification) {
|
||||
throw new NotificationDomainError('Notification not found');
|
||||
}
|
||||
async execute(
|
||||
command: DismissNotificationCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<DismissNotificationErrorCode, { message: string }>>> {
|
||||
try {
|
||||
const notification = await this.notificationRepository.findById(
|
||||
command.notificationId,
|
||||
);
|
||||
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
throw new NotificationDomainError('Cannot dismiss another user\'s notification');
|
||||
}
|
||||
if (!notification) {
|
||||
return Result.err({
|
||||
code: 'NOTIFICATION_NOT_FOUND',
|
||||
details: { message: 'Notification not found' },
|
||||
});
|
||||
}
|
||||
|
||||
if (notification.isDismissed()) {
|
||||
return; // Already dismissed
|
||||
}
|
||||
if (notification.recipientId !== command.recipientId) {
|
||||
return Result.err({
|
||||
code: 'RECIPIENT_MISMATCH',
|
||||
details: { message: "Cannot dismiss another user's notification" },
|
||||
});
|
||||
}
|
||||
|
||||
const updatedNotification = notification.dismiss();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
if (notification.isDismissed()) {
|
||||
this.output.present({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyDismissed: true,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
}
|
||||
|
||||
if (!notification.canDismiss()) {
|
||||
return Result.err({
|
||||
code: 'CANNOT_DISMISS_REQUIRING_RESPONSE',
|
||||
details: { message: 'Cannot dismiss notification that requires response' },
|
||||
});
|
||||
}
|
||||
|
||||
const updatedNotification = notification.dismiss();
|
||||
await this.notificationRepository.update(updatedNotification);
|
||||
|
||||
this.output.present({
|
||||
notificationId: command.notificationId,
|
||||
recipientId: command.recipientId,
|
||||
wasAlreadyDismissed: false,
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,10 +5,22 @@ import {
|
||||
UpdateTypePreferenceUseCase,
|
||||
UpdateQuietHoursUseCase,
|
||||
SetDigestModeUseCase,
|
||||
type GetNotificationPreferencesInput,
|
||||
type GetNotificationPreferencesResult,
|
||||
type UpdateChannelPreferenceCommand,
|
||||
type UpdateChannelPreferenceResult,
|
||||
type UpdateTypePreferenceCommand,
|
||||
type UpdateTypePreferenceResult,
|
||||
type UpdateQuietHoursCommand,
|
||||
type UpdateQuietHoursResult,
|
||||
type SetDigestModeCommand,
|
||||
type SetDigestModeResult,
|
||||
} from './NotificationPreferencesUseCases';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
import type { NotificationPreference , ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { Logger } from '@core/shared/application';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import type { NotificationChannel, NotificationType } from '../../domain/types/NotificationTypes';
|
||||
import { NotificationDomainError } from '../../domain/errors/NotificationDomainError';
|
||||
|
||||
@@ -19,38 +31,46 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
};
|
||||
let logger: Logger;
|
||||
|
||||
beforeEach(() => {
|
||||
preferenceRepository = {
|
||||
getOrCreateDefault: vi.fn(),
|
||||
save: vi.fn(),
|
||||
} as unknown as INotificationPreferenceRepository as any;
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
});
|
||||
|
||||
it('GetNotificationPreferencesQuery returns preferences from repository', async () => {
|
||||
const preference = {
|
||||
id: 'pref-1',
|
||||
} as unknown as NotificationPreference;
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const useCase = new GetNotificationPreferencesQuery(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
logger,
|
||||
);
|
||||
|
||||
const result = await useCase.execute('driver-1');
|
||||
|
||||
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
|
||||
expect(result).toBe(preference);
|
||||
});
|
||||
|
||||
|
||||
beforeEach(() => {
|
||||
preferenceRepository = {
|
||||
getOrCreateDefault: vi.fn(),
|
||||
save: vi.fn(),
|
||||
} as unknown as INotificationPreferenceRepository as any;
|
||||
|
||||
logger = {
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as Logger;
|
||||
});
|
||||
|
||||
it('GetNotificationPreferencesQuery returns preferences from repository', async () => {
|
||||
const preference = {
|
||||
id: 'pref-1',
|
||||
} as unknown as NotificationPreference;
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<GetNotificationPreferencesResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new GetNotificationPreferencesQuery(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
const input: GetNotificationPreferencesInput = { driverId: 'driver-1' };
|
||||
const result = await useCase.execute(input);
|
||||
|
||||
expect(preferenceRepository.getOrCreateDefault).toHaveBeenCalledWith('driver-1');
|
||||
expect(result).toBeInstanceOf(Result);
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(output.present).toHaveBeenCalledWith({ preference });
|
||||
});
|
||||
it('UpdateChannelPreferenceUseCase updates channel preference', async () => {
|
||||
const preference = {
|
||||
updateChannel: vi.fn().mockReturnThis(),
|
||||
@@ -58,19 +78,28 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<UpdateChannelPreferenceResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new UpdateChannelPreferenceUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
await useCase.execute({
|
||||
const command: UpdateChannelPreferenceCommand = {
|
||||
driverId: 'driver-1',
|
||||
channel: 'email' as NotificationChannel,
|
||||
preference: 'enabled' as ChannelPreference,
|
||||
});
|
||||
preference: { enabled: true } as ChannelPreference,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.updateChannel).toHaveBeenCalled();
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', channel: 'email' });
|
||||
});
|
||||
|
||||
it('UpdateTypePreferenceUseCase updates type preference', async () => {
|
||||
@@ -80,19 +109,28 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<UpdateTypePreferenceResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new UpdateTypePreferenceUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
await useCase.execute({
|
||||
const command: UpdateTypePreferenceCommand = {
|
||||
driverId: 'driver-1',
|
||||
type: 'info' as NotificationType,
|
||||
preference: 'enabled' as TypePreference,
|
||||
});
|
||||
type: 'system_announcement' as NotificationType,
|
||||
preference: { enabled: true } as TypePreference,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.updateTypePreference).toHaveBeenCalled();
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({ driverId: 'driver-1', type: 'system_announcement' });
|
||||
});
|
||||
|
||||
it('UpdateQuietHoursUseCase validates hours and updates preferences', async () => {
|
||||
@@ -102,34 +140,56 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new UpdateQuietHoursUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
await useCase.execute({
|
||||
const command: UpdateQuietHoursCommand = {
|
||||
driverId: 'driver-1',
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7);
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
driverId: 'driver-1',
|
||||
startHour: 22,
|
||||
endHour: 7,
|
||||
});
|
||||
|
||||
expect(preference.updateQuietHours).toHaveBeenCalledWith(22, 7);
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
});
|
||||
|
||||
it('UpdateQuietHoursUseCase throws on invalid hours', async () => {
|
||||
it('UpdateQuietHoursUseCase returns error on invalid hours', async () => {
|
||||
const output: UseCaseOutputPort<UpdateQuietHoursResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new UpdateQuietHoursUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
logger,
|
||||
);
|
||||
|
||||
await expect(
|
||||
useCase.execute({ driverId: 'd1', startHour: -1, endHour: 10 }),
|
||||
).rejects.toThrow(NotificationDomainError);
|
||||
const badStart: UpdateQuietHoursCommand = { driverId: 'd1', startHour: -1, endHour: 10 };
|
||||
const result1 = await useCase.execute(badStart);
|
||||
expect(result1.isErr()).toBe(true);
|
||||
const err1 = result1.unwrapErr() as ApplicationErrorCode<'INVALID_START_HOUR', { message: string }>;
|
||||
expect(err1.code).toBe('INVALID_START_HOUR');
|
||||
|
||||
await expect(
|
||||
useCase.execute({ driverId: 'd1', startHour: 10, endHour: 24 }),
|
||||
).rejects.toThrow(NotificationDomainError);
|
||||
const badEnd: UpdateQuietHoursCommand = { driverId: 'd1', startHour: 10, endHour: 24 };
|
||||
const result2 = await useCase.execute(badEnd);
|
||||
expect(result2.isErr()).toBe(true);
|
||||
const err2 = result2.unwrapErr() as ApplicationErrorCode<'INVALID_END_HOUR', { message: string }>;
|
||||
expect(err2.code).toBe('INVALID_END_HOUR');
|
||||
});
|
||||
|
||||
it('SetDigestModeUseCase sets digest mode with valid frequency', async () => {
|
||||
@@ -139,27 +199,52 @@ describe('NotificationPreferencesUseCases', () => {
|
||||
|
||||
preferenceRepository.getOrCreateDefault.mockResolvedValue(preference);
|
||||
|
||||
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new SetDigestModeUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
);
|
||||
|
||||
await useCase.execute({
|
||||
const command: SetDigestModeCommand = {
|
||||
driverId: 'driver-1',
|
||||
enabled: true,
|
||||
frequencyHours: 4,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
|
||||
expect(result.isOk()).toBe(true);
|
||||
expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4);
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
expect(output.present).toHaveBeenCalledWith({
|
||||
driverId: 'driver-1',
|
||||
enabled: true,
|
||||
frequencyHours: 4,
|
||||
});
|
||||
|
||||
expect(preference.setDigestMode).toHaveBeenCalledWith(true, 4);
|
||||
expect(preferenceRepository.save).toHaveBeenCalledWith(preference);
|
||||
});
|
||||
|
||||
it('SetDigestModeUseCase throws on invalid frequency', async () => {
|
||||
it('SetDigestModeUseCase returns error on invalid frequency', async () => {
|
||||
const output: UseCaseOutputPort<SetDigestModeResult> & { present: Mock } = {
|
||||
present: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const useCase = new SetDigestModeUseCase(
|
||||
preferenceRepository as unknown as INotificationPreferenceRepository,
|
||||
output,
|
||||
);
|
||||
|
||||
await expect(
|
||||
useCase.execute({ driverId: 'driver-1', enabled: true, frequencyHours: 0 }),
|
||||
).rejects.toThrow(NotificationDomainError);
|
||||
const command: SetDigestModeCommand = {
|
||||
driverId: 'driver-1',
|
||||
enabled: true,
|
||||
frequencyHours: 0,
|
||||
};
|
||||
|
||||
const result = await useCase.execute(command);
|
||||
expect(result.isErr()).toBe(true);
|
||||
const err = result.unwrapErr() as ApplicationErrorCode<'INVALID_FREQUENCY', { message: string }>;
|
||||
expect(err.code).toBe('INVALID_FREQUENCY');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
* Manages user notification preferences.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase , Logger } from '@core/shared/application';
|
||||
import type { Logger, UseCaseOutputPort } from '@core/shared/application';
|
||||
import { Result } from '@core/shared/application/Result';
|
||||
import type { ApplicationErrorCode } from '@core/shared/errors/ApplicationErrorCode';
|
||||
import { NotificationPreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { ChannelPreference, TypePreference } from '../../domain/entities/NotificationPreference';
|
||||
import type { INotificationPreferenceRepository } from '../../domain/repositories/INotificationPreferenceRepository';
|
||||
@@ -14,21 +16,40 @@ import { NotificationDomainError } from '../../domain/errors/NotificationDomainE
|
||||
/**
|
||||
* Query: GetNotificationPreferencesQuery
|
||||
*/
|
||||
export class GetNotificationPreferencesQuery implements AsyncUseCase<string, NotificationPreference> {
|
||||
export interface GetNotificationPreferencesInput {
|
||||
driverId: string;
|
||||
}
|
||||
|
||||
export interface GetNotificationPreferencesResult {
|
||||
preference: NotificationPreference;
|
||||
}
|
||||
|
||||
export type GetNotificationPreferencesErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class GetNotificationPreferencesQuery {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<GetNotificationPreferencesResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(driverId: string): Promise<NotificationPreference> {
|
||||
async execute(
|
||||
input: GetNotificationPreferencesInput,
|
||||
): Promise<Result<void, ApplicationErrorCode<GetNotificationPreferencesErrorCode, { message: string }>>> {
|
||||
const { driverId } = input;
|
||||
this.logger.debug(`Fetching notification preferences for driver: ${driverId}`);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(driverId);
|
||||
this.logger.info(`Successfully fetched preferences for driver: ${driverId}`);
|
||||
return preferences;
|
||||
this.output.present({ preference: preferences });
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, error);
|
||||
throw error;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(`Failed to fetch preferences for driver: ${driverId}`, err);
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,22 +63,48 @@ export interface UpdateChannelPreferenceCommand {
|
||||
preference: ChannelPreference;
|
||||
}
|
||||
|
||||
export class UpdateChannelPreferenceUseCase implements AsyncUseCase<UpdateChannelPreferenceCommand, void> {
|
||||
export interface UpdateChannelPreferenceResult {
|
||||
driverId: string;
|
||||
channel: NotificationChannel;
|
||||
}
|
||||
|
||||
export type UpdateChannelPreferenceErrorCode =
|
||||
| 'REPOSITORY_ERROR';
|
||||
|
||||
export class UpdateChannelPreferenceUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateChannelPreferenceResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateChannelPreferenceCommand): Promise<void> {
|
||||
this.logger.debug(`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${command.preference}`);
|
||||
async execute(
|
||||
command: UpdateChannelPreferenceCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<UpdateChannelPreferenceErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Updating channel preference for driver: ${command.driverId}, channel: ${command.channel}, preference: ${JSON.stringify(command.preference)}`,
|
||||
);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(
|
||||
command.driverId,
|
||||
);
|
||||
const updated = preferences.updateChannel(command.channel, command.preference);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated channel preference for driver: ${command.driverId}`);
|
||||
this.logger.info(
|
||||
`Successfully updated channel preference for driver: ${command.driverId}`,
|
||||
);
|
||||
this.output.present({ driverId: command.driverId, channel: command.channel });
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`, error);
|
||||
throw error;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to update channel preference for driver: ${command.driverId}, channel: ${command.channel}`,
|
||||
err,
|
||||
);
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -71,22 +118,47 @@ export interface UpdateTypePreferenceCommand {
|
||||
preference: TypePreference;
|
||||
}
|
||||
|
||||
export class UpdateTypePreferenceUseCase implements AsyncUseCase<UpdateTypePreferenceCommand, void> {
|
||||
export interface UpdateTypePreferenceResult {
|
||||
driverId: string;
|
||||
type: NotificationType;
|
||||
}
|
||||
|
||||
export type UpdateTypePreferenceErrorCode = 'REPOSITORY_ERROR';
|
||||
|
||||
export class UpdateTypePreferenceUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateTypePreferenceResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateTypePreferenceCommand): Promise<void> {
|
||||
this.logger.debug(`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${command.preference}`);
|
||||
async execute(
|
||||
command: UpdateTypePreferenceCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<UpdateTypePreferenceErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Updating type preference for driver: ${command.driverId}, type: ${command.type}, preference: ${JSON.stringify(command.preference)}`,
|
||||
);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(
|
||||
command.driverId,
|
||||
);
|
||||
const updated = preferences.updateTypePreference(command.type, command.preference);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated type preference for driver: ${command.driverId}`);
|
||||
this.logger.info(
|
||||
`Successfully updated type preference for driver: ${command.driverId}`,
|
||||
);
|
||||
this.output.present({ driverId: command.driverId, type: command.type });
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`, error);
|
||||
throw error;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(
|
||||
`Failed to update type preference for driver: ${command.driverId}, type: ${command.type}`,
|
||||
err,
|
||||
);
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -100,32 +172,73 @@ export interface UpdateQuietHoursCommand {
|
||||
endHour: number | undefined;
|
||||
}
|
||||
|
||||
export class UpdateQuietHoursUseCase implements AsyncUseCase<UpdateQuietHoursCommand, void> {
|
||||
export interface UpdateQuietHoursResult {
|
||||
driverId: string;
|
||||
startHour: number | undefined;
|
||||
endHour: number | undefined;
|
||||
}
|
||||
|
||||
export type UpdateQuietHoursErrorCode =
|
||||
| 'INVALID_START_HOUR'
|
||||
| 'INVALID_END_HOUR'
|
||||
| 'REPOSITORY_ERROR';
|
||||
|
||||
export class UpdateQuietHoursUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<UpdateQuietHoursResult>,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateQuietHoursCommand): Promise<void> {
|
||||
this.logger.debug(`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`);
|
||||
async execute(
|
||||
command: UpdateQuietHoursCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<UpdateQuietHoursErrorCode, { message: string }>>> {
|
||||
this.logger.debug(
|
||||
`Updating quiet hours for driver: ${command.driverId}, startHour: ${command.startHour}, endHour: ${command.endHour}`,
|
||||
);
|
||||
try {
|
||||
// Validate hours if provided
|
||||
if (command.startHour !== undefined && (command.startHour < 0 || command.startHour > 23)) {
|
||||
this.logger.warn(`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`);
|
||||
throw new NotificationDomainError('Start hour must be between 0 and 23');
|
||||
this.logger.warn(
|
||||
`Invalid start hour provided for driver: ${command.driverId}. startHour: ${command.startHour}`,
|
||||
);
|
||||
return Result.err({
|
||||
code: 'INVALID_START_HOUR',
|
||||
details: { message: 'Start hour must be between 0 and 23' },
|
||||
});
|
||||
}
|
||||
if (command.endHour !== undefined && (command.endHour < 0 || command.endHour > 23)) {
|
||||
this.logger.warn(`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`);
|
||||
throw new NotificationDomainError('End hour must be between 0 and 23');
|
||||
this.logger.warn(
|
||||
`Invalid end hour provided for driver: ${command.driverId}. endHour: ${command.endHour}`,
|
||||
);
|
||||
return Result.err({
|
||||
code: 'INVALID_END_HOUR',
|
||||
details: { message: 'End hour must be between 0 and 23' },
|
||||
});
|
||||
}
|
||||
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(command.driverId);
|
||||
const updated = preferences.updateQuietHours(command.startHour, command.endHour);
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(
|
||||
command.driverId,
|
||||
);
|
||||
const updated = preferences.updateQuietHours(
|
||||
command.startHour,
|
||||
command.endHour,
|
||||
);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.logger.info(`Successfully updated quiet hours for driver: ${command.driverId}`);
|
||||
this.output.present({
|
||||
driverId: command.driverId,
|
||||
startHour: command.startHour,
|
||||
endHour: command.endHour,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, error);
|
||||
throw error;
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
this.logger.error(`Failed to update quiet hours for driver: ${command.driverId}`, err);
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,18 +252,53 @@ export interface SetDigestModeCommand {
|
||||
frequencyHours?: number;
|
||||
}
|
||||
|
||||
export class SetDigestModeUseCase implements AsyncUseCase<SetDigestModeCommand, void> {
|
||||
export interface SetDigestModeResult {
|
||||
driverId: string;
|
||||
enabled: boolean;
|
||||
frequencyHours?: number;
|
||||
}
|
||||
|
||||
export type SetDigestModeErrorCode =
|
||||
| 'INVALID_FREQUENCY'
|
||||
| 'REPOSITORY_ERROR';
|
||||
|
||||
export class SetDigestModeUseCase {
|
||||
constructor(
|
||||
private readonly preferenceRepository: INotificationPreferenceRepository,
|
||||
private readonly output: UseCaseOutputPort<SetDigestModeResult>,
|
||||
) {}
|
||||
|
||||
async execute(command: SetDigestModeCommand): Promise<void> {
|
||||
async execute(
|
||||
command: SetDigestModeCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<SetDigestModeErrorCode, { message: string }>>> {
|
||||
if (command.frequencyHours !== undefined && command.frequencyHours < 1) {
|
||||
throw new NotificationDomainError('Digest frequency must be at least 1 hour');
|
||||
return Result.err({
|
||||
code: 'INVALID_FREQUENCY',
|
||||
details: { message: '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);
|
||||
try {
|
||||
const preferences = await this.preferenceRepository.getOrCreateDefault(
|
||||
command.driverId,
|
||||
);
|
||||
const updated = preferences.setDigestMode(
|
||||
command.enabled,
|
||||
command.frequencyHours,
|
||||
);
|
||||
await this.preferenceRepository.save(updated);
|
||||
this.output.present({
|
||||
driverId: command.driverId,
|
||||
enabled: command.enabled,
|
||||
frequencyHours: command.frequencyHours,
|
||||
});
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
const err = error instanceof Error ? error : new Error(String(error));
|
||||
return Result.err({
|
||||
code: 'REPOSITORY_ERROR',
|
||||
details: { message: err.message },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,9 @@
|
||||
* based on their preferences.
|
||||
*/
|
||||
|
||||
import type { AsyncUseCase, Logger } from '@core/shared/application';
|
||||
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';
|
||||
@@ -43,17 +45,22 @@ export interface SendNotificationResult {
|
||||
deliveryResults: NotificationDeliveryResult[];
|
||||
}
|
||||
|
||||
export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCommand, SendNotificationResult> {
|
||||
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<SendNotificationResult> {
|
||||
async execute(
|
||||
command: SendNotificationCommand,
|
||||
): Promise<Result<void, ApplicationErrorCode<SendNotificationErrorCode, { message: string }>>> {
|
||||
this.logger.debug('Executing SendNotificationUseCase', { command });
|
||||
try {
|
||||
// Get recipient's preferences
|
||||
@@ -84,7 +91,8 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
|
||||
}
|
||||
|
||||
// Determine which channels to use
|
||||
const channels = command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
|
||||
const channels =
|
||||
command.forceChannels ?? preferences.getEnabledChannelsForType(command.type);
|
||||
|
||||
// Check quiet hours (skip external channels during quiet hours)
|
||||
const effectiveChannels = preferences.isInQuietHours()
|
||||
@@ -133,13 +141,19 @@ export class SendNotificationUseCase implements AsyncUseCase<SendNotificationCom
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
this.output.present({
|
||||
notification: primaryNotification!,
|
||||
deliveryResults,
|
||||
};
|
||||
});
|
||||
|
||||
return Result.ok(undefined);
|
||||
} catch (error) {
|
||||
this.logger.error('Error sending notification', error as Error);
|
||||
throw 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user