Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
13d9217
fix: 익명 편지함 조회 api에 lettercount 추가 및 익명 스레드 조회api에 reatAt 추가
yeonjuncho Feb 4, 2026
2a00c08
fix: 익명 스레드 편지함 api에서 중복되는 thread_id 제거 및 session id로 바꾸기 및 스웨거 수정
yeonjuncho Feb 4, 2026
c1a1c90
fix: 익명 편지함, 익명 스레드 편지 응답 구조 변경
yeonjuncho Feb 4, 2026
ee80566
feat: 공지사항 푸시 알림 수동 실행 추가
yeonjuncho Feb 4, 2026
8c9f550
Merge remote-tracking branch 'origin/dev' into feature/#159-fix-익명-탭-…
yeonjuncho Feb 4, 2026
779272e
FIX: findRandomUserByPool에서 CHATING과 PENDING으로 세션 검색해 해당 유저 제외
user040131 Feb 4, 2026
c55253a
FIX: 세션 생성 시 해당 유저들을 포함한 세션을 다시 검색하여 존재 시 에러 반환
user040131 Feb 4, 2026
cec0e87
feat: acceptFriendRequest에서 수신자 대상 푸시 알람 발송
user040131 Feb 5, 2026
121f9d1
fix: FRIEND_MATCH_SUCCESS 에서 BODY에 targetUserNickname 추가
user040131 Feb 5, 2026
83597d7
fix: 공지사항 푸시 알림 수정
yeonjuncho Feb 5, 2026
d5dd1d7
feat: 익명 편지함 hasUnread 필드 추가
yeonjuncho Feb 5, 2026
2b18549
Merge remote-tracking branch 'origin/dev' into feature/#159-fix-익명-탭-…
yeonjuncho Feb 5, 2026
35393ad
Merge pull request #167 from soksak-letter/Fix/#166-세션생성로직변경
user040131 Feb 5, 2026
0631dc9
Merge pull request #162 from soksak-letter/feature/#159-fix-익명-탭-목록-조…
yeonjuncho Feb 5, 2026
c28d4a7
Merge branch 'dev' of https://github.com/soksak-letter/soksak-Server …
user040131 Feb 5, 2026
41c32c1
fix: 변경 사항 변경
user040131 Feb 5, 2026
698eea9
feat: 비밀번호 재설정 API 구현 (#169)
SeojunKim-pumisj Feb 5, 2026
144b85e
docs: 비밀번호 재설정 API 스웨거 작성 (#169)
SeojunKim-pumisj Feb 5, 2026
9f2b642
Merge pull request #171 from soksak-letter/feature/#169-비밀번호-초기화-api-재구현
SeojunKim-pumisj Feb 5, 2026
cdf2cb8
Merge pull request #170 from soksak-letter/feat/#168-푸시알림-설정
SeojunKim-pumisj Feb 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion src/constants/push.constant.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,6 @@ export const NOTIFICATION_MESSAGES = {
// 5. 친구 매칭 성공
FRIEND_MATCH_SUCCESS: {
TITLE: () => "마음이 딱 통했어요! ✨",
BODY: () => "새로운 친구와 연결되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?",
BODY: ({ nickname }) => `${nickname}님과 새로운 친구가 되었습니다. 먼저 따뜻한 인사를 건네보는 건 어떨까요?`,
},
};
14 changes: 13 additions & 1 deletion src/controllers/auth.controller.js
Original file line number Diff line number Diff line change
@@ -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) => {
Expand Down Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/controllers/mailbox.controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions src/errors/mailbox.error.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
11 changes: 6 additions & 5 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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) // 이용약관 동의
Expand Down Expand Up @@ -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);

Expand Down
60 changes: 59 additions & 1 deletion src/jobs/bootstraps/push.bootstrap.js
Original file line number Diff line number Diff line change
@@ -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 });

Expand All @@ -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) => {
Expand All @@ -21,4 +35,48 @@ export const sendPushNotificationWorker = () => {
worker.on('error', (err) => {
console.error(`[Worker Error] 시스템 오류: ${err.message}`);
});
}
};

/**
* 최근 공지사항을 찾아서 큐에 푸시 알림 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,
};
};
17 changes: 17 additions & 0 deletions src/jobs/crons/notice.cron.js
Original file line number Diff line number Diff line change
@@ -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"
}
);
};
4 changes: 3 additions & 1 deletion src/jobs/index.job.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
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";

export const startBatch = async () => {
startWeeklyReportCron();
sendScheduledLettersCron();
sendNoticePushCron();

sendQueuedLettersWorker();
sendPushNotificationWorker();
Expand Down
18 changes: 18 additions & 0 deletions src/repositories/letter.repository.js
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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: {
Expand Down
58 changes: 57 additions & 1 deletion src/repositories/user.repository.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -485,6 +500,7 @@ export const findReceivedLettersBySender = async ({ userId, senderUserId, letter
title: true,
content: true,
deliveredAt: true,
readAt: true,
createdAt: true,
question: {
select: {
Expand Down Expand Up @@ -528,6 +544,7 @@ export const findSentLettersByReceiver = async ({ userId, receiverUserId, letter
title: true,
content: true,
deliveredAt: true,
readAt: true,
createdAt: true,
question: {
select: {
Expand Down Expand Up @@ -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 ==========
/**
* 알림 설정 조회
Expand Down
10 changes: 8 additions & 2 deletions src/schemas/auth.schema.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/schemas/mailbox.schema.js
Original file line number Diff line number Diff line change
@@ -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부터 유효합니다.")
})
});
Loading