Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
15 changes: 15 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ model User {
payments Payment[]
notificationsReceived Notification[] @relation("NotificationRecipient")
notificationsSent Notification[] @relation("NotificationActor")
notificationPreference NotificationPreference?

@@unique([email])
@@unique([username])
Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions src/modules/notification/controller/notification.controller.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,21 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
ParseUUIDPipe,
Patch,
Post,
Query,
UseGuards,
} from "@nestjs/common";
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
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions src/modules/notification/dto/notification-preference.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
43 changes: 43 additions & 0 deletions src/modules/notification/service/notification.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]: {
Expand Down Expand Up @@ -96,6 +100,10 @@ export class NotificationService {
return;
}

if (!(await this.isCategoryEnabled(recipientId, "comment"))) {
return;
}

await this.databaseService.notification.create({
data: {
type: NotificationType.CommentOnProfile,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -223,6 +235,37 @@ export class NotificationService {
});
}

async getPreferences(userId: string): Promise<NotificationPreferences> {
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<NotificationPreferences> {
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<boolean> {
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 },
Expand Down