From 13d92175d3caec9e25afa691c2102f7027054f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=97=B0=EC=A4=80?= Date: Wed, 4 Feb 2026 13:58:10 +0900 Subject: [PATCH 1/6] =?UTF-8?q?fix:=20=EC=9D=B5=EB=AA=85=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=80=ED=95=A8=20=EC=A1=B0=ED=9A=8C=20api=EC=97=90=20letter?= =?UTF-8?q?count=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=9D=B5=EB=AA=85=20?= =?UTF-8?q?=EC=8A=A4=EB=A0=88=EB=93=9C=20=EC=A1=B0=ED=9A=8Capi=EC=97=90=20?= =?UTF-8?q?reatAt=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- prisma/schema.prisma | 4 ++-- src/repositories/letter.repository.js | 18 ++++++++++++++++++ src/repositories/user.repository.js | 2 ++ src/services/mailbox.service.js | 14 +++++++++++++- 4 files changed, 35 insertions(+), 3 deletions(-) diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9238a53..c50b990 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -90,8 +90,8 @@ model UserAgreement { termsAgreed Boolean @map("terms_agreed") privacyAgreed Boolean @map("privacy_agreed") ageOver14Agreed Boolean @map("age_over_14_agreed") - marketingPushAgreed Boolean @map("marketing_push_agreed") - marketingEmailAgreed Boolean @map("marketing_email_agreed") + marketingPushAgreed Boolean @default(false) @map("marketing_push_agreed") + marketingEmailAgreed Boolean @default(false) @map("marketing_email_agreed") agreedAt DateTime @default(now()) @map("agreed_at") user User @relation(fields: [userId], references: [id], onDelete: Cascade) diff --git a/src/repositories/letter.repository.js b/src/repositories/letter.repository.js index 806d8cd..80f3a9a 100644 --- a/src/repositories/letter.repository.js +++ b/src/repositories/letter.repository.js @@ -1,5 +1,6 @@ import { prisma } from "../configs/db.config.js" import { ReferenceNotFoundError } from "../errors/base.error.js"; +import { LETTER_TYPE_ANON } from "../utils/user.util.js"; export const getLetterByUserIdAndAiKeyword = async (senderUserId, keyword) => { const letter = await prisma.letter.findMany({ @@ -349,6 +350,23 @@ export const selectLetterByUserIds = async (userId, targetUserId) => { return count; }; +export const selectAnonymousLetterCountByUserIds = async (userId, targetUserId) => { + if (!userId || !targetUserId) { + return 0; + } + + const count = await prisma.letter.count({ + where: { + letterType: LETTER_TYPE_ANON, + OR: [ + { senderUserId: userId, receiverUserId: targetUserId }, + { senderUserId: targetUserId, receiverUserId: userId }, + ], + }, + }); + return count; +}; + export const selectRecentLetterByUserIds = async (userId, targetUserId) => { return await prisma.letter.findFirst({ where: { diff --git a/src/repositories/user.repository.js b/src/repositories/user.repository.js index b1ce0f8..d195a06 100644 --- a/src/repositories/user.repository.js +++ b/src/repositories/user.repository.js @@ -485,6 +485,7 @@ export const findReceivedLettersBySender = async ({ userId, senderUserId, letter title: true, content: true, deliveredAt: true, + readAt: true, createdAt: true, question: { select: { @@ -528,6 +529,7 @@ export const findSentLettersByReceiver = async ({ userId, receiverUserId, letter title: true, content: true, deliveredAt: true, + readAt: true, createdAt: true, question: { select: { diff --git a/src/services/mailbox.service.js b/src/services/mailbox.service.js index 4c30854..649c22b 100644 --- a/src/services/mailbox.service.js +++ b/src/services/mailbox.service.js @@ -8,7 +8,7 @@ import { import { LETTER_TYPE_ANON, LETTER_TYPE_SELF, makePreview } from "../utils/user.util.js"; import { findFriendById } from "../repositories/friend.repository.js"; import { NotFriendError } from "../errors/friend.error.js"; -import { getFriendLetters, getMyLettersWithFriend } from "../repositories/letter.repository.js"; +import { getFriendLetters, getMyLettersWithFriend, selectAnonymousLetterCountByUserIds } from "../repositories/letter.repository.js"; // ------------------------------ // Mailbox @@ -31,6 +31,15 @@ export const getAnonymousThreads = async (userId) => { const senderIds = Array.from(latestBySender.keys()); const nicknameMap = senderIds.length ? await findUsersNicknameByIds(senderIds) : new Map(); + // 각 발신자별 편지 개수 조회 + const letterCounts = await Promise.all( + senderIds.map(async (senderId) => { + const count = await selectAnonymousLetterCountByUserIds(userId, senderId); + return [senderId, count]; + }) + ); + const letterCountMap = new Map(letterCounts); + const letters = senderIds.map((senderId) => { const l = latestBySender.get(senderId); @@ -43,6 +52,7 @@ export const getAnonymousThreads = async (userId) => { sender: { id: senderId, nickname: nicknameMap.get(senderId) ?? null, + letterCount: letterCountMap.get(senderId) ?? 0, }, stampId: l.design?.stamp?.id ?? null, stampUrl: l.design?.stamp?.assetUrl ?? null, @@ -93,6 +103,7 @@ export const getAnonymousThreadLetters = async (userId, threadIdRaw) => { id: l.id, title: l.title, deliveredAt: l.deliveredAt ?? null, + readAt: l.readAt ?? null, isMine: false, stampId: l.design?.stamp?.id ?? null, stampUrl: l.design?.stamp?.assetUrl ?? null, @@ -113,6 +124,7 @@ export const getAnonymousThreadLetters = async (userId, threadIdRaw) => { id: l.id, title: l.title, deliveredAt: l.deliveredAt ?? null, + readAt: l.readAt ?? null, isMine: true, stampId: l.design?.stamp?.id ?? null, stampUrl: l.design?.stamp?.assetUrl ?? null, From 2a00c08793ded2ba9e498ce086d5fa3f26a1a154 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=97=B0=EC=A4=80?= Date: Wed, 4 Feb 2026 14:41:01 +0900 Subject: [PATCH 2/6] =?UTF-8?q?fix:=20=EC=9D=B5=EB=AA=85=20=EC=8A=A4?= =?UTF-8?q?=EB=A0=88=EB=93=9C=20=ED=8E=B8=EC=A7=80=ED=95=A8=20api=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=A4=91=EB=B3=B5=EB=90=98=EB=8A=94=20thread=5Fid?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20=EB=B0=8F=20session=20id=EB=A1=9C=20?= =?UTF-8?q?=EB=B0=94=EA=BE=B8=EA=B8=B0=20=EB=B0=8F=20=EC=8A=A4=EC=9B=A8?= =?UTF-8?q?=EA=B1=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/mailbox.controller.js | 4 +- src/errors/mailbox.error.js | 4 +- src/index.js | 4 +- src/schemas/mailbox.schema.js | 4 +- src/services/mailbox.service.js | 186 +++++++++++++++++++++----- src/swagger/mailbox.swagger.js | 27 ++-- 6 files changed, 179 insertions(+), 50 deletions(-) diff --git a/src/controllers/mailbox.controller.js b/src/controllers/mailbox.controller.js index 048e260..635059a 100644 --- a/src/controllers/mailbox.controller.js +++ b/src/controllers/mailbox.controller.js @@ -21,8 +21,8 @@ export const handleGetAnonymousThreadLetters = async (req, res, next) => { const userId = getAuthUserId(req); if (!userId) throw new MailboxUnauthorizedError(); - const { threadId } = req.params; - const result = await getAnonymousThreadLetters(userId, threadId); + const { sessionId } = req.params; + const result = await getAnonymousThreadLetters(userId, sessionId); return res.status(200).success(result); } catch (err) { diff --git a/src/errors/mailbox.error.js b/src/errors/mailbox.error.js index e54b0cb..deae07e 100644 --- a/src/errors/mailbox.error.js +++ b/src/errors/mailbox.error.js @@ -7,8 +7,8 @@ export class MailboxUnauthorizedError extends unauthorizedError { } } -export class MailboxInvalidThreadIdError extends BadRequestError { - constructor(code = "MAILBOX_INVALID_THREAD_ID", message = "threadId가 올바르지 않습니다.", data = null) { +export class MailboxInvalidSessionIdError extends BadRequestError { + constructor(code = "MAILBOX_INVALID_SESSION_ID", message = "sessionId가 올바르지 않습니다.", data = null) { super(code, message, data); } } diff --git a/src/index.js b/src/index.js index 74998dd..6005b03 100644 --- a/src/index.js +++ b/src/index.js @@ -27,7 +27,7 @@ import { isRestricted } from "./middlewares/restriction.middleware.js"; import { letterByAiKeywordSchema, letterToMeSchema, letterToOtherSchema, publicCarouselSchema } from "./schemas/letter.schema.js"; import { idParamSchema, ISOTimeSchema } from "./schemas/common.schema.js"; import { pushSubscriptionSchema, onboardingStep1Schema, updateInterestsSchema, updateProfileSchema, updateNotificationSettingsSchema, updateConsentsSchema, updateActivitySchema, createUserAgreementsSchema } from "./schemas/user.schema.js"; -import { threadIdParamSchema } from "./schemas/mailbox.schema.js"; +import { sessionIdParamSchema } from "./schemas/mailbox.schema.js"; import { noticeIdParamSchema } from "./schemas/notice.schema.js"; import { HandleGetHomeDashboard } from "./controllers/dashboard.controller.js"; import { handleInsertUserReport, handleGetUserReports, handleGetUserReport } from "./controllers/report.controller.js"; @@ -209,7 +209,7 @@ app.put("/users/me/push-subscriptions", isLogin, validate(pushSubscriptionSchema // / 편지함 app.get("/mailbox/anonymous", isLogin, handleGetAnonymousThreads); -app.get("/mailbox/anonymous/threads/:threadId/letters", isLogin, validate(threadIdParamSchema), handleGetAnonymousThreadLetters); +app.get("/mailbox/anonymous/threads/:sessionId/letters", isLogin, validate(sessionIdParamSchema), handleGetAnonymousThreadLetters); app.get("/mailbox/friends/threads/:friendId/letters", isLogin, validate(idParamSchema("friendId")), handleGetLetterFromFriend); // 친구 대화 목록 화면 조회 app.get("/mailbox/self", isLogin, handleGetSelfMailbox); diff --git a/src/schemas/mailbox.schema.js b/src/schemas/mailbox.schema.js index 3a0bd2a..1a04560 100644 --- a/src/schemas/mailbox.schema.js +++ b/src/schemas/mailbox.schema.js @@ -1,8 +1,8 @@ import { z } from "zod"; // ========== Mailbox Schema ========== -export const threadIdParamSchema = z.object({ +export const sessionIdParamSchema = z.object({ params: z.object({ - threadId: z.coerce.number("threadId는 숫자여야 합니다.").int("threadId는 정수여야 합니다.").positive("threadId는 1부터 유효합니다.") + sessionId: z.coerce.number("sessionId는 숫자여야 합니다.").int("sessionId는 정수여야 합니다.").positive("sessionId는 1부터 유효합니다.") }) }); diff --git a/src/services/mailbox.service.js b/src/services/mailbox.service.js index 649c22b..b36cc86 100644 --- a/src/services/mailbox.service.js +++ b/src/services/mailbox.service.js @@ -1,14 +1,14 @@ import { findReceivedLettersForThreads, - findReceivedLettersBySender, - findSentLettersByReceiver, findSelfLetters, findUsersNicknameByIds, } from "../repositories/user.repository.js"; import { LETTER_TYPE_ANON, LETTER_TYPE_SELF, makePreview } from "../utils/user.util.js"; import { findFriendById } from "../repositories/friend.repository.js"; import { NotFriendError } from "../errors/friend.error.js"; -import { getFriendLetters, getMyLettersWithFriend, selectAnonymousLetterCountByUserIds } from "../repositories/letter.repository.js"; +import { getFriendLetters, getMyLettersWithFriend } from "../repositories/letter.repository.js"; +import { prisma } from "../configs/db.config.js"; +import { MailboxInvalidSessionIdError } from "../errors/mailbox.error.js"; // ------------------------------ // Mailbox @@ -20,39 +20,76 @@ export const getAnonymousThreads = async (userId) => { letterType: LETTER_TYPE_ANON, }); - const latestBySender = new Map(); // senderUserId -> letter + // sessionId로 그룹화 + const latestBySession = new Map(); // sessionId -> { letter, otherParticipantId } for (const l of receivedLetters) { - if (!l.senderUserId) continue; - if (!latestBySender.has(l.senderUserId)) { - latestBySender.set(l.senderUserId, l); + if (!l.sessionId || !l.senderUserId) continue; + + // 상대방 ID는 senderUserId + const otherParticipantId = l.senderUserId; + + if (!latestBySession.has(l.sessionId)) { + latestBySession.set(l.sessionId, { + letter: l, + otherParticipantId: otherParticipantId + }); + } else { + // 더 최신 편지로 업데이트 + const existing = latestBySession.get(l.sessionId); + const existingTime = existing.letter.deliveredAt + ? new Date(existing.letter.deliveredAt).getTime() + : new Date(existing.letter.createdAt).getTime(); + const currentTime = l.deliveredAt + ? new Date(l.deliveredAt).getTime() + : new Date(l.createdAt).getTime(); + + if (currentTime > existingTime) { + latestBySession.set(l.sessionId, { + letter: l, + otherParticipantId: otherParticipantId + }); + } } } - const senderIds = Array.from(latestBySender.keys()); - const nicknameMap = senderIds.length ? await findUsersNicknameByIds(senderIds) : new Map(); + const sessionIds = Array.from(latestBySession.keys()); + const sessionData = Array.from(latestBySession.values()); + const otherParticipantIds = sessionData.map(s => s.otherParticipantId); + const nicknameMap = otherParticipantIds.length + ? await findUsersNicknameByIds(otherParticipantIds) + : new Map(); - // 각 발신자별 편지 개수 조회 + // 각 세션별 편지 개수 조회 (sessionId 기준, 개별 조회) const letterCounts = await Promise.all( - senderIds.map(async (senderId) => { - const count = await selectAnonymousLetterCountByUserIds(userId, senderId); - return [senderId, count]; + sessionIds.map(async (sessionId) => { + const count = await prisma.letter.count({ + where: { + letterType: LETTER_TYPE_ANON, + sessionId: sessionId, + OR: [ + { receiverUserId: userId }, + { senderUserId: userId } + ] + } + }); + return [sessionId, count]; }) ); const letterCountMap = new Map(letterCounts); - const letters = senderIds.map((senderId) => { - const l = latestBySender.get(senderId); + const letters = sessionIds.map((sessionId) => { + const { letter: l, otherParticipantId } = latestBySession.get(sessionId); return { - threadId: senderId, // threadId = senderUserId + sessionId: sessionId, // threadId -> sessionId로 변경 lastLetterId: l.id, lastLetterTitle: l.title, lastLetterPreview: makePreview(l.content, 30), deliveredAt: l.deliveredAt ?? null, sender: { - id: senderId, - nickname: nicknameMap.get(senderId) ?? null, - letterCount: letterCountMap.get(senderId) ?? 0, + id: otherParticipantId, // 상대방 ID + nickname: nicknameMap.get(otherParticipantId) ?? null, + letterCount: letterCountMap.get(sessionId) ?? 0, // sessionId 기준 개수 }, stampId: l.design?.stamp?.id ?? null, stampUrl: l.design?.stamp?.assetUrl ?? null, @@ -78,21 +115,108 @@ export const getAnonymousThreads = async (userId) => { return { letters }; }; -export const getAnonymousThreadLetters = async (userId, threadIdRaw) => { - const threadId = Number(threadIdRaw); +export const getAnonymousThreadLetters = async (userId, sessionIdRaw) => { + const sessionId = Number(sessionIdRaw); - // 받은 편지 조회 - const receivedLetters = await findReceivedLettersBySender({ - userId, - senderUserId: threadId, - letterType: LETTER_TYPE_ANON, + // 세션 참가자 조회하여 권한 확인 + const session = await prisma.matchingSession.findFirst({ + where: { + id: sessionId, + participants: { + some: { + userId: userId // 현재 사용자가 참가자인 세션만 + } + } + }, + include: { + participants: { + select: { + userId: true + } + } + } }); - // 보낸 편지 조회 - const sentLetters = await findSentLettersByReceiver({ - userId, - receiverUserId: threadId, - letterType: LETTER_TYPE_ANON, + if (!session) { + throw new MailboxInvalidSessionIdError(); + } + + // 받은 편지 조회 (sessionId로 필터링) + const receivedLetters = await prisma.letter.findMany({ + where: { + receiverUserId: userId, + sessionId: sessionId, + letterType: LETTER_TYPE_ANON, + }, + orderBy: [{ deliveredAt: "desc" }, { createdAt: "desc" }], + select: { + id: true, + title: true, + deliveredAt: true, + readAt: true, + createdAt: true, + question: { + select: { + content: true + } + }, + design: { + select: { + paper: { + select: { + id: true, + color: true, + } + }, + stamp: { + select: { + id: true, + name: true, + assetUrl: true + } + }, + }, + }, + }, + }); + + // 보낸 편지 조회 (sessionId로 필터링) + const sentLetters = await prisma.letter.findMany({ + where: { + senderUserId: userId, + sessionId: sessionId, + letterType: LETTER_TYPE_ANON, + }, + orderBy: [{ deliveredAt: "desc" }, { createdAt: "desc" }], + select: { + id: true, + title: true, + deliveredAt: true, + readAt: true, + createdAt: true, + question: { + select: { + content: true + } + }, + design: { + select: { + paper: { + select: { + id: true, + color: true, + } + }, + stamp: { + select: { + id: true, + name: true, + assetUrl: true + } + }, + }, + }, + }, }); // firstQuestion: 받은 편지의 첫 번째 질문 우선, 없으면 보낸 편지의 첫 번째 질문 diff --git a/src/swagger/mailbox.swagger.js b/src/swagger/mailbox.swagger.js index e74fbf0..b961768 100644 --- a/src/swagger/mailbox.swagger.js +++ b/src/swagger/mailbox.swagger.js @@ -3,7 +3,7 @@ * /mailbox/anonymous: * get: * summary: 익명 탭 목록 조회 - * description: 익명으로 받은 편지의 스레드 목록을 조회합니다. senderUserId별 최신 편지 1개씩 반환됩니다. + * description: 익명으로 받은 편지의 스레드 목록을 조회합니다. sessionId별 최신 편지 1개씩 반환됩니다. * tags: [편지함] * security: * - bearerAuth: [] @@ -24,9 +24,9 @@ * items: * type: object * properties: - * threadId: + * sessionId: * type: integer - * description: 스레드 ID (senderUserId) + * description: 세션 ID (MatchingSession ID) * sender: * type: object * properties: @@ -35,6 +35,9 @@ * nickname: * type: string * nullable: true + * letterCount: + * type: integer + * description: 해당 세션의 편지 개수 * lastLetterId: * type: integer * lastLetterTitle: @@ -63,8 +66,6 @@ * type: integer * name: * type: string - * assetUrl: - * type: string * 401: * description: | * 인증 실패: @@ -152,7 +153,7 @@ /** * @swagger - * /mailbox/anonymous/threads/{threadId}/letters: + * /mailbox/anonymous/threads/{sessionId}/letters: * get: * summary: 익명 스레드 편지 목록 조회 * description: 특정 익명 스레드의 편지 목록을 조회합니다. @@ -161,11 +162,11 @@ * - bearerAuth: [] * parameters: * - in: path - * name: threadId + * name: sessionId * required: true * schema: * type: integer - * description: 스레드 ID (senderUserId) + * description: 세션 ID (MatchingSession ID) * responses: * 200: * description: 성공 @@ -195,6 +196,10 @@ * type: string * format: date-time * nullable: true + * readAt: + * type: string + * format: date-time + * nullable: true * isMine: * type: boolean * description: 내가 보낸 편지인지 여부 @@ -220,7 +225,7 @@ * description: | * 잘못된 요청: * - `REQ_BAD_REQUEST`: 요청 유효성 검사 실패 - * - `MAILBOX_INVALID_THREAD_ID`: threadId가 올바르지 않습니다. + * - `MAILBOX_INVALID_SESSION_ID`: sessionId가 올바르지 않습니다. * content: * application/json: * schema: @@ -240,9 +245,9 @@ * error: * properties: * errorCode: - * example: "MAILBOX_INVALID_THREAD_ID" + * example: "MAILBOX_INVALID_SESSION_ID" * reason: - * example: "threadId가 올바르지 않습니다." + * example: "sessionId가 올바르지 않습니다." * 401: * description: | * 인증 실패: From c1a1c90a5950b4263286d6cc334baf66bd1678d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=97=B0=EC=A4=80?= Date: Wed, 4 Feb 2026 14:49:51 +0900 Subject: [PATCH 3/6] =?UTF-8?q?fix:=20=EC=9D=B5=EB=AA=85=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=80=ED=95=A8,=20=EC=9D=B5=EB=AA=85=20=EC=8A=A4=EB=A0=88?= =?UTF-8?q?=EB=93=9C=20=ED=8E=B8=EC=A7=80=20=EC=9D=91=EB=8B=B5=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/mailbox.service.js | 51 ++++++++++----------------------- src/swagger/mailbox.swagger.js | 46 ++++++++++++----------------- 2 files changed, 33 insertions(+), 64 deletions(-) diff --git a/src/services/mailbox.service.js b/src/services/mailbox.service.js index b36cc86..5455410 100644 --- a/src/services/mailbox.service.js +++ b/src/services/mailbox.service.js @@ -91,18 +91,11 @@ export const getAnonymousThreads = async (userId) => { nickname: nicknameMap.get(otherParticipantId) ?? null, letterCount: letterCountMap.get(sessionId) ?? 0, // sessionId 기준 개수 }, - stampId: l.design?.stamp?.id ?? null, - stampUrl: l.design?.stamp?.assetUrl ?? null, - design: l.design - ? { - paper: l.design.paper - ? { - id: l.design.paper.id, - name: l.design.paper.color, // color를 name으로 매핑 - } - : null, - } - : { paper: null }, + design: { + paperId: l.design?.paper?.id ?? null, + stampId: l.design?.stamp?.id ?? null, + stampUrl: l.design?.stamp?.assetUrl ?? null, + }, }; }); @@ -229,18 +222,11 @@ export const getAnonymousThreadLetters = async (userId, sessionIdRaw) => { deliveredAt: l.deliveredAt ?? null, readAt: l.readAt ?? null, isMine: false, - stampId: l.design?.stamp?.id ?? null, - stampUrl: l.design?.stamp?.assetUrl ?? null, - design: l.design - ? { - paper: l.design.paper - ? { - id: l.design.paper.id, - name: l.design.paper.color, // color를 name으로 매핑 - } - : null, - } - : { paper: null }, + design: { + paperId: l.design?.paper?.id ?? null, + stampId: l.design?.stamp?.id ?? null, + stampUrl: l.design?.stamp?.assetUrl ?? null, + }, })); // 보낸 편지에 isMine: true 추가 @@ -250,18 +236,11 @@ export const getAnonymousThreadLetters = async (userId, sessionIdRaw) => { deliveredAt: l.deliveredAt ?? null, readAt: l.readAt ?? null, isMine: true, - stampId: l.design?.stamp?.id ?? null, - stampUrl: l.design?.stamp?.assetUrl ?? null, - design: l.design - ? { - paper: l.design.paper - ? { - id: l.design.paper.id, - name: l.design.paper.color, // color를 name으로 매핑 - } - : null, - } - : { paper: null }, + design: { + paperId: l.design?.paper?.id ?? null, + stampId: l.design?.stamp?.id ?? null, + stampUrl: l.design?.stamp?.assetUrl ?? null, + }, })); // 받은 편지 먼저, 보낸 편지 나중에 배치 diff --git a/src/swagger/mailbox.swagger.js b/src/swagger/mailbox.swagger.js index b961768..3412d07 100644 --- a/src/swagger/mailbox.swagger.js +++ b/src/swagger/mailbox.swagger.js @@ -48,24 +48,19 @@ * type: string * format: date-time * nullable: true - * stampId: - * type: integer - * nullable: true - * stampUrl: - * type: string - * nullable: true - * description: 스탬프 이미지 URL * design: * type: object * properties: - * paper: - * type: object + * paperId: + * type: integer * nullable: true - * properties: - * id: - * type: integer - * name: - * type: string + * stampId: + * type: integer + * nullable: true + * stampUrl: + * type: string + * nullable: true + * description: 스탬프 이미지 URL * 401: * description: | * 인증 실패: @@ -203,24 +198,19 @@ * isMine: * type: boolean * description: 내가 보낸 편지인지 여부 - * stampId: - * type: integer - * nullable: true - * stampUrl: - * type: string - * nullable: true - * description: 스탬프 이미지 URL * design: * type: object * properties: - * paper: - * type: object + * paperId: + * type: integer * nullable: true - * properties: - * id: - * type: integer - * name: - * type: string + * stampId: + * type: integer + * nullable: true + * stampUrl: + * type: string + * nullable: true + * description: 스탬프 이미지 URL * 400: * description: | * 잘못된 요청: From ee80566c2ae89254cf27d04eebde9de6096a7c2d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=97=B0=EC=A4=80?= Date: Wed, 4 Feb 2026 15:44:59 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EC=8B=A4=ED=96=89=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/send-notice-push.js | 33 ++++++++++++++++++ src/repositories/user.repository.js | 54 +++++++++++++++++++++++++++++ src/services/notice.service.js | 49 +++++++++++++++++++++++++- src/services/push.service.js | 8 +++-- 4 files changed, 140 insertions(+), 4 deletions(-) create mode 100644 scripts/send-notice-push.js diff --git a/scripts/send-notice-push.js b/scripts/send-notice-push.js new file mode 100644 index 0000000..ad611a3 --- /dev/null +++ b/scripts/send-notice-push.js @@ -0,0 +1,33 @@ +import "dotenv/config"; +import { sendNoticePushNotification } from "../src/services/notice.service.js"; + +/** + * 공지사항 푸시 알림 전송 스크립트 + * + * 사용 방법: + * node scripts/send-notice-push.js + * + * 기능: + * - 최근 10분 이내 생성된 공지사항을 찾습니다 + * - marketingEnabled: true인 사용자에게 푸시 알림을 전송합니다 + */ +const main = async () => { + try { + console.log("공지사항 푸시 알림 전송을 시작합니다..."); + + const result = await sendNoticePushNotification(); + + console.log("\n=== 전송 결과 ==="); + console.log(`공지사항: ${result.notices.length}개`); + console.log(`대상 사용자: ${result.users}명`); + console.log(`성공: ${result.sent}건`); + console.log(`실패: ${result.failed || 0}건`); + + process.exit(0); + } catch (error) { + console.error("에러 발생:", error); + process.exit(1); + } +}; + +main(); diff --git a/src/repositories/user.repository.js b/src/repositories/user.repository.js index d195a06..31bec79 100644 --- a/src/repositories/user.repository.js +++ b/src/repositories/user.repository.js @@ -325,6 +325,21 @@ export const getPushSubscription = async (userId) => { return subscriptions; } +export const getPushSubscriptionForMarketing = async (userId) => { + const subscriptions = await prisma.pushSubscription.findMany({ + where: { + userId: userId, + user: { + notificationSetting: { + marketingEnabled: true + } + } + } + }) + + return subscriptions; +} + export const deletePushSubscription = async (id) => { await prisma.pushSubscription.delete({ where: {id: id} @@ -641,6 +656,45 @@ export const findNoticeById = async (id) => { }); }; +export const findRecentNotices = async (minutes = 10) => { + const now = new Date(); + const minutesAgo = new Date(now.getTime() - minutes * 60 * 1000); + + return prisma.notice.findMany({ + where: { + createdAt: { + gte: minutesAgo, + lte: now, + }, + }, + orderBy: { createdAt: "desc" }, + select: { + id: true, + title: true, + createdAt: true, + }, + }); +}; + +export const findUsersWithMarketingPushEnabled = async () => { + const users = await prisma.user.findMany({ + where: { + isDeleted: false, + notificationSetting: { + marketingEnabled: true, + }, + pushSubscriptions: { + some: {}, + }, + }, + select: { + id: true, + }, + }); + + return users.map((user) => user.id); +}; + // ========== Notification Repository ========== /** * 알림 설정 조회 diff --git a/src/services/notice.service.js b/src/services/notice.service.js index 466948c..3132b00 100644 --- a/src/services/notice.service.js +++ b/src/services/notice.service.js @@ -1,5 +1,6 @@ -import { findActiveNotices, findNoticeById } from "../repositories/user.repository.js"; +import { findActiveNotices, findNoticeById, findRecentNotices, findUsersWithMarketingPushEnabled } from "../repositories/user.repository.js"; import { NoticeNotFoundError } from "../errors/notice.error.js"; +import { sendPushNotification } from "./push.service.js"; // ------------------------------ // Notice @@ -19,3 +20,49 @@ export const getNoticeDetail = async (noticeId) => { return notice; }; + +export const sendNoticePushNotification = async () => { + // 10분 이내 생성된 공지사항 조회 + const recentNotices = await findRecentNotices(10); + + if (recentNotices.length === 0) { + console.log("최근 10분 이내 생성된 공지사항이 없습니다."); + return { sent: 0, notices: [] }; + } + + console.log(`${recentNotices.length}개의 최근 공지사항을 찾았습니다.`); + + // marketingEnabled: true인 사용자 조회 + const userIds = await findUsersWithMarketingPushEnabled(); + + if (userIds.length === 0) { + console.log("푸시 알림이 활성화된 사용자가 없습니다."); + return { sent: 0, notices: recentNotices, users: 0 }; + } + + console.log(`${userIds.length}명의 사용자에게 푸시 알림을 전송합니다.`); + + // 각 사용자에게 푸시 알림 전송 + const results = await Promise.allSettled( + userIds.map((userId) => + sendPushNotification({ + userId, + type: "NOTICE", + data: {}, + useMarketing: true, // marketingEnabled 체크 + }) + ) + ); + + const successCount = results.filter((r) => r.status === "fulfilled").length; + const failCount = results.filter((r) => r.status === "rejected").length; + + console.log(`푸시 알림 전송 완료: 성공 ${successCount}건, 실패 ${failCount}건`); + + return { + sent: successCount, + failed: failCount, + notices: recentNotices, + users: userIds.length, + }; +}; diff --git a/src/services/push.service.js b/src/services/push.service.js index a801573..9837b41 100644 --- a/src/services/push.service.js +++ b/src/services/push.service.js @@ -1,9 +1,11 @@ import { NOTIFICATION_MESSAGES } from "../constants/push.constant.js"; -import { deletePushSubscription, getPushSubscription } from "../repositories/user.repository.js" +import { deletePushSubscription, getPushSubscription, getPushSubscriptionForMarketing } from "../repositories/user.repository.js" import webpush from "web-push" -export const sendPushNotification = async ({userId, type, data = {}}) => { - const subscriptions = await getPushSubscription(userId); +export const sendPushNotification = async ({userId, type, data = {}, useMarketing = false}) => { + const subscriptions = useMarketing + ? await getPushSubscriptionForMarketing(userId) + : await getPushSubscription(userId); if(subscriptions.length === 0) return; const messageConfig = NOTIFICATION_MESSAGES[type]; From 83597d7f95b3f7adabaa55c3cf170533e1951dd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=97=B0=EC=A4=80?= Date: Thu, 5 Feb 2026 13:39:10 +0900 Subject: [PATCH 5/6] =?UTF-8?q?fix:=20=EA=B3=B5=EC=A7=80=EC=82=AC=ED=95=AD?= =?UTF-8?q?=20=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/send-notice-push.js | 33 --------------- src/jobs/bootstraps/push.bootstrap.js | 60 ++++++++++++++++++++++++++- src/jobs/crons/notice.cron.js | 17 ++++++++ src/jobs/index.job.js | 4 +- src/services/notice.service.js | 51 ++--------------------- 5 files changed, 83 insertions(+), 82 deletions(-) delete mode 100644 scripts/send-notice-push.js create mode 100644 src/jobs/crons/notice.cron.js diff --git a/scripts/send-notice-push.js b/scripts/send-notice-push.js deleted file mode 100644 index ad611a3..0000000 --- a/scripts/send-notice-push.js +++ /dev/null @@ -1,33 +0,0 @@ -import "dotenv/config"; -import { sendNoticePushNotification } from "../src/services/notice.service.js"; - -/** - * 공지사항 푸시 알림 전송 스크립트 - * - * 사용 방법: - * node scripts/send-notice-push.js - * - * 기능: - * - 최근 10분 이내 생성된 공지사항을 찾습니다 - * - marketingEnabled: true인 사용자에게 푸시 알림을 전송합니다 - */ -const main = async () => { - try { - console.log("공지사항 푸시 알림 전송을 시작합니다..."); - - const result = await sendNoticePushNotification(); - - console.log("\n=== 전송 결과 ==="); - console.log(`공지사항: ${result.notices.length}개`); - console.log(`대상 사용자: ${result.users}명`); - console.log(`성공: ${result.sent}건`); - console.log(`실패: ${result.failed || 0}건`); - - process.exit(0); - } catch (error) { - console.error("에러 발생:", error); - process.exit(1); - } -}; - -main(); diff --git a/src/jobs/bootstraps/push.bootstrap.js b/src/jobs/bootstraps/push.bootstrap.js index 5b60dd7..1366441 100644 --- a/src/jobs/bootstraps/push.bootstrap.js +++ b/src/jobs/bootstraps/push.bootstrap.js @@ -1,6 +1,8 @@ import { Queue, Worker } from "bullmq"; import { ioredisConnection } from "../../configs/db.config.js"; import { sendPushNotification } from "../../services/push.service.js"; +import { findRecentNotices, findUsersWithMarketingPushEnabled } from "../../repositories/user.repository.js"; +import { enqueueJob } from "../../utils/queue.util.js"; export const pushQueue = new Queue("send-push", { connection: ioredisConnection }); @@ -12,6 +14,18 @@ export const sendPushNotificationWorker = () => { console.log(`[Job Success] 푸시 알람 전송에 성공했습니다.`); } + + if(job.name === "PUSH_BY_NOTICE") { + console.log(`[Job Start] 공지사항 푸시알림 전송 중. userId: ${job.data.userId}`); + await sendPushNotification({ + userId: job.data.userId, + type: "NOTICE", + data: {}, + useMarketing: true, + }); + + console.log(`[Job Success] 공지사항 푸시 알림 전송에 성공했습니다. userId: ${job.data.userId}`); + } }, {connection: ioredisConnection}); worker.on('failed', (job, err) => { @@ -21,4 +35,48 @@ export const sendPushNotificationWorker = () => { worker.on('error', (err) => { console.error(`[Worker Error] 시스템 오류: ${err.message}`); }); -} \ No newline at end of file +}; + +/** + * 최근 공지사항을 찾아서 큐에 푸시 알림 job 추가 + */ +export const sendNoticePushNotifications = async () => { + // 10분 이내 생성된 공지사항 조회 (기존 함수 재사용) + const recentNotices = await findRecentNotices(10); + + if (recentNotices.length === 0) { + console.log("[Cron Info] 최근 10분 이내 생성된 공지사항이 없습니다."); + return { queued: 0, notices: [] }; + } + + console.log(`[Cron Info] ${recentNotices.length}개의 최근 공지사항을 찾았습니다.`); + + // marketingEnabled: true인 사용자 조회 (기존 함수 재사용) + const userIds = await findUsersWithMarketingPushEnabled(); + + if (userIds.length === 0) { + console.log("[Cron Info] 푸시 알림이 활성화된 사용자가 없습니다."); + return { queued: 0, notices: recentNotices, users: 0 }; + } + + console.log(`[Cron Info] ${userIds.length}명의 사용자에게 푸시 알림을 큐에 추가합니다.`); + + // 각 사용자에게 푸시 알림 job 추가 + const jobs = await Promise.allSettled( + userIds.map((userId) => + enqueueJob(pushQueue, "PUSH_BY_NOTICE", { userId }) + ) + ); + + const successCount = jobs.filter((r) => r.status === "fulfilled").length; + const failCount = jobs.filter((r) => r.status === "rejected").length; + + console.log(`[Cron Success] 푸시 알림 job 추가 완료: 성공 ${successCount}건, 실패 ${failCount}건`); + + return { + queued: successCount, + failed: failCount, + notices: recentNotices, + users: userIds.length, + }; +}; \ No newline at end of file diff --git a/src/jobs/crons/notice.cron.js b/src/jobs/crons/notice.cron.js new file mode 100644 index 0000000..cc007cf --- /dev/null +++ b/src/jobs/crons/notice.cron.js @@ -0,0 +1,17 @@ +import cron from "node-cron"; +import { sendNoticePushNotifications } from "../bootstraps/push.bootstrap.js"; + +export const sendNoticePushCron = () => { + // 10분마다 실행 + cron.schedule( + '*/10 * * * *', + async () => { + console.log("[Cron Start] 공지사항 푸시 알림 전송을 시작합니다."); + await sendNoticePushNotifications(); + }, + { + scheduled: true, + timezone: "Asia/Seoul" + } + ); +}; diff --git a/src/jobs/index.job.js b/src/jobs/index.job.js index 548daa5..fbd12df 100644 --- a/src/jobs/index.job.js +++ b/src/jobs/index.job.js @@ -1,5 +1,6 @@ import { sendScheduledLettersCron } from "./crons/letter.cron.js"; -import { startWeeklyReportCron } from "./crons/weeklyReport.cron.js" +import { startWeeklyReportCron } from "./crons/weeklyReport.cron.js"; +import { sendNoticePushCron } from "./crons/notice.cron.js"; import { sendQueuedLettersWorker } from "./bootstraps/letter.bootstrap.js" import { sendPushNotificationWorker } from "./bootstraps/push.bootstrap.js"; import { sendMailWorker } from "./bootstraps/mail.bootstrap.js"; @@ -7,6 +8,7 @@ import { sendMailWorker } from "./bootstraps/mail.bootstrap.js"; export const startBatch = async () => { startWeeklyReportCron(); sendScheduledLettersCron(); + sendNoticePushCron(); sendQueuedLettersWorker(); sendPushNotificationWorker(); diff --git a/src/services/notice.service.js b/src/services/notice.service.js index 3132b00..f2d795d 100644 --- a/src/services/notice.service.js +++ b/src/services/notice.service.js @@ -1,6 +1,5 @@ -import { findActiveNotices, findNoticeById, findRecentNotices, findUsersWithMarketingPushEnabled } from "../repositories/user.repository.js"; +import { findActiveNotices, findNoticeById } from "../repositories/user.repository.js"; import { NoticeNotFoundError } from "../errors/notice.error.js"; -import { sendPushNotification } from "./push.service.js"; // ------------------------------ // Notice @@ -21,48 +20,6 @@ export const getNoticeDetail = async (noticeId) => { return notice; }; -export const sendNoticePushNotification = async () => { - // 10분 이내 생성된 공지사항 조회 - const recentNotices = await findRecentNotices(10); - - if (recentNotices.length === 0) { - console.log("최근 10분 이내 생성된 공지사항이 없습니다."); - return { sent: 0, notices: [] }; - } - - console.log(`${recentNotices.length}개의 최근 공지사항을 찾았습니다.`); - - // marketingEnabled: true인 사용자 조회 - const userIds = await findUsersWithMarketingPushEnabled(); - - if (userIds.length === 0) { - console.log("푸시 알림이 활성화된 사용자가 없습니다."); - return { sent: 0, notices: recentNotices, users: 0 }; - } - - console.log(`${userIds.length}명의 사용자에게 푸시 알림을 전송합니다.`); - - // 각 사용자에게 푸시 알림 전송 - const results = await Promise.allSettled( - userIds.map((userId) => - sendPushNotification({ - userId, - type: "NOTICE", - data: {}, - useMarketing: true, // marketingEnabled 체크 - }) - ) - ); - - const successCount = results.filter((r) => r.status === "fulfilled").length; - const failCount = results.filter((r) => r.status === "rejected").length; - - console.log(`푸시 알림 전송 완료: 성공 ${successCount}건, 실패 ${failCount}건`); - - return { - sent: successCount, - failed: failCount, - notices: recentNotices, - users: userIds.length, - }; -}; +// sendNoticePushNotification 함수는 삭제됨 +// jobs/bootstraps/push.bootstrap.js의 sendNoticePushNotifications로 대체됨 +// 비동기 메시지 큐(BullMQ)를 통해 처리됩니다. From d5dd1d77a528423769480f1389f288fec03c347b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=A1=B0=EC=97=B0=EC=A4=80?= Date: Thu, 5 Feb 2026 13:59:48 +0900 Subject: [PATCH 6/6] =?UTF-8?q?feat:=20=EC=9D=B5=EB=AA=85=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=80=ED=95=A8=20hasUnread=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/mailbox.service.js | 17 +++++++++++++++++ src/swagger/mailbox.swagger.js | 3 +++ 2 files changed, 20 insertions(+) diff --git a/src/services/mailbox.service.js b/src/services/mailbox.service.js index 5455410..1e96796 100644 --- a/src/services/mailbox.service.js +++ b/src/services/mailbox.service.js @@ -77,6 +77,22 @@ export const getAnonymousThreads = async (userId) => { ); const letterCountMap = new Map(letterCounts); + // 각 세션별 읽지 않은 편지 체크 (받은 편지만 체크) + const unreadChecks = await Promise.all( + sessionIds.map(async (sessionId) => { + const unreadCount = await prisma.letter.count({ + where: { + letterType: LETTER_TYPE_ANON, + sessionId: sessionId, + receiverUserId: userId, + readAt: null, // 읽지 않은 편지 + } + }); + return [sessionId, unreadCount > 0]; // 하나라도 있으면 true + }) + ); + const hasUnreadMap = new Map(unreadChecks); + const letters = sessionIds.map((sessionId) => { const { letter: l, otherParticipantId } = latestBySession.get(sessionId); @@ -86,6 +102,7 @@ export const getAnonymousThreads = async (userId) => { lastLetterTitle: l.title, lastLetterPreview: makePreview(l.content, 30), deliveredAt: l.deliveredAt ?? null, + hasUnread: hasUnreadMap.get(sessionId) ?? false, // 읽지 않은 편지 여부 sender: { id: otherParticipantId, // 상대방 ID nickname: nicknameMap.get(otherParticipantId) ?? null, diff --git a/src/swagger/mailbox.swagger.js b/src/swagger/mailbox.swagger.js index 3412d07..44a1ee3 100644 --- a/src/swagger/mailbox.swagger.js +++ b/src/swagger/mailbox.swagger.js @@ -48,6 +48,9 @@ * type: string * format: date-time * nullable: true + * hasUnread: + * type: boolean + * description: 해당 세션에 읽지 않은 편지가 있는지 여부 (받은 편지 기준) * design: * type: object * properties: