PPI-API 데이터 보강 — plannedStartTime · completionStatus 코드레벨 동작 흐름
마지막 업데이트 2026-07-22
TL;DR
PPI는 두 개의 진실 소스를 합친다 — DynamoDB Lesson(진도·세션·예외 시간)과 ppi-api classes(정규 스케줄·상태·담당 진행자·그룹). 보강(enrichment)은 이 둘을 join해 각 회차에 completionStatus·classDate/Time·groupId·담당자·그리고 가장 중요한 plannedStartTime(실제 입장 기준 시각)을 붙인다. 이 값이 P0 #1 입장 제어의 모든 시간 판정 입력이다.
호출 주체에 따라 세 경로로 갈린다 — 단일 아동(enrichUserLessonsWithPpiApiData, child/member auth), 관리자 전체(enrichLessonsWithPpiApiData, admin auth), 모니터 대시보드(getMonitorDashboardPlannedLessons). 공통 join 규칙은 resolvePlannedClass가 담당하고, 가장 까다로운 부분은 그룹 vs 1:1 시각 분리와 local 우선 병합이다.
세 보강 경로
① 단일 아동 (입장)
enrichUserLessonsWithPpiApiDatalesson-ppi-api-utils.ts:248- child auth →
getChildScheduledClasses/ member auth →getScheduledClasses(+childId 필터) missingTodayClasses산출 → P0 #1LESSON_NOT_PROVISIONED- 상태:
effectiveLessonStatus ?? status
② 관리자 전체
enrichLessonsWithPpiApiData:49getAdminClasses(오늘 범위)- ppi-api-only 클래스는 synthetic lesson 생성
- 시간 필터 +
includeCompleted+ terminal 필터 - 상태: raw
cls.status(effective 아님)
③ 모니터 대시보드
getMonitorDashboardPlannedLessonsmonitor-dashboard-planned-lessons.ts:134- GET /api/monitor-dashboard/planned-lessons
getAdminClasses+isWithinRange+toPlannedLesson- 아동 이름 resolve(
getUserProfile)
공통 join — resolvePlannedClass
모든 경로가 쓰는 핵심. lib/planned-class-resolver.ts:33. ppi-api 한 건(date+time+status+groupId?)과 로컬 lesson.scheduledTime을 합쳐 입장 기준 시각을 만든다.
// date(YYYYMMDD)+time(HH:MM) → KST(+09:00) ISO
const classDateTime = parseClassDateTimeToIso(cls.date, cls.time);
// ★ 그룹이면 scheduledTime(1:1 예외 개방) 무시 → 정규 시각 사용
const scheduledTime = cls.groupId ? undefined : lesson?.scheduledTime;
return {
plannedStartTime: scheduledTime ?? classDateTime, // 입장 기준 시각
isTempOpen: !!scheduledTime, // 1:1 임시 개방
completionStatus: cls.status, classDate: cls.date, classTime: cls.time, ...
};
| 케이스 | plannedStartTime | isTempOpen |
|---|---|---|
| 정규 1:1 / 그룹 | ppi-api classDateTime | false |
1:1 임시 개방 (scheduledTime 있고 groupId 없음) | 로컬 scheduledTime | true |
단일 아동 경로 흐름 (enrichUserLessonsWithPpiApiData)
accessToken 없으면 보강 skip(원본 반환). child면 getChildScheduledClasses(token, userId, today), member면 getScheduledClasses(token, today) 후 childId === userId 필터. status≠200이면 warn + skip.statusMap(lessonIndex → effectiveLessonStatus ?? status)·groupIdMap 만들어 모든 enrichedLessons에 completionStatus·classDate/Time·groupId 부착.plannedStartTime 계산 + DynamoDB 폴백enrichedLessons에서 회차를 찾고, 없으면 getLesson 폴백. 찾으면 resolvePlannedClass로 patch. isLegacyMode면 skip.missingTodayClasses 수집Lesson 레코드가 양쪽 다 없으면 {lessonIndex, plannedStartTime} push + warn. → P0 #1이 이걸 받아 LESSON_NOT_PROVISIONED로 구분.관리자 경로의 추가 동작 (enrichLessonsWithPpiApiData)
- synthetic lesson 생성: ppi-api엔 있지만 로컬에 없는 클래스는
{title:"N회차", activities:[], createdAt:0, isLegacyMode:false}로 가짜 Lesson을 만들어 목록에 포함(:182-199). 표시는 되지만 진도·세션은 없다. - 담당 진행자(memberMap):
memberId/memberName(1:1) 또는groupMemberIds/Names(그룹)를 부착(:93-115). - 시간 필터 +
batchGetLessons:startTime/endTime범위로 거르고, 로컬에 없는 대상만 DynamoDB BatchGetItem 일괄 조회(N건 개별 호출 → 1~2건 배치)(:147-162). - local 우선 병합:
enrichedLocalLessons+ (로컬에 없는)ppiApiLessons. 같은 키는 로컬이 우선(:202-208). - terminal 필터:
includeCompleted=false(기본)면isTerminalStatus(= PENDING 아님)인 회차 제거(:210-212).
KST 시각 변환
모든 날짜 계산이 Asia/Seoul 고정이다. 서버 TZ와 무관하게 동작해야 하므로 dayjs.tz를 쓴다. lesson-ppi-api-utils.ts:22-29:
formatDateToYYYYMMDD(date) => dayjs(date).tz("Asia/Seoul").format("YYYYMMDD");
parseClassDateTime(date, time) => dayjs.tz(`${yyyy}-${mm}-${dd} ${time}`, "Asia/Seoul").toDate();
코드 맵 — 파일별 역할
| 파일 | 역할 |
|---|---|
| lib/lesson-ppi-api-utils.ts | enrichUserLessonsWithPpiApiData(단일 아동)·enrichLessonsWithPpiApiData(관리자)·KST 유틸·isTerminalStatus |
| lib/planned-class-resolver.ts | resolvePlannedClass — 그룹/1:1 분기, plannedStartTime·isTempOpen |
| lib/monitor-dashboard-planned-lessons.ts | getMonitorDashboardPlannedLessons — 모니터 대시보드용, 범위 필터·아동 이름 resolve·toPlannedLesson |
| lib/ppi-api-client.ts | getAdminClasses·getScheduledClasses·getChildScheduledClasses — ppi-api 호출 래퍼 |
| lib/db-queries.ts | getLesson·batchGetLessons — DynamoDB 조회/배치 |
읽을 때 주의할 함정
1. 두 enrichment 함수의 상태 소스가 다르다. 단일 아동 경로는 effectiveLessonStatus ?? status를 쓰지만, 관리자 경로는 raw cls.status를 쓴다(:82). 이유: 예약 수업 목록은 오늘 실제 상태가 필요해서, 같은 lessonIndex에 미래 pending 수업이 있어도 오늘 취소된 수업은 취소로 유지돼야 한다. 두 함수를 혼동해 상태가 다르게 나온다고 의심하기 전에 어느 경로인지 확인할 것.
2. 그룹이면 scheduledTime을 버린다. resolvePlannedClass는 cls.groupId가 있으면 로컬 scheduledTime을 무시하고 정규 classDateTime을 쓴다(planned-class-resolver.ts:38). 그룹 수업에 1:1 임시 개방 시각이 잘못 끼어드는 것을 막는 분기이므로 통합 금지.
3. 관리자 경로의 synthetic lesson은 빈 껍데기. ppi-api-only 클래스로 만든 가짜 Lesson은 activities:[]·createdAt:0이다(:184-198). 목록·시간 판정엔 쓰이지만 진도/세션 데이터는 없으므로, 이 객체로 진도를 읽으려 하면 안 된다.
4. accessToken이 없으면 보강 자체를 건너뛴다. 두 함수 모두 토큰 없으면 원본 lessons를 그대로 반환한다(:259, :55). 이 경우 plannedStartTime·completionStatus가 안 붙어 P0 #1 입장 판정이 "예정된 수업 없음" 경로로 빠질 수 있다.
5. 모든 시각은 KST 고정. formatDateToYYYYMMDD·parseClassDateTime은 서버 TZ가 아닌 Asia/Seoul 기준이다(:22-29). "오늘"의 경계가 서버 로컬 자정이 아니라 KST 자정임을 전제로 디버깅할 것.
6. batchGetLessons는 키 누락을 조용히 생략한다. 관리자 경로는 N건을 BatchGetItem으로 묶는데, 존재하지 않는 키는 결과 맵에 안 들어온다(:157). 단일 아동 경로의 missingTodayClasses처럼 누락을 추적하는 장치가 관리자 경로엔 없으므로, 관리자 목록에서 일부 회차가 빠지면 이 경로를 의심.