Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
35 changes: 29 additions & 6 deletions packages/bot/src/api-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import rateLimit from 'express-rate-limit';
import logger from './lib/logger';
import { Sentry } from './lib/sentry';
import {
getAttendanceChecker,
getCurationCrawler,
getDeadlineReminder,
getFineReminder,
Expand All @@ -17,6 +16,10 @@ import {
getRssPoller,
getWeeklyRanking,
} from './schedulers';
import { getAttendanceService } from './services/attendance.service';
import { getFineService } from './services';
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded } from './services/round.service';
import { AttendanceStatus } from '@blog-study/shared/db';

const BOT_API_SECRET = process.env.BOT_API_SECRET;

Expand Down Expand Up @@ -79,14 +82,34 @@ export function createBotApiServer(): Express {

app.post('/api/trigger/attendance-check', authMiddleware, triggerLimiter, async (_req, res) => {
try {
const attendanceChecker = getAttendanceChecker();
const currentRound = await getCurrentRound();
const prevRound = await getRoundByNumber(currentRound.roundNumber - 1);

if (attendanceChecker.isChecking()) {
return res.status(409).json({ error: '출석 체크가 이미 실행 중입니다' });
if (!prevRound) {
return res.status(400).json({ error: '이전 회차가 없습니다' });
}

const result = await attendanceChecker.check();
res.json({ success: true, result });
if (!isGracePeriodEnded(prevRound)) {
return res.status(400).json({ error: '이전 회차 유예 기간이 아직 종료되지 않았습니다' });
}

const attendanceService = getAttendanceService();
const fineService = getFineService();

// 이전 회차 PENDING → ABSENT 처리
const processedRecords = await attendanceService.processGracePeriodEnd(prevRound.id);
const absentRecords = processedRecords.filter(r => r.status === AttendanceStatus.ABSENT);

// 결석 벌금 부과
for (const record of absentRecords) {
try {
await fineService.create(record.memberId, prevRound.id, 'absent');
} catch (fineError) {
logger.error({ memberId: record.memberId, error: fineError }, '🌐 [API] 결석 벌금 부과 실패');
}
}

res.json({ success: true, result: { roundNumber: prevRound.roundNumber, processedCount: absentRecords.length } });
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '🌐 [API] 출석 체크 에러');
Expand Down
2 changes: 0 additions & 2 deletions packages/bot/src/api/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import type {
PollingCycleResult,
AttendanceCheckResult,
FineReminderResult,
RoundReportResult,
CurationCycleResult,
Expand Down Expand Up @@ -44,7 +43,6 @@ export interface OperationInfo {
*/
export type OperationResult =
| PollingCycleResult
| AttendanceCheckResult
| FineReminderResult
| RoundReportResult
| CurationCycleResult
Expand Down
110 changes: 67 additions & 43 deletions packages/bot/src/scheduler-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import type { Client } from 'discord.js';
import axios from 'axios';
import { parseFeed } from 'feedsmith';
import { getRssPoller } from './schedulers/rss-poller';
import { getAttendanceChecker } from './schedulers/attendance-checker';
import { getFineReminder } from './schedulers/fine-reminder';
import { getRoundReporter } from './schedulers/round-reporter';
import { getCurationCrawler } from './schedulers/curation-crawler';
Expand All @@ -20,20 +19,21 @@ import { getNotificationService } from './services/notification.service';
import { getScoreService } from './services/score.service';
import { getAttendanceService, getFineService } from './services';

import { ActivityScoreType, curationSources, getDb, members } from '@blog-study/shared/db';
import { extractFirstImage, extractOgImage } from '@blog-study/shared/utils';
import { getCurrentRound } from './services/round.service';
import { ActivityScoreType, AttendanceStatus, curationSources, getDb } from '@blog-study/shared/db';
import { extractFirstImage, extractOgImage, formatKSTDate } from '@blog-study/shared/utils';
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded, setCurrentRound } from './services/round.service';
import { Sentry } from './lib/sentry';
import { eq } from 'drizzle-orm';
import logger from './lib/logger';
import { Sentry } from './lib/sentry';

/**
* Job definitions with cron schedules
*/
// pg-boss cron은 UTC 기준. KST = UTC+9
const JOB_DEFINITIONS = [
{ name: 'rss-poll', cron: '*/5 * * * *' }, // 5분마다
{ name: 'attendance-check', cron: '0 0 * * 2' }, // KST 화 09:00 (UTC 화 00:00)
{ name: 'attendance-init', cron: '2 15 * * 0' }, // KST 월 00:02 (UTC 일 15:02) — 회차 시작일 출석 PENDING 생성
{ name: 'attendance-absent', cron: '2 15 * * 1' }, // KST 화 00:02 (UTC 월 15:02) — PENDING → ABSENT + 벌금
{ name: 'fine-reminder', cron: '0 0 * * *' }, // KST 매일 09:00 (UTC 00:00)
{ name: 'round-report', cron: '0 23 * * 1' }, // KST 화 08:00 (UTC 월 23:00)
{ name: 'round-start', cron: '0 23 * * 0' }, // KST 월 08:00 (UTC 일 23:00)
Expand All @@ -51,7 +51,6 @@ const JOB_DEFINITIONS = [
export async function registerAllJobs(boss: PgBoss, client: Client): Promise<void> {
// Initialize scheduler instances with Discord client
const rssPoller = getRssPoller();
const attendanceChecker = getAttendanceChecker();
const fineReminder = getFineReminder();
const roundReporter = getRoundReporter();
const curationCrawler = getCurationCrawler();
Expand Down Expand Up @@ -181,40 +180,6 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
}
});

// P0 #4: 결석/지각 벌금 콜백 설정
// 결석 콜백: 화요일 00:00에 결석자 판정 시 자동 벌금 부과
attendanceChecker.setOnAbsentCallback(async (attendance, round) => {
try {
const db = getDb();
const [member] = await db
.select()
.from(members)
.where(eq(members.id, attendance.memberId))
.limit(1);

if (!member) {
logger.error({ memberId: attendance.memberId }, '✅ [출석] 멤버를 찾을 수 없음');
return;
}

// 결석 벌금 생성
// DM 알림은 보내지 않음 — fine-reminder에서 화요일부터 리마인더로 발송
const fine = await fineService.create(attendance.memberId, round.id, 'absent');

logger.info({
member: member.name,
round: round.roundNumber,
amount: fine.amount,
}, '✅ [출석] 결석 벌금 부과 완료');
} catch (error) {
Sentry.captureException(error);
logger.error({
memberId: attendance.memberId,
error
}, '✅ [출석] 결석 콜백 처리 실패');
}
});

// Set up curation crawl function: fetch RSS → parse → extract content → return CrawledContent[]
// P1 #8: 큐레이션 데이터 품질 개선 - description, thumbnailUrl 추출
curationCrawler.setCrawlFunction(async (url: string): Promise<CrawledContent[]> => {
Expand Down Expand Up @@ -276,8 +241,67 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
await rssPoller.poll();
});

await boss.work('attendance-check', { batchSize: 1 }, async () => {
await attendanceChecker.check();
// 새 큐 생성 (기존에 없는 큐는 명시적으로 생성 필요)
await boss.createQueue('attendance-init');
await boss.createQueue('attendance-absent');

// 회차 시작일 00:02 KST — 다음 회차 전환 + active 멤버 출석 PENDING 레코드 생성
await boss.work('attendance-init', { batchSize: 1 }, async () => {
try {
const currentRound = await getCurrentRound();
const todayStr = formatKSTDate(new Date());
const nextRound = await getRoundByNumber(currentRound.roundNumber + 1);

if (!nextRound || todayStr !== nextRound.startDate) {
logger.info(`✅ [출석 초기화] 오늘(${todayStr})은 다음 회차 시작일이 아님, 스킵`);
return;
}

// 회차 전환
await setCurrentRound(nextRound.roundNumber);
logger.info(`✅ [출석 초기화] ${nextRound.roundNumber}회차로 전환 완료`);

// 출석 PENDING 레코드 생성
const created = await attendanceService.createForRound(nextRound.id);
logger.info(`✅ [출석 초기화] ${nextRound.roundNumber}회차 ${created.length}명 PENDING 레코드 생성`);
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '✅ [출석 초기화] 에러');
}
});

// 화요일 00:02 KST — 이전 회차 PENDING → ABSENT + 결석 벌금 부과
await boss.work('attendance-absent', { batchSize: 1 }, async () => {
try {
const currentRound = await getCurrentRound();
const prevRound = await getRoundByNumber(currentRound.roundNumber - 1);

if (!prevRound) {
logger.info('✅ [결석 처리] 이전 회차 없음, 스킵');
return;
}

if (!isGracePeriodEnded(prevRound)) {
logger.info(`✅ [결석 처리] ${prevRound.roundNumber}회차 유예 기간 미종료, 스킵`);
return;
}

const processedRecords = await attendanceService.processGracePeriodEnd(prevRound.id);
const absentRecords = processedRecords.filter(r => r.status === AttendanceStatus.ABSENT);
logger.info(`✅ [결석 처리] ${prevRound.roundNumber}회차 ${absentRecords.length}명 결석 처리`);

for (const record of absentRecords) {
try {
await fineService.create(record.memberId, prevRound.id, 'absent');
} catch (fineError) {
Sentry.captureException(fineError);
logger.error({ memberId: record.memberId, error: fineError }, '✅ [결석 처리] 벌금 부과 실패');
}
}
} catch (error) {
Sentry.captureException(error);
logger.error({ error }, '✅ [결석 처리] 에러');
}
});

await boss.work('fine-reminder', { batchSize: 1 }, async () => {
Expand Down
1 change: 0 additions & 1 deletion packages/bot/src/schedulers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@
// 모든 스케줄러를 내보냅니다.

export * from './rss-poller';
export * from './attendance-checker';
export * from './fine-reminder';
export * from './round-reporter';
export * from './curation-crawler';
Expand Down
76 changes: 27 additions & 49 deletions packages/bot/src/schedulers/round-reporter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
import { Client } from 'discord.js';
import { and, count, eq } from 'drizzle-orm';
import logger from '../lib/logger';
import { attendance, getDb, members, posts, type Round, rounds, } from '@blog-study/shared/db';
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded, setCurrentRound, } from '../services/round.service';
import { attendance, getDb, members, posts, type Round } from '@blog-study/shared/db';
import { getCurrentRound, getRoundByNumber, isGracePeriodEnded } from '../services/round.service';
import {
type AttendanceSummary,
calculateRoundReportData,
Expand Down Expand Up @@ -112,51 +112,52 @@ export class RoundReporter {
try {
const currentRound = await getCurrentRound();

// 현재 회차의 이전 회차(종료된 회차)를 가져와서 리포트 발송
const prevRound = await getRoundByNumber(currentRound.roundNumber - 1);

if (!prevRound) {
logger.info('📊 [회차 리포트] 이전 회차 없음, 건너뜀');
return {
timestamp: startTime,
roundNumber: 0,
reportSent: false,
newRoundStarted: false,
newRoundNumber: null,
errors: ['이전 회차 없음'],
};
}

// grace period 체크 (수동 트리거 시 건너뜀)
if (!force && !isGracePeriodEnded(currentRound)) {
logger.info(`📊 [회차 리포트] ${currentRound.roundNumber}회차 지각 기간 미종료, 건너뜀`);
if (!force && !isGracePeriodEnded(prevRound)) {
logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 지각 기간 미종료, 건너뜀`);
return {
timestamp: startTime,
roundNumber: currentRound.roundNumber,
roundNumber: prevRound.roundNumber,
reportSent: false,
newRoundStarted: false,
newRoundNumber: null,
errors: ['지각 기간 미종료'],
};
}

logger.info(`📊 [회차 리포트] ${currentRound.roundNumber}회차 리포트 생성 중...`);
logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 리포트 생성 중...`);

const reportData = await buildRoundReportDataForRound(currentRound);
const reportData = await buildRoundReportDataForRound(prevRound);
const notificationService = getNotificationService();
const sent = await notificationService.sendRoundReport(reportData);

if (!sent) {
errors.push('리포트 발송 실패');
}

logger.info(`📊 [회차 리포트] ${currentRound.roundNumber}회차 리포트 ${sent ? '발송 완료 ✅' : '발송 실패 ❌'}`);

// 다음 회차로 전환
const nextRound = await getRoundByNumber(currentRound.roundNumber + 1);
if (nextRound) {
await setCurrentRound(nextRound.roundNumber);
logger.info(`📊 [회차 리포트] 현재 회차 → ${nextRound.roundNumber}회차로 전환`);
} else {
const db = getDb();
await db
.update(rounds)
.set({ isCurrent: false })
.where(eq(rounds.id, currentRound.id));
logger.info(`📊 [회차 리포트] 다음 회차 없음, ${currentRound.roundNumber}회차 종료`);
}
logger.info(`📊 [회차 리포트] ${prevRound.roundNumber}회차 리포트 ${sent ? '발송 완료 ✅' : '발송 실패 ❌'}`);

return {
timestamp: startTime,
roundNumber: currentRound.roundNumber,
roundNumber: prevRound.roundNumber,
reportSent: sent,
newRoundStarted: nextRound !== null,
newRoundNumber: nextRound?.roundNumber ?? null,
newRoundStarted: false,
newRoundNumber: null,
errors,
};
} catch (error) {
Expand Down Expand Up @@ -203,6 +204,7 @@ export class RoundReporter {
const todayStr = formatKSTDate(new Date());
const isTodayRoundStart = todayStr === currentRound.startDate;

// attendance-init(00:02)에서 이미 회차 전환 완료 — 현재 회차 기준으로 알림만 발송
if (force || isTodayRoundStart) {
logger.info(`🚀 [회차 시작] ${currentRound.roundNumber}회차 시작 알림 발송 중...`);

Expand All @@ -225,30 +227,6 @@ export class RoundReporter {
};
}

// 다음 회차 시작일인지 확인
const nextRound = await getRoundByNumber(currentRound.roundNumber + 1);

if (nextRound && (force || todayStr === nextRound.startDate)) {
await setCurrentRound(nextRound.roundNumber);
logger.info(`🚀 [회차 시작] ${nextRound.roundNumber}회차 시작, 회차 전환 완료`);

const notificationService = getNotificationService();
const sent = await notificationService.sendRoundStartAnnouncement(nextRound);

if (!sent) {
errors.push('회차 시작 알림 발송 실패');
}

return {
timestamp: startTime,
roundNumber: nextRound.roundNumber,
reportSent: false,
newRoundStarted: sent,
newRoundNumber: nextRound.roundNumber,
errors,
};
}

logger.info(`🚀 [회차 시작] 오늘(${todayStr})은 회차 시작일이 아님, 건너뜀`);

return {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,8 +344,8 @@ describe('NotificationService Property Tests', () => {
const expectedAbsent = summaries.filter(s => s.status === AttendanceStatus.ABSENT).length;

expect(reportData.submitted.length).toBe(expectedSubmitted);
expect(reportData.late.length).toBe(expectedLate);
expect(reportData.absent.length).toBe(expectedAbsent);
expect(reportData.lateRate).toBeCloseTo(expectedLate / summaries.length, 5);
expect(reportData.absentRate).toBeCloseTo(expectedAbsent / summaries.length, 5);
}
),
{ numRuns: 100 }
Expand Down Expand Up @@ -476,8 +476,6 @@ describe('NotificationService Property Tests', () => {
const reportData = calculateRoundReportData(round, []);

expect(reportData.submitted).toHaveLength(0);
expect(reportData.late).toHaveLength(0);
expect(reportData.absent).toHaveLength(0);
expect(reportData.mvps).toHaveLength(0);
expect(reportData.totalMembers).toBe(0);
expect(reportData.submissionRate).toBe(0);
Expand Down
Loading
Loading