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 01/13] =?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 02/13] =?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 03/13] =?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 04/13] =?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 779272e8d6fc8845d73380dbe443f5fd2a014c23 Mon Sep 17 00:00:00 2001 From: user040131 Date: Thu, 5 Feb 2026 03:54:02 +0900 Subject: [PATCH 05/13] =?UTF-8?q?FIX:=20findRandomUserByPool=EC=97=90?= =?UTF-8?q?=EC=84=9C=20CHATING=EA=B3=BC=20PENDING=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=84=B8=EC=85=98=20=EA=B2=80=EC=83=89=ED=95=B4=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EC=9C=A0=EC=A0=80=20=EC=A0=9C=EC=99=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/repositories/user.repository.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/repositories/user.repository.js b/src/repositories/user.repository.js index b1ce0f8..dc3439e 100644 --- a/src/repositories/user.repository.js +++ b/src/repositories/user.repository.js @@ -179,7 +179,7 @@ export const findRandomUserByPool = async (id) => { const sessions = await prisma.matchingSession.findMany({ where: { - status: "CHATING", + status: { in: ["PENDING", "CHATING"] }, participants: { some: { userId: id } }, }, select: { From c55253a87cc5706cf185a5903b7752157575679f Mon Sep 17 00:00:00 2001 From: user040131 Date: Thu, 5 Feb 2026 03:54:50 +0900 Subject: [PATCH 06/13] =?UTF-8?q?FIX:=20=EC=84=B8=EC=85=98=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=8B=9C=20=ED=95=B4=EB=8B=B9=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EB=93=A4=EC=9D=84=20=ED=8F=AC=ED=95=A8=ED=95=9C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20=EA=B2=80=EC=83=89?= =?UTF-8?q?=ED=95=98=EC=97=AC=20=EC=A1=B4=EC=9E=AC=20=EC=8B=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=20=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/session.service.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/services/session.service.js b/src/services/session.service.js index 6fcb377..bcf422e 100644 --- a/src/services/session.service.js +++ b/src/services/session.service.js @@ -4,7 +4,8 @@ import { updateMatchingSessionToDiscard, updateMatchingSessionToFriends, findSessionParticipantByUserIdAndSessionId, - countMatchingSessionWhichChating + countMatchingSessionWhichChating, + findMatchingSessionByParticipantUserId } from "../repositories/session.repository.js"; import { SessionCountOverError, @@ -46,6 +47,10 @@ export function validateTemperatureScore(temperatureScore) { } export const createMatchingSession = async (userId, targetUserId, questionId, tx) => { + const existingSession = await findMatchingSessionByParticipantUserId(userId, targetUserId); + if (existingSession) { + throw new SessionInternalError('An active session already exists between these users.', undefined, { userId, targetUserId }); + } try { const result = await acceptSessionRequestTx(userId, targetUserId, questionId, tx); if (result == null) throw new SessionInternalError(undefined, undefined, { userId, questionId }); From cec0e8791c26bf2f2622ac5e18983acbdc87cd4d Mon Sep 17 00:00:00 2001 From: user040131 Date: Thu, 5 Feb 2026 12:31:13 +0900 Subject: [PATCH 07/13] =?UTF-8?q?feat:=20acceptFriendRequest=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=8B=A0=EC=9E=90=20=EB=8C=80=EC=83=81=20?= =?UTF-8?q?=ED=91=B8=EC=8B=9C=20=EC=95=8C=EB=9E=8C=20=EB=B0=9C=EC=86=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/friend.service.js | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/services/friend.service.js b/src/services/friend.service.js index e3a08d6..771a336 100644 --- a/src/services/friend.service.js +++ b/src/services/friend.service.js @@ -27,6 +27,7 @@ import { } from "../errors/base.error.js"; import { findMatchingSessionByParticipantUserId, updateMatchingSessionToFriends, updateMatchingSessionToDiscard } from "../repositories/session.repository.js"; import { SessionInternalError, SessionNotFoundError } from "../errors/session.error.js"; +import { sendPushNotification } from "./push.service.js"; async function userExistsOrThrow(userId) { const userById = await findUserById(userId); @@ -197,8 +198,16 @@ export const acceptFriendRequest = async (receiverUserId, requesterUserId) => { receiverUserId, requesterUserId, }); + const nickname = (await findUserById(receiverUserId)).nickname; try { const result = await acceptFriendRequestTx(receiverUserId, requesterUserId); + if(result){ + await sendPushNotification({ + userId: requesterUserId, + type: "FRIEND_MATCH_ACCEPTED", + targetUserNickname: nickname ? nickname : `새 친구(${receiverUserId})` + }) + } return { data: result, }; From 121f9d18affcf66cd5da41063b986c3a1a814792 Mon Sep 17 00:00:00 2001 From: user040131 Date: Thu, 5 Feb 2026 12:33:38 +0900 Subject: [PATCH 08/13] =?UTF-8?q?fix:=20FRIEND=5FMATCH=5FSUCCESS=20?= =?UTF-8?q?=EC=97=90=EC=84=9C=20BODY=EC=97=90=20targetUserNickname=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/push.constant.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/constants/push.constant.js b/src/constants/push.constant.js index 61a8977..dd18ef7 100644 --- a/src/constants/push.constant.js +++ b/src/constants/push.constant.js @@ -41,6 +41,6 @@ export const NOTIFICATION_MESSAGES = { // 5. 친구 매칭 성공 FRIEND_MATCH_SUCCESS: { TITLE: () => "마음이 딱 통했어요! ✨", - BODY: () => "새로운 친구와 연결되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?", + BODY: (targetUserNickname) => `${targetUserNickname}님과 새로운 친구가 되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?`, }, }; \ No newline at end of file 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 09/13] =?UTF-8?q?fix:=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?=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 10/13] =?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: From 41c32c1c0a349609666f1ff24bc471d14882ea7a Mon Sep 17 00:00:00 2001 From: user040131 Date: Thu, 5 Feb 2026 19:15:29 +0900 Subject: [PATCH 11/13] =?UTF-8?q?fix:=20=EB=B3=80=EA=B2=BD=20=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants/push.constant.js | 2 +- src/services/friend.service.js | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/constants/push.constant.js b/src/constants/push.constant.js index dd18ef7..2b11ddf 100644 --- a/src/constants/push.constant.js +++ b/src/constants/push.constant.js @@ -41,6 +41,6 @@ export const NOTIFICATION_MESSAGES = { // 5. 친구 매칭 성공 FRIEND_MATCH_SUCCESS: { TITLE: () => "마음이 딱 통했어요! ✨", - BODY: (targetUserNickname) => `${targetUserNickname}님과 새로운 친구가 되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?`, + BODY: ({ nickname }) => `${nickname}님과 새로운 친구가 되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?`, }, }; \ No newline at end of file diff --git a/src/services/friend.service.js b/src/services/friend.service.js index 771a336..80aff91 100644 --- a/src/services/friend.service.js +++ b/src/services/friend.service.js @@ -28,6 +28,8 @@ import { import { findMatchingSessionByParticipantUserId, updateMatchingSessionToFriends, updateMatchingSessionToDiscard } from "../repositories/session.repository.js"; import { SessionInternalError, SessionNotFoundError } from "../errors/session.error.js"; import { sendPushNotification } from "./push.service.js"; +import { enqueueJob } from "../utils/queue.util.js"; +import { pushQueue } from "../jobs/bootstraps/push.bootstrap.js"; async function userExistsOrThrow(userId) { const userById = await findUserById(userId); @@ -201,13 +203,14 @@ export const acceptFriendRequest = async (receiverUserId, requesterUserId) => { const nickname = (await findUserById(receiverUserId)).nickname; try { const result = await acceptFriendRequestTx(receiverUserId, requesterUserId); - if(result){ - await sendPushNotification({ + if (result) { + await enqueueJob(pushQueue, "PUSH_BY_FRIEND", { userId: requesterUserId, - type: "FRIEND_MATCH_ACCEPTED", - targetUserNickname: nickname ? nickname : `새 친구(${receiverUserId})` - }) - } + type: "FRIEND_MATCH_SUCCESS", + data: { nickname: nickname ?? `새 친구(${receiverUserId})` }, + }); + } + console.log("성공함"); return { data: result, }; From 698eea920799048cba70869bf45d9d46bdc3f081 Mon Sep 17 00:00:00 2001 From: pumisj Date: Thu, 5 Feb 2026 19:16:23 +0900 Subject: [PATCH 12/13] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20API=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/controllers/auth.controller.js | 14 +++++++++++++- src/index.js | 7 ++++--- src/schemas/auth.schema.js | 10 ++++++++-- src/services/auth.service.js | 7 +++++++ 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/src/controllers/auth.controller.js b/src/controllers/auth.controller.js index fa0456f..b953c58 100644 --- a/src/controllers/auth.controller.js +++ b/src/controllers/auth.controller.js @@ -1,6 +1,6 @@ import axios from "axios"; import { getTTLFromToken } from "../Auths/token.js"; -import { checkDuplicatedEmail, checkDuplicatedUsername, signUpUser, loginUser, updateRefreshToken, SendVerifyEmailCode, checkEmailCode, getAccountInfo, resetPassword, logoutUser, withdrawUser, verifySocialAccount, socialLoginUser, socialLoginCertification } from "../services/auth.service.js"; +import { checkDuplicatedEmail, checkDuplicatedUsername, signUpUser, loginUser, updateRefreshToken, SendVerifyEmailCode, checkEmailCode, getAccountInfo, resetPassword, logoutUser, withdrawUser, verifySocialAccount, socialLoginUser, socialLoginCertification, changePassword } from "../services/auth.service.js"; import { createSocialUserDTO } from "../dtos/auth.dto.js"; export const handleSignUp = async (req, res, next) => { @@ -180,6 +180,18 @@ export const handleResetPassword = async (req, res, next) => { try{ const result = await resetPassword({userId, oldPassword, newPassword}); + res.status(200).success(result); + } catch(err) { + next(err); + } +} + +export const handleChangePassword = async (req, res, next) => { + const userId = req.user.id; + const {password} = req.body; + try{ + const result = await changePassword({userId, password}); + res.status(200).success(result); } catch(err) { next(err); diff --git a/src/index.js b/src/index.js index 75d8af4..dcdb717 100644 --- a/src/index.js +++ b/src/index.js @@ -11,7 +11,7 @@ import { specs } from "./configs/swagger.config.js"; import { jwtStrategy } from "./Auths/strategies/jwt.strategy.js"; import { handleGetFriendsList, handlePostFriendsRequest, handleGetIncomingFriendRequests, handleGetOutgoingFriendRequests, handleAcceptFriendRequest, handleRejectFriendRequest, handleDeleteFriendRequest } from "./controllers/friend.controller.js"; import { handleSendMyLetter, handleSendOtherLetter, handleGetLetterDetail, handleRemoveLetterLike, handleAddLetterLike, handleGetPublicLetterFromOther, handleGetPublicLetterFromFriend, handleGetUserLetterStats, handleGetLetterAssets, handleGetLetterByAiKeyword } from "./controllers/letter.controller.js"; -import { handleCheckDuplicatedEmail, handleLogin, handleRefreshToken, handleSignUp, handleSendVerifyEmailCode, handleCheckEmailCode, handleGetAccountInfo, handleResetPassword, handleLogout, handleWithdrawUser, handleCheckDuplicatedUsername, handleSocialLogin, handleSocialLoginCertification, handleSocialLoginCallback } from "./controllers/auth.controller.js"; +import { handleCheckDuplicatedEmail, handleLogin, handleRefreshToken, handleSignUp, handleSendVerifyEmailCode, handleCheckEmailCode, handleGetAccountInfo, handleResetPassword, handleLogout, handleWithdrawUser, handleCheckDuplicatedUsername, handleSocialLogin, handleSocialLoginCertification, handleSocialLoginCallback, handleChangePassword } from "./controllers/auth.controller.js"; import { handlePostMatchingSession, handlePatchMatchingSessionStatusDiscarded, handlePatchMatchingSessionStatusFriends, handlePostSessionReview } from "./controllers/session.controller.js"; import { handleCreateUserAgreements, handlePatchOnboardingStep1, handleGetAllInterests, handleGetMyInterests, handleUpdateMyOnboardingInterests, handleGetMyNotificationSettings, handleUpdateMyNotificationSettings, handleGetMyProfile, handlePatchMyProfile, handlePostMyProfileImage, handlePutMyPushSubscription, handleGetMyConsents, handlePatchMyConsents, handleUpdateActivity, } from "./controllers/user.controller.js"; import { handleGetAnonymousThreads, handleGetAnonymousThreadLetters, handleGetSelfMailbox, handleGetLetterFromFriend, } from "./controllers/mailbox.controller.js"; @@ -20,7 +20,7 @@ import { handleGetCommunityGuidelines, handleGetTerms, handleGetPrivacy, } from import { handleGetWeeklyReport } from "./controllers/weeklyReport.controller.js"; import { handleGetTodayQuestion } from "./controllers/question.controller.js"; import { validate } from "./middlewares/validate.middleware.js"; -import { emailSchema, loginSchema, passwordSchema, SignUpSchema, usernameSchema, verificationConfirmCodeSchema, verificationSendCodeSchema } from "./schemas/auth.schema.js"; +import { changePasswordSchema, emailSchema, loginSchema, resetPasswordSchema, SignUpSchema, usernameSchema, verificationConfirmCodeSchema, verificationSendCodeSchema } from "./schemas/auth.schema.js"; import { handleInsertInquiryAsUser, handleInsertInquiryAsAdmin, handleGetInquiry, handleGetInquiryDetail } from "./controllers/inquiry.controller.js"; import { isLogin } from "./middlewares/auth.middleware.js"; import { isRestricted } from "./middlewares/restriction.middleware.js"; @@ -160,7 +160,8 @@ app.get("/auth/refresh", handleRefreshToken); app.post("/auth/:type/verification-codes", validate(verificationSendCodeSchema), handleSendVerifyEmailCode); // 이메일 인증번호 전송 app.post("/auth/:type/verification-codes/confirm", validate(verificationConfirmCodeSchema), handleCheckEmailCode); // 이메일 인증번호 확인 app.post("/auth/find-id", validate(emailSchema), handleGetAccountInfo); // 아이디 찾기 -app.patch("/auth/reset-password", isLogin, validate(passwordSchema), handleResetPassword); // 비밀번호 찾기 +app.patch("/auth/reset-password", isLogin, validate(resetPasswordSchema), handleResetPassword); // 비밀번호 초기화 +app.patch("/auth/change-password", isLogin, validate(changePasswordSchema), handleChangePassword); // 비밀번호 변경 app.post("/auth/logout", isLogin, handleLogout); // 로그아웃 app.delete("/users", isLogin, handleWithdrawUser); // 탈퇴 app.post("/users/me/agreements", isLogin, validate(createUserAgreementsSchema), handleCreateUserAgreements) // 이용약관 동의 diff --git a/src/schemas/auth.schema.js b/src/schemas/auth.schema.js index 8461f12..76e4181 100644 --- a/src/schemas/auth.schema.js +++ b/src/schemas/auth.schema.js @@ -2,7 +2,7 @@ import { z } from "zod"; const emailPart = z.email("이메일 형식이 올바르지 않습니다."); const usernamePart = z.string("아이디는 필수입니다.").min(6, "아이디는 최소 6자 이상입니다.").max(16, "아이디는 최대 6자 이상입니다."); -const passwordPart = z.string("비밀번호는 필수입니다.").regex(/^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9]{8,16}$/, "비밀번호는 최소 8자-16자 제한입니다. (영문, 숫자 포함해 최대 16자리)"); +const passwordPart = z.string("비밀번호는 필수입니다.").regex(/^(?=.*[a-zA-Z])(?=.*[0-9])[a-zA-Z0-9!@#$%^&*]{8,16}$/, "비밀번호는 최소 8자-16자 제한입니다. (영문, 숫자 포함해 최대 16자리)"); const phoneNumberPart = z.string("전화번호는 필수입니다.").regex(/^01(?:0|1|[6-9])-(?:\d{3}|\d{4})-\d{4}$/, "전화번호 형식이 올바르지 않습니다."); export const SignUpSchema = z.object({ @@ -39,7 +39,13 @@ export const usernameSchema = z.object({ }) }) -export const passwordSchema = z.object({ +export const changePasswordSchema = z.object({ + body: z.object({ + password: passwordPart + }) +}) + +export const resetPasswordSchema = z.object({ body: z.object({ oldPassword: passwordPart, newPassword: passwordPart diff --git a/src/services/auth.service.js b/src/services/auth.service.js index 4782c01..1be245c 100644 --- a/src/services/auth.service.js +++ b/src/services/auth.service.js @@ -300,5 +300,12 @@ export const resetPassword = async ({userId, oldPassword, newPassword}) => { const newPasswordHash = await bcrypt.hash(newPassword, 10); await updatePassword({userId, newPassword: newPasswordHash}); + return { message: "비밀번호 재설정이 완료되었습니다." }; +} + +export const changePassword = async ({userId, password}) => { + const newPasswordHash = await bcrypt.hash(password, 10); + await updatePassword({userId, newPassword: newPasswordHash}); + return { message: "비밀번호 재설정이 완료되었습니다." }; } \ No newline at end of file From 144b85ea6d3ac3f324f0987caec0859c7f150d7d Mon Sep 17 00:00:00 2001 From: pumisj Date: Thu, 5 Feb 2026 19:16:55 +0900 Subject: [PATCH 13/13] =?UTF-8?q?docs:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20API=20=EC=8A=A4?= =?UTF-8?q?=EC=9B=A8=EA=B1=B0=20=EC=9E=91=EC=84=B1=20(#169)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/swagger/auth.swagger.js | 107 +++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/src/swagger/auth.swagger.js b/src/swagger/auth.swagger.js index 0794977..bf573cd 100644 --- a/src/swagger/auth.swagger.js +++ b/src/swagger/auth.swagger.js @@ -691,7 +691,7 @@ * @swagger * /auth/reset-password: * patch: - * summary: 비밀번호 재설정 + * summary: 비밀번호 초기화 * description: "/auth/reset-password/confirm 에서 발급받은 임시 AccessToken이 필요합니다." * tags: [로그인] * security: @@ -810,6 +810,111 @@ * example: "기존 비밀번호를 찾을 수 없습니다." */ +/** + * @swagger + * /auth/change-password: + * patch: + * summary: 비밀번호 변경 + * description: "비밀번호 변경 API입니다." + * tags: [로그인] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - password + * properties: + * password: + * type: string + * example: "Password123!" + * responses: + * 200: + * description: 비밀번호 변경 성공 + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/SuccessResponse' + * - properties: + * success: + * type: object + * properties: + * message: + * type: string + * example: "비밀번호 재설정이 완료되었습니다." + * 401: + * description: | + * 인증 실패: + * - `AUTH_TOKEN_EXPIRED`: 토큰이 만료되었습니다. + * - `AUTH_INVALID_TOKEN`: 액세스 토큰이 아니거나 유효하지 않습니다. + * - `AUTH_NOT_ACCESS_TOKEN`: 액세스 토큰이 아닙니다. + * - `AUTH_EXPIRED_TOKEN`: 이미 로그아웃된 토큰입니다. + * - `AUTH_UNAUTHORIZED`: 액세스 토큰이 유효하지 않습니다. + * - `AUTH_NOT_FOUND`: 인증 토큰이 없습니다. + * content: + * application/json: + * schema: + * oneOf: + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_TOKEN_EXPIRED" + * reason: + * example: "토큰이 만료되었습니다." + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_INVALID_TOKEN" + * reason: + * example: "액세스 토큰이 아니거나 유효하지 않습니다." + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_NOT_ACCESS_TOKEN" + * reason: + * example: "액세스 토큰이 아닙니다." + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_EXPIRED_TOKEN" + * reason: + * example: "이미 로그아웃된 토큰입니다." + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_UNAUTHORIZED" + * reason: + * example: "액세스 토큰이 유효하지 않습니다." + * - allOf: + * - $ref: '#/components/schemas/ErrorResponse' + * - properties: + * error: + * properties: + * errorCode: + * example: "AUTH_NOT_FOUND" + * reason: + * example: "인증 토큰이 없습니다." + */ + /** * @swagger * /auth/logout: