Skip to content
Merged
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
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);
}
}
4 changes: 2 additions & 2 deletions src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -210,7 +210,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
56 changes: 56 additions & 0 deletions src/repositories/user.repository.js
Original file line number Diff line number Diff line change
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
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