From 5c8b6e8260c88b45d2eaefd90e19fabaec028cf0 Mon Sep 17 00:00:00 2001 From: wkdalswn11 Date: Wed, 29 Jul 2026 18:23:09 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=8C=EB=A6=BC=20API=20=EA=B0=9C?= =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20OpenAPI=20=EB=AC=B8=EC=84=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification-sse.controller.ts | 15 ++-- .../notification/notification.controller.ts | 31 ++++--- src/modules/notification/notification.docs.ts | 12 ++- .../notification/notification.repository.ts | 86 +++++++++++++------ .../notification/notification.route.ts | 36 ++++++-- src/modules/notification/notification.type.ts | 24 +++++- .../notification/notification.validator.ts | 26 ++++++ 7 files changed, 179 insertions(+), 51 deletions(-) diff --git a/src/modules/notification/notification-sse.controller.ts b/src/modules/notification/notification-sse.controller.ts index 4ec47c6c..590f1053 100644 --- a/src/modules/notification/notification-sse.controller.ts +++ b/src/modules/notification/notification-sse.controller.ts @@ -19,13 +19,9 @@ import { notificationSseService } from "./notification-sse.service"; * 브라우저를 닫거나 네트워크가 끊기면 * close 이벤트를 통해 연결 정보를 제거한다. */ -const subscribe = (req: Request, res: Response): void => { +const subscribe = async (req: Request, res: Response): Promise => { const userId = req.user?.id; - /* - * authenticate 미들웨어를 통과했더라도 - * 방어적으로 사용자 정보를 다시 확인한다. - */ if (!userId) { throw new AppError("UNAUTHORIZED", { message: "인증 정보가 없습니다.", @@ -46,13 +42,16 @@ const subscribe = (req: Request, res: Response): void => { res.flushHeaders(); /* - * 사용자의 SSE 연결을 등록한다. + * SSE Service에 사용자의 연결을 등록한다. + * + * 등록된 연결은 notification 이벤트와 + * heartbeat 전송에 사용된다. */ notificationSseService.addConnection(userId, res); /* - * 브라우저 종료, 새로고침 등으로 - * 연결이 종료되면 SSE 연결을 제거한다. + * 브라우저 종료, 새로고침, 네트워크 연결 종료 등으로 + * SSE 연결이 종료되면 등록된 연결을 제거한다. */ req.on("close", () => { notificationSseService.removeConnection(userId, res); diff --git a/src/modules/notification/notification.controller.ts b/src/modules/notification/notification.controller.ts index 00ce1d2a..09d503b9 100644 --- a/src/modules/notification/notification.controller.ts +++ b/src/modules/notification/notification.controller.ts @@ -4,10 +4,13 @@ import { AppError } from "../../lib/app-error"; import { notificationService } from "./notification.service"; -import type { NotificationIdParam } from "./notification.validator"; +import type { NotificationIdParam, NotificationListQuery } from "./notification.validator"; -/** - * 현재 로그인한 사용자의 알림 목록을 조회합니다. +/* + * 현재 로그인한 사용자의 알림 목록을 조회한다. + * + * validate 미들웨어에서 검증된 page와 limit을 + * res.locals.query에서 조회하여 Service로 전달한다. */ const getNotifications: RequestHandler = async (req, res) => { if (!req.user) { @@ -16,7 +19,9 @@ const getNotifications: RequestHandler = async (req, res) => { }); } - const data = await notificationService.getNotifications(req.user.id); + const { page, limit } = res.locals.query as NotificationListQuery; + + const data = await notificationService.getNotifications(req.user.id, page, limit); res.status(200).json({ success: true, @@ -25,8 +30,9 @@ const getNotifications: RequestHandler = async (req, res) => { }); }; -/** - * 현재 로그인한 사용자의 읽지 않은 알림 개수를 조회합니다. +/* + * 현재 로그인한 사용자의 + * 읽지 않은 알림 개수를 조회한다. */ const getUnreadCount: RequestHandler = async (req, res) => { if (!req.user) { @@ -44,8 +50,12 @@ const getUnreadCount: RequestHandler = async (req, res) => { }); }; -/** - * 특정 알림을 읽음 상태로 변경합니다. +/* + * 현재 로그인한 사용자의 + * 단일 알림을 읽음 처리한다. + * + * validate 미들웨어에서 검증된 notificationId를 + * res.locals.params에서 조회하여 Service로 전달한다. */ const readNotification: RequestHandler = async (req, res) => { if (!req.user) { @@ -65,8 +75,9 @@ const readNotification: RequestHandler = async (req, res) => { }); }; -/** - * 현재 로그인한 사용자의 모든 알림을 읽음 상태로 변경합니다. +/* + * 현재 로그인한 사용자의 + * 모든 미읽음 알림을 읽음 처리한다. */ const readAllNotifications: RequestHandler = async (req, res) => { if (!req.user) { diff --git a/src/modules/notification/notification.docs.ts b/src/modules/notification/notification.docs.ts index 165c3b1c..730300d9 100644 --- a/src/modules/notification/notification.docs.ts +++ b/src/modules/notification/notification.docs.ts @@ -1,6 +1,7 @@ import { z } from "zod"; import { registerRouterDocs } from "../../config/openapi-router"; + import { notificationRouter } from "./notification.route"; const authHeaderSchema = z.object({ @@ -23,7 +24,16 @@ registerRouterDocs(notificationRouter, { endpoints: { "GET /": { summary: "알림 목록 조회", - description: "로그인한 사용자의 만료되지 않은 알림을 최신순으로 최대 5개 조회합니다.", + description: [ + "로그인한 사용자의 만료되지 않은 알림을 페이지 단위로 조회합니다.", + "", + "- page Query를 통해 조회할 페이지를 지정합니다.", + "- page를 생략하면 기본값으로 1이 적용됩니다.", + "- limit Query를 통해 한 페이지에 조회할 알림 개수를 지정합니다.", + "- limit를 생략하면 기본값으로 5가 적용됩니다.", + "- 알림은 생성일 기준 최신순으로 조회됩니다.", + "- 응답에는 알림 목록과 페이지네이션 정보가 함께 반환됩니다.", + ].join("\n"), responses: { 200: "알림 목록 조회 성공", }, diff --git a/src/modules/notification/notification.repository.ts b/src/modules/notification/notification.repository.ts index 02e92edd..2fedc6e3 100644 --- a/src/modules/notification/notification.repository.ts +++ b/src/modules/notification/notification.repository.ts @@ -1,12 +1,10 @@ -import { NotificationType } from "@prisma/client"; +import { NotificationType, type Prisma } from "@prisma/client"; import { prisma } from "../../lib/prisma"; import type { DbClient } from "../../utils/transaction"; import type { CreateNotificationInput } from "./notification.type"; -const NOTIFICATION_LIST_LIMIT = 5; - /* * 알림 조회 및 생성 결과에서 공통으로 반환할 필드를 정의한다. * @@ -26,36 +24,70 @@ const notificationSelect = { } as const; /* - * 사용자의 유효한 알림 목록을 조회한다. + * 사용자의 유효한 알림 목록 조회에 필요한 값을 정의한다. + * + * skip은 건너뛸 알림 개수이며, + * take는 한 번에 조회할 알림 개수이다. + */ +interface FindManyByUserIdInput { + userId: string; + skip: number; + take: number; +} + +/* + * 사용자의 유효한 알림 목록과 전체 개수를 조회한다. * * expiresAt이 null인 무기한 알림과 * 현재 시각보다 expiresAt이 이후인 알림만 조회한다. * - * 최신순으로 최대 5개까지 반환한다. + * 목록 조회와 전체 개수 조회는 동일한 조건을 사용하며, + * Promise.all을 이용해 병렬로 실행한다. + * + * createdAt이 같은 알림이 존재할 수 있으므로 + * id를 보조 정렬 조건으로 사용해 조회 순서를 안정적으로 유지한다. */ -async function findManyByUserId(userId: string, db: DbClient = prisma) { +async function findManyByUserId(input: FindManyByUserIdInput, db: DbClient = prisma) { const now = new Date(); - return db.notification.findMany({ - where: { - userId, - OR: [ + const where: Prisma.NotificationWhereInput = { + userId: input.userId, + OR: [ + { + expiresAt: null, + }, + { + expiresAt: { + gt: now, + }, + }, + ], + }; + + const [notifications, totalCount] = await Promise.all([ + db.notification.findMany({ + where, + select: notificationSelect, + orderBy: [ { - expiresAt: null, + createdAt: "desc", }, { - expiresAt: { - gt: now, - }, + id: "desc", }, ], - }, - select: notificationSelect, - orderBy: { - createdAt: "desc", - }, - take: NOTIFICATION_LIST_LIMIT, - }); + skip: input.skip, + take: input.take, + }), + db.notification.count({ + where, + }), + ]); + + return { + notifications, + totalCount, + }; } /* @@ -159,7 +191,7 @@ async function markAllAsRead( chatExpiresAt: Date, db: DbClient = prisma, ) { - const unreadCondition = { + const unreadCondition: Prisma.NotificationWhereInput = { userId, isRead: false, OR: [ @@ -208,9 +240,13 @@ async function markAllAsRead( * 다른 도메인의 Service에서 전달받은 사용자, 알림 타입, * 제목, 내용, 이동 경로, 만료일을 저장한다. * - * linkUrl과 expiresAt이 전달되지 않으면 - * null로 저장한다. + * linkUrl은 선택값이므로 전달되지 않으면 null로 저장한다. + * + * expiresAt은 알림 생성 시 반드시 전달해야 한다. + * 만료되는 알림은 실제 만료 시각을 전달하고, + * 무기한 알림인 경우에만 명시적으로 null을 전달한다. */ + async function create(input: CreateNotificationInput, db: DbClient = prisma) { return db.notification.create({ data: { @@ -219,7 +255,7 @@ async function create(input: CreateNotificationInput, db: DbClient = prisma) { title: input.title, content: input.content, linkUrl: input.linkUrl ?? null, - expiresAt: input.expiresAt ?? null, + expiresAt: input.expiresAt, }, select: notificationSelect, }); diff --git a/src/modules/notification/notification.route.ts b/src/modules/notification/notification.route.ts index 471c97d5..913586f7 100644 --- a/src/modules/notification/notification.route.ts +++ b/src/modules/notification/notification.route.ts @@ -5,28 +5,52 @@ import { validate } from "../../middlewares/validate"; import { asyncHandler } from "../../utils/async-handler.util"; import { notificationController } from "./notification.controller"; -import { notificationIdParamSchema } from "./notification.validator"; +import { notificationIdParamSchema, notificationListQuerySchema } from "./notification.validator"; export const notificationRouter = Router(); -// 알림 목록 조회 -notificationRouter.get("/", authenticate, asyncHandler(notificationController.getNotifications)); +/* + * 현재 로그인한 사용자의 알림 목록을 페이지 단위로 조회한다. + * + * page와 limit Query String을 검증한 뒤 + * 변환된 값을 res.locals.query에 저장한다. + */ +notificationRouter.get( + "/", + authenticate, + validate({ + query: notificationListQuerySchema, + }), + asyncHandler(notificationController.getNotifications), +); -// 읽지 않은 알림 개수 조회 +/* + * 현재 로그인한 사용자의 + * 유효한 미읽음 알림 개수를 조회한다. + */ notificationRouter.get( "/unread-count", authenticate, asyncHandler(notificationController.getUnreadCount), ); -// 모든 알림 읽음 처리 +/* + * 현재 로그인한 사용자의 + * 유효한 미읽음 알림을 모두 읽음 처리한다. + */ notificationRouter.patch( "/read-all", authenticate, asyncHandler(notificationController.readAllNotifications), ); -// 단일 알림 읽음 처리 +/* + * 알림 ID를 검증한 뒤 + * 현재 로그인한 사용자의 단일 알림을 읽음 처리한다. + * + * 검증된 notificationId는 + * res.locals.params에 저장된다. + */ notificationRouter.patch( "/:notificationId/read", authenticate, diff --git a/src/modules/notification/notification.type.ts b/src/modules/notification/notification.type.ts index fb640962..fcbdb7c9 100644 --- a/src/modules/notification/notification.type.ts +++ b/src/modules/notification/notification.type.ts @@ -6,7 +6,14 @@ export interface CreateNotificationInput { title: string; content: string; linkUrl?: string | null; - expiresAt?: Date | null; + + /* + * 알림 생성 시 만료 정책을 반드시 명시한다. + * + * 만료되는 알림은 실제 만료 시각을 전달하고, + * 무기한 알림만 명시적으로 null을 전달한다. + */ + expiresAt: Date | null; } export interface NotificationItem { @@ -21,8 +28,23 @@ export interface NotificationItem { createdAt: Date; } +/* + * 알림 목록 조회 시 사용하는 페이지네이션 정보를 정의한다. + */ +export interface NotificationPagination { + page: number; + limit: number; + totalCount: number; + totalPages: number; + hasNextPage: boolean; +} + +/* + * 알림 목록과 페이지네이션 정보를 함께 반환한다. + */ export interface NotificationListResponse { notifications: NotificationItem[]; + pagination: NotificationPagination; } export interface UnreadNotificationCountResponse { diff --git a/src/modules/notification/notification.validator.ts b/src/modules/notification/notification.validator.ts index ba8d408d..cd9d0099 100644 --- a/src/modules/notification/notification.validator.ts +++ b/src/modules/notification/notification.validator.ts @@ -1,5 +1,31 @@ import { z } from "zod"; +/* + * 알림 목록 조회 Query를 검증한다. + * + * page는 1 이상, + * limit는 1 이상 100 이하의 값만 허용한다. + * + * Query String은 문자열로 전달되므로 + * z.coerce.number()를 사용해 숫자로 변환한다. + */ +export const notificationListQuerySchema = z.object({ + page: z.coerce + .number() + .int("페이지는 정수여야 합니다.") + .min(1, "페이지는 1 이상이어야 합니다.") + .default(1), + + limit: z.coerce + .number() + .int("조회 개수는 정수여야 합니다.") + .min(1, "조회 개수는 1 이상이어야 합니다.") + .max(100, "조회 개수는 100 이하여야 합니다.") + .default(5), +}); + +export type NotificationListQuery = z.infer; + export const notificationIdParamSchema = z.object({ notificationId: z.coerce .number()