diff --git a/prisma/migrations/20260710203704_notification_preferences/migration.sql b/prisma/migrations/20260710203704_notification_preferences/migration.sql new file mode 100644 index 0000000..443c6e4 --- /dev/null +++ b/prisma/migrations/20260710203704_notification_preferences/migration.sql @@ -0,0 +1,17 @@ +-- CreateTable +CREATE TABLE "NotificationPreference" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "comment" BOOLEAN NOT NULL DEFAULT true, + "reaction" BOOLEAN NOT NULL DEFAULT true, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "NotificationPreference_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "NotificationPreference_userId_key" ON "NotificationPreference"("userId"); + +-- AddForeignKey +ALTER TABLE "NotificationPreference" ADD CONSTRAINT "NotificationPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b96c4fb..83f1101 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -64,6 +64,7 @@ model User { payments Payment[] notificationsReceived Notification[] @relation("NotificationRecipient") notificationsSent Notification[] @relation("NotificationActor") + notificationPreference NotificationPreference? @@unique([email]) @@unique([username]) @@ -324,6 +325,20 @@ model Notification { @@index([recipientId, createdAt]) } +// Per-user opt-out for notification categories. Absence of a row means every +// category is enabled (default all-on). System notifications are never gated +// here — they are always delivered. +model NotificationPreference { + id String @id @default(uuid(7)) + userId String @unique + comment Boolean @default(true) + reaction Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + enum ActivityType { AccountCreated ListCreated diff --git a/src/modules/notification/controller/notification.controller.ts b/src/modules/notification/controller/notification.controller.ts index ad209de..77cfadf 100644 --- a/src/modules/notification/controller/notification.controller.ts +++ b/src/modules/notification/controller/notification.controller.ts @@ -1,4 +1,5 @@ import { + Body, Controller, Delete, Get, @@ -6,6 +7,7 @@ import { HttpStatus, Param, ParseUUIDPipe, + Patch, Post, Query, UseGuards, @@ -13,6 +15,7 @@ import { import { ApiTags } from "@nestjs/swagger"; import { AuthGuard, Session, type UserSession } from "@thallesp/nestjs-better-auth"; import { GetNotificationsDto } from "../dto/get-notifications.dto"; +import { UpdateNotificationPreferencesDto } from "../dto/notification-preference.dto"; import { NotificationService } from "../service/notification.service"; // Literal paths are declared before `/:notificationId`, otherwise Nest matches "read"/"unread" as @@ -40,6 +43,20 @@ export class NotificationController { return { count }; } + @Get("/preferences") + async getPreferences(@Session() session: UserSession) { + const preferences = await this.notificationService.getPreferences(session.user.id); + + return { preferences }; + } + + @Patch("/preferences") + async updatePreferences(@Session() session: UserSession, @Body() body: UpdateNotificationPreferencesDto) { + const preferences = await this.notificationService.updatePreferences(session.user.id, body); + + return { preferences }; + } + @Post("/read/all") @HttpCode(HttpStatus.NO_CONTENT) async markAllAsRead(@Session() session: UserSession) { diff --git a/src/modules/notification/dto/notification-preference.dto.ts b/src/modules/notification/dto/notification-preference.dto.ts new file mode 100644 index 0000000..4dce126 --- /dev/null +++ b/src/modules/notification/dto/notification-preference.dto.ts @@ -0,0 +1,19 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsBoolean, IsOptional } from "class-validator"; + +export class UpdateNotificationPreferencesDto { + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: "boolean", description: "Receive notifications for comments on your profile." }) + readonly comment?: boolean; + + @IsOptional() + @IsBoolean() + @ApiPropertyOptional({ type: "boolean", description: "Receive notifications for reactions to your content." }) + readonly reaction?: boolean; +} + +export interface NotificationPreferences { + readonly comment: boolean; + readonly reaction: boolean; +} diff --git a/src/modules/notification/service/notification.service.ts b/src/modules/notification/service/notification.service.ts index 145d917..a3c8807 100644 --- a/src/modules/notification/service/notification.service.ts +++ b/src/modules/notification/service/notification.service.ts @@ -5,12 +5,16 @@ import { ERROR_CODES } from "@/shared/constants/error-codes"; import { AppException } from "@/shared/exceptions/app.exceptions"; import { DatabaseService } from "@/shared/infra/database/database.service"; import { GetNotificationsByUserDto } from "../dto/get-notifications.dto"; +import { NotificationPreferences, UpdateNotificationPreferencesDto } from "../dto/notification-preference.dto"; import { CreateCommentNotificationDto, CreateReactionNotificationDto, CreateSystemNotificationDto, } from "../dto/notification.dto"; +// Absence of a preference row means every category is enabled. +const DEFAULT_PREFERENCES: NotificationPreferences = { comment: true, reaction: true }; + const REACTION_TARGETS = { [ReactionType.Comment]: { type: NotificationType.ReactionOnComment, relation: "comment", sourceField: "commentId" }, [ReactionType.Activity]: { @@ -96,6 +100,10 @@ export class NotificationService { return; } + if (!(await this.isCategoryEnabled(recipientId, "comment"))) { + return; + } + await this.databaseService.notification.create({ data: { type: NotificationType.CommentOnProfile, @@ -134,6 +142,10 @@ export class NotificationService { return; } + if (!(await this.isCategoryEnabled(owner.userId, "reaction"))) { + return; + } + await this.databaseService.notification.create({ data: { type: target.type, @@ -223,6 +235,37 @@ export class NotificationService { }); } + async getPreferences(userId: string): Promise { + const preference = await this.databaseService.notificationPreference.findUnique({ + where: { userId }, + select: { comment: true, reaction: true }, + }); + + return preference ?? DEFAULT_PREFERENCES; + } + + async updatePreferences(userId: string, dto: UpdateNotificationPreferencesDto): Promise { + const data = { + ...(dto.comment !== undefined && { comment: dto.comment }), + ...(dto.reaction !== undefined && { reaction: dto.reaction }), + }; + + const preference = await this.databaseService.notificationPreference.upsert({ + where: { userId }, + create: { userId, ...data }, + update: data, + select: { comment: true, reaction: true }, + }); + + return preference; + } + + private async isCategoryEnabled(userId: string, category: keyof NotificationPreferences): Promise { + const preferences = await this.getPreferences(userId); + + return preferences[category]; + } + private async updateReadAt(userId: string, notificationId: string, readAt: Date | null) { const { count } = await this.databaseService.notification.updateMany({ where: { id: notificationId, recipientId: userId },