@@ -8,7 +8,6 @@ import type { Client } from 'discord.js';
88import axios from 'axios' ;
99import { parseFeed } from 'feedsmith' ;
1010import { getRssPoller } from './schedulers/rss-poller' ;
11- import { getAttendanceChecker } from './schedulers/attendance-checker' ;
1211import { getFineReminder } from './schedulers/fine-reminder' ;
1312import { getRoundReporter } from './schedulers/round-reporter' ;
1413import { getCurationCrawler } from './schedulers/curation-crawler' ;
@@ -20,20 +19,21 @@ import { getNotificationService } from './services/notification.service';
2019import { getScoreService } from './services/score.service' ;
2120import { getAttendanceService , getFineService } from './services' ;
2221
23- import { ActivityScoreType , curationSources , getDb , members } from '@blog-study/shared/db' ;
24- import { extractFirstImage , extractOgImage } from '@blog-study/shared/utils' ;
25- import { getCurrentRound } from './services/round.service' ;
22+ import { ActivityScoreType , AttendanceStatus , curationSources , getDb } from '@blog-study/shared/db' ;
23+ import { extractFirstImage , extractOgImage , formatKSTDate } from '@blog-study/shared/utils' ;
24+ import { getCurrentRound , getRoundByNumber , isGracePeriodEnded , setCurrentRound } from './services/round.service' ;
25+ import { Sentry } from './lib/sentry' ;
2626import { eq } from 'drizzle-orm' ;
2727import logger from './lib/logger' ;
28- import { Sentry } from './lib/sentry' ;
2928
3029/**
3130 * Job definitions with cron schedules
3231 */
3332// pg-boss cron은 UTC 기준. KST = UTC+9
3433const JOB_DEFINITIONS = [
3534 { name : 'rss-poll' , cron : '*/5 * * * *' } , // 5분마다
36- { name : 'attendance-check' , cron : '0 0 * * 2' } , // KST 화 09:00 (UTC 화 00:00)
35+ { name : 'attendance-init' , cron : '2 15 * * 0' } , // KST 월 00:02 (UTC 일 15:02) — 회차 시작일 출석 PENDING 생성
36+ { name : 'attendance-absent' , cron : '2 15 * * 1' } , // KST 화 00:02 (UTC 월 15:02) — PENDING → ABSENT + 벌금
3737 { name : 'fine-reminder' , cron : '0 0 * * *' } , // KST 매일 09:00 (UTC 00:00)
3838 { name : 'round-report' , cron : '0 23 * * 1' } , // KST 화 08:00 (UTC 월 23:00)
3939 { name : 'round-start' , cron : '0 23 * * 0' } , // KST 월 08:00 (UTC 일 23:00)
@@ -51,7 +51,6 @@ const JOB_DEFINITIONS = [
5151export async function registerAllJobs ( boss : PgBoss , client : Client ) : Promise < void > {
5252 // Initialize scheduler instances with Discord client
5353 const rssPoller = getRssPoller ( ) ;
54- const attendanceChecker = getAttendanceChecker ( ) ;
5554 const fineReminder = getFineReminder ( ) ;
5655 const roundReporter = getRoundReporter ( ) ;
5756 const curationCrawler = getCurationCrawler ( ) ;
@@ -181,40 +180,6 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
181180 }
182181 } ) ;
183182
184- // P0 #4: 결석/지각 벌금 콜백 설정
185- // 결석 콜백: 화요일 00:00에 결석자 판정 시 자동 벌금 부과
186- attendanceChecker . setOnAbsentCallback ( async ( attendance , round ) => {
187- try {
188- const db = getDb ( ) ;
189- const [ member ] = await db
190- . select ( )
191- . from ( members )
192- . where ( eq ( members . id , attendance . memberId ) )
193- . limit ( 1 ) ;
194-
195- if ( ! member ) {
196- logger . error ( { memberId : attendance . memberId } , '✅ [출석] 멤버를 찾을 수 없음' ) ;
197- return ;
198- }
199-
200- // 결석 벌금 생성
201- // DM 알림은 보내지 않음 — fine-reminder에서 화요일부터 리마인더로 발송
202- const fine = await fineService . create ( attendance . memberId , round . id , 'absent' ) ;
203-
204- logger . info ( {
205- member : member . name ,
206- round : round . roundNumber ,
207- amount : fine . amount ,
208- } , '✅ [출석] 결석 벌금 부과 완료' ) ;
209- } catch ( error ) {
210- Sentry . captureException ( error ) ;
211- logger . error ( {
212- memberId : attendance . memberId ,
213- error
214- } , '✅ [출석] 결석 콜백 처리 실패' ) ;
215- }
216- } ) ;
217-
218183 // Set up curation crawl function: fetch RSS → parse → extract content → return CrawledContent[]
219184 // P1 #8: 큐레이션 데이터 품질 개선 - description, thumbnailUrl 추출
220185 curationCrawler . setCrawlFunction ( async ( url : string ) : Promise < CrawledContent [ ] > => {
@@ -276,8 +241,67 @@ export async function registerAllJobs(boss: PgBoss, client: Client): Promise<voi
276241 await rssPoller . poll ( ) ;
277242 } ) ;
278243
279- await boss . work ( 'attendance-check' , { batchSize : 1 } , async ( ) => {
280- await attendanceChecker . check ( ) ;
244+ // 새 큐 생성 (기존에 없는 큐는 명시적으로 생성 필요)
245+ await boss . createQueue ( 'attendance-init' ) ;
246+ await boss . createQueue ( 'attendance-absent' ) ;
247+
248+ // 회차 시작일 00:02 KST — 다음 회차 전환 + active 멤버 출석 PENDING 레코드 생성
249+ await boss . work ( 'attendance-init' , { batchSize : 1 } , async ( ) => {
250+ try {
251+ const currentRound = await getCurrentRound ( ) ;
252+ const todayStr = formatKSTDate ( new Date ( ) ) ;
253+ const nextRound = await getRoundByNumber ( currentRound . roundNumber + 1 ) ;
254+
255+ if ( ! nextRound || todayStr !== nextRound . startDate ) {
256+ logger . info ( `✅ [출석 초기화] 오늘(${ todayStr } )은 다음 회차 시작일이 아님, 스킵` ) ;
257+ return ;
258+ }
259+
260+ // 회차 전환
261+ await setCurrentRound ( nextRound . roundNumber ) ;
262+ logger . info ( `✅ [출석 초기화] ${ nextRound . roundNumber } 회차로 전환 완료` ) ;
263+
264+ // 출석 PENDING 레코드 생성
265+ const created = await attendanceService . createForRound ( nextRound . id ) ;
266+ logger . info ( `✅ [출석 초기화] ${ nextRound . roundNumber } 회차 ${ created . length } 명 PENDING 레코드 생성` ) ;
267+ } catch ( error ) {
268+ Sentry . captureException ( error ) ;
269+ logger . error ( { error } , '✅ [출석 초기화] 에러' ) ;
270+ }
271+ } ) ;
272+
273+ // 화요일 00:02 KST — 이전 회차 PENDING → ABSENT + 결석 벌금 부과
274+ await boss . work ( 'attendance-absent' , { batchSize : 1 } , async ( ) => {
275+ try {
276+ const currentRound = await getCurrentRound ( ) ;
277+ const prevRound = await getRoundByNumber ( currentRound . roundNumber - 1 ) ;
278+
279+ if ( ! prevRound ) {
280+ logger . info ( '✅ [결석 처리] 이전 회차 없음, 스킵' ) ;
281+ return ;
282+ }
283+
284+ if ( ! isGracePeriodEnded ( prevRound ) ) {
285+ logger . info ( `✅ [결석 처리] ${ prevRound . roundNumber } 회차 유예 기간 미종료, 스킵` ) ;
286+ return ;
287+ }
288+
289+ const processedRecords = await attendanceService . processGracePeriodEnd ( prevRound . id ) ;
290+ const absentRecords = processedRecords . filter ( r => r . status === AttendanceStatus . ABSENT ) ;
291+ logger . info ( `✅ [결석 처리] ${ prevRound . roundNumber } 회차 ${ absentRecords . length } 명 결석 처리` ) ;
292+
293+ for ( const record of absentRecords ) {
294+ try {
295+ await fineService . create ( record . memberId , prevRound . id , 'absent' ) ;
296+ } catch ( fineError ) {
297+ Sentry . captureException ( fineError ) ;
298+ logger . error ( { memberId : record . memberId , error : fineError } , '✅ [결석 처리] 벌금 부과 실패' ) ;
299+ }
300+ }
301+ } catch ( error ) {
302+ Sentry . captureException ( error ) ;
303+ logger . error ( { error } , '✅ [결석 처리] 에러' ) ;
304+ }
281305 } ) ;
282306
283307 await boss . work ( 'fine-reminder' , { batchSize : 1 } , async ( ) => {
0 commit comments