- QA-1 Backend API contract: APPROVE (minor type contract 2) - QA-2 Persona forget algorithm: APPROVE (math verified) - QA-3 Auth & security: REQUEST_CHANGES - email normalize missing / JWT revalidate / secret fallback / whitelist - QA-4 Frontend runtime: REQUEST_CHANGES - review double-submit race / subjects/[id] NaN / history error states / native alerts - QA-5 Prisma & transactions: REQUEST_CHANGES - reviews.submit snapshot outside tx / tag cascade data loss / iteration race - QA-6 Build & deploy: REQUEST_CHANGES - missing migrations, postinstall, lockfile, PM2 ecosystem Follow-up fix proposals saved to .claude/state/pending-fix-proposals.jsonl (18 tickets)
75 lines
4.3 KiB
Markdown
75 lines
4.3 KiB
Markdown
# QA-5 Report — Prisma Schema & Transaction Integrity
|
|
|
|
> Worker: Explore agent · Lead review: 승인
|
|
|
|
## Summary
|
|
- **verdict**: REQUEST_CHANGES
|
|
- critical: 2, major: 2, minor: 2
|
|
|
|
## Schema 요약
|
|
- 모든 enum (Persona / ReviewIntensity / StudyResult / ReviewStatus) 정의 완료.
|
|
- User 는 email unique, createdAt default, onboardedAt nullable.
|
|
- Subject/Tag 는 userId cascade, (subjectId, name) composite unique, 필요한 index 포함.
|
|
- StudyLog 는 (userId, studiedAt) index, User/Subject cascade, Tag FK 는 onDelete 미지정.
|
|
- ReviewSchedule 은 (userId, status, scheduledAt) index, StudyLog cascade.
|
|
- SkillSnapshot 은 `@@unique([userId, tagId])` 와 Tag cascade.
|
|
|
|
## Critical Findings
|
|
|
|
### [critical] C1 `reviews.service.submit()` — snapshot upsert 가 트랜잭션 바깥
|
|
- **Location**: `backend/src/reviews/reviews.service.ts:45-132`
|
|
- **Evidence**:
|
|
```ts
|
|
// 1) outside tx: find review + status check
|
|
// 2) outside tx: user fetch
|
|
// 3) outside tx: snapshot upsert ← ❌
|
|
// 4) inside tx: update review + create next schedule
|
|
```
|
|
동일 reviewId 에 대한 concurrent 요청이 1-2 단계를 통과한 뒤 각자 snapshot.upsert 를 호출 → sampleCount 가 2번 증가하고 s0 도 두 번 덮어씀. trait 의 atomicity 가 깨진다.
|
|
- **Impact**: 데이터 일관성 손상. QA-4 C1 (review double-submit race) 가 동시에 존재하면 프로덕션에서 실제 발생 가능.
|
|
- **Suggested fix**: snapshot upsert 와 review 업데이트를 같은 `prisma.$transaction(async (tx) => { ... })` 안에 포함.
|
|
```ts
|
|
return this.prisma.$transaction(async (tx) => {
|
|
const fresh = await tx.reviewSchedule.update({
|
|
where: { id: reviewId, status: 'pending' }, // atomic guard
|
|
data: { status: 'done', reviewedAt: new Date(), result },
|
|
});
|
|
// snapshot upsert with tx
|
|
// create next schedule with iteration = fresh.iteration + 1
|
|
});
|
|
```
|
|
`update where status = 'pending'` 이 조건 불만족시 P2025 를 던지므로 그걸로 "already processed" 판정.
|
|
|
|
### [critical] C2 Tag 삭제 cascade → SkillSnapshot 영구 손실
|
|
- **Location**: `backend/prisma/schema.prisma` SkillSnapshot 의 `tag` 관계 `onDelete: Cascade`
|
|
- **Evidence**: `tags.service.remove()` → `prisma.tag.delete(...)` → SkillSnapshot cascade 삭제.
|
|
- **Impact**: 사용자가 태그를 "정리" 하려다가 수개월치 복습 데이터(s0, sampleCount)를 복구 불가능하게 잃는다.
|
|
- **Suggested fix**: 옵션 ① Tag 에 `archivedAt DateTime?` 필드 추가해서 soft-delete. ② SkillSnapshot → Tag onDelete 를 `SetNull` 로 바꾸고 `tagId` 를 nullable 로 변경. ③ Tag 삭제 전 snapshot 의 s0/sampleCount 를 archive 테이블로 옮김.
|
|
|
|
## Major Findings
|
|
|
|
### [major] M1 iteration 증가 race
|
|
- **Location**: `backend/src/reviews/reviews.service.ts:125`
|
|
- **Evidence**: `iteration: review.iteration + 1` — `review` 는 tx 바깥에서 조회된 snapshot. 두 요청이 iteration=1 을 동시에 계산하면 pending 행이 둘 생긴다.
|
|
- **Suggested fix**: tx 안에서 `tx.reviewSchedule.findFirst({ where: { studyLogId, status: 'done' }, orderBy: { iteration: 'desc' }})` 로 최신 iteration 을 가져오고 +1.
|
|
|
|
### [major] M2 StudyLog.tag FK 에 onDelete 미지정
|
|
- **Location**: `backend/prisma/schema.prisma`
|
|
- **Impact**: Tag 삭제 시 StudyLog.tagId 가 orphan 으로 남아 FK 제약 위반.
|
|
- **Suggested fix**: `tag @relation(fields: [tagId], references: [id], onDelete: SetNull)` 로 전환. (tagId 는 이미 Int? 이므로 SetNull 가능)
|
|
|
|
## Minor Findings
|
|
|
|
### [minor] m1 snapshot upsert 의 findUnique + upsert 패턴
|
|
- **Location**: `study-logs.service.ts:82-104`, `reviews.service.ts:65-90`
|
|
- **Evidence**: 이미 upsert 한 단계로 처리 가능. `findUnique` 후 `upsert` 는 race 에서 sampleCount 부조화.
|
|
- **Suggested fix**: 단일 `upsert({ create, update: { sampleCount: { increment: 1 }, s0: computed }})` 사용. 기존 s0 접근이 필요하면 `findUnique` 를 같은 tx 안에서 먼저 호출.
|
|
|
|
### [minor] m2 demo 사용자 seed bcrypt round 확인
|
|
- **Location**: `backend/prisma/seed.ts`
|
|
- **Evidence**: `bcrypt.hash(DEMO_PASSWORD, 10)` — auth.service 와 동일 rounds. OK. upsert 방식이라 idempotent 하므로 seed 재실행 안전.
|
|
|
|
## Verdict
|
|
|
|
**REQUEST_CHANGES** — critical 2건 (concurrent review race, tag cascade 데이터 손실) 이 프로덕션 배포 차단 요소.
|