Skip to content
This repository was archived by the owner on Dec 6, 2025. It is now read-only.
114 changes: 82 additions & 32 deletions src/jobs/poll/poll.activate.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,63 +9,114 @@
import { createLimit } from '#core/utils/Limiter';
import prisma from '#core/prisma';
import { logger } from '#core/logger';
import { sendSseNotification } from '#sse/sseEmitter';
import type { NotificationPayload } from '#sse/types';
import { isPast, isWithinInterval, getDelayMs } from './poll.time.utils';

import { getUserIdsForApartment } from '#modules/auth/auth.service';
import { getAdminIdByApartmentName } from '#modules/auth/auth.repo';
import { createAndSendNotification } from '#core/utils/notificationHelper';

const limit = createLimit(5);

/**
* @description Poll 시작 시 주민 + 관리자에게 종료 알림 전송
*/
export const sendPollStartNotifications = async (pollId: string) => {
// Poll → Board → Apartment 정보 조회
const poll = await prisma.poll.findUnique({
where: { id: pollId },
select: {
board: {
select: {
apartment: { select: { apartmentName: true } },
},
},
},
});

if (!poll?.board?.apartment?.apartmentName) {
logger.polls.warn(`Poll ${pollId}: 알림 전송 실패 — apartmentName 조회 실패`);
return;
}

const apartmentName = poll.board.apartment.apartmentName;

// 주민 ID 조회 (service → repo 경유)
const residentUserIds = (await getUserIdsForApartment(apartmentName)).map((u) => u.id);

// 관리자 조회 (아파트의 adminId 기반)
let adminIds: string[] = [];
try {
const aptAdmin = await getAdminIdByApartmentName(apartmentName);
const adminId = aptAdmin?.adminId ? String(aptAdmin.adminId) : null;
adminIds = adminId ? [adminId] : [];
} catch (e) {
logger.polls.warn(`Poll ${pollId}: 관리자 조회 실패 — ${e instanceof Error ? e.message : String(e)}`);
}

// 중복 제거
const targetIds = Array.from(new Set([...residentUserIds, ...adminIds]));

if (targetIds.length === 0) {
logger.polls.debug(`Poll ${pollId}: 알림 전송 대상 없음`);
return;
}

const content = '새로운 투표가 시작되었습니다.';

// 동시성 제한 비동기 병렬 전송 (best-effort)
const tasks = targetIds.map((uid) =>
limit(async () => {
try {
await createAndSendNotification(
{
content,
notificationType: 'POLL_REG',
recipientId: uid,
pollId,
},
uid
);
} catch {
// 전송 실패 무시 (best-effort)
}
})
);

// limit 내부에서 실패가 발생해도 전체 흐름을 깨지 않도록 allSettled
await Promise.allSettled(tasks);

logger.polls.debug(`Poll ${pollId}: 알림 전송 완료 (총 ${targetIds.length}명)`);
};

/**
* 개별 Poll 활성화 처리
* @param pollId - 활성화 대상 Poll ID
* @param userId - Poll 작성자 ID
*/
export const activatePoll = async (pollId: string, userId: string): Promise<void> => {
export const activatePoll = async (pollId: string): Promise<void> => {
try {
// 상태 변경
const updated = await prisma.poll.update({
await prisma.poll.update({
where: { id: pollId },
data: { status: 'IN_PROGRESS' },
});

// Notification 생성
const notification = await prisma.notification.create({
data: {
content: '새로운 투표가 시작되었습니다.',
notificationType: 'POLL_REG',
pollId: updated.id,
recipientId: userId,
},
});

// SSE 전송
const payload: NotificationPayload = {
notificationId: notification.id,
content: notification.content,
notificationType: notification.notificationType,
notifiedAt: notification.notifiedAt.toISOString(),
isChecked: notification.isChecked,
complaintId: notification.complaintId,
noticeId: notification.noticeId,
pollId: notification.pollId,
};

sendSseNotification(payload);
// 알림 함수 호출
await sendPollStartNotifications(pollId);

logger.polls.debug(`투표 ${pollId} 활성화 완료`);
} catch (error) {
logger.polls.error(error as Error, `투표 ${pollId} 활성화 중 오류 발생`);
}
};

/**
* 활성화 가능한 Poll 전체 처리
*/
export const activateReadyPolls = async (): Promise<void> => {
try {
const polls = await prisma.poll.findMany({
where: { status: 'PENDING' },
select: { id: true, userId: true, startDate: true },
select: { id: true, startDate: true },
});

if (polls.length === 0) {
Expand All @@ -76,12 +127,12 @@ export const activateReadyPolls = async (): Promise<void> => {
const tasks = polls.map((poll) =>
limit(async () => {
if (isPast(poll.startDate)) {
await activatePoll(poll.id, poll.userId);
await activatePoll(poll.id);
} else if (isWithinInterval(poll.startDate)) {
const delay = getDelayMs(poll.startDate);
setTimeout(async () => {
try {
await activatePoll(poll.id, poll.userId);
await activatePoll(poll.id);
} catch (err) {
logger.polls.error(err as Error, `예약된 투표 ${poll.id} 활성화 실패`);
}
Expand All @@ -91,7 +142,6 @@ export const activateReadyPolls = async (): Promise<void> => {
);

await Promise.all(tasks);

logger.polls.debug(`투표 활성화 처리 완료 (${polls.length}건 검사)`);
} catch (error) {
logger.polls.error(error as Error, '투표 활성화 처리 중 오류 발생');
Expand Down
118 changes: 88 additions & 30 deletions src/jobs/poll/poll.expire.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,47 +9,102 @@
import { createLimit } from '#core/utils/Limiter';
import prisma from '#core/prisma';
import { logger } from '#core/logger';
import { sendSseNotification } from '#sse/sseEmitter';
import type { NotificationPayload } from '#core/sse/types';
import { isPast, isWithinInterval, getDelayMs } from './poll.time.utils';

import { getUserIdsForApartment } from '#modules/auth/auth.service';
import { getAdminIdByApartmentName } from '#modules/auth/auth.repo';
import { createAndSendNotification } from '#core/utils/notificationHelper';

const limit = createLimit(5);

/**
* @description Poll 종료 시 주민 + 관리자에게 종료 알림 전송
*/
export const sendPollCloseNotifications = async (pollId: string) => {
// Poll → Board → Apartment 정보 조회
const poll = await prisma.poll.findUnique({
where: { id: pollId },
select: {
board: {
select: {
apartment: {
select: { apartmentName: true },
},
},
},
},
});

if (!poll?.board?.apartment?.apartmentName) {
logger.polls.warn(`Poll ${pollId}: 알림 전송 실패 — apartmentName 조회 실패`);
return;
}

const apartmentName = poll.board.apartment.apartmentName;

// 주민 ID 조회 (service → repo 경유)
const residentUserIds = (await getUserIdsForApartment(apartmentName)).map((u) => u.id);

// 관리자 조회 (아파트의 adminId 기반)
const aptAdmin = await getAdminIdByApartmentName(apartmentName);
const adminId =
(aptAdmin as unknown as { adminId?: string | null }).adminId ??
(aptAdmin as unknown as { admin?: { id: string } | null }).admin?.id ??
null;

const adminIds = adminId ? [adminId] : [];

// 중복 제거
const targetIds = Array.from(new Set([...residentUserIds, ...adminIds]));

if (targetIds.length === 0) {
logger.polls.debug(`Poll ${pollId}: 종료 알림 대상 없음`);
return;
}

const content = '투표가 종료되었습니다.';

// 동시성 제한 비동기 병렬 전송 (best-effort)
const tasks = targetIds.map((uid) =>
limit(async () => {
try {
await createAndSendNotification(
{
content,
notificationType: 'POLL_CLOSED',
recipientId: uid,
pollId,
},
uid
);
} catch {
// 전송 실패 무시 (best-effort)
}
})
);

// 전체 흐름 깨지 않도록 allSettled
await Promise.allSettled(tasks);

await Promise.all(tasks);

logger.polls.debug(`Poll ${pollId}: 종료 알림 전송 완료 (총 ${targetIds.length}명)`);
};

/**
* 개별 Poll 만료 처리
* @param pollId - 만료 처리 대상 Poll ID
*/
export const closePoll = async (pollId: string): Promise<void> => {
try {
// 상태 변경
const updated = await prisma.poll.update({
await prisma.poll.update({
where: { id: pollId },
data: { status: 'CLOSED' },
});

// Notification 생성
const notification = await prisma.notification.create({
data: {
content: '투표가 종료되었습니다.',
notificationType: 'POLL_CLOSED',
pollId,
recipientId: updated.userId,
},
});

// SSE 전송
const payload: NotificationPayload = {
notificationId: notification.id,
content: notification.content,
notificationType: notification.notificationType,
notifiedAt: notification.notifiedAt.toISOString(),
isChecked: notification.isChecked,
complaintId: notification.complaintId,
noticeId: notification.noticeId,
pollId: notification.pollId,
};

sendSseNotification(payload);
// 알림 함수 호출
await sendPollCloseNotifications(pollId);

logger.polls.debug(`투표 ${pollId} 만료 처리 완료`);
} catch (error) {
Expand All @@ -72,11 +127,14 @@ export const closeExpiredPolls = async (): Promise<void> => {
return;
}

const tasks = polls.map((poll) => {
const tasks = polls.map((poll) =>
limit(async () => {
if (isPast(poll.endDate)) {
await closePoll(poll.id);
} else if (isWithinInterval(poll.endDate)) {
return;
}

if (isWithinInterval(poll.endDate)) {
const delay = getDelayMs(poll.endDate);
setTimeout(async () => {
try {
Expand All @@ -86,8 +144,8 @@ export const closeExpiredPolls = async (): Promise<void> => {
}
}, delay);
}
});
});
})
);

await Promise.all(tasks);
logger.polls.debug(`투표 만료 처리 완료 (${polls.length}건 검사)`);
Expand Down
13 changes: 13 additions & 0 deletions src/modules/apartments/apartments.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,16 @@ export const getCount = async (query: ApartmentRequestQueryDto, userRole: UserRo
where,
});
};

/**
* 아파트 ID → apartmentName 조회
* @returns apartmentName | null
*/
export const getApartmentNameByIdRepo = async (apartmentId: string) => {
const apt = await prisma.apartment.findUnique({
where: { id: apartmentId },
select: { apartmentName: true },
});

return apt?.apartmentName ?? null;
};
24 changes: 24 additions & 0 deletions src/modules/auth/auth.repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -354,3 +354,27 @@ export const getAdminIdByApartmentName = async (apartmentName: string) => {
},
});
};

/**
* 특정 아파트에 속한 USER 유저들의 ID 리스트 조회
*
* @description
* - 알림 전송 등의 내부 용도에서 사용하기 위해 id만 반환합니다.
* - User → Resident → Apartment 관계를 기반으로 필터링합니다.
*
* @param apartmentName 조회할 아파트 이름
* @returns USER id 리스트
*/
export const getUserIdListByApartmentName = async (apartmentName: string) => {
return prisma.user.findMany({
where: {
role: UserRole.USER,
resident: {
apartment: {
apartmentName,
},
},
},
select: { id: true },
});
};
14 changes: 14 additions & 0 deletions src/modules/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
deleteApartmentRepo,
getSuperAdminIdList,
getAdminIdByApartmentName,
getUserIdListByApartmentName,
} from './auth.repo';
import { SignupSuperAdminRequestDto, SignupAdminRequestDto, SignupUserRequestDto } from './dto/register.dto';
import { LoginDto } from './dto/login.dto';
Expand Down Expand Up @@ -185,3 +186,16 @@ export const cleanupRejectedUsers = async (role: UserRole, adminId: string | und
}
await deleteRejectedUser(targetRole, apartmentName);
};

/**
* 특정 아파트에 속한 USER ID 리스트 반환
*
* @description
* - 외부(controller)에서 호출되는 비즈니스 로직 계층입니다.
* - 알림 전송, SSE 대상 조회 등에서 재사용됩니다.
*
* @param apartmentName 조회 대상 아파트 이름
*/
export const getUserIdsForApartment = async (apartmentName: string) => {
return await getUserIdListByApartmentName(apartmentName);
};
Loading