- ProblemSet.audioUrls Json? (영어 듣기 mp3 배열)
- Problem 에 bodyText/choices/answerNumber/passageId/needsReview 추가
- Problem @@index([passageId]) 로 역방향 조회 보장
- 신규 Passage 모델 (공통 지문 [N~M] 분리용)
- examRound 추가 거부 — 기존 examType 이 이미 'sat'|'mock-june'|'mock-sept'|'academy' 커버
- .gitignore 에 data/kice/ (저작권 자료)
migration: 20260411183246_kice_text_fields
shadow DB INDEX 권한 이슈는 mariadb 에서 GRANT 로 해결
Single-instance ToastProvider with requestAnimationFrame countdown,
hover-pause, per-variant SVG icons, circular SVG progress ring,
and slide-in-from-bottom animation. Wraps children in layout.tsx.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- harness-review 2nd pass (base 6d8deef → head 71cf469): APPROVE
- 1st pass 의 major 1 + minor 4 전부 해결 검증
- 회귀 없음, AI residuals clean
- 새 recommendation 2건 (iterationsToTarget null 가드, Problem IDOR)
은 Phase 7 확장 시 주의사항으로 tracking
.claude/state/review-result.json: 2nd pass 결과
.claude/state/review-result.1st.json: 1st pass 결과 (히스토리)
- stats.service.ts: ceilingP = σ(k·(1-D)) 계산. 이 문제의 이론적 P 상한.
effectiveTarget = min(targetP, ceilingP - 0.02) 로 좁혀서 실제 도달 가능한
현실적 목표를 쓴다. 응답에 ceilingP/effectiveTarget 추가.
- api.ts: MasteryPathResponse 에 두 필드 추가.
- stats/page.tsx: capped 인 경우 '이 난이도의 한계는 X% 야. N회차 K일 뒤 그
한계 근처에 도달해' 카피 + 목표 배지에 '(한계)' 표시.
- MasteryPathChart: 기준선을 effectiveTarget 으로.
트랙 1 — Pretendard 웹폰트 확실히 로드
- frontend/src/app/layout.tsx: Pretendard Variable CDN link 추가
- frontend/src/styles/theme.ts: font.sans 에 'Pretendard Variable' 우선
트랙 3 — 통계 재설계 (까먹음 → 마스터리 경로)
- backend/src/stats/stats.service.ts: masteryPath() 추가. 이번 복습부터 계속
correct 를 가정한 best-case 궤적을 10회차까지 시뮬레이션해서 각 회차별
s0/p/daysFromNow 를 반환. P >= targetP 도달 시 iterationsToTarget +
daysToTarget 도 함께 전달.
- backend/src/stats/stats.controller.ts: GET /api/stats/mastery-path 추가.
- frontend/src/lib/api.ts: MasteryPathResponse 타입 + ForgetCurveResponse
의 tag.subject 를 실제 select 에 맞춰 좁힘.
- frontend/src/components/charts/MasteryPathChart.tsx: recharts LineChart
로 X=iteration, Y=P% + 목표 수평선 + S₀ 보조 라인.
- frontend/src/app/stats/page.tsx: [마스터리 경로 | 망각 곡선] 토글.
마스터리 뷰는 'N회차 + K일 뒤 마스터 예상' 카피 + 회차별 요약 테이블.
트랙 5 — 드래그/선택 기본 금지 + 예외
- frontend/src/styles/GlobalStyle.ts: body 에 user-select:none +
input/textarea/[data-selectable='true'] 만 예외 허용. img 드래그 금지.
- frontend/src/components/ui/primitives.tsx: Selectable 컴포넌트 추가.
- review/page.tsx, review/history/page.tsx, study/history/page.tsx:
문제 제목과 메모에 data-selectable 부여 (검색용 복사 허용).
- backend/tsconfig.json: rootDir=./src + include/exclude to fix nest build
output (was emitting dist/src/main.js, now dist/main.js)
- backend/src/auth/auth.module.ts: @nestjs/jwt 11 tightened signOptions.
expiresIn to ms StringValue; cast env-sourced string via
JwtModuleOptions['signOptions']['expiresIn'] to satisfy the type
without pulling in @types/jsonwebtoken.
- backend/prisma/migrations/20260411153520_init: initial migration
generated by 'prisma migrate dev --name init' against the corrected
schema (SkillSnapshot.tagId nullable, SetNull cascades).
Verified end-to-end against https://reloop-api.nabomhalang.co.kr on
Dev VM: login → create study-log → queue → submit → 409 guard all
passing.
backend/package.json
- Add 'postinstall: prisma generate' — fresh pnpm install now produces
a usable @prisma/client without a manual step.
- Add engines.node >= 20 and packageManager pnpm@9.12.0.
frontend/package.json
- Same engines + packageManager pin.
frontend/next.config.js
- output: 'standalone' enabled for PM2's .next/standalone/server.js
pattern (reduces production bundle size, removes runtime node_modules
dependency for the web process).
frontend/.env.example (new)
- Documents NEXT_PUBLIC_API_URL as a BUILD-TIME variable.
ecosystem.config.js (new)
- PM2 config for reloop-api (cwd=backend, script=dist/main.js, port
3001) and reloop-web (cwd=frontend, script=.next/standalone/server.js,
port 3000, HOSTNAME=0.0.0.0). max_memory_restart=512M each with
separate error/out log files.
README.md (new)
- Root deploy guide: stack, env vars table (backend + frontend),
local dev quickstart, prod deploy steps (rsync → pnpm install
--frozen-lockfile → prisma migrate deploy → build → copy .next/static
and public into standalone → pm2 start), reverse proxy mapping,
pm2 ops. Also covers Cluster F items: 'Dev VM 에서 prisma migrate
dev --name init' for migrations and 'pnpm install' for lockfile
generation.
.gitignore
- Ignore .claude/state/session* + .claude/state/*.lock +
.claude/sessions/ (ephemeral harness runtime metadata).
components/ui/Modal.tsx (new)
- ConfirmDialog: overlay + card backed by theme tokens, role=dialog +
aria-modal, backdrop click = cancel, stopPropagation on card.
app/subjects/[id]/page.tsx
- subjectId = Number.isFinite(raw) && raw > 0 ? raw : null
- Early return rendering InvalidWrap when subjectId is null; no API call
with NaN, no infinite 'loading' deadend for /subjects/abc.
- Tag delete uses ConfirmDialog via confirmDelete state (no confirm()).
app/subjects/page.tsx
- Native confirm()/alert() eliminated. Delete flow uses ConfirmDialog
with danger tone + ToastBar for success/failure feedback.
app/review/history/page.tsx
app/study/history/page.tsx
app/stats/page.tsx
- Fetches wrapped in useCallback, .catch() sets an error state, render
shows ErrorBlock with a '다시 시도' retry button.
- stats/page.tsx gets both subjectsError (full-page) and curveError
(inline in the curve card) plus independent retries.
- Cancelled-flag pattern (let cancelled = false) prevents stale
setState after unmount across all four pages.
auth.service.ts
- register() / login() normalize email via trim().toLowerCase() before
every query; stored email is already normalized.
- register() conflict message is now generic 'registration failed'
(status still 409) to reduce enumeration surface.
jwt.strategy.ts
- validate() is async, injects PrismaService, re-fetches the user from
DB with select { id, email }, throws UnauthorizedException('user not
found') if the row is gone. Deleted/deactivated accounts can no
longer reuse an outstanding 30d token.
- Constructor throws if JWT_SECRET env var is missing (hardcoded
dev fallback 'dev-reloop-secret-change-me' removed).
auth.module.ts
- JwtModule.registerAsync with useFactory that throws if JWT_SECRET is
absent. No silent fallback.
main.ts
- ValidationPipe: forbidNonWhitelisted flipped false → true. Unknown
DTO fields now fail fast instead of being silently stripped.
backend/src/reviews/reviews.service.ts
- submit(): entire write path (status flip + snapshot upsert + next
schedule create) now runs inside prisma.$transaction(async tx => ...)
- updateMany where { id, status: 'pending' } + count===0 → ConflictException
acts as the atomicity gate; concurrent POSTs can no longer double-write.
- Next iteration is re-fetched inside the tx via findFirst done+desc, so
racing requests can't both compute iteration=N+1 off a stale read.
- Single const now = new Date() shared across snapshot and schedule call
(also addresses QA-2 minor m2).
- skip() gets the same updateMany guard and throws ConflictException when
the row has already been processed.
frontend/src/app/review/page.tsx
- Global 'submitting: boolean' replaced with 'pending: Set<number>' +
isPending(id) helper.
- submit()/skip() guard re-entry via if (isPending(id)) return and use
functional Set updates in finally.
- All four Button elements disabled={isPending(q.id)} — only the card in
flight is locked, not the whole page.
Closes the double-submit race that corrupted sampleCount / iteration.
- SkillSnapshot.tagId: Int → Int?, Tag relation → onDelete: SetNull
- StudyLog.tag: explicit onDelete: SetNull (was implicit Restrict)
- DashboardService.summary() filters { tagId: { not: null } } so orphaned
snapshots don't leak into the top-skills UI
- Schema header notes that prisma migrate dev --name relax_tag_cascade
must run on Dev VM before deploy
Prevents silent data loss when users delete a tag (previous cascade
erased all skill history for that tag permanently).