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/constants/push.constant.js b/src/constants/push.constant.js index 61a8977..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: () => "새로운 친구와 연결되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?", + BODY: ({ nickname }) => `${nickname}님과 새로운 친구가 되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?`, }, }; \ No newline at end of file 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/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 c99f26b..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,14 +20,14 @@ 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"; 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"; @@ -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) // 이용약관 동의 @@ -210,7 +211,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/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/repositories/letter.repository.js b/src/repositories/letter.repository.js index 292a3a3..c695160 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..8962762 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: { @@ -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} @@ -485,6 +500,7 @@ export const findReceivedLettersBySender = async ({ userId, senderUserId, letter title: true, content: true, deliveredAt: true, + readAt: true, createdAt: true, question: { select: { @@ -528,6 +544,7 @@ export const findSentLettersByReceiver = async ({ userId, receiverUserId, letter title: true, content: true, deliveredAt: true, + readAt: true, createdAt: true, question: { select: { @@ -639,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/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/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/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 diff --git a/src/services/friend.service.js b/src/services/friend.service.js index e3a08d6..80aff91 100644 --- a/src/services/friend.service.js +++ b/src/services/friend.service.js @@ -27,6 +27,9 @@ 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"; +import { enqueueJob } from "../utils/queue.util.js"; +import { pushQueue } from "../jobs/bootstraps/push.bootstrap.js"; async function userExistsOrThrow(userId) { const userById = await findUserById(userId); @@ -197,8 +200,17 @@ export const acceptFriendRequest = async (receiverUserId, requesterUserId) => { receiverUserId, requesterUserId, }); + const nickname = (await findUserById(receiverUserId)).nickname; try { const result = await acceptFriendRequestTx(receiverUserId, requesterUserId); + if (result) { + await enqueueJob(pushQueue, "PUSH_BY_FRIEND", { + userId: requesterUserId, + type: "FRIEND_MATCH_SUCCESS", + data: { nickname: nickname ?? `새 친구(${receiverUserId})` }, + }); + } + console.log("성공함"); return { data: result, }; diff --git a/src/services/mailbox.service.js b/src/services/mailbox.service.js index 4c30854..1e96796 100644 --- a/src/services/mailbox.service.js +++ b/src/services/mailbox.service.js @@ -1,7 +1,5 @@ import { findReceivedLettersForThreads, - findReceivedLettersBySender, - findSentLettersByReceiver, findSelfLetters, findUsersNicknameByIds, } from "../repositories/user.repository.js"; @@ -9,6 +7,8 @@ import { LETTER_TYPE_ANON, LETTER_TYPE_SELF, makePreview } from "../utils/user.u import { findFriendById } from "../repositories/friend.repository.js"; import { NotFriendError } from "../errors/friend.error.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,42 +20,99 @@ 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(); - const letters = senderIds.map((senderId) => { - const l = latestBySender.get(senderId); + // 각 세션별 편지 개수 조회 (sessionId 기준, 개별 조회) + const letterCounts = await Promise.all( + 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 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); return { - threadId: senderId, // threadId = senderUserId + sessionId: sessionId, // threadId -> sessionId로 변경 lastLetterId: l.id, lastLetterTitle: l.title, lastLetterPreview: makePreview(l.content, 30), deliveredAt: l.deliveredAt ?? null, + hasUnread: hasUnreadMap.get(sessionId) ?? false, // 읽지 않은 편지 여부 sender: { - id: senderId, - nickname: nicknameMap.get(senderId) ?? null, + id: otherParticipantId, // 상대방 ID + nickname: nicknameMap.get(otherParticipantId) ?? null, + letterCount: letterCountMap.get(sessionId) ?? 0, // sessionId 기준 개수 + }, + design: { + paperId: l.design?.paper?.id ?? null, + stampId: l.design?.stamp?.id ?? null, + stampUrl: l.design?.stamp?.assetUrl ?? null, }, - 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 }, }; }); @@ -68,21 +125,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: 받은 편지의 첫 번째 질문 우선, 없으면 보낸 편지의 첫 번째 질문 @@ -93,19 +237,13 @@ 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, - 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 추가 @@ -113,19 +251,13 @@ 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, - 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/services/notice.service.js b/src/services/notice.service.js index 466948c..f2d795d 100644 --- a/src/services/notice.service.js +++ b/src/services/notice.service.js @@ -19,3 +19,7 @@ export const getNoticeDetail = async (noticeId) => { return notice; }; + +// sendNoticePushNotification 함수는 삭제됨 +// jobs/bootstraps/push.bootstrap.js의 sendNoticePushNotifications로 대체됨 +// 비동기 메시지 큐(BullMQ)를 통해 처리됩니다. diff --git a/src/services/push.service.js b/src/services/push.service.js index 60bfee7..1a0a9f6 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]; 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 }); 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: diff --git a/src/swagger/mailbox.swagger.js b/src/swagger/mailbox.swagger.js index e74fbf0..44a1ee3 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: @@ -45,26 +48,22 @@ * type: string * format: date-time * nullable: true - * stampId: - * type: integer - * nullable: true - * stampUrl: - * type: string - * nullable: true - * description: 스탬프 이미지 URL + * hasUnread: + * type: boolean + * description: 해당 세션에 읽지 않은 편지가 있는지 여부 (받은 편지 기준) * design: * type: object * properties: - * paper: - * type: object + * paperId: + * type: integer * nullable: true - * properties: - * id: - * type: integer - * name: - * type: string - * assetUrl: - * type: string + * stampId: + * type: integer + * nullable: true + * stampUrl: + * type: string + * nullable: true + * description: 스탬프 이미지 URL * 401: * description: | * 인증 실패: @@ -152,7 +151,7 @@ /** * @swagger - * /mailbox/anonymous/threads/{threadId}/letters: + * /mailbox/anonymous/threads/{sessionId}/letters: * get: * summary: 익명 스레드 편지 목록 조회 * description: 특정 익명 스레드의 편지 목록을 조회합니다. @@ -161,11 +160,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,32 +194,31 @@ * type: string * format: date-time * nullable: true + * readAt: + * type: string + * format: date-time + * nullable: true * 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: | * 잘못된 요청: * - `REQ_BAD_REQUEST`: 요청 유효성 검사 실패 - * - `MAILBOX_INVALID_THREAD_ID`: threadId가 올바르지 않습니다. + * - `MAILBOX_INVALID_SESSION_ID`: sessionId가 올바르지 않습니다. * content: * application/json: * schema: @@ -240,9 +238,9 @@ * error: * properties: * errorCode: - * example: "MAILBOX_INVALID_THREAD_ID" + * example: "MAILBOX_INVALID_SESSION_ID" * reason: - * example: "threadId가 올바르지 않습니다." + * example: "sessionId가 올바르지 않습니다." * 401: * description: | * 인증 실패: