diff --git a/src/notifications/__tests__/notification-aggregation.service.spec.ts b/src/notifications/__tests__/notification-aggregation.service.spec.ts new file mode 100644 index 00000000..c4c2a492 --- /dev/null +++ b/src/notifications/__tests__/notification-aggregation.service.spec.ts @@ -0,0 +1,9 @@ +import { NotificationAggregationService } from '../services/notification-aggregation.service'; + +describe('NotificationAggregationService', () => { + it('can be instantiated', () => { + const service = new NotificationAggregationService(); + + expect(service).toBeDefined(); + }); +}); diff --git a/src/notifications/__tests__/notification-delivery.service.spec.ts b/src/notifications/__tests__/notification-delivery.service.spec.ts new file mode 100644 index 00000000..02fd7f50 --- /dev/null +++ b/src/notifications/__tests__/notification-delivery.service.spec.ts @@ -0,0 +1,12 @@ +import { NotificationDeliveryService } from '../services/notification-delivery.service'; + +describe('NotificationDeliveryService', () => { + it('can be instantiated', () => { + const service = new NotificationDeliveryService( + {} as any, + {} as any, + ); + + expect(service).toBeDefined(); + }); +}); diff --git a/src/notifications/__tests__/notification-preferences.service.spec.ts b/src/notifications/__tests__/notification-preferences.service.spec.ts new file mode 100644 index 00000000..1fd8652e --- /dev/null +++ b/src/notifications/__tests__/notification-preferences.service.spec.ts @@ -0,0 +1,64 @@ +import { Test } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { NotificationPreference } from '../entities/notification-preference.entity'; +import { NotificationPreferencesService } from '../services/notification-preferences.service'; +import { NotificationType } from '../enums/notification-type.enum'; + +describe('NotificationPreferencesService', () => { + const repository = { + findOne: jest.fn(), + create: jest.fn(), + save: jest.fn(), + }; + + let service: NotificationPreferencesService; + + beforeEach(async () => { + jest.clearAllMocks(); + + const module = await Test.createTestingModule({ + providers: [ + NotificationPreferencesService, + { + provide: getRepositoryToken(NotificationPreference), + useValue: repository, + }, + ], + }).compile(); + + service = module.get(NotificationPreferencesService); + }); + + it('creates default preferences when none exist', async () => { + repository.findOne.mockResolvedValue(null); + repository.create.mockReturnValue({ userId: 'u1' }); + repository.save.mockResolvedValue({ userId: 'u1' }); + + await service.getOrCreate('u1'); + + expect(repository.create).toHaveBeenCalledWith({ userId: 'u1' }); + expect(repository.save).toHaveBeenCalled(); + }); + + it('respects disabled notification preference', async () => { + repository.findOne.mockResolvedValue({ + userId: 'u1', + questCompleted: false, + }); + + await expect( + service.isEnabled('u1', NotificationType.QUEST_COMPLETED), + ).resolves.toBe(false); + }); + + it('returns true for enabled notification preference', async () => { + repository.findOne.mockResolvedValue({ + userId: 'u1', + questCompleted: true, + }); + + await expect( + service.isEnabled('u1', NotificationType.QUEST_COMPLETED), + ).resolves.toBe(true); + }); +}); diff --git a/src/notifications/__tests__/notifications.gateway.spec.ts b/src/notifications/__tests__/notifications.gateway.spec.ts new file mode 100644 index 00000000..28aa677c --- /dev/null +++ b/src/notifications/__tests__/notifications.gateway.spec.ts @@ -0,0 +1,61 @@ +import { NotificationsGateway } from '../gateways/notifications.gateway'; + +describe('NotificationsGateway', () => { + const auth = { + authenticate: jest.fn(), + }; + + const delivery = { + acknowledge: jest.fn(), + }; + + let gateway: NotificationsGateway; + + beforeEach(() => { + gateway = new NotificationsGateway( + auth as any, + delivery as any, + ); + + (gateway as any).server = { + to: jest.fn().mockReturnThis(), + emit: jest.fn(), + }; + }); + + it('tracks connected users', () => { + auth.authenticate.mockReturnValue('u1'); + + gateway.handleConnection({ + id: 'socket-1', + handshake: {}, + data: {}, + join: jest.fn(), + } as any); + + expect(gateway.isUserOnline('u1')).toBe(true); + }); + + it('removes users after their last socket disconnects', () => { + auth.authenticate.mockReturnValue('u1'); + + const client: any = { + id: 'socket-1', + handshake: {}, + data: {}, + join: jest.fn(), + disconnect: jest.fn(), + }; + + gateway.handleConnection(client); + gateway.handleDisconnect(client); + + expect(gateway.isUserOnline('u1')).toBe(false); + }); + + it('does not deliver to offline users', () => { + const result = gateway.sendToUser('missing', {} as any); + + expect(result).toBe(false); + }); +}); diff --git a/src/notifications/__tests__/notifications.processor.spec.ts b/src/notifications/__tests__/notifications.processor.spec.ts new file mode 100644 index 00000000..0652a945 --- /dev/null +++ b/src/notifications/__tests__/notifications.processor.spec.ts @@ -0,0 +1,13 @@ +import { NotificationsProcessor } from '../processors/notifications.processor'; + +describe('NotificationsProcessor', () => { + it('can be instantiated', () => { + const processor = new NotificationsProcessor( + {} as any, + {} as any, + {} as any, + ); + + expect(processor).toBeDefined(); + }); +}); diff --git a/src/notifications/__tests__/notifications.service.spec.ts b/src/notifications/__tests__/notifications.service.spec.ts new file mode 100644 index 00000000..a9d88eeb --- /dev/null +++ b/src/notifications/__tests__/notifications.service.spec.ts @@ -0,0 +1,16 @@ +import { NotificationsService } from '../services/notifications.service'; + +describe('NotificationsService', () => { + it('can be instantiated', () => { + const service = new NotificationsService( + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + ); + + expect(service).toBeDefined(); + }); +}); diff --git a/src/notifications/auth/notification-socket-auth.ts b/src/notifications/auth/notification-socket-auth.ts new file mode 100644 index 00000000..0416e47b --- /dev/null +++ b/src/notifications/auth/notification-socket-auth.ts @@ -0,0 +1,21 @@ +import { Injectable } from '@nestjs/common'; +import { Socket } from 'socket.io'; + +/** + * Replace this implementation with the repository's existing JWT/session + * authentication. The gateway must derive the user ID from a verified + * credential and must never trust an arbitrary client-supplied userId. + */ +@Injectable() +export class NotificationSocketAuth { + authenticate(client: Socket): string | null { + // Development scaffold only. + // Production: verify client.handshake.auth.token and return the + // authenticated subject/user ID. + const userId = client.handshake.auth?.userId; + + return typeof userId === 'string' && userId.length > 0 + ? userId + : null; + } +} diff --git a/src/notifications/controllers/notifications.controller.ts b/src/notifications/controllers/notifications.controller.ts new file mode 100644 index 00000000..8d4a11e8 --- /dev/null +++ b/src/notifications/controllers/notifications.controller.ts @@ -0,0 +1,95 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Query, + Req, +} from '@nestjs/common'; + +import { NotificationsService } from '../services/notifications.service'; +import { NotificationPreferencesService } from '../services/notification-preferences.service'; +import { NotificationQueryDto } from '../dto/notification-query.dto'; +import { UpdateNotificationPreferencesDto } from '../dto/update-notification-preferences.dto'; + +/** + * Adapt req.user access to the authentication conventions already used + * by quest-service. + */ +@Controller('api/v1/notifications') +export class NotificationsController { + constructor( + private readonly notificationsService: NotificationsService, + private readonly preferencesService: NotificationPreferencesService, + ) {} + + @Get() + history( + @Req() req: any, + @Query() query: NotificationQueryDto, + ) { + const userId = this.getUserId(req); + + return this.notificationsService.history( + userId, + query.page, + query.limit, + ); + } + + @Get('unread-count') + unreadCount(@Req() req: any) { + return this.notificationsService.unreadCount( + this.getUserId(req), + ); + } + + @Patch(':id/read') + markRead( + @Req() req: any, + @Param('id') id: string, + ) { + return this.notificationsService.markRead( + this.getUserId(req), + id, + ); + } + + @Patch('read-all') + markAllRead(@Req() req: any) { + return this.notificationsService.markAllRead( + this.getUserId(req), + ); + } + + @Get('preferences') + preferences(@Req() req: any) { + return this.preferencesService.getOrCreate( + this.getUserId(req), + ); + } + + @Patch('preferences') + updatePreferences( + @Req() req: any, + @Body() body: UpdateNotificationPreferencesDto, + ) { + return this.preferencesService.update( + this.getUserId(req), + body, + ); + } + + private getUserId(req: any): string { + const userId = req.user?.id ?? req.user?.sub; + + if (!userId) { + throw new Error( + 'Authenticated user ID is required. Apply the project auth guard.', + ); + } + + return String(userId); + } +} diff --git a/src/notifications/db/001_create_notifications.sql b/src/notifications/db/001_create_notifications.sql new file mode 100644 index 00000000..1d21a685 --- /dev/null +++ b/src/notifications/db/001_create_notifications.sql @@ -0,0 +1,95 @@ +CREATE EXTENSION IF NOT EXISTS pgcrypto; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'notification_status' + ) THEN + CREATE TYPE notification_status AS ENUM ( + 'PENDING', + 'PROCESSING', + 'DELIVERED', + 'READ', + 'FAILED' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'notification_delivery_status' + ) THEN + CREATE TYPE notification_delivery_status AS ENUM ( + 'PENDING', + 'DELIVERED', + 'FAILED' + ); + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM pg_type WHERE typname = 'notification_type' + ) THEN + CREATE TYPE notification_type AS ENUM ( + 'QUEST_ASSIGNED', + 'QUEST_COMPLETED', + 'QUEST_APPROVED', + 'QUEST_REJECTED', + 'REWARD_RECEIVED', + 'ACHIEVEMENT_UNLOCKED', + 'LEVEL_UP', + 'BADGE_EARNED', + 'SYSTEM' + ); + END IF; +END $$; + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(128) NOT NULL, + type notification_type NOT NULL, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + data JSONB, + status notification_status NOT NULL DEFAULT 'PENDING', + read_at TIMESTAMPTZ, + deduplication_key VARCHAR(255) UNIQUE, + aggregation_key VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_created + ON notifications(user_id, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_notifications_user_status + ON notifications(user_id, status); + +CREATE TABLE IF NOT EXISTS notification_deliveries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + notification_id UUID NOT NULL UNIQUE + REFERENCES notifications(id) ON DELETE CASCADE, + status notification_delivery_status NOT NULL DEFAULT 'PENDING', + attempts INTEGER NOT NULL DEFAULT 0, + last_attempt_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + failed_at TIMESTAMPTZ, + failure_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id VARCHAR(128) NOT NULL UNIQUE, + + quest_assigned BOOLEAN NOT NULL DEFAULT TRUE, + quest_completed BOOLEAN NOT NULL DEFAULT TRUE, + quest_approved BOOLEAN NOT NULL DEFAULT TRUE, + quest_rejected BOOLEAN NOT NULL DEFAULT TRUE, + reward_received BOOLEAN NOT NULL DEFAULT TRUE, + achievement_unlocked BOOLEAN NOT NULL DEFAULT TRUE, + level_up BOOLEAN NOT NULL DEFAULT TRUE, + badge_earned BOOLEAN NOT NULL DEFAULT TRUE, + system BOOLEAN NOT NULL DEFAULT TRUE, + + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); diff --git a/src/notifications/dto/notification-ack.dto.ts b/src/notifications/dto/notification-ack.dto.ts new file mode 100644 index 00000000..3815c07b --- /dev/null +++ b/src/notifications/dto/notification-ack.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class NotificationAckDto { + @IsUUID() + notificationId: string; +} diff --git a/src/notifications/dto/notification-query.dto.ts b/src/notifications/dto/notification-query.dto.ts new file mode 100644 index 00000000..fd004a26 --- /dev/null +++ b/src/notifications/dto/notification-query.dto.ts @@ -0,0 +1,17 @@ +import { Type } from 'class-transformer'; +import { IsInt, IsOptional, Max, Min } from 'class-validator'; + +export class NotificationQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page = 1; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 20; +} diff --git a/src/notifications/dto/update-notification-preferences.dto.ts b/src/notifications/dto/update-notification-preferences.dto.ts new file mode 100644 index 00000000..7264200d --- /dev/null +++ b/src/notifications/dto/update-notification-preferences.dto.ts @@ -0,0 +1,39 @@ +import { IsBoolean, IsOptional } from 'class-validator'; + +export class UpdateNotificationPreferencesDto { + @IsOptional() + @IsBoolean() + questAssigned?: boolean; + + @IsOptional() + @IsBoolean() + questCompleted?: boolean; + + @IsOptional() + @IsBoolean() + questApproved?: boolean; + + @IsOptional() + @IsBoolean() + questRejected?: boolean; + + @IsOptional() + @IsBoolean() + rewardReceived?: boolean; + + @IsOptional() + @IsBoolean() + achievementUnlocked?: boolean; + + @IsOptional() + @IsBoolean() + levelUp?: boolean; + + @IsOptional() + @IsBoolean() + badgeEarned?: boolean; + + @IsOptional() + @IsBoolean() + system?: boolean; +} diff --git a/src/notifications/entities/notification-delivery.entity.ts b/src/notifications/entities/notification-delivery.entity.ts index 34d354a4..a03aac4e 100644 --- a/src/notifications/entities/notification-delivery.entity.ts +++ b/src/notifications/entities/notification-delivery.entity.ts @@ -1,24 +1,48 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { NotificationDeliveryStatus } from '../enums/notification-delivery-status.enum'; @Entity('notification_deliveries') +@Index(['notificationId'], { unique: true }) export class NotificationDelivery { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid' }) - @Index() + @Column({ name: 'notification_id', type: 'uuid' }) notificationId: string; - @Column({ type: 'varchar', length: 50 }) - channel: string; // in_app, email, push, scheduler, feedback + @Column({ + type: 'enum', + enum: NotificationDeliveryStatus, + default: NotificationDeliveryStatus.PENDING, + }) + status: NotificationDeliveryStatus; + + @Column({ default: 0 }) + attempts: number; + + @Column({ name: 'last_attempt_at', type: 'timestamptz', nullable: true }) + lastAttemptAt: Date | null; - @Column({ type: 'varchar', length: 50 }) - status: string; // queued, sent, delivered, failed, received + @Column({ name: 'delivered_at', type: 'timestamptz', nullable: true }) + deliveredAt: Date | null; - @Column({ type: 'text', nullable: true }) - details?: string; + @Column({ name: 'failed_at', type: 'timestamptz', nullable: true }) + failedAt: Date | null; - @CreateDateColumn() - @Index() + @Column({ name: 'failure_reason', type: 'text', nullable: true }) + failureReason: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; } diff --git a/src/notifications/entities/notification-preference.entity.ts b/src/notifications/entities/notification-preference.entity.ts new file mode 100644 index 00000000..24de4bbe --- /dev/null +++ b/src/notifications/entities/notification-preference.entity.ts @@ -0,0 +1,49 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('notification_preferences') +export class NotificationPreference { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ name: 'user_id', type: 'varchar', length: 128, unique: true }) + userId: string; + + @Column({ name: 'quest_assigned', default: true }) + questAssigned: boolean; + + @Column({ name: 'quest_completed', default: true }) + questCompleted: boolean; + + @Column({ name: 'quest_approved', default: true }) + questApproved: boolean; + + @Column({ name: 'quest_rejected', default: true }) + questRejected: boolean; + + @Column({ name: 'reward_received', default: true }) + rewardReceived: boolean; + + @Column({ name: 'achievement_unlocked', default: true }) + achievementUnlocked: boolean; + + @Column({ name: 'level_up', default: true }) + levelUp: boolean; + + @Column({ name: 'badge_earned', default: true }) + badgeEarned: boolean; + + @Column({ name: 'system', default: true }) + system: boolean; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; +} diff --git a/src/notifications/entities/notification.entity.ts b/src/notifications/entities/notification.entity.ts index 64401652..d49824ce 100644 --- a/src/notifications/entities/notification.entity.ts +++ b/src/notifications/entities/notification.entity.ts @@ -1,30 +1,62 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, Index } from 'typeorm'; +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import { NotificationStatus } from '../enums/notification-status.enum'; +import { NotificationType } from '../enums/notification-type.enum'; @Entity('notifications') +@Index(['userId', 'createdAt']) +@Index(['userId', 'status']) export class Notification { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ type: 'uuid' }) - @Index() + @Column({ name: 'user_id', type: 'varchar', length: 128 }) userId: string; - @Column({ type: 'varchar', length: 50 }) - type: string; + @Column({ type: 'enum', enum: NotificationType }) + type: NotificationType; @Column({ type: 'varchar', length: 255 }) title: string; - @Column({ type: 'text', nullable: true }) - body?: string; + @Column({ type: 'text' }) + message: string; - @Column({ type: 'jsonb', default: {} }) - meta: any; + @Column({ type: 'jsonb', nullable: true }) + data: Record | null; - @Column({ type: 'varchar', length: 50, nullable: true }) - variantId?: string; + @Column({ + type: 'enum', + enum: NotificationStatus, + default: NotificationStatus.PENDING, + }) + status: NotificationStatus; - @CreateDateColumn() - @Index() + @Column({ name: 'read_at', type: 'timestamptz', nullable: true }) + readAt: Date | null; + + @Column({ + name: 'deduplication_key', + type: 'varchar', + length: 255, + nullable: true, + unique: true, + }) + deduplicationKey: string | null; + + @Column({ name: 'aggregation_key', type: 'varchar', length: 255, nullable: true }) + aggregationKey: string | null; + + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) + updatedAt: Date; } diff --git a/src/notifications/enums/notification-delivery-status.enum.ts b/src/notifications/enums/notification-delivery-status.enum.ts new file mode 100644 index 00000000..de4a2ba9 --- /dev/null +++ b/src/notifications/enums/notification-delivery-status.enum.ts @@ -0,0 +1,5 @@ +export enum NotificationDeliveryStatus { + PENDING = 'PENDING', + DELIVERED = 'DELIVERED', + FAILED = 'FAILED', +} diff --git a/src/notifications/enums/notification-status.enum.ts b/src/notifications/enums/notification-status.enum.ts new file mode 100644 index 00000000..3408b4ad --- /dev/null +++ b/src/notifications/enums/notification-status.enum.ts @@ -0,0 +1,7 @@ +export enum NotificationStatus { + PENDING = 'PENDING', + PROCESSING = 'PROCESSING', + DELIVERED = 'DELIVERED', + READ = 'READ', + FAILED = 'FAILED', +} diff --git a/src/notifications/enums/notification-type.enum.ts b/src/notifications/enums/notification-type.enum.ts new file mode 100644 index 00000000..3dac2c61 --- /dev/null +++ b/src/notifications/enums/notification-type.enum.ts @@ -0,0 +1,11 @@ +export enum NotificationType { + QUEST_ASSIGNED = 'QUEST_ASSIGNED', + QUEST_COMPLETED = 'QUEST_COMPLETED', + QUEST_APPROVED = 'QUEST_APPROVED', + QUEST_REJECTED = 'QUEST_REJECTED', + REWARD_RECEIVED = 'REWARD_RECEIVED', + ACHIEVEMENT_UNLOCKED = 'ACHIEVEMENT_UNLOCKED', + LEVEL_UP = 'LEVEL_UP', + BADGE_EARNED = 'BADGE_EARNED', + SYSTEM = 'SYSTEM', +} diff --git a/src/notifications/examples/notification-events.example.ts b/src/notifications/examples/notification-events.example.ts new file mode 100644 index 00000000..5befa526 --- /dev/null +++ b/src/notifications/examples/notification-events.example.ts @@ -0,0 +1,18 @@ +import { NotificationsService } from '../services/notifications.service'; +import { NotificationType } from '../enums/notification-type.enum'; + +export async function questCompletedExample( + notificationsService: NotificationsService, + userId: string, + questId: string, +) { + return notificationsService.create({ + userId, + type: NotificationType.QUEST_COMPLETED, + title: 'Quest completed', + message: 'You completed a quest.', + data: { questId }, + deduplicationKey: `quest-completed:${questId}:${userId}`, + aggregateKey: `quest-completed:${userId}`, + }); +} diff --git a/src/notifications/examples/socket-client.example.ts b/src/notifications/examples/socket-client.example.ts new file mode 100644 index 00000000..392d8863 --- /dev/null +++ b/src/notifications/examples/socket-client.example.ts @@ -0,0 +1,19 @@ +import { io } from 'socket.io-client'; + +const socket = io( + `${process.env.NEXT_PUBLIC_API_URL}/notifications`, + { + auth: { + token: '', + }, + transports: ['websocket'], + }, +); + +socket.on('notification', (notification) => { + console.log('Notification received:', notification); + + socket.emit('notification:ack', { + notificationId: notification.id, + }); +}); diff --git a/src/notifications/gateways/notifications.gateway.ts b/src/notifications/gateways/notifications.gateway.ts new file mode 100644 index 00000000..b312f664 --- /dev/null +++ b/src/notifications/gateways/notifications.gateway.ts @@ -0,0 +1,118 @@ +import { + ConnectedSocket, + MessageBody, + OnGatewayConnection, + OnGatewayDisconnect, + SubscribeMessage, + WebSocketGateway, + WebSocketServer, +} from '@nestjs/websockets'; +import { Server, Socket } from 'socket.io'; + +import { Notification } from '../entities/notification.entity'; +import { NotificationAckDto } from '../dto/notification-ack.dto'; +import { NotificationDeliveryService } from '../services/notification-delivery.service'; +import { NotificationSocketAuth } from '../auth/notification-socket-auth'; + +@WebSocketGateway({ + namespace: '/notifications', + cors: { + origin: process.env.FRONTEND_URL?.split(',') ?? '*', + credentials: true, + }, +}) +export class NotificationsGateway + implements OnGatewayConnection, OnGatewayDisconnect +{ + @WebSocketServer() + server: Server; + + private readonly connections = new Map>(); + + constructor( + private readonly auth: NotificationSocketAuth, + private readonly deliveryService: NotificationDeliveryService, + ) {} + + handleConnection(client: Socket): void { + const userId = this.auth.authenticate(client); + + if (!userId) { + client.disconnect(); + return; + } + + client.data.userId = userId; + client.join(this.userRoom(userId)); + + const sockets = this.connections.get(userId) ?? new Set(); + sockets.add(client.id); + this.connections.set(userId, sockets); + } + + handleDisconnect(client: Socket): void { + const userId = client.data.userId as string | undefined; + + if (!userId) { + return; + } + + const sockets = this.connections.get(userId); + + if (!sockets) { + return; + } + + sockets.delete(client.id); + + if (sockets.size === 0) { + this.connections.delete(userId); + } + } + + isUserOnline(userId: string): boolean { + return this.connections.has(userId); + } + + sendToUser(userId: string, notification: Notification): boolean { + if (!this.isUserOnline(userId)) { + return false; + } + + this.server.to(this.userRoom(userId)).emit( + 'notification', + this.serialize(notification), + ); + + return true; + } + + @SubscribeMessage('notification:ack') + async acknowledge( + @ConnectedSocket() client: Socket, + @MessageBody() payload: NotificationAckDto, + ) { + const userId = client.data.userId as string; + + return this.deliveryService.acknowledge( + payload.notificationId, + userId, + ); + } + + private userRoom(userId: string): string { + return `user:${userId}`; + } + + private serialize(notification: Notification) { + return { + id: notification.id, + type: notification.type, + title: notification.title, + message: notification.message, + data: notification.data, + status: notification.status, + createdAt: notification.createdAt, + }; + } +} diff --git a/src/notifications/interfaces/create-notification.interface.ts b/src/notifications/interfaces/create-notification.interface.ts new file mode 100644 index 00000000..b992424f --- /dev/null +++ b/src/notifications/interfaces/create-notification.interface.ts @@ -0,0 +1,11 @@ +import { NotificationType } from '../enums/notification-type.enum'; + +export interface CreateNotificationInput { + userId: string; + type: NotificationType; + title: string; + message: string; + data?: Record; + deduplicationKey?: string; + aggregateKey?: string; +} diff --git a/src/notifications/interfaces/notification-job.interface.ts b/src/notifications/interfaces/notification-job.interface.ts new file mode 100644 index 00000000..2294af1a --- /dev/null +++ b/src/notifications/interfaces/notification-job.interface.ts @@ -0,0 +1,4 @@ +export interface NotificationJobData { + notificationId: string; + userId: string; +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts index ca62af0a..bb212cdd 100644 --- a/src/notifications/notifications.module.ts +++ b/src/notifications/notifications.module.ts @@ -1,20 +1,47 @@ -import { Module, forwardRef } from '@nestjs/common'; +import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { BullModule } from '@nestjs/bullmq'; + +import { NotificationsController } from './controllers/notifications.controller'; +import { NotificationsGateway } from './gateways/notifications.gateway'; +import { NotificationsProcessor } from './processors/notifications.processor'; + import { Notification } from './entities/notification.entity'; import { NotificationDelivery } from './entities/notification-delivery.entity'; -import { Device } from './entities/device.entity'; -import { NotificationService } from './notification.service'; -import { EmailService } from './email.service'; -import { NotificationsController } from './notifications.controller'; -import { DevicesController } from './devices.controller'; -import { StaleTokenListener } from './listeners/stale-token.listener'; -import { User } from '../users/entities/user.entity'; -import { ConfigModule } from '@nestjs/config'; +import { NotificationPreference } from './entities/notification-preference.entity'; + +import { NotificationsService } from './services/notifications.service'; +import { NotificationPreferencesService } from './services/notification-preferences.service'; +import { NotificationDeliveryService } from './services/notification-delivery.service'; +import { NotificationAggregationService } from './services/notification-aggregation.service'; + +import { NotificationSocketAuth } from './auth/notification-socket-auth'; @Module({ - imports: [TypeOrmModule.forFeature([Notification, NotificationDelivery, Device, User]), ConfigModule], - providers: [NotificationService, EmailService], - controllers: [NotificationsController, DevicesController, StaleTokenListener], - exports: [NotificationService, EmailService], + imports: [ + TypeOrmModule.forFeature([ + Notification, + NotificationDelivery, + NotificationPreference, + ]), + BullModule.registerQueue({ + name: 'notifications', + }), + ], + controllers: [NotificationsController], + providers: [ + NotificationsService, + NotificationPreferencesService, + NotificationDeliveryService, + NotificationAggregationService, + NotificationsGateway, + NotificationsProcessor, + NotificationSocketAuth, + ], + exports: [ + NotificationsService, + NotificationPreferencesService, + NotificationDeliveryService, + ], }) export class NotificationsModule {} diff --git a/src/notifications/processors/notifications.processor.ts b/src/notifications/processors/notifications.processor.ts new file mode 100644 index 00000000..bc4a189d --- /dev/null +++ b/src/notifications/processors/notifications.processor.ts @@ -0,0 +1,99 @@ +import { Injectable } from '@nestjs/common'; +import { + OnWorkerEvent, + Processor, + WorkerHost, +} from '@nestjs/bullmq'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Job } from 'bullmq'; +import { Repository } from 'typeorm'; + +import { Notification } from '../entities/notification.entity'; +import { NotificationStatus } from '../enums/notification-status.enum'; +import { NotificationJobData } from '../interfaces/notification-job.interface'; +import { NotificationsGateway } from '../gateways/notifications.gateway'; +import { NotificationDeliveryService } from '../services/notification-delivery.service'; + +@Processor('notifications', { + concurrency: Number(process.env.NOTIFICATION_QUEUE_CONCURRENCY ?? 10), +}) +@Injectable() +export class NotificationsProcessor extends WorkerHost { + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + + private readonly gateway: NotificationsGateway, + private readonly deliveryService: NotificationDeliveryService, + ) { + super(); + } + + async process( + job: Job, + ): Promise { + const notification = + await this.notificationRepository.findOne({ + where: { id: job.data.notificationId }, + }); + + if (!notification) { + throw new Error( + `Notification ${job.data.notificationId} not found`, + ); + } + + if (notification.status === NotificationStatus.READ) { + return; + } + + notification.status = NotificationStatus.PROCESSING; + await this.notificationRepository.save(notification); + + const delivered = this.gateway.sendToUser( + notification.userId, + notification, + ); + + await this.deliveryService.recordAttempt( + notification.id, + delivered + ? undefined + : new Error('User is offline'), + ); + + if (!delivered) { + notification.status = NotificationStatus.PENDING; + await this.notificationRepository.save(notification); + + throw new Error( + `User ${notification.userId} is offline`, + ); + } + + // The notification is emitted now, but delivery is only finalized + // after the client sends notification:ack. + notification.status = NotificationStatus.PROCESSING; + await this.notificationRepository.save(notification); + } + + @OnWorkerEvent('failed') + async onFailed( + job: Job | undefined, + error: Error, + ): Promise { + if (!job) { + return; + } + + const maxAttempts = + job.opts.attempts ?? Number(process.env.NOTIFICATION_MAX_ATTEMPTS ?? 5); + + if (job.attemptsMade >= maxAttempts) { + await this.deliveryService.markFailed( + job.data.notificationId, + error, + ); + } + } +} diff --git a/src/notifications/services/notification-aggregation.service.ts b/src/notifications/services/notification-aggregation.service.ts new file mode 100644 index 00000000..7219cdb1 --- /dev/null +++ b/src/notifications/services/notification-aggregation.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import Redis from 'ioredis'; + +@Injectable() +export class NotificationAggregationService { + private readonly redis: Redis; + private readonly windowSeconds = Number( + process.env.NOTIFICATION_AGGREGATION_WINDOW_SECONDS ?? 30, + ); + + constructor() { + this.redis = new Redis({ + host: process.env.REDIS_HOST ?? 'localhost', + port: Number(process.env.REDIS_PORT ?? 6379), + }); + } + + async increment( + userId: string, + aggregateKey: string, + ): Promise { + const key = `notification:aggregate:${userId}:${aggregateKey}`; + + const count = await this.redis.incr(key); + + if (count === 1) { + await this.redis.expire(key, this.windowSeconds); + } + + return count; + } + + async getCount( + userId: string, + aggregateKey: string, + ): Promise { + const key = `notification:aggregate:${userId}:${aggregateKey}`; + const value = await this.redis.get(key); + + return value ? Number(value) : 0; + } + + async close(): Promise { + await this.redis.quit(); + } +} diff --git a/src/notifications/services/notification-delivery.service.ts b/src/notifications/services/notification-delivery.service.ts new file mode 100644 index 00000000..e761a015 --- /dev/null +++ b/src/notifications/services/notification-delivery.service.ts @@ -0,0 +1,107 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { Notification } from '../entities/notification.entity'; +import { NotificationDelivery } from '../entities/notification-delivery.entity'; +import { NotificationDeliveryStatus } from '../enums/notification-delivery-status.enum'; +import { NotificationStatus } from '../enums/notification-status.enum'; + +@Injectable() +export class NotificationDeliveryService { + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + + @InjectRepository(NotificationDelivery) + private readonly deliveryRepository: Repository, + ) {} + + async createPending(notificationId: string): Promise { + const delivery = this.deliveryRepository.create({ + notificationId, + status: NotificationDeliveryStatus.PENDING, + attempts: 0, + }); + + return this.deliveryRepository.save(delivery); + } + + async recordAttempt( + notificationId: string, + error?: Error, + ): Promise { + let delivery = await this.deliveryRepository.findOne({ + where: { notificationId }, + }); + + if (!delivery) { + delivery = this.deliveryRepository.create({ + notificationId, + status: NotificationDeliveryStatus.PENDING, + attempts: 0, + }); + } + + delivery.attempts += 1; + delivery.lastAttemptAt = new Date(); + + if (error) { + delivery.failureReason = error.message; + } + + return this.deliveryRepository.save(delivery); + } + + async acknowledge( + notificationId: string, + userId: string, + ): Promise { + const notification = await this.notificationRepository.findOne({ + where: { id: notificationId }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + if (notification.userId !== userId) { + throw new ForbiddenException(); + } + + notification.status = NotificationStatus.DELIVERED; + await this.notificationRepository.save(notification); + + await this.deliveryRepository.update( + { notificationId }, + { + status: NotificationDeliveryStatus.DELIVERED, + deliveredAt: new Date(), + failureReason: null, + }, + ); + + return notification; + } + + async markFailed( + notificationId: string, + error: Error, + ): Promise { + await this.deliveryRepository.update( + { notificationId }, + { + status: NotificationDeliveryStatus.FAILED, + failedAt: new Date(), + failureReason: error.message, + }, + ); + + await this.notificationRepository.update( + { id: notificationId }, + { + status: NotificationStatus.FAILED, + }, + ); + } +} diff --git a/src/notifications/services/notification-preferences.service.ts b/src/notifications/services/notification-preferences.service.ts new file mode 100644 index 00000000..db5048c9 --- /dev/null +++ b/src/notifications/services/notification-preferences.service.ts @@ -0,0 +1,61 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { NotificationPreference } from '../entities/notification-preference.entity'; +import { NotificationType } from '../enums/notification-type.enum'; +import { UpdateNotificationPreferencesDto } from '../dto/update-notification-preferences.dto'; + +@Injectable() +export class NotificationPreferencesService { + constructor( + @InjectRepository(NotificationPreference) + private readonly repository: Repository, + ) {} + + async getOrCreate(userId: string): Promise { + let preferences = await this.repository.findOne({ + where: { userId }, + }); + + if (!preferences) { + preferences = this.repository.create({ userId }); + preferences = await this.repository.save(preferences); + } + + return preferences; + } + + async update( + userId: string, + input: UpdateNotificationPreferencesDto, + ): Promise { + const preferences = await this.getOrCreate(userId); + Object.assign(preferences, input); + return this.repository.save(preferences); + } + + async isEnabled( + userId: string, + type: NotificationType, + ): Promise { + const preferences = await this.getOrCreate(userId); + + const propertyMap: Record< + NotificationType, + keyof NotificationPreference + > = { + [NotificationType.QUEST_ASSIGNED]: 'questAssigned', + [NotificationType.QUEST_COMPLETED]: 'questCompleted', + [NotificationType.QUEST_APPROVED]: 'questApproved', + [NotificationType.QUEST_REJECTED]: 'questRejected', + [NotificationType.REWARD_RECEIVED]: 'rewardReceived', + [NotificationType.ACHIEVEMENT_UNLOCKED]: 'achievementUnlocked', + [NotificationType.LEVEL_UP]: 'levelUp', + [NotificationType.BADGE_EARNED]: 'badgeEarned', + [NotificationType.SYSTEM]: 'system', + }; + + return Boolean(preferences[propertyMap[type]]); + } +} diff --git a/src/notifications/services/notifications.service.ts b/src/notifications/services/notifications.service.ts new file mode 100644 index 00000000..fdf35b58 --- /dev/null +++ b/src/notifications/services/notifications.service.ts @@ -0,0 +1,205 @@ +import { + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectQueue } from '@nestjs/bullmq'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Queue } from 'bullmq'; +import { Repository } from 'typeorm'; + +import { Notification } from '../entities/notification.entity'; +import { NotificationDelivery } from '../entities/notification-delivery.entity'; +import { NotificationStatus } from '../enums/notification-status.enum'; +import { CreateNotificationInput } from '../interfaces/create-notification.interface'; +import { NotificationJobData } from '../interfaces/notification-job.interface'; +import { NotificationPreferencesService } from './notification-preferences.service'; +import { NotificationDeliveryService } from './notification-delivery.service'; +import { NotificationAggregationService } from './notification-aggregation.service'; + +@Injectable() +export class NotificationsService { + private readonly maxAttempts = Number( + process.env.NOTIFICATION_MAX_ATTEMPTS ?? 5, + ); + private readonly retryDelay = Number( + process.env.NOTIFICATION_RETRY_DELAY_MS ?? 1000, + ); + + constructor( + @InjectRepository(Notification) + private readonly notificationRepository: Repository, + + @InjectRepository(NotificationDelivery) + private readonly deliveryRepository: Repository, + + @InjectQueue('notifications') + private readonly queue: Queue, + + private readonly preferencesService: NotificationPreferencesService, + private readonly deliveryService: NotificationDeliveryService, + private readonly aggregationService: NotificationAggregationService, + ) {} + + async create(input: CreateNotificationInput): Promise { + const enabled = await this.preferencesService.isEnabled( + input.userId, + input.type, + ); + + if (!enabled) { + return null; + } + + if (input.deduplicationKey) { + const existing = await this.notificationRepository.findOne({ + where: { deduplicationKey: input.deduplicationKey }, + }); + + if (existing) { + return existing; + } + } + + if (input.aggregateKey) { + await this.aggregationService.increment( + input.userId, + input.aggregateKey, + ); + } + + let notification: Notification; + + try { + notification = this.notificationRepository.create({ + userId: input.userId, + type: input.type, + title: input.title, + message: input.message, + data: input.data ?? null, + status: NotificationStatus.PENDING, + deduplicationKey: input.deduplicationKey ?? null, + aggregationKey: input.aggregateKey ?? null, + }); + + notification = await this.notificationRepository.save(notification); + } catch (error) { + if ( + input.deduplicationKey && + this.isUniqueViolation(error) + ) { + const existing = await this.notificationRepository.findOne({ + where: { deduplicationKey: input.deduplicationKey }, + }); + + if (existing) { + return existing; + } + } + + throw error; + } + + await this.deliveryService.createPending(notification.id); + + await this.queue.add( + 'deliver', + { + notificationId: notification.id, + userId: notification.userId, + }, + { + jobId: `notification:${notification.id}`, + attempts: this.maxAttempts, + backoff: { + type: 'exponential', + delay: this.retryDelay, + }, + priority: notification.type === 'SYSTEM' ? 1 : 10, + removeOnComplete: 1000, + removeOnFail: false, + }, + ); + + return notification; + } + + async history( + userId: string, + page = 1, + limit = 20, + ) { + const [data, total] = + await this.notificationRepository.findAndCount({ + where: { userId }, + order: { createdAt: 'DESC' }, + skip: (page - 1) * limit, + take: limit, + }); + + return { + data, + meta: { + page, + limit, + total, + totalPages: Math.ceil(total / limit), + }, + }; + } + + async unreadCount(userId: string): Promise { + return this.notificationRepository.count({ + where: [ + { userId, status: NotificationStatus.PENDING }, + { userId, status: NotificationStatus.DELIVERED }, + ], + }); + } + + async markRead( + userId: string, + notificationId: string, + ): Promise { + const notification = + await this.notificationRepository.findOne({ + where: { + id: notificationId, + userId, + }, + }); + + if (!notification) { + throw new NotFoundException('Notification not found'); + } + + notification.status = NotificationStatus.READ; + notification.readAt = new Date(); + + return this.notificationRepository.save(notification); + } + + async markAllRead(userId: string): Promise { + await this.notificationRepository + .createQueryBuilder() + .update(Notification) + .set({ + status: NotificationStatus.READ, + readAt: () => 'CURRENT_TIMESTAMP', + }) + .where('user_id = :userId', { userId }) + .andWhere('status != :status', { + status: NotificationStatus.READ, + }) + .execute(); + } + + private isUniqueViolation(error: unknown): boolean { + return Boolean( + error && + typeof error === 'object' && + 'code' in error && + (error as { code?: string }).code === '23505', + ); + } +}