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
15 changes: 7 additions & 8 deletions src/modules/notification/notification-sse.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> => {
const userId = req.user?.id;

/*
* authenticate 미들웨어를 통과했더라도
* 방어적으로 사용자 정보를 다시 확인한다.
*/
if (!userId) {
throw new AppError("UNAUTHORIZED", {
message: "인증 정보가 없습니다.",
Expand All @@ -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);
Expand Down
31 changes: 21 additions & 10 deletions src/modules/notification/notification.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -25,8 +30,9 @@ const getNotifications: RequestHandler = async (req, res) => {
});
};

/**
* 현재 로그인한 사용자의 읽지 않은 알림 개수를 조회합니다.
/*
* 현재 로그인한 사용자의
* 읽지 않은 알림 개수를 조회한다.
*/
const getUnreadCount: RequestHandler = async (req, res) => {
if (!req.user) {
Expand All @@ -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) {
Expand All @@ -65,8 +75,9 @@ const readNotification: RequestHandler = async (req, res) => {
});
};

/**
* 현재 로그인한 사용자의 모든 알림을 읽음 상태로 변경합니다.
/*
* 현재 로그인한 사용자의
* 모든 미읽음 알림을 읽음 처리한다.
*/
const readAllNotifications: RequestHandler = async (req, res) => {
if (!req.user) {
Expand Down
12 changes: 11 additions & 1 deletion src/modules/notification/notification.docs.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { z } from "zod";

import { registerRouterDocs } from "../../config/openapi-router";

import { notificationRouter } from "./notification.route";

const authHeaderSchema = z.object({
Expand All @@ -23,7 +24,16 @@ registerRouterDocs(notificationRouter, {
endpoints: {
"GET /": {
summary: "알림 목록 조회",
description: "로그인한 사용자의 만료되지 않은 알림을 최신순으로 최대 5개 조회합니다.",
description: [
"로그인한 사용자의 만료되지 않은 알림을 페이지 단위로 조회합니다.",
"",
"- page Query를 통해 조회할 페이지를 지정합니다.",
"- page를 생략하면 기본값으로 1이 적용됩니다.",
"- limit Query를 통해 한 페이지에 조회할 알림 개수를 지정합니다.",
"- limit를 생략하면 기본값으로 5가 적용됩니다.",
"- 알림은 생성일 기준 최신순으로 조회됩니다.",
"- 응답에는 알림 목록과 페이지네이션 정보가 함께 반환됩니다.",
].join("\n"),
responses: {
200: "알림 목록 조회 성공",
},
Expand Down
86 changes: 61 additions & 25 deletions src/modules/notification/notification.repository.ts
Original file line number Diff line number Diff line change
@@ -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;

/*
* 알림 조회 및 생성 결과에서 공통으로 반환할 필드를 정의한다.
*
Expand All @@ -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,
};
}

/*
Expand Down Expand Up @@ -159,7 +191,7 @@ async function markAllAsRead(
chatExpiresAt: Date,
db: DbClient = prisma,
) {
const unreadCondition = {
const unreadCondition: Prisma.NotificationWhereInput = {
userId,
isRead: false,
OR: [
Expand Down Expand Up @@ -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: {
Expand All @@ -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,
});
Expand Down
36 changes: 30 additions & 6 deletions src/modules/notification/notification.route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
24 changes: 23 additions & 1 deletion src/modules/notification/notification.type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@ export interface CreateNotificationInput {
title: string;
content: string;
linkUrl?: string | null;
expiresAt?: Date | null;

/*
* 알림 생성 시 만료 정책을 반드시 명시한다.
*
* 만료되는 알림은 실제 만료 시각을 전달하고,
* 무기한 알림만 명시적으로 null을 전달한다.
*/
expiresAt: Date | null;
}

export interface NotificationItem {
Expand All @@ -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 {
Expand Down
26 changes: 26 additions & 0 deletions src/modules/notification/notification.validator.ts
Original file line number Diff line number Diff line change
@@ -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<typeof notificationListQuerySchema>;

export const notificationIdParamSchema = z.object({
notificationId: z.coerce
.number()
Expand Down