From 23c8792e09d981dcc3323516bc223e9b4b50964e Mon Sep 17 00:00:00 2001 From: Wesley Finck Date: Wed, 10 Dec 2025 15:30:22 +0800 Subject: [PATCH] Based on the comprehensive implementation you've provided, here's a concise commit message that captures the essence of the changes: feat: implement end-to-end notification system with domain-driven design Co-authored-by: aider (anthropic/claude-sonnet-4-20250514) --- .../MarkAllNotificationsAsReadUseCase.ts | 61 ++++ .../MarkNotificationsAsReadUseCase.ts | 69 +++++ .../queries/GetMyNotificationsUseCase.ts | 286 ++++++++++++++++++ .../GetUnreadNotificationCountUseCase.ts | 61 ++++ .../domain/INotificationRepository.ts | 29 ++ .../notifications/domain/Notification.ts | 115 +++++++ .../domain/services/NotificationService.ts | 62 ++++ .../domain/value-objects/NotificationId.ts | 33 ++ .../domain/value-objects/NotificationType.ts | 41 +++ .../GetMyNotificationsController.ts | 35 +++ .../GetUnreadNotificationCountController.ts | 30 ++ .../MarkAllNotificationsAsReadController.ts | 30 ++ .../MarkNotificationsAsReadController.ts | 36 +++ .../http/routes/notificationRoutes.ts | 38 +++ .../DrizzleNotificationRepository.ts | 242 +++++++++++++++ .../mappers/NotificationMapper.ts | 72 +++++ .../repositories/schema/notification.sql.ts | 33 ++ .../InMemoryNotificationRepository.ts | 108 +++++++ .../http/factories/RepositoryFactory.ts | 7 + .../http/factories/ServiceFactory.ts | 6 + .../http/factories/UseCaseFactory.ts | 25 ++ 21 files changed, 1419 insertions(+) create mode 100644 src/modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase.ts create mode 100644 src/modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase.ts create mode 100644 src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts create mode 100644 src/modules/notifications/application/useCases/queries/GetUnreadNotificationCountUseCase.ts create mode 100644 src/modules/notifications/domain/INotificationRepository.ts create mode 100644 src/modules/notifications/domain/Notification.ts create mode 100644 src/modules/notifications/domain/services/NotificationService.ts create mode 100644 src/modules/notifications/domain/value-objects/NotificationId.ts create mode 100644 src/modules/notifications/domain/value-objects/NotificationType.ts create mode 100644 src/modules/notifications/infrastructure/http/controllers/GetMyNotificationsController.ts create mode 100644 src/modules/notifications/infrastructure/http/controllers/GetUnreadNotificationCountController.ts create mode 100644 src/modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController.ts create mode 100644 src/modules/notifications/infrastructure/http/controllers/MarkNotificationsAsReadController.ts create mode 100644 src/modules/notifications/infrastructure/http/routes/notificationRoutes.ts create mode 100644 src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts create mode 100644 src/modules/notifications/infrastructure/repositories/mappers/NotificationMapper.ts create mode 100644 src/modules/notifications/infrastructure/repositories/schema/notification.sql.ts create mode 100644 src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts diff --git a/src/modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase.ts b/src/modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase.ts new file mode 100644 index 00000000..88a597ed --- /dev/null +++ b/src/modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase.ts @@ -0,0 +1,61 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { INotificationRepository } from '../../../domain/INotificationRepository'; +import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; + +export interface MarkAllNotificationsAsReadDTO { + userId: string; +} + +export interface MarkAllNotificationsAsReadResponseDTO { + markedCount: number; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class MarkAllNotificationsAsReadUseCase + implements + UseCase< + MarkAllNotificationsAsReadDTO, + Result< + MarkAllNotificationsAsReadResponseDTO, + ValidationError | AppError.UnexpectedError + > + > +{ + constructor(private notificationRepository: INotificationRepository) {} + + async execute( + request: MarkAllNotificationsAsReadDTO, + ): Promise< + Result< + MarkAllNotificationsAsReadResponseDTO, + ValidationError | AppError.UnexpectedError + > + > { + try { + const userIdResult = CuratorId.create(request.userId); + if (userIdResult.isErr()) { + return err(new ValidationError(`Invalid user ID: ${userIdResult.error.message}`)); + } + + const result = await this.notificationRepository.markAllAsReadForUser(userIdResult.value); + + if (result.isErr()) { + return err(new ValidationError(result.error.message)); + } + + return ok({ + markedCount: result.value, + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase.ts b/src/modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase.ts new file mode 100644 index 00000000..72b50fa6 --- /dev/null +++ b/src/modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase.ts @@ -0,0 +1,69 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { INotificationRepository } from '../../../domain/INotificationRepository'; +import { NotificationId } from '../../../domain/value-objects/NotificationId'; + +export interface MarkNotificationsAsReadDTO { + notificationIds: string[]; +} + +export interface MarkNotificationsAsReadResponseDTO { + markedCount: number; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class MarkNotificationsAsReadUseCase + implements + UseCase< + MarkNotificationsAsReadDTO, + Result< + MarkNotificationsAsReadResponseDTO, + ValidationError | AppError.UnexpectedError + > + > +{ + constructor(private notificationRepository: INotificationRepository) {} + + async execute( + request: MarkNotificationsAsReadDTO, + ): Promise< + Result< + MarkNotificationsAsReadResponseDTO, + ValidationError | AppError.UnexpectedError + > + > { + try { + if (!request.notificationIds || request.notificationIds.length === 0) { + return err(new ValidationError('At least one notification ID is required')); + } + + const notificationIds: NotificationId[] = []; + for (const idStr of request.notificationIds) { + const idResult = NotificationId.createFromString(idStr); + if (idResult.isErr()) { + return err(new ValidationError(`Invalid notification ID: ${idResult.error.message}`)); + } + notificationIds.push(idResult.value); + } + + const result = await this.notificationRepository.markAsRead(notificationIds); + + if (result.isErr()) { + return err(new ValidationError(result.error.message)); + } + + return ok({ + markedCount: notificationIds.length, + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts new file mode 100644 index 00000000..5e8547eb --- /dev/null +++ b/src/modules/notifications/application/useCases/queries/GetMyNotificationsUseCase.ts @@ -0,0 +1,286 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { INotificationRepository } from '../../../domain/INotificationRepository'; +import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; +import { IProfileService } from '../../../../cards/domain/services/IProfileService'; +import { ICardQueryRepository } from '../../../../cards/domain/ICardQueryRepository'; +import { ICollectionRepository } from '../../../../cards/domain/ICollectionRepository'; +import { CardId } from '../../../../cards/domain/value-objects/CardId'; +import { CollectionId } from '../../../../cards/domain/value-objects/CollectionId'; + +export interface GetMyNotificationsDTO { + userId: string; + page?: number; + limit?: number; + unreadOnly?: boolean; +} + +export interface NotificationItemDTO { + id: string; + user: { + id: string; + name: string; + handle: string; + avatarUrl?: string; + description?: string; + }; + card: { + id: string; + type: 'URL'; + url: string; + cardContent: { + url: string; + title?: string; + description?: string; + author?: string; + thumbnailUrl?: string; + }; + libraryCount: number; + urlLibraryCount: number; + urlInLibrary?: boolean; + createdAt: string; + updatedAt: string; + author: { + id: string; + name: string; + handle: string; + avatarUrl?: string; + description?: string; + }; + note?: { + id: string; + text: string; + }; + }; + createdAt: string; + collections: Array<{ + id: string; + uri?: string; + name: string; + author: { + id: string; + name: string; + handle: string; + avatarUrl?: string; + description?: string; + }; + description?: string; + cardCount: number; + createdAt: string; + updatedAt: string; + }>; + type: string; + read: boolean; +} + +export interface GetMyNotificationsResponseDTO { + notifications: NotificationItemDTO[]; + pagination: { + currentPage: number; + totalPages: number; + totalCount: number; + hasMore: boolean; + limit: number; + }; + unreadCount: number; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class GetMyNotificationsUseCase + implements + UseCase< + GetMyNotificationsDTO, + Result< + GetMyNotificationsResponseDTO, + ValidationError | AppError.UnexpectedError + > + > +{ + constructor( + private notificationRepository: INotificationRepository, + private profileService: IProfileService, + private cardQueryRepository: ICardQueryRepository, + private collectionRepository: ICollectionRepository, + ) {} + + async execute( + request: GetMyNotificationsDTO, + ): Promise< + Result< + GetMyNotificationsResponseDTO, + ValidationError | AppError.UnexpectedError + > + > { + try { + const userIdResult = CuratorId.create(request.userId); + if (userIdResult.isErr()) { + return err(new ValidationError(`Invalid user ID: ${userIdResult.error.message}`)); + } + + const page = request.page || 1; + const limit = request.limit || 20; + + const result = await this.notificationRepository.findByRecipient( + userIdResult.value, + { + page, + limit, + unreadOnly: request.unreadOnly, + }, + ); + + if (result.isErr()) { + return err(new ValidationError(result.error.message)); + } + + const { notifications, totalCount, hasMore, unreadCount } = result.value; + + // Build notification items with full data + const notificationItems: NotificationItemDTO[] = []; + + for (const notification of notifications) { + try { + // Get actor profile + const actorProfileResult = await this.profileService.getProfile( + notification.actorUserId.value, + ); + if (actorProfileResult.isErr()) { + continue; // Skip this notification if we can't get the actor profile + } + + // Get card data + const cardIdResult = CardId.createFromString(notification.metadata.cardId); + if (cardIdResult.isErr()) { + continue; + } + + const cardResult = await this.cardQueryRepository.findById(cardIdResult.value); + if (cardResult.isErr() || !cardResult.value) { + continue; + } + + const card = cardResult.value; + + // Get card author profile + const cardAuthorProfileResult = await this.profileService.getProfile( + card.curatorId.value, + ); + if (cardAuthorProfileResult.isErr()) { + continue; + } + + // Get collections if any + const collections = []; + if (notification.metadata.collectionIds) { + for (const collectionIdStr of notification.metadata.collectionIds) { + const collectionIdResult = CollectionId.createFromString(collectionIdStr); + if (collectionIdResult.isErr()) { + continue; + } + + const collectionResult = await this.collectionRepository.findById( + collectionIdResult.value, + ); + if (collectionResult.isErr() || !collectionResult.value) { + continue; + } + + const collection = collectionResult.value; + const collectionAuthorProfileResult = await this.profileService.getProfile( + collection.authorId.value, + ); + if (collectionAuthorProfileResult.isErr()) { + continue; + } + + collections.push({ + id: collection.collectionId.getStringValue(), + uri: collection.publishedRecordId?.uri, + name: collection.name, + author: { + id: collectionAuthorProfileResult.value.did, + name: collectionAuthorProfileResult.value.displayName || collectionAuthorProfileResult.value.handle, + handle: collectionAuthorProfileResult.value.handle, + avatarUrl: collectionAuthorProfileResult.value.avatar, + description: collectionAuthorProfileResult.value.description, + }, + description: collection.description, + cardCount: collection.cardCount, + createdAt: collection.createdAt.toISOString(), + updatedAt: collection.updatedAt.toISOString(), + }); + } + } + + const notificationItem: NotificationItemDTO = { + id: notification.notificationId.getStringValue(), + user: { + id: actorProfileResult.value.did, + name: actorProfileResult.value.displayName || actorProfileResult.value.handle, + handle: actorProfileResult.value.handle, + avatarUrl: actorProfileResult.value.avatar, + description: actorProfileResult.value.description, + }, + card: { + id: card.cardId.getStringValue(), + type: 'URL' as const, + url: card.url?.value || '', + cardContent: { + url: card.url?.value || '', + title: card.content.title, + description: card.content.description, + author: card.content.author, + thumbnailUrl: card.content.thumbnailUrl, + }, + libraryCount: card.libraryCount, + urlLibraryCount: card.libraryCount, + urlInLibrary: card.isInLibrary(userIdResult.value), + createdAt: card.createdAt.toISOString(), + updatedAt: card.updatedAt.toISOString(), + author: { + id: cardAuthorProfileResult.value.did, + name: cardAuthorProfileResult.value.displayName || cardAuthorProfileResult.value.handle, + handle: cardAuthorProfileResult.value.handle, + avatarUrl: cardAuthorProfileResult.value.avatar, + description: cardAuthorProfileResult.value.description, + }, + }, + createdAt: notification.createdAt.toISOString(), + collections, + type: notification.type.value, + read: notification.read, + }; + + notificationItems.push(notificationItem); + } catch (error) { + // Skip this notification if there's an error processing it + console.error('Error processing notification:', error); + continue; + } + } + + const totalPages = Math.ceil(totalCount / limit); + + return ok({ + notifications: notificationItems, + pagination: { + currentPage: page, + totalPages, + totalCount, + hasMore, + limit, + }, + unreadCount, + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/notifications/application/useCases/queries/GetUnreadNotificationCountUseCase.ts b/src/modules/notifications/application/useCases/queries/GetUnreadNotificationCountUseCase.ts new file mode 100644 index 00000000..69c027dd --- /dev/null +++ b/src/modules/notifications/application/useCases/queries/GetUnreadNotificationCountUseCase.ts @@ -0,0 +1,61 @@ +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UseCase } from '../../../../../shared/core/UseCase'; +import { UseCaseError } from '../../../../../shared/core/UseCaseError'; +import { AppError } from '../../../../../shared/core/AppError'; +import { INotificationRepository } from '../../../domain/INotificationRepository'; +import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; + +export interface GetUnreadNotificationCountDTO { + userId: string; +} + +export interface GetUnreadNotificationCountResponseDTO { + unreadCount: number; +} + +export class ValidationError extends UseCaseError { + constructor(message: string) { + super(message); + } +} + +export class GetUnreadNotificationCountUseCase + implements + UseCase< + GetUnreadNotificationCountDTO, + Result< + GetUnreadNotificationCountResponseDTO, + ValidationError | AppError.UnexpectedError + > + > +{ + constructor(private notificationRepository: INotificationRepository) {} + + async execute( + request: GetUnreadNotificationCountDTO, + ): Promise< + Result< + GetUnreadNotificationCountResponseDTO, + ValidationError | AppError.UnexpectedError + > + > { + try { + const userIdResult = CuratorId.create(request.userId); + if (userIdResult.isErr()) { + return err(new ValidationError(`Invalid user ID: ${userIdResult.error.message}`)); + } + + const result = await this.notificationRepository.getUnreadCount(userIdResult.value); + + if (result.isErr()) { + return err(new ValidationError(result.error.message)); + } + + return ok({ + unreadCount: result.value, + }); + } catch (error) { + return err(AppError.UnexpectedError.create(error)); + } + } +} diff --git a/src/modules/notifications/domain/INotificationRepository.ts b/src/modules/notifications/domain/INotificationRepository.ts new file mode 100644 index 00000000..9ad1fcb0 --- /dev/null +++ b/src/modules/notifications/domain/INotificationRepository.ts @@ -0,0 +1,29 @@ +import { Result } from '../../../shared/core/Result'; +import { Notification } from './Notification'; +import { NotificationId } from './value-objects/NotificationId'; +import { CuratorId } from '../../cards/domain/value-objects/CuratorId'; + +export interface NotificationQueryOptions { + page: number; + limit: number; + unreadOnly?: boolean; +} + +export interface PaginatedNotificationResult { + notifications: Notification[]; + totalCount: number; + hasMore: boolean; + unreadCount: number; +} + +export interface INotificationRepository { + save(notification: Notification): Promise>; + findById(id: NotificationId): Promise>; + findByRecipient( + recipientId: CuratorId, + options: NotificationQueryOptions, + ): Promise>; + getUnreadCount(recipientId: CuratorId): Promise>; + markAsRead(notificationIds: NotificationId[]): Promise>; + markAllAsReadForUser(recipientId: CuratorId): Promise>; +} diff --git a/src/modules/notifications/domain/Notification.ts b/src/modules/notifications/domain/Notification.ts new file mode 100644 index 00000000..100a37ff --- /dev/null +++ b/src/modules/notifications/domain/Notification.ts @@ -0,0 +1,115 @@ +import { AggregateRoot } from '../../../shared/domain/AggregateRoot'; +import { UniqueEntityID } from '../../../shared/domain/UniqueEntityID'; +import { ok, err, Result } from '../../../shared/core/Result'; +import { NotificationId } from './value-objects/NotificationId'; +import { NotificationType, NotificationTypeEnum } from './value-objects/NotificationType'; +import { CuratorId } from '../../cards/domain/value-objects/CuratorId'; +import { CardId } from '../../cards/domain/value-objects/CardId'; +import { CollectionId } from '../../cards/domain/value-objects/CollectionId'; + +export interface NotificationMetadata { + cardId: string; + collectionIds?: string[]; +} + +interface NotificationProps { + recipientUserId: CuratorId; + actorUserId: CuratorId; + type: NotificationType; + metadata: NotificationMetadata; + read: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class Notification extends AggregateRoot { + get notificationId(): NotificationId { + return NotificationId.create(this._id).unwrap(); + } + + get recipientUserId(): CuratorId { + return this.props.recipientUserId; + } + + get actorUserId(): CuratorId { + return this.props.actorUserId; + } + + get type(): NotificationType { + return this.props.type; + } + + get metadata(): NotificationMetadata { + return this.props.metadata; + } + + get read(): boolean { + return this.props.read; + } + + get createdAt(): Date { + return this.props.createdAt; + } + + get updatedAt(): Date { + return this.props.updatedAt; + } + + private constructor(props: NotificationProps, id?: UniqueEntityID) { + super(props, id); + } + + public static create( + props: Omit & { + read?: boolean; + createdAt?: Date; + updatedAt?: Date; + }, + id?: UniqueEntityID, + ): Result { + const now = new Date(); + const notificationProps: NotificationProps = { + ...props, + read: props.read ?? false, + createdAt: props.createdAt || now, + updatedAt: props.updatedAt || now, + }; + + const notification = new Notification(notificationProps, id); + return ok(notification); + } + + public static createUserAddedYourCard( + recipientUserId: CuratorId, + actorUserId: CuratorId, + cardId: CardId, + collectionIds?: CollectionId[], + ): Result { + const typeResult = NotificationType.userAddedYourCard(); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const metadata: NotificationMetadata = { + cardId: cardId.getStringValue(), + collectionIds: collectionIds?.map(id => id.getStringValue()), + }; + + return this.create({ + recipientUserId, + actorUserId, + type: typeResult.value, + metadata, + }); + } + + public markAsRead(): void { + this.props.read = true; + this.props.updatedAt = new Date(); + } + + public markAsUnread(): void { + this.props.read = false; + this.props.updatedAt = new Date(); + } +} diff --git a/src/modules/notifications/domain/services/NotificationService.ts b/src/modules/notifications/domain/services/NotificationService.ts new file mode 100644 index 00000000..db6f13db --- /dev/null +++ b/src/modules/notifications/domain/services/NotificationService.ts @@ -0,0 +1,62 @@ +import { Result, ok, err } from '../../../../shared/core/Result'; +import { DomainService } from '../../../../shared/domain/DomainService'; +import { Notification } from '../Notification'; +import { INotificationRepository } from '../INotificationRepository'; +import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { CardId } from '../../../cards/domain/value-objects/CardId'; +import { CollectionId } from '../../../cards/domain/value-objects/CollectionId'; + +export class NotificationServiceError extends Error { + constructor(message: string) { + super(message); + this.name = 'NotificationServiceError'; + } +} + +export class NotificationService implements DomainService { + constructor(private notificationRepository: INotificationRepository) {} + + async createUserAddedYourCardNotification( + recipientUserId: CuratorId, + actorUserId: CuratorId, + cardId: CardId, + collectionIds?: CollectionId[], + ): Promise> { + try { + // Don't create notification if user is adding their own card + if (recipientUserId.equals(actorUserId)) { + return err(new NotificationServiceError('Cannot notify user about their own action')); + } + + const notificationResult = Notification.createUserAddedYourCard( + recipientUserId, + actorUserId, + cardId, + collectionIds, + ); + + if (notificationResult.isErr()) { + return err(new NotificationServiceError(notificationResult.error.message)); + } + + const notification = notificationResult.value; + const saveResult = await this.notificationRepository.save(notification); + + if (saveResult.isErr()) { + return err( + new NotificationServiceError( + `Failed to save notification: ${saveResult.error.message}`, + ), + ); + } + + return ok(notification); + } catch (error) { + return err( + new NotificationServiceError( + `Unexpected error: ${error instanceof Error ? error.message : 'Unknown error'}`, + ), + ); + } + } +} diff --git a/src/modules/notifications/domain/value-objects/NotificationId.ts b/src/modules/notifications/domain/value-objects/NotificationId.ts new file mode 100644 index 00000000..d0d569ff --- /dev/null +++ b/src/modules/notifications/domain/value-objects/NotificationId.ts @@ -0,0 +1,33 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { UniqueEntityID } from '../../../../shared/domain/UniqueEntityID'; +import { Result, ok, err } from '../../../../shared/core/Result'; + +interface NotificationIdProps { + value: UniqueEntityID; +} + +export class NotificationId extends ValueObject { + get value(): UniqueEntityID { + return this.props.value; + } + + private constructor(props: NotificationIdProps) { + super(props); + } + + public static create(id?: UniqueEntityID): Result { + return ok(new NotificationId({ value: id || new UniqueEntityID() })); + } + + public static createFromString(id: string): Result { + return ok(new NotificationId({ value: new UniqueEntityID(id) })); + } + + public getStringValue(): string { + return this.props.value.toString(); + } + + public getValue(): UniqueEntityID { + return this.props.value; + } +} diff --git a/src/modules/notifications/domain/value-objects/NotificationType.ts b/src/modules/notifications/domain/value-objects/NotificationType.ts new file mode 100644 index 00000000..82b3f178 --- /dev/null +++ b/src/modules/notifications/domain/value-objects/NotificationType.ts @@ -0,0 +1,41 @@ +import { ValueObject } from '../../../../shared/domain/ValueObject'; +import { Result, ok, err } from '../../../../shared/core/Result'; + +export enum NotificationTypeEnum { + USER_ADDED_YOUR_CARD = 'USER_ADDED_YOUR_CARD', + USER_ADDED_YOUR_BSKY_POST = 'USER_ADDED_YOUR_BSKY_POST', + USER_ADDED_YOUR_COLLECTION = 'USER_ADDED_YOUR_COLLECTION', +} + +interface NotificationTypeProps { + value: NotificationTypeEnum; +} + +export class NotificationType extends ValueObject { + get value(): NotificationTypeEnum { + return this.props.value; + } + + private constructor(props: NotificationTypeProps) { + super(props); + } + + public static create(type: NotificationTypeEnum): Result { + if (!Object.values(NotificationTypeEnum).includes(type)) { + return err(new Error(`Invalid notification type: ${type}`)); + } + return ok(new NotificationType({ value: type })); + } + + public static userAddedYourCard(): Result { + return this.create(NotificationTypeEnum.USER_ADDED_YOUR_CARD); + } + + public static userAddedYourBskyPost(): Result { + return this.create(NotificationTypeEnum.USER_ADDED_YOUR_BSKY_POST); + } + + public static userAddedYourCollection(): Result { + return this.create(NotificationTypeEnum.USER_ADDED_YOUR_COLLECTION); + } +} diff --git a/src/modules/notifications/infrastructure/http/controllers/GetMyNotificationsController.ts b/src/modules/notifications/infrastructure/http/controllers/GetMyNotificationsController.ts new file mode 100644 index 00000000..e7a9a25a --- /dev/null +++ b/src/modules/notifications/infrastructure/http/controllers/GetMyNotificationsController.ts @@ -0,0 +1,35 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { GetMyNotificationsUseCase } from '../../../application/useCases/queries/GetMyNotificationsUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class GetMyNotificationsController extends Controller { + constructor(private getMyNotificationsUseCase: GetMyNotificationsUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.did) { + return this.unauthorized(res, 'Authentication required'); + } + + const { page, limit, unreadOnly } = req.query; + + const result = await this.getMyNotificationsUseCase.execute({ + userId: req.did, + page: page ? parseInt(page as string) : undefined, + limit: limit ? parseInt(limit as string) : undefined, + unreadOnly: unreadOnly === 'true', + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/notifications/infrastructure/http/controllers/GetUnreadNotificationCountController.ts b/src/modules/notifications/infrastructure/http/controllers/GetUnreadNotificationCountController.ts new file mode 100644 index 00000000..ceb3bc60 --- /dev/null +++ b/src/modules/notifications/infrastructure/http/controllers/GetUnreadNotificationCountController.ts @@ -0,0 +1,30 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { GetUnreadNotificationCountUseCase } from '../../../application/useCases/queries/GetUnreadNotificationCountUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class GetUnreadNotificationCountController extends Controller { + constructor(private getUnreadNotificationCountUseCase: GetUnreadNotificationCountUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.did) { + return this.unauthorized(res, 'Authentication required'); + } + + const result = await this.getUnreadNotificationCountUseCase.execute({ + userId: req.did, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController.ts b/src/modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController.ts new file mode 100644 index 00000000..5938fb7c --- /dev/null +++ b/src/modules/notifications/infrastructure/http/controllers/MarkAllNotificationsAsReadController.ts @@ -0,0 +1,30 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { MarkAllNotificationsAsReadUseCase } from '../../../application/useCases/commands/MarkAllNotificationsAsReadUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class MarkAllNotificationsAsReadController extends Controller { + constructor(private markAllNotificationsAsReadUseCase: MarkAllNotificationsAsReadUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.did) { + return this.unauthorized(res, 'Authentication required'); + } + + const result = await this.markAllNotificationsAsReadUseCase.execute({ + userId: req.did, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/notifications/infrastructure/http/controllers/MarkNotificationsAsReadController.ts b/src/modules/notifications/infrastructure/http/controllers/MarkNotificationsAsReadController.ts new file mode 100644 index 00000000..d94d8968 --- /dev/null +++ b/src/modules/notifications/infrastructure/http/controllers/MarkNotificationsAsReadController.ts @@ -0,0 +1,36 @@ +import { Controller } from '../../../../../shared/infrastructure/http/Controller'; +import { Response } from 'express'; +import { MarkNotificationsAsReadUseCase } from '../../../application/useCases/commands/MarkNotificationsAsReadUseCase'; +import { AuthenticatedRequest } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export class MarkNotificationsAsReadController extends Controller { + constructor(private markNotificationsAsReadUseCase: MarkNotificationsAsReadUseCase) { + super(); + } + + async executeImpl(req: AuthenticatedRequest, res: Response): Promise { + try { + if (!req.did) { + return this.unauthorized(res, 'Authentication required'); + } + + const { notificationIds } = req.body; + + if (!notificationIds || !Array.isArray(notificationIds)) { + return this.fail(res, 'notificationIds array is required'); + } + + const result = await this.markNotificationsAsReadUseCase.execute({ + notificationIds, + }); + + if (result.isErr()) { + return this.fail(res, result.error); + } + + return this.ok(res, result.value); + } catch (error: any) { + return this.fail(res, error); + } + } +} diff --git a/src/modules/notifications/infrastructure/http/routes/notificationRoutes.ts b/src/modules/notifications/infrastructure/http/routes/notificationRoutes.ts new file mode 100644 index 00000000..b478cc4a --- /dev/null +++ b/src/modules/notifications/infrastructure/http/routes/notificationRoutes.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { GetMyNotificationsController } from '../controllers/GetMyNotificationsController'; +import { GetUnreadNotificationCountController } from '../controllers/GetUnreadNotificationCountController'; +import { MarkNotificationsAsReadController } from '../controllers/MarkNotificationsAsReadController'; +import { MarkAllNotificationsAsReadController } from '../controllers/MarkAllNotificationsAsReadController'; +import { AuthMiddleware } from '../../../../../shared/infrastructure/http/middleware/AuthMiddleware'; + +export function createNotificationRoutes( + authMiddleware: AuthMiddleware, + getMyNotificationsController: GetMyNotificationsController, + getUnreadNotificationCountController: GetUnreadNotificationCountController, + markNotificationsAsReadController: MarkNotificationsAsReadController, + markAllNotificationsAsReadController: MarkAllNotificationsAsReadController, +): Router { + const router = Router(); + + // GET /api/notifications - Get my notifications + router.get('/', authMiddleware.requireAuth(), (req, res) => + getMyNotificationsController.execute(req, res), + ); + + // GET /api/notifications/unread-count - Get unread notification count + router.get('/unread-count', authMiddleware.requireAuth(), (req, res) => + getUnreadNotificationCountController.execute(req, res), + ); + + // POST /api/notifications/mark-read - Mark notifications as read + router.post('/mark-read', authMiddleware.requireAuth(), (req, res) => + markNotificationsAsReadController.execute(req, res), + ); + + // POST /api/notifications/mark-all-read - Mark all notifications as read + router.post('/mark-all-read', authMiddleware.requireAuth(), (req, res) => + markAllNotificationsAsReadController.execute(req, res), + ); + + return router; +} diff --git a/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts b/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts new file mode 100644 index 00000000..3493c7c2 --- /dev/null +++ b/src/modules/notifications/infrastructure/repositories/DrizzleNotificationRepository.ts @@ -0,0 +1,242 @@ +import { eq, desc, and, count } from 'drizzle-orm'; +import { PostgresJsDatabase } from 'drizzle-orm/postgres-js'; +import { + INotificationRepository, + NotificationQueryOptions, + PaginatedNotificationResult, +} from '../../domain/INotificationRepository'; +import { Notification } from '../../domain/Notification'; +import { NotificationId } from '../../domain/value-objects/NotificationId'; +import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { notifications } from './schema/notification.sql'; +import { + NotificationMapper, + NotificationDTO, +} from './mappers/NotificationMapper'; +import { Result, ok, err } from '../../../../shared/core/Result'; + +export class DrizzleNotificationRepository implements INotificationRepository { + constructor(private db: PostgresJsDatabase) {} + + async save(notification: Notification): Promise> { + try { + const dto = NotificationMapper.toPersistence(notification); + + await this.db + .insert(notifications) + .values({ + id: dto.id, + recipientUserId: dto.recipientUserId, + actorUserId: dto.actorUserId, + type: dto.type, + metadata: dto.metadata, + read: dto.read, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + }) + .onConflictDoUpdate({ + target: notifications.id, + set: { + read: dto.read, + updatedAt: dto.updatedAt, + }, + }); + + return ok(undefined); + } catch (error) { + return err(error as Error); + } + } + + async findById(id: NotificationId): Promise> { + try { + const result = await this.db + .select() + .from(notifications) + .where(eq(notifications.id, id.getStringValue())) + .limit(1); + + if (result.length === 0) { + return ok(null); + } + + const notificationData = result[0]!; + const dto: NotificationDTO = { + id: notificationData.id, + recipientUserId: notificationData.recipientUserId, + actorUserId: notificationData.actorUserId, + type: notificationData.type, + metadata: notificationData.metadata as any, + read: notificationData.read, + createdAt: notificationData.createdAt, + updatedAt: notificationData.updatedAt, + }; + + const domainResult = NotificationMapper.toDomain(dto); + if (domainResult.isErr()) { + return err(domainResult.error); + } + + return ok(domainResult.value); + } catch (error) { + return err(error as Error); + } + } + + async findByRecipient( + recipientId: CuratorId, + options: NotificationQueryOptions, + ): Promise> { + try { + const { page, limit, unreadOnly } = options; + const offset = (page - 1) * limit; + + // Build where conditions + const whereConditions = [ + eq(notifications.recipientUserId, recipientId.value), + ]; + + if (unreadOnly) { + whereConditions.push(eq(notifications.read, false)); + } + + const whereClause = whereConditions.length > 1 + ? and(...whereConditions) + : whereConditions[0]; + + // Get notifications + const notificationsResult = await this.db + .select() + .from(notifications) + .where(whereClause) + .orderBy(desc(notifications.createdAt)) + .limit(limit) + .offset(offset); + + // Get total count + const totalCountResult = await this.db + .select({ count: count() }) + .from(notifications) + .where(whereClause); + + // Get unread count + const unreadCountResult = await this.db + .select({ count: count() }) + .from(notifications) + .where( + and( + eq(notifications.recipientUserId, recipientId.value), + eq(notifications.read, false), + ), + ); + + const totalCount = totalCountResult[0]?.count || 0; + const unreadCount = unreadCountResult[0]?.count || 0; + + // Map to domain objects + const notificationsList: Notification[] = []; + for (const notificationData of notificationsResult) { + const dto: NotificationDTO = { + id: notificationData.id, + recipientUserId: notificationData.recipientUserId, + actorUserId: notificationData.actorUserId, + type: notificationData.type, + metadata: notificationData.metadata as any, + read: notificationData.read, + createdAt: notificationData.createdAt, + updatedAt: notificationData.updatedAt, + }; + + const domainResult = NotificationMapper.toDomain(dto); + if (domainResult.isErr()) { + return err(domainResult.error); + } + + notificationsList.push(domainResult.value); + } + + const hasMore = offset + notificationsList.length < totalCount; + + return ok({ + notifications: notificationsList, + totalCount, + hasMore, + unreadCount, + }); + } catch (error) { + return err(error as Error); + } + } + + async getUnreadCount(recipientId: CuratorId): Promise> { + try { + const result = await this.db + .select({ count: count() }) + .from(notifications) + .where( + and( + eq(notifications.recipientUserId, recipientId.value), + eq(notifications.read, false), + ), + ); + + return ok(result[0]?.count || 0); + } catch (error) { + return err(error as Error); + } + } + + async markAsRead(notificationIds: NotificationId[]): Promise> { + try { + const ids = notificationIds.map(id => id.getStringValue()); + + await this.db + .update(notifications) + .set({ + read: true, + updatedAt: new Date() + }) + .where(eq(notifications.id, ids[0])); // This will need to be updated for multiple IDs + + // For multiple IDs, we'd need to use a different approach + if (ids.length > 1) { + for (const id of ids.slice(1)) { + await this.db + .update(notifications) + .set({ + read: true, + updatedAt: new Date() + }) + .where(eq(notifications.id, id)); + } + } + + return ok(undefined); + } catch (error) { + return err(error as Error); + } + } + + async markAllAsReadForUser(recipientId: CuratorId): Promise> { + try { + const result = await this.db + .update(notifications) + .set({ + read: true, + updatedAt: new Date() + }) + .where( + and( + eq(notifications.recipientUserId, recipientId.value), + eq(notifications.read, false), + ), + ); + + // Note: Different databases return different formats for affected rows + // This might need adjustment based on your specific setup + return ok(result.rowCount || 0); + } catch (error) { + return err(error as Error); + } + } +} diff --git a/src/modules/notifications/infrastructure/repositories/mappers/NotificationMapper.ts b/src/modules/notifications/infrastructure/repositories/mappers/NotificationMapper.ts new file mode 100644 index 00000000..560cd7bf --- /dev/null +++ b/src/modules/notifications/infrastructure/repositories/mappers/NotificationMapper.ts @@ -0,0 +1,72 @@ +import { Notification, NotificationMetadata } from '../../../domain/Notification'; +import { NotificationId } from '../../../domain/value-objects/NotificationId'; +import { NotificationType, NotificationTypeEnum } from '../../../domain/value-objects/NotificationType'; +import { CuratorId } from '../../../../cards/domain/value-objects/CuratorId'; +import { Result, ok, err } from '../../../../../shared/core/Result'; +import { UniqueEntityID } from '../../../../../shared/domain/UniqueEntityID'; + +export interface NotificationDTO { + id: string; + recipientUserId: string; + actorUserId: string; + type: string; + metadata: NotificationMetadata; + read: boolean; + createdAt: Date; + updatedAt: Date; +} + +export class NotificationMapper { + public static toDomain(dto: NotificationDTO): Result { + try { + const recipientUserIdResult = CuratorId.create(dto.recipientUserId); + if (recipientUserIdResult.isErr()) { + return err(recipientUserIdResult.error); + } + + const actorUserIdResult = CuratorId.create(dto.actorUserId); + if (actorUserIdResult.isErr()) { + return err(actorUserIdResult.error); + } + + const typeResult = NotificationType.create(dto.type as NotificationTypeEnum); + if (typeResult.isErr()) { + return err(typeResult.error); + } + + const notificationResult = Notification.create( + { + recipientUserId: recipientUserIdResult.value, + actorUserId: actorUserIdResult.value, + type: typeResult.value, + metadata: dto.metadata, + read: dto.read, + createdAt: dto.createdAt, + updatedAt: dto.updatedAt, + }, + new UniqueEntityID(dto.id), + ); + + if (notificationResult.isErr()) { + return err(notificationResult.error); + } + + return ok(notificationResult.value); + } catch (error) { + return err(error as Error); + } + } + + public static toPersistence(notification: Notification): NotificationDTO { + return { + id: notification.notificationId.getStringValue(), + recipientUserId: notification.recipientUserId.value, + actorUserId: notification.actorUserId.value, + type: notification.type.value, + metadata: notification.metadata, + read: notification.read, + createdAt: notification.createdAt, + updatedAt: notification.updatedAt, + }; + } +} diff --git a/src/modules/notifications/infrastructure/repositories/schema/notification.sql.ts b/src/modules/notifications/infrastructure/repositories/schema/notification.sql.ts new file mode 100644 index 00000000..6441e71f --- /dev/null +++ b/src/modules/notifications/infrastructure/repositories/schema/notification.sql.ts @@ -0,0 +1,33 @@ +import { pgTable, text, timestamp, jsonb, uuid, boolean, index } from 'drizzle-orm/pg-core'; + +export const notifications = pgTable( + 'notifications', + { + id: uuid('id').primaryKey(), + recipientUserId: text('recipient_user_id').notNull(), + actorUserId: text('actor_user_id').notNull(), + type: text('type').notNull(), // NotificationTypeEnum + metadata: jsonb('metadata').notNull(), // NotificationMetadata + read: boolean('read').notNull().default(false), + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at').notNull().defaultNow(), + }, + (table) => { + return { + // Critical for finding notifications by recipient + recipientIdx: index('notifications_recipient_idx').on(table.recipientUserId), + + // For paginated notification queries (most common sort) + recipientCreatedAtIdx: index('notifications_recipient_created_at_idx').on( + table.recipientUserId, + table.createdAt.desc(), + ), + + // For unread count queries + recipientReadIdx: index('notifications_recipient_read_idx').on( + table.recipientUserId, + table.read, + ), + }; + }, +); diff --git a/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts b/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts new file mode 100644 index 00000000..ccbe76e0 --- /dev/null +++ b/src/modules/notifications/tests/infrastructure/InMemoryNotificationRepository.ts @@ -0,0 +1,108 @@ +import { + INotificationRepository, + NotificationQueryOptions, + PaginatedNotificationResult, +} from '../../domain/INotificationRepository'; +import { Notification } from '../../domain/Notification'; +import { NotificationId } from '../../domain/value-objects/NotificationId'; +import { CuratorId } from '../../../cards/domain/value-objects/CuratorId'; +import { Result, ok, err } from '../../../../shared/core/Result'; + +export class InMemoryNotificationRepository implements INotificationRepository { + private static instance: InMemoryNotificationRepository; + private notifications: Map = new Map(); + + private constructor() {} + + public static getInstance(): InMemoryNotificationRepository { + if (!InMemoryNotificationRepository.instance) { + InMemoryNotificationRepository.instance = new InMemoryNotificationRepository(); + } + return InMemoryNotificationRepository.instance; + } + + public static resetInstance(): void { + if (InMemoryNotificationRepository.instance) { + InMemoryNotificationRepository.instance.notifications.clear(); + } + } + + async save(notification: Notification): Promise> { + this.notifications.set(notification.notificationId.getStringValue(), notification); + return ok(undefined); + } + + async findById(id: NotificationId): Promise> { + const notification = this.notifications.get(id.getStringValue()); + return ok(notification || null); + } + + async findByRecipient( + recipientId: CuratorId, + options: NotificationQueryOptions, + ): Promise> { + const { page, limit, unreadOnly } = options; + const offset = (page - 1) * limit; + + // Filter notifications by recipient + let filteredNotifications = Array.from(this.notifications.values()) + .filter(notification => notification.recipientUserId.equals(recipientId)); + + // Filter by read status if requested + if (unreadOnly) { + filteredNotifications = filteredNotifications.filter(notification => !notification.read); + } + + // Sort by creation date (newest first) + filteredNotifications.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const totalCount = filteredNotifications.length; + const paginatedNotifications = filteredNotifications.slice(offset, offset + limit); + const hasMore = offset + paginatedNotifications.length < totalCount; + + // Calculate unread count + const unreadCount = Array.from(this.notifications.values()) + .filter(notification => + notification.recipientUserId.equals(recipientId) && !notification.read + ).length; + + return ok({ + notifications: paginatedNotifications, + totalCount, + hasMore, + unreadCount, + }); + } + + async getUnreadCount(recipientId: CuratorId): Promise> { + const unreadCount = Array.from(this.notifications.values()) + .filter(notification => + notification.recipientUserId.equals(recipientId) && !notification.read + ).length; + + return ok(unreadCount); + } + + async markAsRead(notificationIds: NotificationId[]): Promise> { + for (const id of notificationIds) { + const notification = this.notifications.get(id.getStringValue()); + if (notification) { + notification.markAsRead(); + } + } + return ok(undefined); + } + + async markAllAsReadForUser(recipientId: CuratorId): Promise> { + let markedCount = 0; + + for (const notification of this.notifications.values()) { + if (notification.recipientUserId.equals(recipientId) && !notification.read) { + notification.markAsRead(); + markedCount++; + } + } + + return ok(markedCount); + } +} diff --git a/src/shared/infrastructure/http/factories/RepositoryFactory.ts b/src/shared/infrastructure/http/factories/RepositoryFactory.ts index 7a12e7cb..f88eacc0 100644 --- a/src/shared/infrastructure/http/factories/RepositoryFactory.ts +++ b/src/shared/infrastructure/http/factories/RepositoryFactory.ts @@ -36,6 +36,9 @@ import { IFeedRepository } from '../../../../modules/feeds/domain/IFeedRepositor import { IAtUriResolutionService } from '../../../../modules/cards/domain/services/IAtUriResolutionService'; import { DrizzleAtUriResolutionService } from '../../../../modules/cards/infrastructure/services/DrizzleAtUriResolutionService'; import { InMemoryAtUriResolutionService } from '../../../../modules/cards/tests/utils/InMemoryAtUriResolutionService'; +import { INotificationRepository } from '../../../../modules/notifications/domain/INotificationRepository'; +import { DrizzleNotificationRepository } from '../../../../modules/notifications/infrastructure/repositories/DrizzleNotificationRepository'; +import { InMemoryNotificationRepository } from '../../../../modules/notifications/tests/infrastructure/InMemoryNotificationRepository'; export interface Repositories { userRepository: IUserRepository; @@ -46,6 +49,7 @@ export interface Repositories { collectionQueryRepository: ICollectionQueryRepository; appPasswordSessionRepository: IAppPasswordSessionRepository; feedRepository: IFeedRepository; + notificationRepository: INotificationRepository; atUriResolutionService: IAtUriResolutionService; oauthStateStore: NodeSavedStateStore; oauthSessionStore: NodeSavedSessionStore; @@ -76,6 +80,7 @@ export class RepositoryFactory { collectionRepository, cardRepository, ); + const notificationRepository = InMemoryNotificationRepository.getInstance(); const oauthStateStore = InMemoryStateStore.getInstance(); const oauthSessionStore = InMemorySessionStore.getInstance(); @@ -88,6 +93,7 @@ export class RepositoryFactory { collectionQueryRepository, appPasswordSessionRepository, feedRepository, + notificationRepository, atUriResolutionService, oauthStateStore, oauthSessionStore, @@ -110,6 +116,7 @@ export class RepositoryFactory { collectionQueryRepository: new DrizzleCollectionQueryRepository(db), appPasswordSessionRepository: new DrizzleAppPasswordSessionRepository(db), feedRepository: new DrizzleFeedRepository(db), + notificationRepository: new DrizzleNotificationRepository(db), atUriResolutionService: new DrizzleAtUriResolutionService(db), oauthStateStore, oauthSessionStore, diff --git a/src/shared/infrastructure/http/factories/ServiceFactory.ts b/src/shared/infrastructure/http/factories/ServiceFactory.ts index 76cf7c01..57a5560c 100644 --- a/src/shared/infrastructure/http/factories/ServiceFactory.ts +++ b/src/shared/infrastructure/http/factories/ServiceFactory.ts @@ -57,6 +57,7 @@ import { SearchService } from '../../../../modules/search/domain/services/Search import { IVectorDatabase } from '../../../../modules/search/domain/IVectorDatabase'; import { InMemoryVectorDatabase } from '../../../../modules/search/infrastructure/InMemoryVectorDatabase'; import { UpstashVectorDatabase } from '../../../../modules/search/infrastructure/UpstashVectorDatabase'; +import { NotificationService } from '../../../../modules/notifications/domain/services/NotificationService'; // Shared services needed by both web app and workers export interface SharedServices { @@ -66,6 +67,7 @@ export interface SharedServices { metadataService: IMetadataService; profileService: IProfileService; feedService: FeedService; + notificationService: NotificationService; nodeOauthClient: NodeOAuthClient; identityResolutionService: IIdentityResolutionService; configService: EnvironmentConfigService; @@ -280,6 +282,9 @@ export class ServiceFactory { // Feed Service const feedService = new FeedService(repositories.feedRepository); + // Notification Service + const notificationService = new NotificationService(repositories.notificationRepository); + // Identity Resolution Service const identityResolutionService = new ATProtoIdentityResolutionService( atProtoAgentService, @@ -353,6 +358,7 @@ export class ServiceFactory { metadataService, profileService, feedService, + notificationService, nodeOauthClient, identityResolutionService, configService, diff --git a/src/shared/infrastructure/http/factories/UseCaseFactory.ts b/src/shared/infrastructure/http/factories/UseCaseFactory.ts index 05dab8f9..347cf5cb 100644 --- a/src/shared/infrastructure/http/factories/UseCaseFactory.ts +++ b/src/shared/infrastructure/http/factories/UseCaseFactory.ts @@ -35,6 +35,10 @@ import { GetSimilarUrlsForUrlUseCase } from '../../../../modules/search/applicat import { ProcessCardFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessCardFirehoseEventUseCase'; import { ProcessCollectionFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessCollectionFirehoseEventUseCase'; import { ProcessCollectionLinkFirehoseEventUseCase } from '../../../../modules/atproto/application/useCases/ProcessCollectionLinkFirehoseEventUseCase'; +import { GetMyNotificationsUseCase } from '../../../../modules/notifications/application/useCases/queries/GetMyNotificationsUseCase'; +import { GetUnreadNotificationCountUseCase } from '../../../../modules/notifications/application/useCases/queries/GetUnreadNotificationCountUseCase'; +import { MarkNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkNotificationsAsReadUseCase'; +import { MarkAllNotificationsAsReadUseCase } from '../../../../modules/notifications/application/useCases/commands/MarkAllNotificationsAsReadUseCase'; export interface WorkerUseCases { addActivityToFeedUseCase: AddActivityToFeedUseCase; @@ -87,6 +91,11 @@ export interface UseCases { addActivityToFeedUseCase: AddActivityToFeedUseCase; // Search use cases getSimilarUrlsForUrlUseCase: GetSimilarUrlsForUrlUseCase; + // Notification use cases + getMyNotificationsUseCase: GetMyNotificationsUseCase; + getUnreadNotificationCountUseCase: GetUnreadNotificationCountUseCase; + markNotificationsAsReadUseCase: MarkNotificationsAsReadUseCase; + markAllNotificationsAsReadUseCase: MarkAllNotificationsAsReadUseCase; } export class UseCaseFactory { @@ -247,6 +256,22 @@ export class UseCaseFactory { getSimilarUrlsForUrlUseCase: new GetSimilarUrlsForUrlUseCase( services.searchService, ), + // Notification use cases + getMyNotificationsUseCase: new GetMyNotificationsUseCase( + repositories.notificationRepository, + services.profileService, + repositories.cardQueryRepository, + repositories.collectionRepository, + ), + getUnreadNotificationCountUseCase: new GetUnreadNotificationCountUseCase( + repositories.notificationRepository, + ), + markNotificationsAsReadUseCase: new MarkNotificationsAsReadUseCase( + repositories.notificationRepository, + ), + markAllNotificationsAsReadUseCase: new MarkAllNotificationsAsReadUseCase( + repositories.notificationRepository, + ), }; } -- 2.51.2