diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index 2ba3e3a..3715b98 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -15,6 +15,7 @@ import { CurrencyHubModule } from './currency-hub/currency-hub.module'; import { LessonQuizResultModule } from './lesson-quiz-result/lesson-quiz-result.module'; import { ProgressModule } from './progress/progress.module'; import { RecommendationModule } from './recommendation/recommendation.module'; +import { NotificationPreferenceModule } from './notification-preference/notification-preference.module'; @Module({ imports: [ @@ -47,6 +48,7 @@ import { RecommendationModule } from './recommendation/recommendation.module'; LessonQuizResultModule, ProgressModule, RecommendationModule, + NotificationPreferenceModule, ], controllers: [AppController], providers: [ diff --git a/backend/src/notification-preference/controllers/notification-preference.controller.spec.ts b/backend/src/notification-preference/controllers/notification-preference.controller.spec.ts new file mode 100644 index 0000000..b7bcaa0 --- /dev/null +++ b/backend/src/notification-preference/controllers/notification-preference.controller.spec.ts @@ -0,0 +1,97 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { NotificationPreferenceController } from "./notification-preference.controller" +import { NotificationPreferenceService } from "../providers/notification-preference.service" +import { UserRole } from "src/roles/roles.enum" +import { UpdateNotificationPreferenceDto } from "../dto/update-notification-preference.dto" + +describe("NotificationPreferenceController", () => { + let controller: NotificationPreferenceController + let service: NotificationPreferenceService + + const mockUser = { + id: "user-id", + role: UserRole.STUDENT, + } + + const mockPreference = { + id: "pref-id", + userId: "user-id", + role: UserRole.STUDENT, + lessonUpdates: true, + viaEmail: true, + viaInApp: true, + } + + const mockNotificationPreferenceService = { + findByUser: jest.fn().mockResolvedValue(mockPreference), + update: jest.fn().mockResolvedValue(mockPreference), + resetToDefaults: jest.fn().mockResolvedValue(mockPreference), + remove: jest.fn().mockResolvedValue(undefined), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [NotificationPreferenceController], + providers: [ + { + provide: NotificationPreferenceService, + useValue: mockNotificationPreferenceService, + }, + ], + }).compile() + + controller = module.get(NotificationPreferenceController) + service = module.get(NotificationPreferenceService) + }) + + it("should be defined", () => { + expect(controller).toBeDefined() + }) + + describe("getPreferences", () => { + it("should return user preferences", async () => { + const req = { user: mockUser } as any + + const result = await controller.getPreferences(req) + + expect(service.findByUser).toHaveBeenCalledWith(mockUser.id, mockUser.role) + expect(result).toEqual(mockPreference) + }) + }) + + describe("updatePreferences", () => { + it("should update user preferences", async () => { + const req = { user: mockUser } as any + const updateDto: UpdateNotificationPreferenceDto = { + lessonUpdates: false, + viaEmail: false, + } + + const result = await controller.updatePreferences(req, updateDto) + + expect(service.update).toHaveBeenCalledWith(mockUser.id, mockUser.role, updateDto) + expect(result).toEqual(mockPreference) + }) + }) + + describe("resetPreferences", () => { + it("should reset preferences to defaults", async () => { + const req = { user: mockUser } as any + + const result = await controller.resetPreferences(req) + + expect(service.resetToDefaults).toHaveBeenCalledWith(mockUser.id, mockUser.role) + expect(result).toEqual(mockPreference) + }) + }) + + describe("deletePreferences", () => { + it("should delete user preferences", async () => { + const req = { user: mockUser } as any + + await controller.deletePreferences(req) + + expect(service.remove).toHaveBeenCalledWith(mockUser.id, mockUser.role) + }) + }) +}) diff --git a/backend/src/notification-preference/controllers/notification-preference.controller.ts b/backend/src/notification-preference/controllers/notification-preference.controller.ts new file mode 100644 index 0000000..9529ede --- /dev/null +++ b/backend/src/notification-preference/controllers/notification-preference.controller.ts @@ -0,0 +1,75 @@ +import { Controller, Get, Put, Body, UseGuards, Request, Post, Delete, HttpCode, HttpStatus } from "@nestjs/common" +import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth } from "@nestjs/swagger" +import { UserRole } from "src/roles/roles.enum" +import { RolesGuard } from "src/roles/roles.guard" +import { NotificationPreferenceService } from "../providers/notification-preference.service" +import { UpdateNotificationPreferenceDto } from "../dto/update-notification-preference.dto" + +interface RequestUser { + id: string + role: UserRole +} + +interface RequestWithUser extends Request { + user: RequestUser +} + +@ApiTags("notification-preferences") +@Controller("notification-preferences") +@UseGuards(RolesGuard) +@ApiBearerAuth() +export class NotificationPreferenceController { + constructor(private readonly notificationPreferenceService: NotificationPreferenceService) {} + + @Get() + @ApiOperation({ summary: 'Get current user notification preferences' }) + @ApiResponse({ + status: 200, + description: 'User notification preferences retrieved successfully', + }) + async getPreferences(@Request() req: any) { + return await this.notificationPreferenceService.findByUser( + req.user.id, + req.user.role, + ); + } + + @Put() + @ApiOperation({ summary: "Update current user notification preferences" }) + @ApiResponse({ + status: 200, + description: "Notification preferences updated successfully", + }) + @ApiResponse({ status: 400, description: "Invalid input data" }) + async updatePreferences(@Request() req: any, @Body() updateDto: UpdateNotificationPreferenceDto) { + return await this.notificationPreferenceService.update(req.user.id, req.user.role, updateDto) + } + + @Post('reset') + @ApiOperation({ summary: 'Reset notification preferences to defaults' }) + @ApiResponse({ + status: 200, + description: 'Notification preferences reset to defaults', + }) + async resetPreferences(@Request() req: any) { + return await this.notificationPreferenceService.resetToDefaults( + req.user.id, + req.user.role, + ); + } + + @Delete() + @HttpCode(HttpStatus.NO_CONTENT) + @ApiOperation({ summary: 'Delete current user notification preferences' }) + @ApiResponse({ + status: 204, + description: 'Notification preferences deleted successfully', + }) + @ApiResponse({ status: 404, description: 'Preferences not found' }) + async deletePreferences(@Request() req: any) { + await this.notificationPreferenceService.remove( + req.user.id, + req.user.role, + ); + } +} diff --git a/backend/src/notification-preference/dto/create-notification-preference.dto.ts b/backend/src/notification-preference/dto/create-notification-preference.dto.ts new file mode 100644 index 0000000..83ece27 --- /dev/null +++ b/backend/src/notification-preference/dto/create-notification-preference.dto.ts @@ -0,0 +1,111 @@ +import { IsBoolean, IsEnum, IsOptional, IsString, IsUUID, Matches } from "class-validator" +import { UserRole } from "../../roles/roles.enum" + +export class CreateNotificationPreferenceDto { + @IsUUID() + userId: string + + @IsEnum(UserRole) + role: UserRole + + // Notification Categories + @IsOptional() + @IsBoolean() + courseEnrollment?: boolean + + @IsOptional() + @IsBoolean() + courseCompletion?: boolean + + @IsOptional() + @IsBoolean() + lessonUpdates?: boolean + + @IsOptional() + @IsBoolean() + lessonCompletion?: boolean + + @IsOptional() + @IsBoolean() + quizResults?: boolean + + @IsOptional() + @IsBoolean() + quizReminders?: boolean + + @IsOptional() + @IsBoolean() + daoUpdates?: boolean + + @IsOptional() + @IsBoolean() + daoProposals?: boolean + + @IsOptional() + @IsBoolean() + daoVoting?: boolean + + @IsOptional() + @IsBoolean() + systemAnnouncements?: boolean + + @IsOptional() + @IsBoolean() + maintenance?: boolean + + @IsOptional() + @IsBoolean() + profileUpdates?: boolean + + @IsOptional() + @IsBoolean() + passwordChanges?: boolean + + // Delivery Channels + @IsOptional() + @IsBoolean() + viaEmail?: boolean + + @IsOptional() + @IsBoolean() + viaInApp?: boolean + + @IsOptional() + @IsBoolean() + viaSms?: boolean + + @IsOptional() + @IsBoolean() + viaPush?: boolean + + // Timing Preferences + @IsOptional() + @IsBoolean() + instantDelivery?: boolean + + @IsOptional() + @IsBoolean() + dailyDigest?: boolean + + @IsOptional() + @IsBoolean() + weeklyDigest?: boolean + + @IsOptional() + @IsString() + @Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, { + message: "quietHoursStart must be in HH:MM format", + }) + quietHoursStart?: string + + @IsOptional() + @IsString() + @Matches(/^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$/, { + message: "quietHoursEnd must be in HH:MM format", + }) + quietHoursEnd?: string + + @IsOptional() + @IsString() + timezone?: string +} diff --git a/backend/src/notification-preference/dto/query-notification-preference.dto.ts b/backend/src/notification-preference/dto/query-notification-preference.dto.ts new file mode 100644 index 0000000..0d6c3f9 --- /dev/null +++ b/backend/src/notification-preference/dto/query-notification-preference.dto.ts @@ -0,0 +1,8 @@ +import { IsOptional, IsEnum } from "class-validator" +import { UserRole } from "../../roles/roles.enum" + +export class QueryNotificationPreferenceDto { + @IsOptional() + @IsEnum(UserRole) + role?: UserRole +} diff --git a/backend/src/notification-preference/dto/update-notification-preference.dto.ts b/backend/src/notification-preference/dto/update-notification-preference.dto.ts new file mode 100644 index 0000000..78f6258 --- /dev/null +++ b/backend/src/notification-preference/dto/update-notification-preference.dto.ts @@ -0,0 +1,6 @@ +import { PartialType, OmitType } from "@nestjs/mapped-types" +import { CreateNotificationPreferenceDto } from "./create-notification-preference.dto" + +export class UpdateNotificationPreferenceDto extends PartialType( + OmitType(CreateNotificationPreferenceDto, ["userId", "role"] as const), +) {} diff --git a/backend/src/notification-preference/entities/notification-preference.entity.ts b/backend/src/notification-preference/entities/notification-preference.entity.ts new file mode 100644 index 0000000..a3b732e --- /dev/null +++ b/backend/src/notification-preference/entities/notification-preference.entity.ts @@ -0,0 +1,98 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, Index, Unique } from "typeorm" +import { UserRole } from "../../roles/roles.enum" + +@Entity("notification_preferences") +@Unique(["userId", "role"]) +@Index(["userId", "role"]) +export class NotificationPreference { + @PrimaryGeneratedColumn("uuid") + id: string + + @Column("uuid") + @Index() + userId: string + + @Column({ + type: "enum", + enum: UserRole, + }) + role: UserRole + + // Notification Categories + @Column({ default: true }) + courseEnrollment: boolean + + @Column({ default: true }) + courseCompletion: boolean + + @Column({ default: true }) + lessonUpdates: boolean + + @Column({ default: true }) + lessonCompletion: boolean + + @Column({ default: true }) + quizResults: boolean + + @Column({ default: true }) + quizReminders: boolean + + @Column({ default: true }) + daoUpdates: boolean + + @Column({ default: true }) + daoProposals: boolean + + @Column({ default: true }) + daoVoting: boolean + + @Column({ default: true }) + systemAnnouncements: boolean + + @Column({ default: false }) + maintenance: boolean + + @Column({ default: true }) + profileUpdates: boolean + + @Column({ default: true }) + passwordChanges: boolean + + // Delivery Channels + @Column({ default: true }) + viaEmail: boolean + + @Column({ default: true }) + viaInApp: boolean + + @Column({ default: false }) + viaSms: boolean + + @Column({ default: false }) + viaPush: boolean + + // Timing Preferences + @Column({ default: true }) + instantDelivery: boolean + + @Column({ default: false }) + dailyDigest: boolean + + @Column({ default: false }) + weeklyDigest: boolean + + @Column({ type: "time", nullable: true }) + quietHoursStart: string // Format: HH:MM + + @Column({ type: "time", nullable: true }) + quietHoursEnd: string // Format: HH:MM + + @Column({ default: "UTC" }) + timezone: string + + @CreateDateColumn() + createdAt: Date + + @UpdateDateColumn() + updatedAt: Date +} diff --git a/backend/src/notification-preference/notification-preference.e2e-spec.ts b/backend/src/notification-preference/notification-preference.e2e-spec.ts new file mode 100644 index 0000000..942b92a --- /dev/null +++ b/backend/src/notification-preference/notification-preference.e2e-spec.ts @@ -0,0 +1,198 @@ +import { Test, type TestingModule } from "@nestjs/testing" +import { type INestApplication, ValidationPipe } from "@nestjs/common" +import * as request from "supertest" +import { getRepositoryToken } from "@nestjs/typeorm" +import type { Repository } from "typeorm" +import { NotificationPreferenceModule } from "./notification-preference.module" +import { NotificationPreference } from "./entities/notification-preference.entity" +import { UserRole } from "../roles/roles.enum" +import { RolesGuard } from "../roles/roles.guard" + +describe("NotificationPreference (e2e)", () => { + let app: INestApplication + let repository: Repository + + const mockUser = { + id: "user-id", + role: UserRole.STUDENT, + } + + const mockRolesGuard = { + canActivate: jest.fn(() => true), + } + + beforeAll(async () => { + const moduleFixture: TestingModule = await Test.createTestingModule({ + imports: [NotificationPreferenceModule], + }) + .overrideProvider(getRepositoryToken(NotificationPreference)) + .useValue({ + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), + }) + .overrideGuard(RolesGuard) + .useValue(mockRolesGuard) + .compile() + + app = moduleFixture.createNestApplication() + app.useGlobalPipes(new ValidationPipe({ transform: true })) + + // Mock request user + app.use((req, res, next) => { + req.user = mockUser + next() + }) + + repository = moduleFixture.get>(getRepositoryToken(NotificationPreference)) + + await app.init() + }) + + afterAll(async () => { + await app.close() + }) + + describe("/notification-preferences (GET)", () => { + it("should return user preferences", async () => { + const mockPreference = { + id: "pref-id", + userId: mockUser.id, + role: mockUser.role, + lessonUpdates: true, + viaEmail: true, + viaInApp: true, + } + + jest.spyOn(repository, "findOne").mockResolvedValue(mockPreference as any) + + return request(app.getHttpServer()) + .get("/notification-preferences") + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("id", "pref-id") + expect(res.body).toHaveProperty("lessonUpdates", true) + }) + }) + + it("should create default preferences if none exist", async () => { + const defaultPreference = { + id: "new-pref-id", + userId: mockUser.id, + role: mockUser.role, + lessonUpdates: true, + viaEmail: true, + viaInApp: true, + } + + jest.spyOn(repository, "findOne").mockResolvedValue(null) + jest.spyOn(repository, "create").mockReturnValue(defaultPreference as any) + jest.spyOn(repository, "save").mockResolvedValue(defaultPreference as any) + + return request(app.getHttpServer()) + .get("/notification-preferences") + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("id", "new-pref-id") + }) + }) + }) + + describe("/notification-preferences (PUT)", () => { + it("should update user preferences", async () => { + const updateDto = { + lessonUpdates: false, + viaEmail: false, + } + + const updatedPreference = { + id: "pref-id", + userId: mockUser.id, + role: mockUser.role, + lessonUpdates: false, + viaEmail: false, + viaInApp: true, + } + + jest.spyOn(repository, "findOne").mockResolvedValue({ + id: "pref-id", + userId: mockUser.id, + role: mockUser.role, + lessonUpdates: true, + viaEmail: true, + viaInApp: true, + } as any) + jest.spyOn(repository, "save").mockResolvedValue(updatedPreference as any) + + return request(app.getHttpServer()) + .put("/notification-preferences") + .send(updateDto) + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("lessonUpdates", false) + expect(res.body).toHaveProperty("viaEmail", false) + }) + }) + + it("should validate input data", async () => { + const invalidDto = { + quietHoursStart: "invalid-time", + lessonUpdates: "not-a-boolean", + } + + return request(app.getHttpServer()).put("/notification-preferences").send(invalidDto).expect(400) + }) + }) + + describe("/notification-preferences/reset (POST)", () => { + it("should reset preferences to defaults", async () => { + const defaultPreference = { + id: "pref-id", + userId: mockUser.id, + role: mockUser.role, + lessonUpdates: true, + viaEmail: true, + viaInApp: true, + maintenance: false, + } + + jest.spyOn(repository, "findOne").mockResolvedValue({ + id: "pref-id", + userId: mockUser.id, + role: mockUser.role, + lessonUpdates: false, + viaEmail: false, + } as any) + jest.spyOn(repository, "save").mockResolvedValue(defaultPreference as any) + + return request(app.getHttpServer()) + .post("/notification-preferences/reset") + .expect(200) + .expect((res) => { + expect(res.body).toHaveProperty("lessonUpdates", true) + expect(res.body).toHaveProperty("viaEmail", true) + expect(res.body).toHaveProperty("maintenance", false) + }) + }) + }) + + describe("/notification-preferences (DELETE)", () => { + it("should delete user preferences", async () => { + jest.spyOn(repository, "delete").mockResolvedValue({ affected: 1 } as any) + + return request(app.getHttpServer()).delete("/notification-preferences").expect(204) + }) + + it("should return 404 if preferences not found", async () => { + jest.spyOn(repository, "delete").mockResolvedValue({ affected: 0 } as any) + + return request(app.getHttpServer()).delete("/notification-preferences").expect(404) + }) + }) +}) diff --git a/backend/src/notification-preference/notification-preference.integration.spec.ts b/backend/src/notification-preference/notification-preference.integration.spec.ts new file mode 100644 index 0000000..6fa51aa --- /dev/null +++ b/backend/src/notification-preference/notification-preference.integration.spec.ts @@ -0,0 +1,172 @@ +import { Test, type TestingModule } from "@nestjs/testing" +import { NotificationService } from "../notification/notification.service" +import { getRepositoryToken } from "@nestjs/typeorm" +import { NotificationPreference } from "./entities/notification-preference.entity" +import { Notification } from "../notification/entities/notification.entity" +import { UserRole } from "../roles/roles.enum" +import { NotificationType } from "../notification/enums/notification.enums" +import { NotificationPreferenceService } from "./providers/notification-preference.service" + +describe("NotificationPreference Integration", () => { + let notificationPreferenceService: NotificationPreferenceService + let notificationService: NotificationService + + const mockPreferenceRepository = { + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(() => ({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), + } + + const mockNotificationRepository = { + create: jest.fn(), + save: jest.fn(), + find: jest.fn(), + findOne: jest.fn(), + findAndCount: jest.fn(), + update: jest.fn(), + remove: jest.fn(), + count: jest.fn(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationPreferenceService, + NotificationService, + { + provide: getRepositoryToken(NotificationPreference), + useValue: mockPreferenceRepository, + }, + { + provide: getRepositoryToken(Notification), + useValue: mockNotificationRepository, + }, + ], + }).compile() + + notificationPreferenceService = module.get(NotificationPreferenceService) + notificationService = module.get(NotificationService) + }) + + it("should filter notifications based on user preferences", async () => { + const userId = "user-id" + const role = UserRole.STUDENT + + // Mock user preferences - lesson updates disabled + const mockPreference = { + id: "pref-id", + userId, + role, + lessonUpdates: false, // Disabled + viaInApp: true, + viaEmail: true, + } + + mockPreferenceRepository.findOne.mockResolvedValue(mockPreference) + + // Test that notification is not sent when preference is disabled + const shouldReceive = await notificationPreferenceService.shouldReceiveNotification( + userId, + role, + NotificationType.NEW_LESSON, + "inApp", + ) + + expect(shouldReceive).toBe(false) + }) + + it("should respect channel preferences", async () => { + const userId = "user-id" + const role = UserRole.STUDENT + + // Mock user preferences - email disabled, in-app enabled + const mockPreference = { + id: "pref-id", + userId, + role, + lessonUpdates: true, + viaInApp: true, + viaEmail: false, // Email disabled + } + + mockPreferenceRepository.findOne.mockResolvedValue(mockPreference) + + // Should receive in-app notification + const shouldReceiveInApp = await notificationPreferenceService.shouldReceiveNotification( + userId, + role, + NotificationType.NEW_LESSON, + "inApp", + ) + + // Should not receive email notification + const shouldReceiveEmail = await notificationPreferenceService.shouldReceiveNotification( + userId, + role, + NotificationType.NEW_LESSON, + "email", + ) + + expect(shouldReceiveInApp).toBe(true) + expect(shouldReceiveEmail).toBe(false) + }) + + it("should handle quiet hours correctly", async () => { + const userId = "user-id" + const role = UserRole.STUDENT + + // Mock user preferences with quiet hours + const mockPreference = { + id: "pref-id", + userId, + role, + quietHoursStart: "22:00", + quietHoursEnd: "08:00", + timezone: "UTC", + } + + mockPreferenceRepository.findOne.mockResolvedValue(mockPreference) + + // Mock current time to be within quiet hours (23:30) + jest.spyOn(Intl.DateTimeFormat.prototype, "format").mockReturnValue("23:30") + + const isInQuietHours = await notificationPreferenceService.isInQuietHours(userId, role) + + expect(isInQuietHours).toBe(true) + }) + + it("should create default preferences for new users", async () => { + const userId = "new-user-id" + const role = UserRole.STUDENT + + // Mock no existing preferences + mockPreferenceRepository.findOne.mockResolvedValue(null) + + // Mock creation of default preferences + const defaultPreference = { + id: "new-pref-id", + userId, + role, + lessonUpdates: true, + viaInApp: true, + viaEmail: true, + } + + mockPreferenceRepository.create.mockReturnValue(defaultPreference) + mockPreferenceRepository.save.mockResolvedValue(defaultPreference) + + const result = await notificationPreferenceService.findByUser(userId, role) + + expect(mockPreferenceRepository.create).toHaveBeenCalledWith({ + userId, + role, + }) + expect(result).toEqual(defaultPreference) + }) +}) diff --git a/backend/src/notification-preference/notification-preference.module.ts b/backend/src/notification-preference/notification-preference.module.ts new file mode 100644 index 0000000..bdb9d97 --- /dev/null +++ b/backend/src/notification-preference/notification-preference.module.ts @@ -0,0 +1,13 @@ +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { NotificationPreference } from "./entities/notification-preference.entity" +import { NotificationPreferenceController } from "./controllers/notification-preference.controller" +import { NotificationPreferenceService } from "./providers/notification-preference.service" + +@Module({ + imports: [TypeOrmModule.forFeature([NotificationPreference])], + controllers: [NotificationPreferenceController], + providers: [NotificationPreferenceService], + exports: [NotificationPreferenceService], +}) +export class NotificationPreferenceModule {} diff --git a/backend/src/notification-preference/providers/notification-preference.service.spec.ts b/backend/src/notification-preference/providers/notification-preference.service.spec.ts new file mode 100644 index 0000000..dd3c652 --- /dev/null +++ b/backend/src/notification-preference/providers/notification-preference.service.spec.ts @@ -0,0 +1,238 @@ +import { Test, TestingModule } from "@nestjs/testing" +import { getRepositoryToken } from "@nestjs/typeorm" +import { Repository } from "typeorm" +import { NotFoundException } from "@nestjs/common" +import { NotificationPreferenceService } from "./notification-preference.service" +import { NotificationPreference } from "../entities/notification-preference.entity" +import { UserRole } from "src/roles/roles.enum" +import { NotificationType } from "src/notification/enums/notification.enums" + +type MockRepository = Partial, jest.Mock>> + +const createMockRepository = (): MockRepository => ({ + create: jest.fn(), + save: jest.fn(), + findOne: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), +}) + +describe("NotificationPreferenceService", () => { + let service: NotificationPreferenceService + let repository: MockRepository + + const mockPreference: NotificationPreference = { + id: "test-id", + userId: "user-id", + role: UserRole.STUDENT, + courseEnrollment: true, + courseCompletion: true, + lessonUpdates: true, + lessonCompletion: true, + quizResults: true, + quizReminders: true, + daoUpdates: true, + daoProposals: true, + daoVoting: true, + systemAnnouncements: true, + maintenance: false, + profileUpdates: true, + passwordChanges: true, + viaEmail: true, + viaInApp: true, + viaSms: false, + viaPush: false, + instantDelivery: true, + dailyDigest: false, + weeklyDigest: false, + quietHoursStart: null, + quietHoursEnd: null, + timezone: "UTC", + createdAt: new Date(), + updatedAt: new Date(), + } + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + NotificationPreferenceService, + { + provide: getRepositoryToken(NotificationPreference), + useValue: createMockRepository(), + }, + ], + }).compile() + + service = module.get(NotificationPreferenceService) + repository = module.get>(getRepositoryToken(NotificationPreference)) + }) + + it("should be defined", () => { + expect(service).toBeDefined() + }) + + describe("create", () => { + it("should create notification preferences", async () => { + const createDto = { + userId: "user-id", + role: UserRole.STUDENT, + lessonUpdates: true, + viaEmail: true, + } + + repository.create.mockReturnValue(mockPreference) + repository.save.mockResolvedValue(mockPreference) + + const result = await service.create(createDto) + + expect(repository.create).toHaveBeenCalledWith(createDto) + expect(repository.save).toHaveBeenCalledWith(mockPreference) + expect(result).toEqual(mockPreference) + }) + }) + + describe("findByUser", () => { + it("should return existing preferences", async () => { + repository.findOne.mockResolvedValue(mockPreference) + + const result = await service.findByUser("user-id", UserRole.STUDENT) + + expect(repository.findOne).toHaveBeenCalledWith({ + where: { userId: "user-id", role: UserRole.STUDENT }, + }) + expect(result).toEqual(mockPreference) + }) + + it("should create default preferences if none exist", async () => { + repository.findOne.mockResolvedValue(null) + repository.create.mockReturnValue(mockPreference) + repository.save.mockResolvedValue(mockPreference) + + const result = await service.findByUser("user-id", UserRole.STUDENT) + + expect(repository.create).toHaveBeenCalledWith({ + userId: "user-id", + role: UserRole.STUDENT, + }) + expect(result).toEqual(mockPreference) + }) + }) + + describe("update", () => { + it("should update preferences", async () => { + const updateDto = { lessonUpdates: false, viaEmail: false } + const updatedPreference = { ...mockPreference, ...updateDto } + + repository.findOne.mockResolvedValue(mockPreference) + repository.save.mockResolvedValue(updatedPreference) + + const result = await service.update("user-id", UserRole.STUDENT, updateDto) + + expect(repository.save).toHaveBeenCalledWith(updatedPreference) + expect(result).toEqual(updatedPreference) + }) + }) + + describe("remove", () => { + it("should delete preferences", async () => { + repository.delete.mockResolvedValue({ affected: 1 }) + + await service.remove("user-id", UserRole.STUDENT) + + expect(repository.delete).toHaveBeenCalledWith({ + userId: "user-id", + role: UserRole.STUDENT, + }) + }) + + it("should throw NotFoundException if preferences not found", async () => { + repository.delete.mockResolvedValue({ affected: 0 }) + + await expect(service.remove("user-id", UserRole.STUDENT)).rejects.toThrow(NotFoundException) + }) + }) + + describe("shouldReceiveNotification", () => { + beforeEach(() => { + repository.findOne.mockResolvedValue(mockPreference) + }) + + it("should return true for enabled notification type and channel", async () => { + const result = await service.shouldReceiveNotification( + "user-id", + UserRole.STUDENT, + NotificationType.LESSON_COMPLETION, + "inApp", + ) + + expect(result).toBe(true) + }) + + it("should return false for disabled channel", async () => { + const result = await service.shouldReceiveNotification( + "user-id", + UserRole.STUDENT, + NotificationType.LESSON_COMPLETION, + "sms", + ) + + expect(result).toBe(false) + }) + + it("should return false for disabled notification type", async () => { + const disabledPreference = { + ...mockPreference, + lessonCompletion: false, + } + repository.findOne.mockResolvedValue(disabledPreference) + + const result = await service.shouldReceiveNotification( + "user-id", + UserRole.STUDENT, + NotificationType.LESSON_COMPLETION, + "inApp", + ) + + expect(result).toBe(false) + }) + }) + + describe("isInQuietHours", () => { + it("should return false when no quiet hours set", async () => { + repository.findOne.mockResolvedValue(mockPreference) + + const result = await service.isInQuietHours("user-id", UserRole.STUDENT) + + expect(result).toBe(false) + }) + + it("should check quiet hours correctly", async () => { + const quietPreference = { + ...mockPreference, + quietHoursStart: "22:00", + quietHoursEnd: "08:00", + timezone: "UTC", + } + repository.findOne.mockResolvedValue(quietPreference) + + // Mock current time to be within quiet hours + jest.spyOn(Intl.DateTimeFormat.prototype, "format").mockReturnValue("23:30") + + const result = await service.isInQuietHours("user-id", UserRole.STUDENT) + + expect(result).toBe(true) + }) + }) + + describe("resetToDefaults", () => { + it("should reset preferences to default values", async () => { + repository.findOne.mockResolvedValue(mockPreference) + repository.save.mockResolvedValue(mockPreference) + + const result = await service.resetToDefaults("user-id", UserRole.STUDENT) + + expect(repository.save).toHaveBeenCalled() + expect(result).toEqual(mockPreference) + }) + }) +}) diff --git a/backend/src/notification-preference/providers/notification-preference.service.ts b/backend/src/notification-preference/providers/notification-preference.service.ts new file mode 100644 index 0000000..b25ac5c --- /dev/null +++ b/backend/src/notification-preference/providers/notification-preference.service.ts @@ -0,0 +1,271 @@ +import { Injectable, NotFoundException } from "@nestjs/common" +import { Repository } from "typeorm" +import { NotificationPreference } from "../entities/notification-preference.entity" +import { CreateNotificationPreferenceDto } from "../dto/create-notification-preference.dto" +import { UserRole } from "src/roles/roles.enum" +import { UpdateNotificationPreferenceDto } from "../dto/update-notification-preference.dto" +import { NotificationType } from "src/notification/enums/notification.enums" +import { InjectRepository } from "@nestjs/typeorm" + +@Injectable() +export class NotificationPreferenceService { + constructor( + @InjectRepository(NotificationPreference) + private readonly preferenceRepository: Repository, + ) {} + + /** + * Create notification preferences for a user + */ + async create(createDto: CreateNotificationPreferenceDto): Promise { + const preference = this.preferenceRepository.create(createDto) + return await this.preferenceRepository.save(preference) + } + + /** + * Get user's notification preferences + */ + async findByUser(userId: string, role: UserRole): Promise { + let preference = await this.preferenceRepository.findOne({ + where: { userId, role }, + }) + + // Create default preferences if none exist + if (!preference) { + preference = await this.create({ userId, role }) + } + + return preference + } + + /** + * Update user's notification preferences + */ + async update( + userId: string, + role: UserRole, + updateDto: UpdateNotificationPreferenceDto, + ): Promise { + const preference = await this.findByUser(userId, role) + + Object.assign(preference, updateDto) + return await this.preferenceRepository.save(preference) + } + + /** + * Delete user's notification preferences + */ + async remove(userId: string, role: UserRole): Promise { + const result = await this.preferenceRepository.delete({ userId, role }) + if (result.affected === 0) { + throw new NotFoundException("Notification preferences not found") + } + } + + /** + * Check if user wants to receive a specific type of notification via a specific channel + */ + async shouldReceiveNotification( + userId: string, + role: UserRole, + notificationType: NotificationType, + channel: "email" | "inApp" | "sms" | "push" = "inApp", + ): Promise { + const preferences = await this.findByUser(userId, role) + + // Check if the channel is enabled + const channelEnabled = this.isChannelEnabled(preferences, channel) + if (!channelEnabled) { + return false + } + + // Check if the notification type is enabled + return this.isNotificationTypeEnabled(preferences, notificationType) + } + + /** + * Check if a delivery channel is enabled + */ + private isChannelEnabled(preferences: NotificationPreference, channel: "email" | "inApp" | "sms" | "push"): boolean { + switch (channel) { + case "email": + return preferences.viaEmail + case "inApp": + return preferences.viaInApp + case "sms": + return preferences.viaSms + case "push": + return preferences.viaPush + default: + return false + } + } + + /** + * Check if a notification type is enabled + */ + private isNotificationTypeEnabled(preferences: NotificationPreference, notificationType: NotificationType): boolean { + switch (notificationType) { + case NotificationType.COURSE_ENROLLMENT: + return preferences.courseEnrollment + case NotificationType.COURSE_COMPLETION: + return preferences.courseCompletion + case NotificationType.NEW_LESSON: + return preferences.lessonUpdates + case NotificationType.LESSON_COMPLETION: + return preferences.lessonCompletion + case NotificationType.QUIZ_RESULT: + return preferences.quizResults + case NotificationType.QUIZ_REMINDER: + return preferences.quizReminders + case NotificationType.DAO_UPDATE: + return preferences.daoUpdates + case NotificationType.DAO_PROPOSAL: + return preferences.daoProposals + case NotificationType.DAO_VOTING: + return preferences.daoVoting + case NotificationType.SYSTEM_ANNOUNCEMENT: + return preferences.systemAnnouncements + case NotificationType.MAINTENANCE: + return preferences.maintenance + case NotificationType.PROFILE_UPDATE: + return preferences.profileUpdates + case NotificationType.PASSWORD_CHANGE: + return preferences.passwordChanges + default: + return true // Default to allowing unknown notification types + } + } + + /** + * Check if user is in quiet hours + */ + async isInQuietHours(userId: string, role: UserRole): Promise { + const preferences = await this.findByUser(userId, role) + + if (!preferences.quietHoursStart || !preferences.quietHoursEnd) { + return false + } + + const now = new Date() + const userTimezone = preferences.timezone || "UTC" + + // Convert current time to user's timezone + const userTime = new Intl.DateTimeFormat("en-US", { + timeZone: userTimezone, + hour12: false, + hour: "2-digit", + minute: "2-digit", + }).format(now) + + const currentTime = userTime.replace(":", "") + const quietStart = preferences.quietHoursStart.replace(":", "") + const quietEnd = preferences.quietHoursEnd.replace(":", "") + + // Handle cases where quiet hours span midnight + if (quietStart <= quietEnd) { + return currentTime >= quietStart && currentTime <= quietEnd + } else { + return currentTime >= quietStart || currentTime <= quietEnd + } + } + + /** + * Get users who want to receive a specific notification type + */ + async getUsersForNotification( + notificationType: NotificationType, + channel: "email" | "inApp" | "sms" | "push" = "inApp", + role?: UserRole, + ): Promise { + const queryBuilder = this.preferenceRepository.createQueryBuilder("pref") + + // Add role filter if specified + if (role) { + queryBuilder.where("pref.role = :role", { role }) + } + + // Add channel filter + switch (channel) { + case "email": + queryBuilder.andWhere("pref.viaEmail = true") + break + case "inApp": + queryBuilder.andWhere("pref.viaInApp = true") + break + case "sms": + queryBuilder.andWhere("pref.viaSms = true") + break + case "push": + queryBuilder.andWhere("pref.viaPush = true") + break + } + + // Add notification type filter + const typeColumn = this.getNotificationTypeColumn(notificationType) + if (typeColumn) { + queryBuilder.andWhere(`pref.${typeColumn} = true`) + } + + return await queryBuilder.getMany() + } + + /** + * Map notification type to database column + */ + private getNotificationTypeColumn(notificationType: NotificationType): string | null { + const mapping: Record = { + [NotificationType.COURSE_ENROLLMENT]: "courseEnrollment", + [NotificationType.COURSE_COMPLETION]: "courseCompletion", + [NotificationType.NEW_LESSON]: "lessonUpdates", + [NotificationType.LESSON_COMPLETION]: "lessonCompletion", + [NotificationType.QUIZ_RESULT]: "quizResults", + [NotificationType.QUIZ_REMINDER]: "quizReminders", + [NotificationType.DAO_UPDATE]: "daoUpdates", + [NotificationType.DAO_PROPOSAL]: "daoProposals", + [NotificationType.DAO_VOTING]: "daoVoting", + [NotificationType.SYSTEM_ANNOUNCEMENT]: "systemAnnouncements", + [NotificationType.MAINTENANCE]: "maintenance", + [NotificationType.PROFILE_UPDATE]: "profileUpdates", + [NotificationType.PASSWORD_CHANGE]: "passwordChanges", + } + + return mapping[notificationType] || null + } + + /** + * Reset preferences to default values + */ + async resetToDefaults(userId: string, role: UserRole): Promise { + const preference = await this.findByUser(userId, role) + + // Reset to default values + Object.assign(preference, { + courseEnrollment: true, + courseCompletion: true, + lessonUpdates: true, + lessonCompletion: true, + quizResults: true, + quizReminders: true, + daoUpdates: true, + daoProposals: true, + daoVoting: true, + systemAnnouncements: true, + maintenance: false, + profileUpdates: true, + passwordChanges: true, + viaEmail: true, + viaInApp: true, + viaSms: false, + viaPush: false, + instantDelivery: true, + dailyDigest: false, + weeklyDigest: false, + quietHoursStart: null, + quietHoursEnd: null, + timezone: "UTC", + }) + + return await this.preferenceRepository.save(preference) + } +} diff --git a/backend/src/notification/notification.module.ts b/backend/src/notification/notification.module.ts index 1a31e68..025c734 100644 --- a/backend/src/notification/notification.module.ts +++ b/backend/src/notification/notification.module.ts @@ -1,11 +1,12 @@ -import { Module } from '@nestjs/common'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { NotificationController } from './notification.controller'; -import { NotificationService } from './notification.service'; -import { Notification } from './entities/notification.entity'; +import { Module } from "@nestjs/common" +import { TypeOrmModule } from "@nestjs/typeorm" +import { NotificationController } from "./notification.controller" +import { NotificationService } from "./notification.service" +import { Notification } from "./entities/notification.entity" +import { NotificationPreferenceModule } from "../notification-preference/notification-preference.module" @Module({ - imports: [TypeOrmModule.forFeature([Notification])], + imports: [TypeOrmModule.forFeature([Notification]), NotificationPreferenceModule], controllers: [NotificationController], providers: [NotificationService], exports: [NotificationService], diff --git a/backend/src/notification/notification.service.ts b/backend/src/notification/notification.service.ts index 5ccd207..f8de951 100644 --- a/backend/src/notification/notification.service.ts +++ b/backend/src/notification/notification.service.ts @@ -1,29 +1,26 @@ -import { Injectable, NotFoundException } from '@nestjs/common'; -import { type Repository, type FindOptionsWhere, Between } from 'typeorm'; -import type { Notification } from './entities/notification.entity'; -import type { CreateNotificationDto } from './dto/create-notification.dto'; -import type { - SendNotificationDto, - BulkSendNotificationDto, -} from './dto/send-notification.dto'; -import type { QueryNotificationDto } from './dto/query-notification.dto'; -import type { UserRole } from './enums/notification.enums'; +import { Injectable, NotFoundException } from "@nestjs/common" +import { type Repository, type FindOptionsWhere, Between } from "typeorm" +import type { Notification } from "./entities/notification.entity" +import type { CreateNotificationDto } from "./dto/create-notification.dto" +import type { SendNotificationDto, BulkSendNotificationDto } from "./dto/send-notification.dto" +import type { QueryNotificationDto } from "./dto/query-notification.dto" +import type { UserRole } from "./enums/notification.enums" +import { NotificationPreferenceService } from "src/notification-preference/providers/notification-preference.service" @Injectable() export class NotificationService { - private notificationRepository: Repository; + private notificationRepository: Repository - constructor(notificationRepository: Repository) { - this.notificationRepository = notificationRepository; + constructor( + notificationRepository: Repository, + private readonly notificationPreferenceService: NotificationPreferenceService, + ) { + this.notificationRepository = notificationRepository } - async create( - createNotificationDto: CreateNotificationDto, - ): Promise { - const notification = this.notificationRepository.create( - createNotificationDto, - ); - return await this.notificationRepository.save(notification); + async create(createNotificationDto: CreateNotificationDto): Promise { + const notification = this.notificationRepository.create(createNotificationDto) + return await this.notificationRepository.save(notification) } async sendNotification( @@ -31,18 +28,42 @@ export class NotificationService { senderId?: string, senderRole?: UserRole, ): Promise { + // Check if user wants to receive this type of notification + const shouldReceive = await this.notificationPreferenceService.shouldReceiveNotification( + sendNotificationDto.recipientId, + sendNotificationDto.recipientRole, + sendNotificationDto.type, + "inApp", + ) + + if (!shouldReceive) { + // User doesn't want this notification, return null or skip + return null + } + + // Check if user is in quiet hours + const isInQuietHours = await this.notificationPreferenceService.isInQuietHours( + sendNotificationDto.recipientId, + sendNotificationDto.recipientRole, + ) + + if (isInQuietHours) { + // Schedule for later or skip immediate delivery + // For now, we'll still send but could implement queuing + } + const notificationData: CreateNotificationDto = { ...sendNotificationDto, senderId, senderRole, - }; + } - const notification = await this.create(notificationData); + const notification = await this.create(notificationData) // Here you can add real-time notification logic (WebSocket, SSE, etc.) // await this.sendRealTimeNotification(notification); - return notification; + return notification } async sendBulkNotifications( @@ -50,63 +71,70 @@ export class NotificationService { senderId?: string, senderRole?: UserRole, ): Promise { - const notifications = bulkSendDto.recipientIds.map((recipientId) => - this.notificationRepository.create({ + const notifications: Notification[] = [] + + for (const recipientId of bulkSendDto.recipientIds) { + // Check preferences for each recipient + const shouldReceive = await this.notificationPreferenceService.shouldReceiveNotification( recipientId, - recipientRole: bulkSendDto.recipientRole, - type: bulkSendDto.type, - message: bulkSendDto.message, - metadata: bulkSendDto.metadata, - senderId, - senderRole, - }), - ); - - const savedNotifications = - await this.notificationRepository.save(notifications); + bulkSendDto.recipientRole, + bulkSendDto.type, + "inApp", + ) + + if (shouldReceive) { + const notification = this.notificationRepository.create({ + recipientId, + recipientRole: bulkSendDto.recipientRole, + type: bulkSendDto.type, + message: bulkSendDto.message, + metadata: bulkSendDto.metadata, + senderId, + senderRole, + }) + notifications.push(notification) + } + } + + const savedNotifications = await this.notificationRepository.save(notifications) // Send real-time notifications for each recipient // for (const notification of savedNotifications) { // await this.sendRealTimeNotification(notification); // } - return savedNotifications; + return savedNotifications } - async findUserNotifications( - userId: string, - userRole: UserRole, - queryDto: QueryNotificationDto, - ) { - const { type, isRead, fromDate, toDate, page = 1, limit = 20 } = queryDto; + async findUserNotifications(userId: string, userRole: UserRole, queryDto: QueryNotificationDto) { + const { type, isRead, fromDate, toDate, page = 1, limit = 20 } = queryDto const where: FindOptionsWhere = { recipientId: userId, recipientRole: userRole, - }; + } if (type) { - where.type = type; + where.type = type } - if (typeof isRead === 'boolean') { - where.isRead = isRead; + if (typeof isRead === "boolean") { + where.isRead = isRead } if (fromDate || toDate) { where.createdAt = Between( - fromDate ? new Date(fromDate) : new Date('1970-01-01'), + fromDate ? new Date(fromDate) : new Date("1970-01-01"), toDate ? new Date(toDate) : new Date(), - ); + ) } - const [notifications, total] = - await this.notificationRepository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); + const [notifications, total] = await this.notificationRepository.findAndCount({ + where, + order: { createdAt: "DESC" }, + skip: (page - 1) * limit, + take: limit, + }) return { notifications, @@ -114,28 +142,24 @@ export class NotificationService { page, limit, totalPages: Math.ceil(total / limit), - }; + } } - async markAsRead( - notificationId: string, - userId: string, - userRole: UserRole, - ): Promise { + async markAsRead(notificationId: string, userId: string, userRole: UserRole): Promise { const notification = await this.notificationRepository.findOne({ where: { id: notificationId, recipientId: userId, recipientRole: userRole, }, - }); + }) if (!notification) { - throw new NotFoundException('Notification not found'); + throw new NotFoundException("Notification not found") } - notification.isRead = true; - return await this.notificationRepository.save(notification); + notification.isRead = true + return await this.notificationRepository.save(notification) } async markAllAsRead(userId: string, userRole: UserRole): Promise { @@ -146,27 +170,23 @@ export class NotificationService { isRead: false, }, { isRead: true }, - ); + ) } - async deleteNotification( - notificationId: string, - userId: string, - userRole: UserRole, - ): Promise { + async deleteNotification(notificationId: string, userId: string, userRole: UserRole): Promise { const notification = await this.notificationRepository.findOne({ where: { id: notificationId, recipientId: userId, recipientRole: userRole, }, - }); + }) if (!notification) { - throw new NotFoundException('Notification not found'); + throw new NotFoundException("Notification not found") } - await this.notificationRepository.remove(notification); + await this.notificationRepository.remove(notification) } async getUnreadCount(userId: string, userRole: UserRole): Promise { @@ -176,37 +196,36 @@ export class NotificationService { recipientRole: userRole, isRead: false, }, - }); + }) } // Admin-only methods async findAllNotifications(queryDto: QueryNotificationDto) { - const { type, isRead, fromDate, toDate, page = 1, limit = 20 } = queryDto; + const { type, isRead, fromDate, toDate, page = 1, limit = 20 } = queryDto - const where: FindOptionsWhere = {}; + const where: FindOptionsWhere = {} if (type) { - where.type = type; + where.type = type } - if (typeof isRead === 'boolean') { - where.isRead = isRead; + if (typeof isRead === "boolean") { + where.isRead = isRead } if (fromDate || toDate) { where.createdAt = Between( - fromDate ? new Date(fromDate) : new Date('1970-01-01'), + fromDate ? new Date(fromDate) : new Date("1970-01-01"), toDate ? new Date(toDate) : new Date(), - ); + ) } - const [notifications, total] = - await this.notificationRepository.findAndCount({ - where, - order: { createdAt: 'DESC' }, - skip: (page - 1) * limit, - take: limit, - }); + const [notifications, total] = await this.notificationRepository.findAndCount({ + where, + order: { createdAt: "DESC" }, + skip: (page - 1) * limit, + take: limit, + }) return { notifications, @@ -214,19 +233,19 @@ export class NotificationService { page, limit, totalPages: Math.ceil(total / limit), - }; + } } async deleteNotificationAsAdmin(notificationId: string): Promise { const notification = await this.notificationRepository.findOne({ where: { id: notificationId }, - }); + }) if (!notification) { - throw new NotFoundException('Notification not found'); + throw new NotFoundException("Notification not found") } - await this.notificationRepository.remove(notification); + await this.notificationRepository.remove(notification) } // Helper method for triggering notifications from other services @@ -243,13 +262,13 @@ export class NotificationService { { recipientId, recipientRole, - type: type as unknown as Notification['type'], + type: type as unknown as Notification["type"], message, metadata, }, senderId, senderRole, - ); + ) } // Future: Real-time notification method