From b8b0f8d9c826ea2f0eb87feaba63fbc937520335 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 22 Feb 2026 18:23:08 +0900 Subject: [PATCH 01/12] [BACKEND] [FEATURE] Implement notifications system --- .../migration.sql | 21 ++ prisma/models/notification.prisma | 14 + prisma/models/user.prisma | 1 + src/app.module.ts | 52 ++-- src/collab/signaling/signal.ts | 270 +++++++++--------- src/main.ts | 182 ++++++------ .../dto/notification-test.dto.ts | 28 ++ src/notifications/notifications.controller.ts | 82 ++++++ src/notifications/notifications.module.ts | 10 + src/notifications/notifications.service.ts | 120 ++++++++ src/notifications/notifications.socket.ts | 188 ++++++++++++ src/notifications/notifications.types.ts | 16 ++ 12 files changed, 739 insertions(+), 245 deletions(-) create mode 100644 prisma/migrations/20260222071209_add_notifications/migration.sql create mode 100644 prisma/models/notification.prisma create mode 100644 src/notifications/dto/notification-test.dto.ts create mode 100644 src/notifications/notifications.controller.ts create mode 100644 src/notifications/notifications.module.ts create mode 100644 src/notifications/notifications.service.ts create mode 100644 src/notifications/notifications.socket.ts create mode 100644 src/notifications/notifications.types.ts diff --git a/prisma/migrations/20260222071209_add_notifications/migration.sql b/prisma/migrations/20260222071209_add_notifications/migration.sql new file mode 100644 index 0000000..33ad754 --- /dev/null +++ b/prisma/migrations/20260222071209_add_notifications/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "Notification" ( + "id" SERIAL NOT NULL, + "userId" INTEGER NOT NULL, + "title" TEXT NOT NULL, + "message" TEXT NOT NULL, + "type" TEXT NOT NULL, + "read" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "Notification_userId_createdAt_idx" ON "Notification"("userId", "createdAt"); + +-- CreateIndex +CREATE INDEX "Notification_userId_read_idx" ON "Notification"("userId", "read"); + +-- AddForeignKey +ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/models/notification.prisma b/prisma/models/notification.prisma new file mode 100644 index 0000000..bbf3d8c --- /dev/null +++ b/prisma/models/notification.prisma @@ -0,0 +1,14 @@ +model Notification { + id Int @id @default(autoincrement()) + userId Int + title String + message String + type String + read Boolean @default(false) + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) + @@index([userId, read]) +} diff --git a/prisma/models/user.prisma b/prisma/models/user.prisma index 37c7eaa..9795c90 100644 --- a/prisma/models/user.prisma +++ b/prisma/models/user.prisma @@ -25,6 +25,7 @@ model User { hostingSession WorkSession[] @relation("WorkSessionHost") refreshTokens RefreshToken[] + notifications Notification[] } model Role { diff --git a/src/app.module.ts b/src/app.module.ts index 1f5561f..1582e7f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,25 +1,27 @@ -import { Module } from "@nestjs/common"; -import { ConfigModule } from "@nestjs/config"; -import { S3Module } from "@s3/s3.module"; -import { UserModule } from "@user/user.module"; -import { ProjectModule } from "@projects/project.module"; -import { WorkSessionModule } from "./routes/work-session/work-session.module"; -import { PrismaModule } from "@prisma/prisma.module"; -import { AuthModule } from "@auth/auth.module"; -import { ScheduleModule } from "@nestjs/schedule"; -import { TasksModule } from "./tasks/tasks.module"; - -@Module({ - imports: [ - ConfigModule.forRoot({ isGlobal: true }), - ScheduleModule.forRoot(), - PrismaModule, - AuthModule, - S3Module, - UserModule, - ProjectModule, - WorkSessionModule, - TasksModule - ] -}) -export class AppModule {} +import { Module } from "@nestjs/common"; +import { ConfigModule } from "@nestjs/config"; +import { S3Module } from "@s3/s3.module"; +import { UserModule } from "@user/user.module"; +import { ProjectModule } from "@projects/project.module"; +import { WorkSessionModule } from "./routes/work-session/work-session.module"; +import { PrismaModule } from "@prisma/prisma.module"; +import { AuthModule } from "@auth/auth.module"; +import { ScheduleModule } from "@nestjs/schedule"; +import { TasksModule } from "./tasks/tasks.module"; +import { NotificationsModule } from "./notifications/notifications.module"; + +@Module({ + imports: [ + ConfigModule.forRoot({ isGlobal: true }), + ScheduleModule.forRoot(), + PrismaModule, + AuthModule, + S3Module, + UserModule, + ProjectModule, + WorkSessionModule, + TasksModule, + NotificationsModule, + ] +}) +export class AppModule {} diff --git a/src/collab/signaling/signal.ts b/src/collab/signaling/signal.ts index 2d77f11..14f9679 100644 --- a/src/collab/signaling/signal.ts +++ b/src/collab/signaling/signal.ts @@ -1,133 +1,137 @@ -import { WebSocket, WebSocketServer } from "ws"; -import http from "http"; -import * as map from "lib0/map"; - -const WS_READY_STATE_CONNECTING = 0; -const WS_READY_STATE_OPEN = 1; -const PING_TIMEOUT = 30000; - -const topics = new Map>(); - -interface WSMessage { - type: "subscribe" | "unsubscribe" | "publish" | "ping" | "pong"; - topics?: string[]; - topic?: string; - clients?: number; -} - -export let wss: WebSocketServer; - -export const setupWebSocketServer = (server: http.Server): void => { - wss = new WebSocketServer({ noServer: true }); - - const send = (conn: WebSocket, message: WSMessage): void => { - if ( - conn.readyState !== WS_READY_STATE_CONNECTING && - conn.readyState !== WS_READY_STATE_OPEN - ) { - conn.close(); - } - try { - conn.send(JSON.stringify(message)); - } catch { - conn.close(); - return; - } - }; - - const onConnection = (conn: WebSocket): void => { - const subscribedTopics = new Set(); - let closed = false; - let pongReceived = true; - const pingInterval = setInterval(() => { - if (!pongReceived) { - conn.close(); - clearInterval(pingInterval); - } else { - pongReceived = false; - try { - conn.ping(); - } catch { - conn.close(); - } - } - }, PING_TIMEOUT); - - conn.on("pong", () => { - pongReceived = true; - }); - - conn.on("close", () => { - subscribedTopics.forEach((topicName) => { - const subs = topics.get(topicName) || new Set(); - subs.delete(conn); - if (subs.size === 0) { - topics.delete(topicName); - } - }); - subscribedTopics.clear(); - closed = true; - }); - - conn.on("message", (raw: import("ws").RawData): void => { - let message: WSMessage; - - try { - const parsed = JSON.parse( - typeof raw === "string" ? raw : raw.toString() - ); - message = parsed as WSMessage; - } catch { - conn.close(); - return; - } - - if (message && message.type && !closed) { - switch (message.type) { - case "subscribe": - (message.topics || []).forEach((topicName: string) => { - if (typeof topicName === "string") { - const topic = map.setIfUndefined( - topics, - topicName, - () => new Set() - ); - topic.add(conn); - subscribedTopics.add(topicName); - } - }); - break; - case "unsubscribe": - (message.topics || []).forEach((topicName: string) => { - const subs = topics.get(topicName); - if (subs) { - subs.delete(conn); - } - }); - break; - case "publish": - if (message.topic) { - const receivers = topics.get(message.topic); - if (receivers) { - message.clients = receivers.size; - receivers.forEach((receiver) => send(receiver, message)); - } - } - break; - case "ping": - send(conn, { type: "pong" } as WSMessage); - break; - } - } - }); - }; - - wss.on("connection", onConnection); - - server.on("upgrade", (request, socket, head) => { - const handleAuth = (ws: WebSocket): void => { - wss.emit("connection", ws, request); - }; - wss.handleUpgrade(request, socket, head, handleAuth); - }); -}; +import { WebSocket, WebSocketServer } from "ws"; +import http from "http"; +import * as map from "lib0/map"; + +const WS_READY_STATE_CONNECTING = 0; +const WS_READY_STATE_OPEN = 1; +const PING_TIMEOUT = 30000; + +const topics = new Map>(); + +interface WSMessage { + type: "subscribe" | "unsubscribe" | "publish" | "ping" | "pong"; + topics?: string[]; + topic?: string; + clients?: number; +} + +export let wss: WebSocketServer; + +export const setupWebSocketServer = (server: http.Server): void => { + wss = new WebSocketServer({ noServer: true }); + + const send = (conn: WebSocket, message: WSMessage): void => { + if ( + conn.readyState !== WS_READY_STATE_CONNECTING && + conn.readyState !== WS_READY_STATE_OPEN + ) { + conn.close(); + } + try { + conn.send(JSON.stringify(message)); + } catch { + conn.close(); + return; + } + }; + + const onConnection = (conn: WebSocket): void => { + const subscribedTopics = new Set(); + let closed = false; + let pongReceived = true; + const pingInterval = setInterval(() => { + if (!pongReceived) { + conn.close(); + clearInterval(pingInterval); + } else { + pongReceived = false; + try { + conn.ping(); + } catch { + conn.close(); + } + } + }, PING_TIMEOUT); + + conn.on("pong", () => { + pongReceived = true; + }); + + conn.on("close", () => { + subscribedTopics.forEach((topicName) => { + const subs = topics.get(topicName) || new Set(); + subs.delete(conn); + if (subs.size === 0) { + topics.delete(topicName); + } + }); + subscribedTopics.clear(); + closed = true; + }); + + conn.on("message", (raw: import("ws").RawData): void => { + let message: WSMessage; + + try { + const parsed = JSON.parse( + typeof raw === "string" ? raw : raw.toString() + ); + message = parsed as WSMessage; + } catch { + conn.close(); + return; + } + + if (message && message.type && !closed) { + switch (message.type) { + case "subscribe": + (message.topics || []).forEach((topicName: string) => { + if (typeof topicName === "string") { + const topic = map.setIfUndefined( + topics, + topicName, + () => new Set() + ); + topic.add(conn); + subscribedTopics.add(topicName); + } + }); + break; + case "unsubscribe": + (message.topics || []).forEach((topicName: string) => { + const subs = topics.get(topicName); + if (subs) { + subs.delete(conn); + } + }); + break; + case "publish": + if (message.topic) { + const receivers = topics.get(message.topic); + if (receivers) { + message.clients = receivers.size; + receivers.forEach((receiver) => send(receiver, message)); + } + } + break; + case "ping": + send(conn, { type: "pong" } as WSMessage); + break; + } + } + }); + }; + + wss.on("connection", onConnection); + + server.on("upgrade", (request, socket, head) => { + const url = request.url ?? ""; + if (!url.startsWith("/socket/webrtc")) { + return; + } + const handleAuth = (ws: WebSocket): void => { + wss.emit("connection", ws, request); + }; + wss.handleUpgrade(request, socket, head, handleAuth); + }); +}; diff --git a/src/main.ts b/src/main.ts index f0c685c..e58ee73 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,87 +1,95 @@ -import { NestFactory } from "@nestjs/core"; -import { ValidationPipe } from "@nestjs/common"; -import { AppModule } from "./app.module"; -import { - NestExpressApplication, - ExpressAdapter -} from "@nestjs/platform-express"; -import { setupSwagger } from "./swagger"; -import { format } from "date-fns-tz"; -import { setupWebSocketServer } from "./collab/signaling/signal"; -import { Logger } from "@nestjs/common"; -import express, { Request, Response, NextFunction } from "express"; -import { ConfigService } from "@nestjs/config"; -import * as path from "path"; -import * as dotenv from "dotenv"; -import * as http from "http"; -import cookieParser from "cookie-parser"; - -if (process.env["NODE_ENV"] === "production") { - dotenv.config({ path: ".env.production" }); -} else { - dotenv.config(); -} - -(async () => { - const expressApp = express(); - - const server = http.createServer(expressApp); - - const app = await NestFactory.create( - AppModule, - new ExpressAdapter(expressApp) - ); - - const logger = new Logger("HTTP"); - const configService = app.get(ConfigService); - const frontendUrl = configService.get( - "FRONTEND_URL", - "http://localhost:3000" - ); - - app.use(cookieParser()); - app.useLogger(["log", "error", "warn", "debug"]); - - app.useGlobalPipes( - new ValidationPipe({ - whitelist: true, - forbidNonWhitelisted: true, - transform: true - }) - ); - - app.useStaticAssets(path.join(__dirname, "..", "public")); - app.setBaseViewsDir(path.join(__dirname, "..", "views")); - app.setViewEngine("ejs"); - - app.enableCors({ - origin: frontendUrl, - credentials: true, - methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], - allowedHeaders: ["Content-Type", "Authorization"] - }); - - app.use((req: Request, res: Response, next: NextFunction) => { - const start = Date.now(); - const date = format(new Date(), "dd-MM-yyyy HH:mm:ss.SSS"); - - res.on("finish", () => { - const duration = Date.now() - start; - logger.log( - `[${date}] ${req.method} ${req.originalUrl} → ${res.statusCode} (${duration}ms)` - ); - }); - - next(); - }); - setupSwagger(app); - - await app.init(); - - setupWebSocketServer(server); - - const PORT = process.env["PORT"] || 3000; - server.listen(PORT, () => { - logger.log(`Server listening on port ${PORT}`); - }); -})(); +import { NestFactory } from "@nestjs/core"; +import { ValidationPipe } from "@nestjs/common"; +import { AppModule } from "./app.module"; +import { + NestExpressApplication, + ExpressAdapter +} from "@nestjs/platform-express"; +import { setupSwagger } from "./swagger"; +import { format } from "date-fns-tz"; +import { setupWebSocketServer } from "./collab/signaling/signal"; +import { Logger } from "@nestjs/common"; +import express, { Request, Response, NextFunction } from "express"; +import { ConfigService } from "@nestjs/config"; +import * as path from "path"; +import * as dotenv from "dotenv"; +import * as http from "http"; +import cookieParser from "cookie-parser"; +import { NotificationsService } from "./notifications/notifications.service"; +import { setupNotificationSocket } from "./notifications/notifications.socket"; + +if (process.env["NODE_ENV"] === "production") { + dotenv.config({ path: ".env.production" }); +} else { + dotenv.config(); +} + +(async () => { + const expressApp = express(); + + const server = http.createServer(expressApp); + + const app = await NestFactory.create( + AppModule, + new ExpressAdapter(expressApp) + ); + + const logger = new Logger("HTTP"); + const configService = app.get(ConfigService); + const frontendUrl = configService.get( + "FRONTEND_URL", + "http://localhost:3000" + ); + + app.use(cookieParser()); + app.useLogger(["log", "error", "warn", "debug"]); + + app.useGlobalPipes( + new ValidationPipe({ + whitelist: true, + forbidNonWhitelisted: true, + transform: true + }) + ); + + app.useStaticAssets(path.join(__dirname, "..", "public")); + app.setBaseViewsDir(path.join(__dirname, "..", "views")); + app.setViewEngine("ejs"); + + app.enableCors({ + origin: frontendUrl, + credentials: true, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], + allowedHeaders: ["Content-Type", "Authorization"] + }); + + app.use((req: Request, res: Response, next: NextFunction) => { + const start = Date.now(); + const date = format(new Date(), "dd-MM-yyyy HH:mm:ss.SSS"); + + res.on("finish", () => { + const duration = Date.now() - start; + logger.log( + `[${date}] ${req.method} ${req.originalUrl} → ${res.statusCode} (${duration}ms)` + ); + }); + + next(); + }); + setupSwagger(app); + + await app.init(); + + const notificationsService = app.get(NotificationsService); + const jwtSecret = configService.getOrThrow("JWT_SECRET"); + + setupWebSocketServer(server); + setupNotificationSocket(server, { + notificationsService, + jwtSecret, + }); + const PORT = process.env["PORT"] || 3000; + server.listen(PORT, () => { + logger.log(`Server listening on port ${PORT}`); + }); +})(); diff --git a/src/notifications/dto/notification-test.dto.ts b/src/notifications/dto/notification-test.dto.ts new file mode 100644 index 0000000..fae513e --- /dev/null +++ b/src/notifications/dto/notification-test.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsString, MaxLength } from "class-validator"; + +export class NotificationTestDto { + @ApiProperty({ + example: "Test Notification", + description: "The title of the test notification" + }) + @IsString() + @MaxLength(120) + title!: string; + + @ApiProperty({ + example: "This is a test notification message.", + description: "The message content of the test notification" + }) + @IsString() + @MaxLength(500) + message!: string; + + @ApiProperty({ + example: "notification", + description: "The type of the notification (friend request, message, etc.)" + }) + @IsString() + @MaxLength(100) + type!: string; +} diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts new file mode 100644 index 0000000..b787b90 --- /dev/null +++ b/src/notifications/notifications.controller.ts @@ -0,0 +1,82 @@ +import { + Body, + Controller, + HttpCode, + HttpStatus, + Param, + Patch, + Post, + Request, + UseGuards, + ValidationPipe, +} from "@nestjs/common"; +import { ApiBearerAuth, ApiOperation, ApiResponse, ApiTags } from "@nestjs/swagger"; +import { JwtAuthGuard } from "@auth/guards/jwt-auth.guard"; +import { RequestWithUser } from "@auth/auth.types"; +import { NotificationTestDto } from "./dto/notification-test.dto"; +import { NotificationsService } from "./notifications.service"; +import { NotificationPayload } from "./notifications.types"; + +@ApiTags("notifications") +@ApiBearerAuth("JWT-auth") +@UseGuards(JwtAuthGuard) +@Controller("notifications") +export class NotificationsController { + constructor(private readonly notificationsService: NotificationsService) {} + + // TODO: Remove this endpoint after testing, or restrict it to admin users + @ApiOperation({ summary: "Send a test notification to the current user" }) + @ApiResponse({ status: HttpStatus.OK, description: "Notification created and sent" }) + @Post("test") + @HttpCode(HttpStatus.OK) + async sendTestNotification( + @Request() req: RequestWithUser, + @Body(ValidationPipe) body: NotificationTestDto, + ): Promise<{ statusCode: number; message: string; data: NotificationPayload }> { + const payload = await this.notificationsService.createNotification({ + userId: req.user.id, + title: body.title, + message: body.message, + type: body.type, + }); + + return { + statusCode: HttpStatus.OK, + message: "Notification sent", + data: payload, + }; + } + + @ApiOperation({ summary: "set one notification as read" }) + @ApiResponse({ status: HttpStatus.OK, description: "Notification marked as read" }) + @Patch(":id/read") + @HttpCode(HttpStatus.OK) + async markAsRead( + @Request() req: RequestWithUser, + @Param("id") id: string, + ): Promise<{ statusCode: number; message: string; data: NotificationPayload }> { + const payload = await this.notificationsService.markAsRead(req.user.id, id); + + return { + statusCode: HttpStatus.OK, + message: "Notification marked as read", + data: payload, + }; + } + + @ApiOperation({ summary: "set notifications as read" }) + @ApiResponse({ status: HttpStatus.OK, description: "All notifications as read" }) + @Patch("read-all") + @HttpCode(HttpStatus.OK) + async markAllAsRead( + @Request() req: RequestWithUser, + ): Promise<{ statusCode: number; message: string; data: { modifiedCount: number } }> { + const modifiedCount = await this.notificationsService.markAllAsRead(req.user.id); + + return { + statusCode: HttpStatus.OK, + message: "All notifications as read", + data: { modifiedCount }, + }; + } +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..3afff78 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,10 @@ +import { Module } from "@nestjs/common"; +import { NotificationsController } from "./notifications.controller"; +import { NotificationsService } from "./notifications.service"; + +@Module({ + controllers: [NotificationsController], + providers: [NotificationsService], + exports: [NotificationsService], +}) +export class NotificationsModule {} diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts new file mode 100644 index 0000000..fce249b --- /dev/null +++ b/src/notifications/notifications.service.ts @@ -0,0 +1,120 @@ +import { BadRequestException, Injectable, NotFoundException } from "@nestjs/common"; +import { PrismaService } from "@prisma/prisma.service"; +import { CreateNotificationInput, NotificationPayload } from "./notifications.types"; +import { emitNotificationToUser } from "./notifications.socket"; + +const MAX_NOTIFICATIONS_PER_USER = 50; + +@Injectable() +export class NotificationsService { + constructor(private readonly prisma: PrismaService) {} + + async getUserNotifications(userId: number): Promise { + const notifications = await this.prisma.notification.findMany({ + where: { userId }, + orderBy: { createdAt: "desc" }, + }); + + return notifications.map((notification) => this.toPayload(notification)); + } + + async createNotification(input: CreateNotificationInput): Promise { + const created = await this.prisma.$transaction(async (tx) => { + const notification = await tx.notification.create({ + data: { + userId: input.userId, + title: input.title, + message: input.message, + type: input.type, + }, + }); + + const extraNotifications = await tx.notification.findMany({ + where: { userId: input.userId }, + orderBy: [ + { createdAt: "desc" }, + { id: "desc" }, + ], + skip: MAX_NOTIFICATIONS_PER_USER, + select: { id: true }, + }); + + if (extraNotifications.length > 0) { + await tx.notification.deleteMany({ + where: { + id: { in: extraNotifications.map((entry) => entry.id) }, + }, + }); + } + + return notification; + }); + + const payload = this.toPayload(created); + emitNotificationToUser(input.userId, payload); + return payload; + } + + async markAsRead(userId: number, notificationIdRaw: string): Promise { + const notificationId = Number(notificationIdRaw); + if (!Number.isInteger(notificationId)) { + throw new BadRequestException("Invalid notification id"); + } + + const notification = await this.prisma.notification.findFirst({ + where: { + id: notificationId, + userId, + }, + }); + + if (!notification) { + throw new NotFoundException("Notification not found"); + } + + if (notification.read) { + return this.toPayload(notification); + } + + const updated = await this.prisma.notification.update({ + where: { id: notificationId }, + data: { read: true }, + }); + + return this.toPayload(updated); + } + + async markAllAsRead(userId: number): Promise { + const result = await this.prisma.notification.updateMany({ + where: { + userId, + read: false, + }, + data: { + read: true, + }, + }); + + return result.count; + } + + private toPayload(notification: { + id: number; + userId: number; + title: string; + message: string; + type: string; + read: boolean; + createdAt: Date; + }): NotificationPayload { + return { + id: String(notification.id), + userId: notification.userId, + title: notification.title, + message: notification.message, + type: notification.type, + read: notification.read, + createdAt: notification.createdAt.toISOString(), + }; + } +} diff --git a/src/notifications/notifications.socket.ts b/src/notifications/notifications.socket.ts new file mode 100644 index 0000000..6c8b13a --- /dev/null +++ b/src/notifications/notifications.socket.ts @@ -0,0 +1,188 @@ +import http from "http"; +import { JwtPayload as JwtLibPayload, verify } from "jsonwebtoken"; +import { WebSocket, WebSocketServer } from "ws"; +import { NotificationPayload } from "./notifications.types"; +import type { NotificationsService } from "./notifications.service"; + +const NOTIFICATION_SOCKET_PATH = "/socket/notifications"; +const WS_READY_STATE_OPEN = 1; +const PING_TIMEOUT = 30000; + +type NotificationSocketOptions = { + notificationsService: NotificationsService; + jwtSecret: string; +}; + +type NotificationJwtPayload = JwtLibPayload & { + sub?: number; + email?: string; +}; + +type NotificationWsMessage = + | { type: "notification"; payload: NotificationPayload } + | { type: "notifications:init"; payload: NotificationPayload[] }; + +let notificationWss: WebSocketServer | undefined; +const userSockets = new Map>(); + +const roomSet = (userId: number): Set => { + const existing = userSockets.get(userId); + if (existing) { + return existing; + } + + const created = new Set(); + userSockets.set(userId, created); + return created; +}; + +const onConnection = async (socket: WebSocket, request: http.IncomingMessage, options: NotificationSocketOptions) => { + const userId = authenticateUser(request, options.jwtSecret); + if (!userId) { + socket.close(); + return; + } + + roomSet(userId).add(socket); + + try { + const notifications = await options.notificationsService.getUserNotifications(userId); + send(socket, { type: "notifications:init", payload: notifications }); + } catch { + send(socket, { type: "notifications:init", payload: [] }); + } + + let pongReceived = true; + const pingInterval = setInterval(() => { + if (!pongReceived) { + socket.close(); + clearInterval(pingInterval); + return; + } + + pongReceived = false; + try { + socket.ping(); + } catch { + socket.close(); + } + }, PING_TIMEOUT); + + socket.on("pong", () => { + pongReceived = true; + }); + + socket.on("close", () => { + clearInterval(pingInterval); + const sockets = userSockets.get(userId); + if (!sockets) { + return; + } + sockets.delete(socket); + if (sockets.size === 0) { + userSockets.delete(userId); + } + }); +}; + +const send = (socket: WebSocket, message: NotificationWsMessage): void => { + if (socket.readyState !== WS_READY_STATE_OPEN) { + return; + } + + try { + socket.send(JSON.stringify(message)); + } catch { + socket.close(); + } +}; + +const readTokenFromRequest = (request: http.IncomingMessage): string | null => { + const requestUrl = new URL(request.url ?? "/", "http://localhost"); + const queryToken = requestUrl.searchParams.get("token"); + if (typeof queryToken === "string" && queryToken.trim().length > 0) { + return queryToken.trim(); + } + + const authHeader = request.headers.authorization; + if (!authHeader) { + return null; + } + + const [scheme, token] = authHeader.split(" "); + if (scheme?.toLowerCase() !== "bearer" || !token) { + return null; + } + + return token; +}; + +const authenticateUser = (request: http.IncomingMessage, jwtSecret: string): number | null => { + try { + const token = readTokenFromRequest(request); + if (!token) { + return null; + } + + const payload = verify(token, jwtSecret) as NotificationJwtPayload; + if (typeof payload?.sub !== "number") { + return null; + } + + return payload.sub; + } catch { + return null; + } +}; + +export const setupNotificationSocket = ( + server: http.Server, + options: NotificationSocketOptions, +): WebSocketServer => { + if (notificationWss) { + return notificationWss; + } + + notificationWss = new WebSocketServer({ noServer: true }); + + notificationWss.on("connection", (socket, request) => onConnection(socket, request, options)); + + server.on("upgrade", (request, socket, head) => { + const requestUrl = new URL(request.url ?? "/", "http://localhost"); + if (!requestUrl.pathname.startsWith(NOTIFICATION_SOCKET_PATH)) { + return; + } + + notificationWss?.handleUpgrade(request, socket, head, (websocket) => { + notificationWss?.emit("connection", websocket, request); + }); + }); + + return notificationWss; +}; + +export const emitNotificationToUser = ( + userId: string | number, + payload: NotificationPayload, +): void => { + const numericUserId = Number(userId); + if (!Number.isFinite(numericUserId)) { + return; + } + + const sockets = userSockets.get(numericUserId); + if (!sockets || sockets.size === 0) { + return; + } + + const message: NotificationWsMessage = { + type: "notification", + payload, + }; + + sockets.forEach((socket) => { + send(socket, message); + }); +}; + +export { NOTIFICATION_SOCKET_PATH }; diff --git a/src/notifications/notifications.types.ts b/src/notifications/notifications.types.ts new file mode 100644 index 0000000..21d437c --- /dev/null +++ b/src/notifications/notifications.types.ts @@ -0,0 +1,16 @@ +export type NotificationPayload = { + id: string; + userId: number; + title: string; + message: string; + type: string; + read: boolean; + createdAt: string; +}; + +export type CreateNotificationInput = { + userId: number; + title: string; + message: string; + type: string; +}; From 213357acebccbb7bd201772d0d56f5b842551ba3 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 11:33:19 +0900 Subject: [PATCH 02/12] [BACKEND] [FIX] Remove unused markAllAsRead endpoint from notifications controller --- src/notifications/notifications.controller.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/src/notifications/notifications.controller.ts b/src/notifications/notifications.controller.ts index b787b90..5b5c2e1 100644 --- a/src/notifications/notifications.controller.ts +++ b/src/notifications/notifications.controller.ts @@ -63,20 +63,4 @@ export class NotificationsController { data: payload, }; } - - @ApiOperation({ summary: "set notifications as read" }) - @ApiResponse({ status: HttpStatus.OK, description: "All notifications as read" }) - @Patch("read-all") - @HttpCode(HttpStatus.OK) - async markAllAsRead( - @Request() req: RequestWithUser, - ): Promise<{ statusCode: number; message: string; data: { modifiedCount: number } }> { - const modifiedCount = await this.notificationsService.markAllAsRead(req.user.id); - - return { - statusCode: HttpStatus.OK, - message: "All notifications as read", - data: { modifiedCount }, - }; - } } From 02501dda4f0cf08f53496034e5a036d02a587dc9 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 11:33:42 +0900 Subject: [PATCH 03/12] [BACKEND] [REFactor] Enhance WebSocket connection handling and authentication logic --- src/notifications/notifications.socket.ts | 121 ++++++++++++---------- 1 file changed, 64 insertions(+), 57 deletions(-) diff --git a/src/notifications/notifications.socket.ts b/src/notifications/notifications.socket.ts index 6c8b13a..4afc741 100644 --- a/src/notifications/notifications.socket.ts +++ b/src/notifications/notifications.socket.ts @@ -22,6 +22,8 @@ type NotificationWsMessage = | { type: "notification"; payload: NotificationPayload } | { type: "notifications:init"; payload: NotificationPayload[] }; +type ClientAuthMessage = { type: "auth"; token: string }; + let notificationWss: WebSocketServer | undefined; const userSockets = new Map>(); @@ -36,52 +38,82 @@ const roomSet = (userId: number): Set => { return created; }; -const onConnection = async (socket: WebSocket, request: http.IncomingMessage, options: NotificationSocketOptions) => { - const userId = authenticateUser(request, options.jwtSecret); - if (!userId) { - socket.close(); - return; - } +const onConnection = async (socket: WebSocket, _request: http.IncomingMessage, options: NotificationSocketOptions) : Promise => { + let userId: number | null = null; + let pongReceived = true; + let pingInterval: NodeJS.Timeout | null = null; - roomSet(userId).add(socket); + const cleanup = (): void => { + if (pingInterval) { + clearInterval(pingInterval); + pingInterval = null; + } + if (userId !== null) { + const sockets = userSockets.get(userId); + sockets?.delete(socket); + if (sockets && sockets.size === 0) { + userSockets.delete(userId); + } + } + }; - try { - const notifications = await options.notificationsService.getUserNotifications(userId); - send(socket, { type: "notifications:init", payload: notifications }); - } catch { - send(socket, { type: "notifications:init", payload: [] }); - } + const startSession = async (authenticatedUserId: number): Promise => { + userId = authenticatedUserId; + roomSet(userId).add(socket); - let pongReceived = true; - const pingInterval = setInterval(() => { - if (!pongReceived) { - socket.close(); - clearInterval(pingInterval); - return; + try { + const notifications = await options.notificationsService.getUserNotifications(userId); + send(socket, { type: "notifications:init", payload: notifications }); + } catch { + send(socket, { type: "notifications:init", payload: [] }); } - pongReceived = false; + pingInterval = setInterval(() => { + if (!pongReceived) { + socket.close(); + cleanup(); + return; + } + + pongReceived = false; + try { + socket.ping(); + } catch { + socket.close(); + cleanup(); + } + }, PING_TIMEOUT); + }; + + const handleAuthMessage = async (raw: import("ws").RawData): Promise => { try { - socket.ping(); + const parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as ClientAuthMessage; + if (parsed?.type !== "auth" || typeof parsed.token !== "string") { + socket.close(); + return; + } + + const authenticatedUserId = authenticateToken(parsed.token, options.jwtSecret); + if (!authenticatedUserId) { + socket.close(); + return; + } + + socket.off("message", handleAuthMessage as any); + await startSession(authenticatedUserId); } catch { socket.close(); } - }, PING_TIMEOUT); + }; + + socket.on("message", handleAuthMessage as any); socket.on("pong", () => { pongReceived = true; }); socket.on("close", () => { - clearInterval(pingInterval); - const sockets = userSockets.get(userId); - if (!sockets) { - return; - } - sockets.delete(socket); - if (sockets.size === 0) { - userSockets.delete(userId); - } + cleanup(); }); }; @@ -97,33 +129,8 @@ const send = (socket: WebSocket, message: NotificationWsMessage): void => { } }; -const readTokenFromRequest = (request: http.IncomingMessage): string | null => { - const requestUrl = new URL(request.url ?? "/", "http://localhost"); - const queryToken = requestUrl.searchParams.get("token"); - if (typeof queryToken === "string" && queryToken.trim().length > 0) { - return queryToken.trim(); - } - - const authHeader = request.headers.authorization; - if (!authHeader) { - return null; - } - - const [scheme, token] = authHeader.split(" "); - if (scheme?.toLowerCase() !== "bearer" || !token) { - return null; - } - - return token; -}; - -const authenticateUser = (request: http.IncomingMessage, jwtSecret: string): number | null => { +const authenticateToken = (token: string, jwtSecret: string): number | null => { try { - const token = readTokenFromRequest(request); - if (!token) { - return null; - } - const payload = verify(token, jwtSecret) as NotificationJwtPayload; if (typeof payload?.sub !== "number") { return null; From 5f96e3d922b7792a71aad0f34949f80ab422b727 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 13:44:19 +0900 Subject: [PATCH 04/12] [FIX] Improve error handling in markAsRea --- src/notifications/notifications.service.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index fce249b..a5198e0 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -72,15 +72,20 @@ export class NotificationsService { throw new NotFoundException("Notification not found"); } - if (notification.read) { - return this.toPayload(notification); - } - - const updated = await this.prisma.notification.update({ - where: { id: notificationId }, + const result = await this.prisma.notification.updateMany({ + where: { id: notificationId, userId }, data: { read: true }, }); + if (result.count === 0) { + throw new NotFoundException("Notification not found"); + } + + const updated = await this.prisma.notification.findUnique({ where: { id: notificationId } }); + if (!updated) { + throw new NotFoundException("Notification not found"); + } + return this.toPayload(updated); } From d7e7aa3bd36c8c9de84de1b979fc9f90744e3cdb Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 14:06:04 +0900 Subject: [PATCH 05/12] [BACKEND] [FIX] Handle WebSocket upgrade --- src/main.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/main.ts b/src/main.ts index e58ee73..4980b6e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -88,6 +88,12 @@ if (process.env["NODE_ENV"] === "production") { notificationsService, jwtSecret, }); + + server.on("upgrade", (request, socket) => { + socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); + socket.destroy(); + }); + const PORT = process.env["PORT"] || 3000; server.listen(PORT, () => { logger.log(`Server listening on port ${PORT}`); From 722d5700089c353a9825d3a5d96daecd7d754f1e Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 14:16:24 +0900 Subject: [PATCH 06/12] [BACKEND] [FIX] Enhance raw data handling in WebSocket authentication --- src/notifications/notifications.socket.ts | 24 ++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/notifications/notifications.socket.ts b/src/notifications/notifications.socket.ts index 4afc741..98669d7 100644 --- a/src/notifications/notifications.socket.ts +++ b/src/notifications/notifications.socket.ts @@ -85,9 +85,31 @@ const onConnection = async (socket: WebSocket, _request: http.IncomingMessage, o }, PING_TIMEOUT); }; + const normalizeRawDataToString = (raw: import("ws").RawData): string | null => { + if (typeof raw === "string") { + return raw; + } + if (raw instanceof Buffer) { + return raw.toString(); + } + if (Array.isArray(raw)) { + return Buffer.concat(raw).toString(); + } + if (raw instanceof ArrayBuffer) { + return Buffer.from(raw).toString(); + } + return null; + }; + const handleAuthMessage = async (raw: import("ws").RawData): Promise => { try { - const parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString()) as ClientAuthMessage; + const rawString = normalizeRawDataToString(raw); + if (rawString === null) { + socket.close(); + return; + } + + const parsed = JSON.parse(rawString) as ClientAuthMessage; if (parsed?.type !== "auth" || typeof parsed.token !== "string") { socket.close(); return; From 7eb415ea55049505b58aad884b8296a6397982af Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 14:18:43 +0900 Subject: [PATCH 07/12] [MAIN][FIX]: lint error --- src/main.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index 4980b6e..2236d3e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -89,7 +89,7 @@ if (process.env["NODE_ENV"] === "production") { jwtSecret, }); - server.on("upgrade", (request, socket) => { + server.on("upgrade", (_request, socket) => { socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); }); From d72ca5c1817027ad526f04f7b9e8c30767866aa9 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 14:29:38 +0900 Subject: [PATCH 08/12] [BACKEND] [FIX] ws upgrade handler --- src/collab/signaling/signal.ts | 4 +++- src/main.ts | 7 +++++-- src/notifications/notifications.socket.ts | 9 ++++++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/collab/signaling/signal.ts b/src/collab/signaling/signal.ts index 14f9679..b9d90f4 100644 --- a/src/collab/signaling/signal.ts +++ b/src/collab/signaling/signal.ts @@ -16,6 +16,7 @@ interface WSMessage { } export let wss: WebSocketServer; +type UpgradeRequest = http.IncomingMessage & { _wsHandled?: boolean }; export const setupWebSocketServer = (server: http.Server): void => { wss = new WebSocketServer({ noServer: true }); @@ -124,11 +125,12 @@ export const setupWebSocketServer = (server: http.Server): void => { wss.on("connection", onConnection); - server.on("upgrade", (request, socket, head) => { + server.on("upgrade", (request: UpgradeRequest, socket, head) => { const url = request.url ?? ""; if (!url.startsWith("/socket/webrtc")) { return; } + request._wsHandled = true; const handleAuth = (ws: WebSocket): void => { wss.emit("connection", ws, request); }; diff --git a/src/main.ts b/src/main.ts index 2236d3e..5692354 100644 --- a/src/main.ts +++ b/src/main.ts @@ -16,7 +16,7 @@ import * as dotenv from "dotenv"; import * as http from "http"; import cookieParser from "cookie-parser"; import { NotificationsService } from "./notifications/notifications.service"; -import { setupNotificationSocket } from "./notifications/notifications.socket"; +import { setupNotificationSocket, UpgradeRequest } from "./notifications/notifications.socket"; if (process.env["NODE_ENV"] === "production") { dotenv.config({ path: ".env.production" }); @@ -89,7 +89,10 @@ if (process.env["NODE_ENV"] === "production") { jwtSecret, }); - server.on("upgrade", (_request, socket) => { + server.on("upgrade", (request: UpgradeRequest, socket) => { + if (request._wsHandled || socket.destroyed || socket.writableEnded) { + return; + } socket.write("HTTP/1.1 404 Not Found\r\n\r\n"); socket.destroy(); }); diff --git a/src/notifications/notifications.socket.ts b/src/notifications/notifications.socket.ts index 98669d7..7d4153b 100644 --- a/src/notifications/notifications.socket.ts +++ b/src/notifications/notifications.socket.ts @@ -13,6 +13,8 @@ type NotificationSocketOptions = { jwtSecret: string; }; +export type UpgradeRequest = http.IncomingMessage & { _wsHandled?: boolean }; + type NotificationJwtPayload = JwtLibPayload & { sub?: number; email?: string; @@ -121,14 +123,14 @@ const onConnection = async (socket: WebSocket, _request: http.IncomingMessage, o return; } - socket.off("message", handleAuthMessage as any); + socket.off("message", handleAuthMessage); await startSession(authenticatedUserId); } catch { socket.close(); } }; - socket.on("message", handleAuthMessage as any); + socket.on("message", handleAuthMessage); socket.on("pong", () => { pongReceived = true; @@ -176,12 +178,13 @@ export const setupNotificationSocket = ( notificationWss.on("connection", (socket, request) => onConnection(socket, request, options)); - server.on("upgrade", (request, socket, head) => { + server.on("upgrade", (request: UpgradeRequest, socket, head) => { const requestUrl = new URL(request.url ?? "/", "http://localhost"); if (!requestUrl.pathname.startsWith(NOTIFICATION_SOCKET_PATH)) { return; } + request._wsHandled = true; notificationWss?.handleUpgrade(request, socket, head, (websocket) => { notificationWss?.emit("connection", websocket, request); }); From 225b7a7904f32dd93982edcb2224ecd5eda85bbd Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Sun, 1 Mar 2026 14:42:35 +0900 Subject: [PATCH 09/12] [BACKEND] [FIX] Ensure notification retrieval respects maximum limit per user --- src/notifications/notifications.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index a5198e0..9f22f1c 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -13,6 +13,7 @@ export class NotificationsService { const notifications = await this.prisma.notification.findMany({ where: { userId }, orderBy: { createdAt: "desc" }, + take: MAX_NOTIFICATIONS_PER_USER, }); return notifications.map((notification) => this.toPayload(notification)); From 353658c2cee2fcafe807e2dd78d004c40146ae4c Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Fri, 6 Mar 2026 19:29:46 +0900 Subject: [PATCH 10/12] [BACKEND] [REFACT] Rename setupWebSocketServer to setupCollaborationSocket for clarity --- src/collab/signaling/signal.ts | 2 +- src/main.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/collab/signaling/signal.ts b/src/collab/signaling/signal.ts index b9d90f4..08fb50c 100644 --- a/src/collab/signaling/signal.ts +++ b/src/collab/signaling/signal.ts @@ -18,7 +18,7 @@ interface WSMessage { export let wss: WebSocketServer; type UpgradeRequest = http.IncomingMessage & { _wsHandled?: boolean }; -export const setupWebSocketServer = (server: http.Server): void => { +export const setupCollaborationSocket = (server: http.Server): void => { wss = new WebSocketServer({ noServer: true }); const send = (conn: WebSocket, message: WSMessage): void => { diff --git a/src/main.ts b/src/main.ts index 5692354..21d6c1b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -7,7 +7,7 @@ import { } from "@nestjs/platform-express"; import { setupSwagger } from "./swagger"; import { format } from "date-fns-tz"; -import { setupWebSocketServer } from "./collab/signaling/signal"; +import { setupCollaborationSocket } from "./collab/signaling/signal"; import { Logger } from "@nestjs/common"; import express, { Request, Response, NextFunction } from "express"; import { ConfigService } from "@nestjs/config"; @@ -83,7 +83,7 @@ if (process.env["NODE_ENV"] === "production") { const notificationsService = app.get(NotificationsService); const jwtSecret = configService.getOrThrow("JWT_SECRET"); - setupWebSocketServer(server); + setupCollaborationSocket(server); setupNotificationSocket(server, { notificationsService, jwtSecret, From f4ba8da67e2c70677de550c42c973c6d6bdc5c39 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Fri, 6 Mar 2026 19:56:15 +0900 Subject: [PATCH 11/12] [BACKEND][FIX] Convert notification ID to string in payload --- src/notifications/notifications.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/notifications/notifications.service.ts b/src/notifications/notifications.service.ts index 9f22f1c..3ee7238 100644 --- a/src/notifications/notifications.service.ts +++ b/src/notifications/notifications.service.ts @@ -114,7 +114,7 @@ export class NotificationsService { createdAt: Date; }): NotificationPayload { return { - id: String(notification.id), + id: notification.id.toString(), userId: notification.userId, title: notification.title, message: notification.message, From 77242518ad6cabf0bb7ef6962083dcb288093cd0 Mon Sep 17 00:00:00 2001 From: Guillame Tran Date: Fri, 6 Mar 2026 20:09:35 +0900 Subject: [PATCH 12/12] [BACKEND][REFACTO] Change notification type from string to enum --- prisma/models/notification.prisma | 7 ++++++- src/notifications/dto/notification-test.dto.ts | 13 +++++++------ src/notifications/notifications.types.ts | 8 ++++++-- 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/prisma/models/notification.prisma b/prisma/models/notification.prisma index bbf3d8c..f3bd04e 100644 --- a/prisma/models/notification.prisma +++ b/prisma/models/notification.prisma @@ -1,9 +1,14 @@ +enum NotificationType { + INFO + WARNING +} + model Notification { id Int @id @default(autoincrement()) userId Int title String message String - type String + type NotificationType read Boolean @default(false) createdAt DateTime @default(now()) diff --git a/src/notifications/dto/notification-test.dto.ts b/src/notifications/dto/notification-test.dto.ts index fae513e..50cc4a9 100644 --- a/src/notifications/dto/notification-test.dto.ts +++ b/src/notifications/dto/notification-test.dto.ts @@ -1,5 +1,6 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsString, MaxLength } from "class-validator"; +import { IsEnum, IsString, MaxLength } from "class-validator"; +import { NOTIFICATION_TYPES, NotificationType } from "../notifications.types"; export class NotificationTestDto { @ApiProperty({ @@ -19,10 +20,10 @@ export class NotificationTestDto { message!: string; @ApiProperty({ - example: "notification", - description: "The type of the notification (friend request, message, etc.)" + example: "INFO", + description: "The type of the notification", + enum: NOTIFICATION_TYPES }) - @IsString() - @MaxLength(100) - type!: string; + @IsEnum(NOTIFICATION_TYPES) + type!: NotificationType; } diff --git a/src/notifications/notifications.types.ts b/src/notifications/notifications.types.ts index 21d437c..c6ef8c9 100644 --- a/src/notifications/notifications.types.ts +++ b/src/notifications/notifications.types.ts @@ -1,9 +1,13 @@ +export const NOTIFICATION_TYPES = ["INFO", "WARNING"] as const; + +export type NotificationType = typeof NOTIFICATION_TYPES[number]; + export type NotificationPayload = { id: string; userId: number; title: string; message: string; - type: string; + type: NotificationType; read: boolean; createdAt: string; }; @@ -12,5 +16,5 @@ export type CreateNotificationInput = { userId: number; title: string; message: string; - type: string; + type: NotificationType; };