Refactor Study Desk application structure

This commit is contained in:
Elijah 2026-08-07 19:31:23 -07:00
parent faaccf8a7e
commit 089439ed90
145 changed files with 8087 additions and 3412 deletions

20
.dockerignore Normal file
View file

@ -0,0 +1,20 @@
node_modules
.next
.git
.forgejo
.github
.env*
!.env.example
dev.db*
study.db*
data
coverage
.test-databases
.npm-cache
.prisma-cache
audit-results
*.log
*.tsbuildinfo
out.css
temp.css
test.css

View file

@ -1,4 +1,4 @@
name: Automated Container Build name: Verify and publish container
on: on:
push: push:
@ -16,10 +16,41 @@ jobs:
run: | run: |
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
- name: Build and Push Image - name: Install locked dependencies
run: | run: npm ci
# Force the entire image path string to lowercase dynamically
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]')
docker build -t "$IMAGE_PATH" . - name: Run tests
docker push "$IMAGE_PATH" run: npm test
- name: Validate Prisma schema and migration drift
run: |
npx prisma validate
DATABASE_URL=file:./ci-migration.test.db npx prisma migrate deploy
npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
- name: Build application
run: npm run build
- name: Lint changed source files
run: |
FILES=$(git diff --name-only HEAD^ HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs')
if [ -n "$FILES" ]; then npx eslint $FILES; fi
- name: Build and smoke production image
run: |
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}"
CI_SECRET=$(openssl rand -hex 32)
docker build -t "$IMAGE_SHA" .
docker run -d --name study-smoke -p 3000:3726 -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
for attempt in $(seq 1 30); do
if [ "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" ]; then break; fi
sleep 2
done
test "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy"
curl --fail http://127.0.0.1:3000/login > /dev/null
curl --fail http://127.0.0.1:3000/api/auth/setup-status | grep '"setupRequired":true'
docker rm -f study-smoke
docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest"
docker push "$IMAGE_SHA"
docker push "$IMAGE_PATH:latest"

2
.gitattributes vendored Normal file
View file

@ -0,0 +1,2 @@
# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output.
src/generated/prisma/** -whitespace

6
.gitignore vendored
View file

@ -12,12 +12,18 @@
# testing # testing
/coverage /coverage
/.test-databases/
# local SQLite study data # local SQLite study data
/dev.db /dev.db
/dev.db-journal /dev.db-journal
/dev.db-shm /dev.db-shm
/dev.db-wal /dev.db-wal
/data/
/study.db
/study.db-journal
/study.db-shm
/study.db-wal
# next.js # next.js
/.next/ /.next/

View file

@ -1,11 +1,19 @@
# ---- base ----
FROM node:22-slim AS base
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
# ---- deps ---- # ---- deps ----
FROM node:22-slim AS deps FROM base AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm install RUN npm ci
# ---- production dependencies ----
FROM deps AS runtime-deps
RUN npm prune --omit=dev
# ---- builder ---- # ---- builder ----
FROM node:22-slim AS builder FROM base AS builder
WORKDIR /app WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . COPY . .
@ -13,13 +21,13 @@ RUN npx prisma generate
RUN npm run build RUN npm run build
# ---- runner ---- # ---- runner ----
FROM node:22-slim AS runner FROM base AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV PORT=3726 ENV PORT=3726
ENV HOSTNAME=0.0.0.0
ENV DATABASE_URL="file:/app/data/study.db" ENV DATABASE_URL="file:/app/data/study.db"
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app
COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/standalone ./
@ -27,10 +35,13 @@ COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.ts ./ COPY --from=builder /app/prisma.config.ts ./
RUN npm install prisma@^7.8.0 COPY --from=builder /app/scripts ./scripts
COPY --from=runtime-deps /app/node_modules ./node_modules
COPY docker-entrypoint.sh ./ COPY docker-entrypoint.sh ./
RUN chmod +x docker-entrypoint.sh RUN chmod +x docker-entrypoint.sh
# Running as root to avoid permission denied on Unraid host-mounted volumes # Running as root to avoid permission denied on Unraid host-mounted volumes
EXPOSE 3726 EXPOSE 3726
VOLUME ["/app/data"] VOLUME ["/app/data"]
HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3726/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"]
ENTRYPOINT ["./docker-entrypoint.sh"] ENTRYPOINT ["./docker-entrypoint.sh"]

View file

@ -1,4 +1,77 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). Study Desk is a self-hosted, single-user study application.
## Local development
```powershell
npm.cmd ci
npx.cmd prisma generate
npm.cmd run dev
```
The automated test harness always creates a uniquely named database under
`.test-databases/`, applies committed migrations, and removes the database after
the run. It refuses `dev.db`, `/app/data/study.db`, existing databases, and paths
that are not explicitly test-named.
## Production container
Set a unique session secret of at least 32 characters. Production startup fails
before migrations or the HTTP server when the secret is missing, short, or the
published development fallback.
```powershell
$env:SESSION_SECRET = '<at-least-32-random-characters>'
docker compose -f docker-compose.yml up --build
```
The host listens on `http://localhost:3000`; the container listens on port 3726.
Development Compose is intentionally explicit and is never auto-merged:
```powershell
docker compose -f docker-compose.dev.yml up --build
```
For first-time production setup, either provide an Argon2 encoded
`ADMIN_PASSWORD_HASH`, or temporarily set `ALLOW_INITIAL_SETUP=true` for the
one-time setup request. Remove the flag after setup. Production HTTP password
reset initiation is unavailable; run this on the server instead:
```powershell
npm.cmd run auth:reset
```
If TLS terminates at a trusted reverse proxy, forward requests only from that
proxy and set `SECURE_COOKIES=true`. Leave it false for intentional plain HTTP;
Secure cookies cannot be used over plain HTTP.
## Database backup, migration adoption, and restore
Never run migration tests against the only copy of a study database. Create a
lock-safe SQLite backup through the backup API and verify it before deployment:
```powershell
$env:DATABASE_URL = 'file:./data/study.db'
npm.cmd run db:backup -- --output ./backups/study-before-upgrade.db
npm.cmd run db:preflight
npx.cmd prisma migrate deploy
```
The material-group preflight reports one of four states:
- `APPLY`: migration history is complete and the group schema is absent; run
`prisma migrate deploy` normally.
- `ADOPT`: prior migrations are tracked and the database exactly matches the
intended group schema. After reviewing the verified backup, explicitly run
`npm run db:preflight -- --resolve --backup <backup-path>`.
- `CURRENT`: migration and schema already agree.
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
`db push`, reset, edit applied migrations, or mark anything applied.
Restore drill: stop Study Desk, keep the damaged database as evidence, restore
the verified backup to a new path, run SQLite `integrity_check` plus
`npm run db:preflight`, start the same prior application image against the
restored path, and compare class/deck/quiz counts plus representative content.
Only swap the production path after those checks pass.
## Getting Started ## Getting Started

View file

@ -0,0 +1,67 @@
# Confirmed Findings
Severity: Critical / High / Medium / Low / Observation. Confidence: Confirmed / High-confidence inference / Unverified risk.
Full structured detail for every finding (files, functions, scenario, expected/actual, root cause, impact, evidence, fix direction, complexity, regression test, runtime-confirmation flag) is in `FINDINGS.json` (validated). Source IDs from specialist auditors are in parentheses.
| ID | Severity | Confidence | Title | Area |
|---|---|---|---|---|
| FIN-01 (DBAUD-01, GRP-01) | **Critical** | Confirmed | Schema/migration drift: `MaterialGroup` table + `groupId` columns never migrated — fresh deployments' databases are incompatible with the app (verified: `no such column: Deck.groupId`, `no such table: MaterialGroup`) | DB/migrations |
| FIN-02 (AUTH-01, OPS-03) | **Critical** | High-confidence inference | Hardcoded fallback `SESSION_SECRET` (`"dev-session-secret-change-in-production-must-be-32-chars"`) in `src/lib/auth.ts:15` + `src/proxy.ts:8-9`; empty env var in shipped compose → forgeable session cookie → full auth bypass | Auth/deploy |
| FIN-03 (QUIZ-01, ADV-01) | **High** | Confirmed | In-viewer "Retake Missed" never sets `retakeIds` prop → persisted as a full attempt with a wrong, lower score; pollutes SEQUENTIAL progress | Quiz |
| FIN-04 (CARD-01, IMPT-01, QUIZ-06) | **High** | Confirmed | Stale card ids in saved progress never filtered on resume (`filterAndClampOrder` dead code) → deleting a card mid-session bricks the flashcard study session | Flashcards |
| FIN-05 (OPS-01) | **High** | Confirmed | Port mismatch: container listens on 3726, compose publishes 3000:3000 → production deployment unreachable | Deploy |
| FIN-06 (OPS-06) | **High** | High-confidence inference | No `.dockerignore`: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image | Deploy |
| FIN-07 (OPS-08) | **High** | Confirmed | `docker-compose.override.yml` auto-merges on plain `docker compose up` → production command silently runs dev mode | Deploy |
| FIN-08 (AUTH-04, ADV-09) | Medium | High-confidence inference | First-login provisioning takeover; `ADMIN_PASSWORD_HASH` env is dead config | Auth |
| FIN-09 (AUTH-02) | Medium | High-confidence inference | Proxy auth bypass for any path containing `.` (latent today; would expose any future non-UUID-id route) | Auth |
| FIN-10 (AUTH-03) | Medium | High-confidence inference | Password-reset abuse: token overwrite DoS, spoofable `x-forwarded-for` rate limits, unthrottled `complete` | Auth |
| FIN-11 (QUIZ-02) | Medium | High-confidence inference | Attempt route stores unvalidated `answersJson`; duplicate option ids inflate SATA credit; malformed shapes → 500 | Quiz |
| FIN-12 (QUIZ-03, FE-04) | Medium | Confirmed | Quiz Finish double-submit creates duplicate attempts; failure path silent | Quiz/FE |
| FIN-13 (QUIZ-04) | Medium | High-confidence inference | Progress PATCH can land after Finish's DELETE → stale "Continue" row for a completed quiz | Quiz |
| FIN-14 (CARD-02, FE-05, QUIZ-09) | Medium | High-confidence inference | Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins | Flashcards/Quiz |
| FIN-15 (CARD-03, ADV-07) | Medium | Confirmed | Completed flashcard session never persisted as complete → resume re-shows the last graded card | Flashcards |
| FIN-16 (CARD-04) | Medium | Confirmed | SRS review endpoint enforces membership only — no due-date or `newCardsPerDay` enforcement server-side | SRS |
| FIN-17 (CARD-05, ADV-02) | Medium | Confirmed | Unbounded DB scans: whole `StudyActivity` table per fetch; all SRS card states per set; Navbar polls every 60 s | Perf/DB |
| FIN-18 (FE-01, GRP-03, FE-02, GRP-05) | Medium | Confirmed | Library optimistic mutations are fire-and-forget (no `res.ok`, no rollback); cross-group reorder omits origin group from payload | FE/Groups |
| FIN-19 (GRP-02, CARD-07, AUTH-06) | Medium | Confirmed | Reorder/create endpoints trust client `groupId`/`sortOrder` — no class/type/existence validation; cross-class group membership can leak content into another class's share link | Groups |
| FIN-20 (FE-03) | Medium | Confirmed | Shared quiz session resets and options reshuffle on any parent re-render (unstable inline `quiz` prop) | FE |
| FIN-21 (IMPT-02, FE-08, QUIZ-05, ADV-10) | Medium | Confirmed | `/api/progress` PATCH completely unvalidated; unguarded client `JSON.parse` of persisted progress crashes pages | Quiz/FE |
| FIN-22 (OPS-02) | Medium | High-confidence inference | Entrypoint migrate-deploy chain fragile: crash loop on conflicting DB state; runner prisma CLI install unverified/unpinned | Deploy |
| FIN-23 (OPS-05) | Medium | Confirmed | Non-reproducible installs: `npm install` (not `ci`); unpinned `prisma@^7.8.0` re-install re-resolves the whole tree in the runner | Deploy |
| FIN-24 (OPS-07) | Medium | Confirmed | No healthcheck, no backup, no documented recovery | Deploy |
| FIN-25 (OPS-10, TEST-10) | Medium | Confirmed | CI builds/pushes without tests, lint, prisma validate, or container smoke test — deployment-breaking defects ship green | CI |
| FIN-26 (QUIZ-07) | Low | Confirmed | SATA scoring divides by `correctIds.length` with no zero-guard → NaN | Quiz |
| FIN-27 (QUIZ-08) | Low | Confirmed | Historical review/category breakdown recompute scores from current content, not stored attempt | Quiz |
| FIN-28 (IMPT-03) | Low | Confirmed | Card add/edit endpoints bypass Zod: empty strings storable; CreateTab silently drops incomplete cards | Imports |
| FIN-29 (IMPT-04) | Low | Confirmed | No payload size/string-length/array-length caps on import schemas/routes; whitespace-only names accepted | Imports |
| FIN-30 (IMPT-05) | Low | Confirmed | SATA constraint mismatch: instructions say ≥2 correct, schema enforces ≥1 | Imports |
| FIN-31 (IMPT-06) | Low | Confirmed | Unvalidated `name`/`groupId` override fields on import POST routes → 500; PATCH routes mask failures as 404 | Imports |
| FIN-32 (GRP-04) | Low | Confirmed | Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint/tie-break | Groups |
| FIN-33 (GRP-06) | Low | Confirmed | Keyboard users cannot move items between groups (empty `handleDragOver`) | Groups/a11y |
| FIN-34 (CARD-08) | Low | High-confidence inference | Restart deletes progress fire-and-forget; slow DELETE can remove the fresh session's progress row | Flashcards |
| FIN-35 (CARD-09) | Low | Confirmed | Concurrent first review of same new card → P2002 → generic 500 instead of 409 | SRS |
| FIN-36 (CARD-10) | Low | High-confidence inference | "Previous card" during the 350 ms grade animation races the pending timeout | Flashcards |
| FIN-37 (CARD-11) | Low | Confirmed | SRS set page never refreshes on focus → stale membership after deck deletion in another tab | SRS/FE |
| FIN-38 (CARD-06) | Low | Confirmed | Day boundary hardcoded to Arizona (UTC-7): "today"/new-card limit/streak roll over at 07:00 UTC | SRS |
| FIN-39 (AUTH-05) | Low | Confirmed | Proxy `destroy()` cookie-clear lost on redirect; layout check ignores `sessionGeneration` | Auth |
| FIN-40 (AUTH-08) | Low | Confirmed | Session cookie Secure flag off in shipped compose (plain HTTP, no TLS story) | Auth/deploy |
| FIN-41 (DBAUD-02, AUTH-07) | Low | Confirmed | `/api/share` accepts arbitrary `targetType` → repeatable junk all-NULL `ShareLink` rows | Auth/DB |
| FIN-42 (DBAUD-03) | Low | High-confidence inference | `dev.db` committed in git history (8 commits), remains in blobs on the LAN remote | Repo hygiene |
| FIN-43 (OPS-09) | Low | Confirmed | `.gitignore` gaps (`data/`, `study.db*`); scratch files `out.css`/`temp.css`/`test.css` tracked | Repo hygiene |
| FIN-44 (FE-07, ADV-04) | Low | Confirmed | Dashboard `fetchClasses` no `.catch`/`res.ok` → unhandled rejection, misleading empty state (or render crash) | FE |
| FIN-45 (FE-09) | Low | Confirmed | Effect fetches without `.catch` in GenerateTab and ShareMenu | FE |
| FIN-46 (FE-10) | Low | High-confidence inference | Cross-class navigation fetch race renders wrong-class data; module cache never invalidated | FE |
| FIN-47 (ADV-03) | Low | Confirmed | Logout has no error handling — failed logout strands the user | FE |
| FIN-48 (ADV-05) | Low | Confirmed | `slugify` can yield an empty slug (unreachable class); renames never update the URL slug | Classes |
| FIN-49 (ADV-08) | Low | High-confidence inference | Shared-viewer localStorage keys item-scoped, not token-scoped → session bleed across tokens | Sharing/FE |
| FIN-50 (GRP-07) | Observation | Confirmed | Group `sortOrder` uses inverted desc convention with unvalidated PATCH — latent trap | Groups |
| FIN-51 (FE-11) | Observation | Confirmed | Collapsed-groups state read in effect → one-frame expand flash | FE |
| FIN-52 (AUTH-10) | Observation | Confirmed | Shared quiz links ship the full answer key to anonymous viewers — by design, worth a warning | Sharing |
| FIN-53 (TEST-01) | Low | Confirmed | False-confidence test: "distinct valid state for every rating" never asserts distinctness | Tests |
## Notes on consolidation
- Duplicate findings across specialist reports were merged under one root cause (see `sourceIds`).
- FE-06 ("all /api/* routes unauthenticated") was **refuted** by the adversarial review — `src/proxy.ts` IS the compiled middleware and enforces session checks; the residual truth is FIN-09 (dot-bypass). See REJECTED_FINDINGS.md.
- OPS-02's fresh-volume crash-loop framing was weakened by the adversarial review (on a fresh volume `migrate deploy` succeeds; the app then fails per FIN-01) — reframed as FIN-22.
- FIN-18 severity downgraded from the frontend auditor's High to Medium after adversarial review (no visible break for a single user; silent divergence self-heals on reload).

View file

@ -0,0 +1,38 @@
# Coverage Map
Mapping of repository areas → audit status → specialist report reference. Statuses: done / done+verified (parent-verified with runtime evidence) / partial.
| Area | Files | Auditor | Status | Report ref |
|---|---|---|---|---|
| Prisma schema/migrations/transactions/cascades | prisma/schema.prisma, prisma/migrations/*, prisma.config.ts, src/lib/db.ts, services | DB specialist (task-1) | done+verified (drift reproduced) | sa_20260806_054030_000000000_5ed877563a0d |
| Quiz scoring/attempts/history/retakes/progress | src/lib/scoring.ts, quizService, progressService, quizzes/*, api/quizzes/**, api/progress | Quiz specialist (task-2) | done+verified (server scoring path re-checked) | sa_20260806_054030_000000000_e75a8ce575f9 |
| Flashcards/SRS/progress/deletion/ordering | flashcards/*, spaced-repetition/*, cardService, deckService, spacedRepetitionService, api/cards|decks|spaced-repetition-sets|progress | Flashcard specialist (task-3) | done | sa_20260806_054030_000000000_a222804c133c |
| Auth/protected routes/API authz/sharing | src/proxy.ts, lib/auth.ts, authService, shareService, shareMetadata, api/auth/**, api/share, shared/*, all api routes | Auth specialist (task-4) | done+verified (proxy + fallback secret read directly) | sa_20260806_054030_000000000_aab0c59dbad3 |
| Imports/exports/generation/Zod/malformed input | lib/validation/*, jsonRepair, components/import/*, api/decks|quizzes|material-groups|settings | Import specialist (task-5) | done | sa_20260806_054030_000000000_5e72c2713989 |
| Material groups/library/drag-drop/deletion/orphans | api/material-groups/**, reorder routes, library pages, classService | Groups specialist (task-6) | done | sa_20260806_054030_000000000_9ae1f3dfa282 |
| Frontend state/refresh/races/localStorage/boundaries | all components + pages + shared viewers | Frontend specialist (task-7) | done | sa_20260806_054030_000000000_0e17fd155702 |
| Test quality/coverage gaps | *.test.ts, vitest.config.ts, package.json, CI workflow | Test specialist (task-8) | done | sa_20260806_054030_000000000_cd3652deb3f7 |
| Docker/startup/env/scripts/production/recovery | Dockerfile, docker-entrypoint.sh, compose files, package.json, prisma.config.ts, next.config.ts, .gitignore, CI | Ops specialist (task-9) | done | sa_20260806_054030_000000000_f49cdaacaabf |
| Adversarial review (challenge Critical/High + missed issues) | (all of the above + lightly-covered files) | Adversarial specialist (task-10) | done — 9/10 challenged findings confirmed/weakened, FE-06 refuted, 10 new issues added | sa_20260806_055608_000000000_c550c742d5dc |
## Parent-level verification performed (see VERIFICATION_LOG.md and audit-results/tmp/)
- `npx prisma validate` — PASS
- `npm test` — PASS (30 tests: 20 arcade + 10 spacedRepetition)
- `npm run lint` — 28 baseline problems (9 errors / 19 warnings), all pre-existing
- `npm run build` — PASS (route table incl. "ƒ Proxy (Middleware)")
- `prisma migrate deploy` on fresh temp DB — PASS (creates DB **missing** MaterialGroup/groupId → FIN-01 drift confirmed)
- Temp DB introspection (`audit-results/tmp/inspect-db.cjs`) — `MaterialGroup` table and `groupId` columns absent
- Prisma-shaped SQL against temp DB (`audit-results/tmp/reproduce-drift.cjs`) — `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId`
- `FINDINGS.json` validity — PASS (53 findings; validator `audit-results/tmp/validate-findings.cjs`)
## Coverage gaps (areas NOT fully audited)
1. **Arcade feature internals** — intentionally out of scope (may be removed). Only checked for shared-DB/build/deploy impact (Arcade models/indexes verified in migrations).
2. **Real browser interaction** — no browser available in the audit environment: drag-drop, resume flows, share pages, keyboard a11y, theme behavior were code-verified only; findings needing runtime confirmation are flagged in UNVERIFIED_RISKS.md.
3. **Container build/run** — docker CLI unavailable: OPS-02/FIN-22, FIN-06, FIN-24 need a container run to observe exact failure modes.
4. **git history deep-dive** — limited to drift commit (7af0935), dev.db commits, and migration commits; other commits not diffed line-by-line.
5. **Committed dev.db blob contents** — could not be fully enumerated (no sqlite3 CLI; blob grep only; no admin_password hash found in checked blobs).
6. **Live dev.db** — none exists in the workspace; runtime behavior on a populated database (perf findings FIN-17) not measured.
7. **Network/remote behavior** — the Forgejo remote and CI execution were not reachable; CI findings are static analysis of `.forgejo/workflows/build.yml`.
8. **External services** — none (app calls no LLM/third-party APIs).

View file

@ -0,0 +1,69 @@
# Study Desk — Overnight Read-Only Audit: Executive Summary
Status: **COMPLETE** (2026-08-06)
## Scope and method
Comprehensive read-only audit of the Study repository (Next.js 16 / React 19 / Prisma 7 / SQLite, self-hosted single-user study app). Ten specialist subagents audited: (1) Prisma schema/migrations/transactions/cascades/drift, (2) quiz scoring/attempts/retakes/progress, (3) flashcards/SRS/progress/ordering, (4) auth/protected routes/API authorization/sharing, (5) imports/exports/generation/Zod validation, (6) material groups/library/drag-drop/deletion, (7) frontend state/races/localStorage/boundaries, (8) test quality, (9) Docker/env/production/recovery, (10) adversarial review challenging every Critical/High finding and hunting missed issues. The parent agent independently re-verified all Critical/High claims (code tracing, git history, temp-DB migration chain, DB introspection, Prisma-shaped SQL probes). The Arcade feature set was excluded per scope unless it affects the main app, shared dependencies, database integrity, build, or deployment.
## Constraints honored
- **No source code, tests, migrations, config, package files, lockfiles, docs, or databases modified.** Final `git status --short`: only untracked `.reasonix/` and `audit-results/`; `git diff --check` clean; `src/generated/prisma/` byte-identical before/after all prisma commands (sha256).
- All reports and diagnostic artifacts live under `audit-results/` (reports + `tmp/`: `audit-migration.db`, `inspect-db.cjs`, `reproduce-drift.cjs`, `validate-findings.cjs`, `generated-before.sha256`).
- Existing databases: none found in the workspace; the migration-chain test used a brand-new temp DB.
## Baseline verification
| Check | Result |
|---|---|
| `npm test` | ✅ PASS — 30 tests / 6 files (20 arcade out of scope, 10 spacedRepetition) |
| `npm run lint` | ⚠️ 28 problems (9 errors, 19 warnings) — all pre-existing baseline |
| `npm run build` | ✅ PASS — full route table incl. "ƒ Proxy (Middleware)" |
| `npx prisma validate` | ✅ PASS |
| Migration chain on fresh temp DB | ✅ PASS — 4/4 migrations apply, but the resulting DB is **missing** `MaterialGroup` + `groupId` columns (drift, see below) |
## Findings (53 total: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation)
Full detail in `FINDINGS.json` (validated) and `CONFIRMED_FINDINGS.md`.
### Critical
1. **FIN-01 — Schema/migration drift (Confirmed, runtime-verified).** `prisma/schema.prisma` defines `MaterialGroup` and `Deck/QuizSet/ShareLink.groupId` (added by commit 7af0935, which also rewrote a committed `dev.db` via `prisma db push`) but **no migration creates them**. Every DB built by `prisma migrate deploy` — the `predev` hook and the Docker entrypoint — lacks these objects; the generated client then fails every Deck/QuizSet/ShareLink/MaterialGroup query (`no such column: Deck.groupId`, `no such table: MaterialGroup` — reproduced against a temp DB). **Any fresh install/deploy is non-functional.** The app only works on the developer's db-push-synced local database.
2. **FIN-02 — Hardcoded session-secret fallback (High-confidence inference).** `src/lib/auth.ts:15` and `src/proxy.ts:8-9` fall back to the public constant `"dev-session-secret-change-in-production-must-be-32-chars"` whenever `SESSION_SECRET` is unset — and the shipped `docker-compose.yml` passes `${SESSION_SECRET}`, which is empty by default. Anyone with the source can forge an iron-session cookie (`{isAuthenticated:true, sessionGeneration:1}`) and fully bypass authentication on such deployments.
### High
3. **FIN-03 — "Retake Missed" persists as a full attempt with a wrong lower score** (in-viewer retake never sets the `retakeIds` prop; server scores all unanswered questions as 0) and pollutes SEQUENTIAL progress.
4. **FIN-04 — Deleting a card mid-session bricks the flashcard study session** (`filterAndClampOrder` is dead code; resume never filters stale ids; no skip UI).
5. **FIN-05 — Production compose is unreachable**: container listens on 3726 (`ENV PORT=3726`), compose publishes 3000:3000.
6. **FIN-06 — No `.dockerignore`**: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image.
7. **FIN-07 — `docker-compose.override.yml` auto-merges**: plain `docker compose up` silently runs dev mode in production.
### Medium (selected)
First-login provisioning takeover + dead `ADMIN_PASSWORD_HASH` (FIN-08); proxy auth bypass for dot-containing paths (FIN-09); password-reset abuse (FIN-10); unvalidated attempt `answersJson` (FIN-11); finish double-submit duplicates attempts (FIN-12); progress PATCH/DELETE race (FIN-13); unsequenced progress autosaves (FIN-14); completed session resumes on last card (FIN-15); SRS review endpoint lacks due/new-card-limit enforcement (FIN-16); unbounded DB scans (FIN-17); library optimistic mutations without rollback (FIN-18); unvalidated `groupId`/`sortOrder` incl. cross-class group membership (FIN-19); shared-quiz reshuffle on re-render (FIN-20); unvalidated `/api/progress` + unguarded `JSON.parse` crashes (FIN-21); entrypoint migrate fragility (FIN-22); non-reproducible installs (FIN-23); no healthcheck/backup (FIN-24); CI ships without tests/lint/prisma/smoke gates (FIN-25).
### Low / Observation
25 Low (e.g. SATA div-by-zero NaN, historical score recompute, card-edit endpoints without Zod, no import size caps, sortOrder duplicates after group deletion, keyboard cross-group moves impossible, restart DELETE race, P2002 → 500, git history containing `dev.db`, `.gitignore` gaps, dashboard fetch crash, empty slug from `slugify`, shared-viewer localStorage key collisions) and 3 Observations (group sortOrder desc convention, collapse flash, shared quizzes ship the answer key by design).
## Verification highlights
- FIN-01 disproved attempts: checked whether the client avoids selecting `groupId` (it doesn't — generated `Deck.ts` includes it in every payload), whether pages swallow errors (they don't), and whether any migration creates the objects (grep = 0 hits). Confirmed via live probes instead: see `audit-results/tmp/reproduce-drift.cjs` output in VERIFICATION_LOG.md.
- Adversarial review **refuted** the frontend auditor's "no auth on API routes" claim (proxy is compiled middleware — verified in `.next` artifacts), **weakened** the entrypoint crash-loop framing (fresh-volume `migrate deploy` succeeds), and downgraded the optimistic-reorder severity. It independently confirmed all other Critical/High findings and added 10 missed issues, merged into the final list.
## Repository areas NOT fully audited
1. Arcade feature internals (out of scope; shared-DB/build/deploy impact only — verified in migrations).
2. Real browser interaction (no browser available): drag-drop, resume flows, share pages, keyboard a11y, themes — code-verified; flagged for runtime confirmation in UNVERIFIED_RISKS.md.
3. Container build/run (docker CLI unavailable): FIN-06, FIN-22, FIN-24 need a container run.
4. Deep git-history diff of every commit; full content audit of the committed `dev.db` blobs (no sqlite3 CLI; no credentials found in the checked blobs).
5. Runtime performance on a populated database (FIN-17 not measured).
6. CI/remote execution (Forgejo pipeline analyzed statically).
## Recommended priority (when fixes are authorized)
1. **FIN-01** — generate and commit the missing migration (`prisma migrate dev --name add_material_groups`); add a CI drift gate. Everything else depends on a working fresh deployment.
2. **FIN-02 / FIN-05 / FIN-06 / FIN-07** — harden the deployment path: require `SESSION_SECRET`, fix the port mapping, add `.dockerignore`, rename the dev override, and smoke-test the image in CI.
3. **FIN-03 / FIN-04** — the two most user-visible application bugs (wrong retake scores; stuck flashcard sessions).
4. Then the Medium cluster (validation, races, SRS enforcement, progress handling) and finally Low items + TEST_GAPS.md.

1024
audit-results/FINDINGS.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,15 @@
# Rejected Findings
Findings that were investigated and disproven, or removed as duplicate/stylistic/insignificant, with the reason.
| ID | Original claim | Rejection reason |
|---|---|---|
| FE-06 | "All `/api/*` routes are unauthenticated (no middleware, no per-route checks) — curl without a session cookie returns data" | **Refuted by adversarial review + parent verification.** `src/proxy.ts` IS the compiled Next.js 16 middleware (verified in `.next/server/functions-config-manifest.json`, `middleware.js`, and the compiled chunk; build output shows "ƒ Proxy (Middleware)"). It decrypts the iron-session cookie and enforces `isAuthenticated` + `sessionGeneration` on every path except `/login`, `/api/auth`, `/shared`, `/_next*`, `/favicon*`, and paths containing `.`. The auditor searched for `middleware.ts` (the Next 15 name) and missed `proxy.ts`. The residual truth is the dot-bypass (FIN-09). The claim that "no frontend code handles 401s because there is no auth" is also wrong — there is no 401 because the proxy redirects to /login. |
| QUIZ-06 (standalone) | "Quiz resume with stale question ids crashes the viewer" | Merged into FIN-04. The quiz-side path is currently unreachable (no question editing/deletion exists in the app; quiz deletion cascades progress), so it is a latent variant of the same root cause (dead `filterAndClampOrder`), not a separate live defect. |
| OPS-02 (original framing) | "Fresh-volume deployment crash-loops because `migrate deploy` fails" | **Weakened by adversarial review.** On a fresh volume `prisma migrate deploy` *succeeds* (verified against a temp DB); the fresh-volume failure is FIN-01 (schema drift → app 500s while the container runs). The crash loop requires a secondary trigger (db-push DB, tampering, lock). Reframed as FIN-22 with the crash-loop path demoted. |
| FE-01 (original severity) | Optimistic drag-drop reorder without rollback rated High | **Severity downgraded to Medium by adversarial review.** Mechanics confirmed (fire-and-forget fetch, in-place mutation, origin group omitted from cross-group payload) but impact analysis: sortOrder gaps only, silent divergence self-heals on reload, no visible break for a single user. Merged into FIN-18. |
| AUTH-06 (standalone) | "Cross-class group membership via reorder API exposes another class's content" | Merged into FIN-19 (same root cause: `groupId`/`sortOrder` never validated server-side). The sharing-exposure angle is recorded as impact of FIN-19. |
| DBAUD-04 | "Relative SQLite path resolution may cause dev dual-DB split-brain" | **Disproven.** CLI (`prisma.config.ts`) and app adapter both resolve `file:./dev.db` cwd-relative; `predev` and the app run from the project root → same file. Docker uses absolute `file:/app/data/study.db`. Recorded as verified-safe in UNVERIFIED_RISKS.md. |
| DBAUD-05/06/07, QUIZ-08-verdicts, GRP-01-verdicts (various) | "Cascades broken / transactions partial / StudyProgress duplicates via NULL tricks / id regeneration on card edit / XSS via markdown / client-server boundary violations" | **Disproven** by the respective auditors with evidence: FKs enforced by compile default; nested creates implicit-transactional; service-level guards block duplicates; PATCH edits in place (ids preserved); no `rehype-raw`; no server-module imports in client components. Recorded as verified-safe. |
| FE-11 (as bug) | "Collapsed-groups expand flash" | Retained only as Observation (FIN-51) — cosmetic, no data impact. |
| Various | Style preferences, harmless duplication, generic best practices, speculative micro-optimizations, Arcade-only issues | Excluded per audit scope rules. |

View file

@ -0,0 +1,340 @@
# Study Desk audit evaluation and remediation plan
Date: 2026-08-06
Scope: independent evaluation of `CONFIRMED_FINDINGS.md`, `FINDINGS.json`, the supporting audit reports, and the current repository source.
Change policy for this evaluation: this plan is the only new file; no application source, tests, migrations, configuration, generated files, database, or existing audit artifact was changed.
## Executive verdict
The audit is credible and the central findings hold up against the current source. The most urgent problems are not cosmetic:
1. The committed migration chain does not create `MaterialGroup` or the three `groupId` columns, while the generated Prisma client selects those fields. A database created only with `prisma migrate deploy` is incompatible with the app.
2. Production authentication can use a public fallback session secret, making a deployment with an empty `SESSION_SECRET` forgeable.
3. The in-viewer quiz retake flow loses the retake scope and records the retake as a full, incorrectly scored attempt.
4. Flashcard resume trusts stale IDs and unguarded JSON, so deletion or malformed progress can leave the study viewer blank or crash it.
5. The production container path is presently unreliable: the port mapping is wrong without the development override, the conventional override silently changes normal Compose behavior, the build context can include host `node_modules`, and the runner installs a ranged Prisma CLI outside the lockfile.
I agree with most of the remaining findings as defects or resilience gaps, but not always with their severity or proposed remedy. In particular:
- FIN-04 needs more than wiring in the existing `filterAndClampOrder`: that helper does not preserve the logical current card when a stale ID occurs before the saved index, and it turns a completed index back into the last card.
- FIN-05 should normally use `3000:3726`, preserving the documented host port while mapping it to the actual container port.
- FIN-22 should not be fixed with an automatic migration retry loop. Schema conflicts should fail fast with actionable diagnostics and a documented recovery path.
- FIN-27 is more important than Low because changing quiz content can rewrite the meaning of persisted history.
- FIN-34 is only partially present: the dedicated deck page already awaits both DELETE requests, but the library start/restart path fires them without waiting.
- FIN-38 is not currently a defect for this Arizona-based installation. It becomes a portability requirement only if the app is meant to support a configurable local day boundary.
- FIN-42 does not justify a destructive history rewrite without evidence that the old database contained sensitive data and without coordinating every clone/remote.
- FIN-50 is an intentional convention. Newest named groups must remain first and Uncategorized must remain last; validation should make that convention harder to corrupt.
- FIN-52 is an architectural consequence of local grading in an anonymous read-only viewer. It merits a sharing warning, not removal of the answer key unless the product adopts server-side anonymous grading.
## Finding-by-finding disposition
Priority definitions: P0 = release/deployment blocker; P1 = next remediation batch; P2 = important hardening; P3 = worthwhile backlog; Conditional = verify a stated condition before changing behavior; No separate fix = intentional behavior or covered by another item.
| ID | Verdict | Worth fixing? | Priority and disposition |
|---|---|---:|---|
| FIN-01 | Agree | Yes | **P0.** Add the missing migration plus a safe adoption runbook for already-`db push`-synchronized databases. |
| FIN-02 | Agree, conditional on an unset/blank production secret | Yes | **P0.** Production must fail before serving requests; development may use an explicitly development-only value. |
| FIN-03 | Agree | Yes | **P1.** Make attempted-question scope explicit and derive partial-retake status on the server. |
| FIN-04 | Agree; proposed helper is insufficient as written | Yes | **P1.** Normalize order, index, and results while preserving the logical current card and completed state. |
| FIN-05 | Agree | Yes | **P0.** Map host 3000 to container 3726 and smoke-test it. |
| FIN-06 | Agree with the mechanics; exact runtime failure is unconfirmed | Yes | **P0.** Add `.dockerignore` and prove native modules load in the Linux image. |
| FIN-07 | Agree | Yes | **P0.** Rename the development override and document explicit dev/prod commands. |
| FIN-08 | Partly agree: first-login setup is intentional, accidental exposure and dead config are real | Yes | **P1.** Honor a pre-provisioned admin hash or require an explicit one-time setup mode. |
| FIN-09 | Agree; latent with current IDs | Yes | **P1.** Replace the broad dot bypass with exact public/static path rules. |
| FIN-10 | Agree | Yes | **P1.** Prevent token overwrite, remove spoofable per-IP trust, and make reset initiation a local/admin operation. |
| FIN-11 | Agree | Yes | **P1.** Validate the whole attempt envelope, question scope, option ownership, array uniqueness, and types. |
| FIN-12 | Agree | Yes | **P1.** Add an in-flight guard, disable Finish, and surface failure/retry state. |
| FIN-13 | Agree as a credible timing race | Yes | **P1.** Persist the attempt and clear full-attempt progress in one server transaction. |
| FIN-14 | Agree as a credible timing race | Yes | **P2.** Add session identity and monotonic revisions, not only client debouncing. |
| FIN-15 | Agree | Yes | **P1.** Persist `currentIndex === order.length` and restore the summary state. |
| FIN-16 | Agree; impact is lower in a protected single-user app | Yes | **P2.** Accept only the queue-eligible card, including the intended learn-ahead rule. |
| FIN-17 | Agree that the queries are unbounded; user impact is unmeasured | Conditional | **P3.** Benchmark realistic data first, then optimize queries that exceed the budget. |
| FIN-18 | Agree | Yes | **P2.** Stop mutating shared objects, capture the origin group before mutation, await writes, and rollback/refetch on error. |
| FIN-19 | Agree | Yes | **P1.** Enforce class/type/group membership and server-owned ordering; retain a share-page defense. |
| FIN-20 | Agree | Yes | **P1.** Give the viewer a stable session input so unrelated parent renders cannot reset it. |
| FIN-21 | Agree | Yes | **P1.** Validate progress writes and defensively normalize legacy/corrupt progress on reads. |
| FIN-22 | Partly agree with the operational gap, not the retry remedy | No separate fix | Fail fast; cover CLI pinning in FIN-23 and recovery/health in FIN-24. Do not retry a conflicting migration automatically. |
| FIN-23 | Agree | Yes | **P0.** Use `npm ci` and ship a lockfile-pinned Prisma CLI/runtime path. |
| FIN-24 | Agree | Yes | **P1.** Add a schema-aware health check and tested backup/restore instructions. |
| FIN-25 | Agree | Yes | **P0/P1.** Gate test, build, Prisma validation/drift, and container smoke. Stage lint until its known baseline is resolved. |
| FIN-26 | Agree | Yes | **P1.** Return zero for an invalid zero-correct SATA question and test finite totals. |
| FIN-27 | Agree; severity should be Medium | Yes | **P2.** Persist an immutable result/review snapshot for new attempts, with legacy fallback. |
| FIN-28 | Agree | Yes | **P2.** Reuse a trimmed card-content schema and reject rather than silently drop incomplete Create-tab rows. |
| FIN-29 | Agree | Yes | **P2.** Add reasonable string, array, and request-size limits with clear 400/413 errors. |
| FIN-30 | Agree that the contract is inconsistent | Yes | **P2.** Make SATA imports require at least two correct options, matching the shipped generation instructions; preserve legacy stored data. |
| FIN-31 | Agree | Yes | **P2.** Validate complete request envelopes and map only actual not-found errors to 404. |
| FIN-32 | Agree | Yes | **P2.** Delete/reassign/renumber in one transaction and add deterministic item tie-breaks. |
| FIN-33 | Agree | Yes | **P2.** Add an explicit keyboard-accessible “Move to group” action; do not depend on drag gestures. |
| FIN-34 | Partly agree | Yes | **P2.** Fix the library path that does not await deletion; make deletion session-aware with FIN-14. |
| FIN-35 | Agree | Yes | **P3.** Translate concurrent first-review uniqueness conflicts to 409 and refetch. |
| FIN-36 | Agree as a credible timing race | Yes | **P2.** Disable navigation while grading and cancel/ignore stale animation callbacks. |
| FIN-37 | Agree | Yes | **P3.** Refresh set/deck membership on focus and after local deck-change events. |
| FIN-38 | Factually correct, but Arizona is the current intended boundary | Not now | Keep the existing behavior. Revisit only with an explicit portability requirement and a timezone setting/migration plan. |
| FIN-39 | Agree about the lost clear-cookie response; layout impact is overstated because proxy checks generation | Yes | **P2.** Destroy the cookie on the actual redirect response and test its header. |
| FIN-40 | Deployment-policy gap, not a bug on intentional HTTP | Yes, as documentation/config | **P1.** Document a TLS proxy profile and `SECURE_COOKIES=true`; do not enable Secure cookies on plain HTTP. |
| FIN-41 | Agree | Yes | **P1.** Validate a strict target enum and target existence; reject impossible/null-target rows. |
| FIN-42 | Agree that blobs exist; sensitivity is unproven | Conditional | Do not rewrite history now. Inventory the old DB offline before any public remote; rewrite only with explicit coordination if sensitive data is found. |
| FIN-43 | Partly agree | Yes | **P3.** Ignore `data/` and production DB sidecars. Confirm ownership, then remove unreferenced CSS scratch files. Do not automatically ignore the audit plan. |
| FIN-44 | Agree | Yes | **P2.** Add `res.ok`, error, and retry handling; never present a failed load as an empty dashboard. |
| FIN-45 | Agree | Yes | **P3.** Add abort/error handling to settings/share fetches. |
| FIN-46 | Agree as a credible timing race | Yes | **P2.** Use `AbortController` or a request generation tied to `classSlug`; remove or bound the module cache. |
| FIN-47 | Agree | Yes | **P3.** Show logout failure and retry; do not falsely claim logout if the server call failed. |
| FIN-48 | Partly agree | Yes | **P2.** Guarantee a non-empty unique slug. Keep slugs stable on rename unless redirect/history semantics are deliberately designed. |
| FIN-49 | Agree; impact is same-browser only | Yes | **P3.** Include the share token in local session keys and migrate/ignore old keys safely. |
| FIN-50 | Intentional newest-first convention | No separate fix | Preserve descending named-group order and final Uncategorized placement; restrict arbitrary sort-order writes under FIN-19/31. |
| FIN-51 | Agree, cosmetic only | Not now | Defer unless it can be removed without hydration mismatch or new effect-rule violations. |
| FIN-52 | Expected local-grading design | Warning only | **P3.** Explain in ShareMenu that recipients can inspect correct answers; do not imply answer secrecy. |
| FIN-53 | Agree | Yes | **P1.** Replace the false-confidence assertion and add scoring/scheduling edge cases. |
## Remediation sequence
### Phase 0 — safeguards and test foundation
Do this before behavior or schema changes.
1. Create a database-backed Vitest harness that always points Prisma at a unique disposable SQLite path. It must refuse to run if the resolved path is `dev.db`, `/app/data/study.db`, or any existing non-test database.
2. Add helpers to apply committed migrations to the disposable database and dispose of it after the test process.
3. Record the current lint baseline separately. Do not make a failing baseline a nominally “green” CI gate, and do not mix unrelated lint cleanup into P0 fixes.
4. Add focused pure tests first for scoring, progress normalization, import schemas, and rate limiting. These give fast feedback before route/service integration tests.
5. Before testing any real deployment database, make and verify a restorable backup. No remediation command should run against the only copy.
Exit criteria:
- Tests demonstrably create and use only a disposable DB.
- A deliberate attempt to point the harness at the real DB aborts.
- Existing `npm.cmd test` remains green.
### Phase 1 — restore a safe, reproducible deployment path
Addresses FIN-01, FIN-02, FIN-0510, FIN-2325, FIN-39, and FIN-40.
#### 1A. Repair migration drift without breaking already-pushed databases
1. Generate a new migration; never edit the four applied migrations. It must add:
- `MaterialGroup` with its Class cascade foreign key.
- nullable `groupId` on Deck and QuizSet with `ON DELETE SET NULL`.
- nullable unique `groupId` on ShareLink with `ON DELETE CASCADE`.
- the indexes Prisma expects.
2. Review the generated SQLite table-rebuild SQL by hand for preserved rows, foreign keys, defaults, and unique indexes.
3. Publish a one-time preflight/adoption script that classifies a database as:
- migration-tracked and missing the group schema: apply the migration normally;
- already schema-pushed and exactly matching the intended DDL: after backup and exact introspection, mark the new migration applied with `prisma migrate resolve --applied`;
- partially matching or otherwise inconsistent: stop with diagnostics and require manual recovery; never guess or auto-resolve.
4. Keep the adoption operation explicit. The normal entrypoint should not silently mutate migration history based on table existence.
Verification:
- Fresh empty DB: `prisma migrate deploy` succeeds; `prisma migrate diff --from-migrations ... --to-schema ...` is empty; Deck, QuizSet, ShareLink, and MaterialGroup queries succeed.
- Populated pre-group DB: seed classes/decks/quizzes/shares, apply the new migration, and prove every row/count/relationship is preserved.
- Disposable `db push`-style DB: introspect exact equivalence, resolve the migration, run deploy again, and prove an empty schema diff.
- Partial/conflicting DB: preflight exits nonzero without changing schema or `_prisma_migrations`.
#### 1B. Make session configuration unforgeable and setup explicit
1. Centralize session option construction so `src/lib/auth.ts` and `src/proxy.ts` cannot drift.
2. Require a nonblank, at-least-32-character production `SESSION_SECRET` at runtime. Do not require or bake the real secret during `docker build`.
3. Make the container entrypoint fail before migrations/server startup when the secret is absent or equals the known fallback. Make Compose use required-variable expansion.
4. Allow a development-only fallback only when `NODE_ENV !== "production"`, clearly label it non-production, and cover both branches with tests.
5. Replace `pathname.includes(".")` with exact public/static rules. Ensure every `/api/*` path other than the intentional auth endpoints remains protected even when the URL contains a dot or encoded dot.
6. Construct the unauthenticated redirect first and bind `getIronSession` to that response before `destroy()`, so the returned redirect carries the clearing cookie.
7. Honor a valid `ADMIN_PASSWORD_HASH` when the database has no configured password, or require an explicit one-time setup flag/token. The default production state must not let the first remote request select the admin password.
8. Prefer a local console/CLI initiation for password reset. If the HTTP request endpoint remains, use a global single-user throttle, never replace an unexpired token, rate-limit verify/complete, and do not trust arbitrary `x-forwarded-for` unless a trusted proxy is explicitly configured.
Verification:
- Production startup with missing, blank, short, or known fallback secret exits nonzero before serving.
- A cookie sealed with the old fallback does not authenticate when a real secret is configured.
- Valid login, logout, reset completion, and session-generation invalidation still work.
- Dot-containing protected page/API requests redirect or reject; known static assets still load.
- A stale-generation response contains a `Set-Cookie` deletion header.
- Pre-provisioned hash rejects a different first password and reports setup complete.
#### 1C. Make the image and Compose definitions deterministic
1. Add `.dockerignore` for `node_modules`, `.next`, VCS metadata, environment files, databases/data, coverage, temporary caches, and audit scratch artifacts while retaining source, Prisma schema/migrations, lockfile, and public assets.
2. Change the dependency stage to `npm ci`.
3. Remove `npm install prisma@^7.8.0` from the runner. Ship a Prisma CLI/runtime installed from `package-lock.json` at the exact repository version. If Prisma is needed at runtime, classify it as a runtime dependency and prune/copy dependencies deterministically rather than re-resolving them.
4. Map `3000:3726` in production Compose. Keep `PORT=3726` and `EXPOSE 3726` internally unless there is a deliberate decision to standardize everything on 3000.
5. Rename `docker-compose.override.yml` to a non-auto-merged development filename such as `docker-compose.dev.yml`. Document exact production and development invocations.
6. Add a minimal internal health route that performs schema-aware DB checks (including a query that touches `Deck.groupId` and MaterialGroup), returning no sensitive details. The container health check must call the actual internal port.
7. Add a lock-safe SQLite backup command using the SQLite backup API, plus a documented restore drill. A raw copy of only the main DB while WAL writes are active is not an acceptable backup procedure.
Verification:
- `docker compose -f docker-compose.yml config` shows the production command, production environment, and `3000:3726` only.
- The explicit development Compose command shows dev mode and source mounts.
- Build on a Windows-host context and run on Linux; requiring `better-sqlite3` and `argon2` succeeds in the final image.
- Fresh-volume container becomes healthy and `/login` returns 200 through host port 3000.
- A migration-drifted disposable volume stays unhealthy with an actionable log.
- Backup a populated disposable volume, destroy the disposable container/volume, restore it, and compare row counts plus representative content.
#### 1D. Add CI gates in a sequence that can actually be green
1. Gate `npm ci`, `npm test`, Prisma validate, migration/schema drift, and `npm run build` immediately.
2. Build the production image, start it on a fresh disposable volume with a generated CI secret, wait for health, and smoke `/login` plus setup status.
3. Tag immutable images by commit SHA; optionally move the mutable `latest` tag only after all gates pass.
4. Add strict lint only after the existing 9-error baseline is resolved in an explicit cleanup or after a changed-files lint gate is implemented. Never report baseline lint as passing.
### Phase 2 — make quiz attempts authoritative and immutable
Addresses FIN-03, FIN-1113, FIN-20, FIN-26, FIN-27, and FIN-53.
1. Replace the client-controlled `isPartialRetake` contract with an explicit ordered `questionIds` scope. The server validates that IDs are unique and belong to the quiz, then derives whether the attempt is partial.
2. Accept a structured answers object at the route boundary and persist only canonical server-serialized JSON. Validate that:
- each key is in the attempted scope;
- every selected option belongs to that question;
- selected IDs are unique;
- multiple-choice has at most one selection;
- missing answers score zero rather than silently disappearing.
3. Make the in-viewer “Retake Missed” and “Retake Full Quiz” set explicit local attempt scope. Do not infer retake state from the original prop after the viewer has transitioned internally.
4. Add an `isFinishing` state/ref, disable the Finish button during submission, and show a retryable error without discarding answers.
5. Move attempt creation and full-attempt SEQUENTIAL-progress deletion into one service transaction. Partial retakes must not overwrite or delete a full in-progress session.
6. Guard zero-correct SATA scoring with zero points and deduplicate selections defensively in the pure scorer even though the route also validates them.
7. Add an optional immutable review snapshot to QuizAttempt for new attempts: attempted question/order, prompt/category/rationale, options and correctness, selections, and per-question points. Render new history from that snapshot; keep a clearly tested legacy fallback for old rows.
8. Stabilize QuizViewer initialization. A parent render that changes topics or another surrounding control must not regenerate option order, clear answers, or reset the index. A deliberate restart/retake must use a new session key.
Focused verification:
- Full 5-question attempt scores out of 5 and clears only its matching full progress row.
- Two-question retake scores out of 2, is stored as partial, and leaves full progress untouched.
- An unanswered question in the explicit scope scores zero and remains in the review snapshot.
- Duplicate/unknown question or option IDs, wrong shapes, and client-forged partial flags return 400 without creating an attempt.
- Delayed double-click Finish produces exactly one attempt.
- Changing or deleting current quiz content after an attempt does not change the stored score, category breakdown, or review copy for snapshot-backed attempts.
- Parent rerender preserves question order, option order, current index, and answers; explicit restart changes only the intended session.
### Phase 3 — make progress and flashcard resume race-safe
Addresses FIN-04, FIN-14, FIN-15, FIN-21, FIN-34, and FIN-36.
1. Replace or correct `filterAndClampOrder` with a normalization function that accepts saved order, live IDs, saved index, results/answers, and completion state. It must:
- remove stale and duplicate IDs;
- preserve the saved current ID when it still exists;
- when it was deleted, select the next surviving saved card, otherwise the previous survivor;
- preserve `index === oldOrder.length` as completion by returning `index === newOrder.length`;
- filter result/answer keys to live IDs;
- safely fall back when any JSON field is malformed.
2. Apply normalization before any viewer state initializer calls `JSON.parse`. Treat corrupt legacy progress as recoverable, show a small restore warning, and allow a fresh start.
3. Add Zod schemas for GET/PATCH/DELETE progress inputs, enum values, nonnegative index, content-specific JSON shape, and referenced content existence.
4. Add `sessionId` and monotonic `revision` to progress. The server accepts only a newer revision for the same session; a stale session cannot overwrite or delete a newer session. Serialize client saves but retain server-side revision enforcement because request completion order is not guaranteed.
5. On the last flashcard, persist `currentIndex = order.length`. Restoring that row must show the existing completion summary, not the last card.
6. Await restart deletion before remount/navigation. Make DELETE conditional on the session being cleared so a late old request cannot delete fresh progress.
7. Disable Previous, shuffle, and repeated grading during the 350 ms transition, and clear/cancel the timer on restart/unmount.
Focused verification:
- Stale ID before, at, and after the current index all resume on the correct logical card.
- All saved cards deleted yields a clear empty/completed state rather than a blank viewer.
- Completed sessions reopen on the summary; restart opens card 1.
- Invalid JSON and invalid PATCH payloads produce a safe UI fallback or 400, never a render crash/500.
- Deliver revisions 3, 1, and 2 in that order; the DB retains revision 3.
- Delay an old-session DELETE until after a new-session PATCH; the new session remains.
- Grade then immediately try Previous/restart; no index jump or stale result write occurs.
### Phase 4 — enforce group, ordering, and sharing invariants on the server
Addresses FIN-18, FIN-19, FIN-32, FIN-33, FIN-41, FIN-49, FIN-50, and FIN-52.
1. Move reorder logic into focused services. The route must identify the owning class and content type from the database, not trust the client.
2. Validate every target group exists, belongs to the same class, and matches DECK/QUIZ type. Reject duplicate IDs, foreign-class IDs, type mismatches, and unknown IDs.
3. Prefer an ordered list/group assignment contract and compute contiguous `sortOrder` values server-side. If a complete-set contract is required, verify completeness explicitly.
4. In the library, capture origin group before any update, update state immutably, await `res.ok`, disable conflicting mutations in flight, and rollback/refetch on failure.
5. Delete a group in one transaction: capture affected items, delete/reassign through the FK behavior, then renumber Uncategorized items deterministically. Add `createdAt`/`id` tie-breakers to reads so legacy duplicates are stable.
6. Add a keyboard-accessible Move-to-group menu on every item. Preserve drag-and-drop for pointer users and keep drag listeners off action controls.
7. Validate ShareLink target type and target existence. Enforce exactly one populated target in service logic; consider CHECK constraints in a later SQLite migration after compatibility testing.
8. Retain the shared-page class/type/token checks and additionally assert each selected group item has the groups class and expected content type.
9. Pass the share token/session namespace to shared viewers and include it in localStorage keys. Avoid copying an old item-only session into a different token namespace without explicit user confirmation.
10. Preserve named-group ordering as `sortOrder desc, createdAt desc`, prepend newly created groups locally, and render Uncategorized last. Remove arbitrary `sortOrder` from the ordinary group rename PATCH contract.
11. Add a concise ShareMenu warning that a shared quiz necessarily sends answer/rationale data to the recipients browser for local grading.
Focused verification:
- Cross-class and cross-type group assignment returns 400/409 and changes no rows.
- A failing reorder restores/refetches the visible order.
- Deleting a group yields unique contiguous Uncategorized item order and does not delete decks/quizzes.
- Newer named groups stay above older groups; Uncategorized remains last in quizzes, flashcards, and import selectors.
- A keyboard-only user can move an item between two groups and hear/see confirmation.
- Invalid share target types and missing IDs create zero rows.
- A tampered group share cannot render a foreign-class item.
- Two tokens for the same content maintain independent local sessions.
### Phase 5 — validation, SRS integrity, and user-visible error handling
Addresses FIN-16, FIN-2831, FIN-35, FIN-37, FIN-4348, and the measurable part of FIN-17.
1. Reuse shared Zod schemas for card create/edit, deck/quiz create/edit, classes, imports, and material groups. Trim before minimum checks and add documented maximums for names, descriptions, Markdown content, options, questions, and cards.
2. Reject a Create-tab submission containing any partially filled card; identify the row(s) instead of silently dropping them.
3. Enforce at least two correct options for imported SATA questions while leaving existing stored questions readable. Add a targeted message explaining how to repair invalid generated JSON.
4. Narrow Prisma error mapping: P2025 becomes 404, P2002 becomes 409 where appropriate, validation is 400/422, and unexpected failures remain 500 with non-sensitive server diagnostics.
5. In SRS review, verify the submitted card is the queue-eligible card for the current set/time/new-card allowance (including deliberate learn-ahead). Retain the existing state-version comparison. Map concurrent first-review P2002 to 409 and return/refetch current study state.
6. Refresh SRS membership on focus and after same-tab deck changes.
7. Add abortable, checked fetch helpers or a small consistent pattern for Dashboard, GenerateTab, ShareMenu, logout, and class library requests. Distinguish loading, empty, error, and retry states.
8. Tie library responses to the active `classSlug`; abort or ignore stale responses. Bound or remove module-level caches that can outlive their class data.
9. Guarantee class slug generation produces a non-empty unique slug, for example a stable `class-<short-id>` fallback. Keep existing slugs stable on rename.
10. Add `data/`, `study.db`, and sidecars to `.gitignore`. Confirm `out.css`, `temp.css`, and `test.css` are unreferenced scratch artifacts before removing them in a separate cleanup change.
11. For performance, first seed a disposable benchmark DB approximating expected upper use (for example 100k activity rows, 10k cards, and several SRS sets). Add date predicates/aggregation and narrower SRS queries only where the measured request/poll budget is exceeded. Preserve long-streak correctness when limiting the activity window.
Focused verification:
- Whitespace-only and oversized content is rejected with an actionable client error and no partial write.
- SATA 1-correct import fails; 2+-correct passes; multiple-choice still requires exactly one.
- SRS cannot review a not-due/non-selected/new-limit-exhausted card; valid due and learn-ahead reviews still work.
- Concurrent first review produces one success and one handled conflict, not a generic 500.
- Focus refresh removes a deleted deck from the SRS set UI.
- Failed dashboard/settings/share/logout requests have visible, retryable states and no unhandled promise rejection.
- Rapid A→B class navigation cannot render A data under B.
- Creating a class named only punctuation yields a navigable, unique slug; rename does not break the old URL.
- Performance tests record query counts and latency before/after and prove the 53-week display plus longer current streak remain correct.
## Cross-cutting release verification
Run after every phase, with focused tests first and the full suite last.
Automated gates:
1. `npm.cmd test`
2. `npx.cmd prisma validate`
3. migration deploy + empty migration/schema diff against a fresh disposable DB whenever schema changes
4. `npm.cmd run build`
5. `git diff --check`
6. production image build, fresh-volume health wait, and HTTP smoke when Docker/deployment files change
7. lint against changed files; full `npm.cmd run lint` only becomes blocking after the known baseline is cleared
Manual browser matrix for affected phases:
1. Authenticated routes and corresponding public/shared routes.
2. Fresh, resumed, completed, restarted, partial-retake, and stale/deleted-content sessions.
3. Loading, empty, server-error, malformed-persisted-data, and retry states.
4. Light and dark themes.
5. Narrow mobile viewport with no horizontal overflow.
6. Keyboard-only navigation, including group moves and all modified icon controls.
7. Throttled-network checks for Finish, autosave, restart, class navigation, reorder, and logout.
8. Two-tab checks for progress revisions, SRS conflicts, and focus refresh.
Release/rollback rules:
- Back up and restore-test the SQLite database before the first migration-bearing release.
- Deploy schema and code as one versioned release; do not run a newer generated Prisma client against an older database.
- Keep the prior image and verified pre-migration backup until post-deploy smoke and representative data checks pass.
- If migration preflight sees a partial or unknown schema state, stop. Do not run `db push`, edit an applied migration, reset the database, or auto-mark the migration applied.
## Explicitly deferred or rejected work
- **FIN-38:** no timezone-setting work without a product requirement to support a non-Arizona study day.
- **FIN-42:** no history rewrite without an offline sensitivity review and explicit remote/clone coordination.
- **FIN-51:** no cosmetic state-initialization change unless it avoids both hydration mismatch and the existing effect-rule class of lint failures.
- **FIN-22 retry proposal:** no automatic migration retry/backoff for schema conflicts; fail fast with health diagnostics and the adoption/recovery runbook.
- No changes to the audits already rejected hypotheses (global API auth absence, split-brain SQLite paths, broken cascades/transactions, Markdown XSS, or client/server import violations) unless new evidence appears.
## Recommended delivery slices
Keep the implementation reviewable rather than landing all remediation at once:
1. **Release blocker:** FIN-01/02/05/06/07/23/25 plus migration adoption, container smoke, and backup prerequisites.
2. **Quiz correctness:** FIN-03/11/12/13/20/26/27/53.
3. **Progress integrity:** FIN-04/14/15/21/34/36.
4. **Group/share integrity:** FIN-18/19/32/33/41/49/50/52.
5. **Auth recovery and deployment posture:** FIN-08/09/10/24/39/40.
6. **Validation, SRS, UI resilience, and measured performance:** the remaining accepted items.
Each slice should be independently releasable, have its own focused regression tests, and finish with the cross-cutting gates above.

View file

@ -0,0 +1,20 @@
# Subagent Reports
Full specialist reports were produced by 10 read-only subagents. Each returned a structured markdown report with a findings table and per-finding detail blocks (ID, severity, confidence, files:lines, scenario, expected/actual, root cause, impact, evidence, test coverage, fix direction, complexity, regression test, runtime-confirmation flag). The parent agent re-verified the Critical/High claims directly (see VERIFICATION_LOG.md) and consolidated/deduplicated into `FINDINGS.json` (53 findings).
## Report index (all reports were reviewed in full by the parent)
| # | Auditor | Ref | Key output |
|---|---|---|---|
| 1 | DB / migrations / transactions / cascades / drift | sa_20260806_054030_000000000_5ed877563a0d | **DBAUD-01 Critical migration drift** (MaterialGroup/groupId never migrated; commit 7af0935 db-push artifact); DBAUD-02 junk ShareLink rows; DBAUD-03 dev.db in git history; verified-safe: cascades, transactions, path resolution, single-row AuthSecurity |
| 2 | Quiz scoring / attempts / retakes / progress | sa_20260806_054030_000000000_e75a8ce575f9 | **QUIZ-01 High retake-scoring bug** (in-viewer retake never sets retakeIds); QUIZ-02..09 (unvalidated answersJson, double-submit, progress races, dead filter, div-by-zero, historical recompute, multi-tab) |
| 3 | Flashcards / SRS / progress / deletion / ordering | sa_20260806_054030_000000000_a222804c133c | CARD-01..11: dead stale-id filter (stuck sessions), unsequenced saves, completed-session resume, SRS due/limit not enforced, unbounded scans, Arizona day boundary, restart race, P2002, animation race, focus staleness |
| 4 | Auth / protected routes / API authz / sharing | sa_20260806_054030_000000000_aab0c59dbad3 | **AUTH-01 Critical hardcoded session secret**; AUTH-02 dot-bypass; AUTH-03 reset abuse; AUTH-04 first-login takeover + dead ADMIN_PASSWORD_HASH; AUTH-05..10 (cookie-clear loss, cross-class group membership, share validation, secure flag, no auth tests, answer-key exposure); verified proxy compiled as middleware |
| 5 | Imports / exports / generation / Zod / malformed input | sa_20260806_054030_000000000_5e72c2713989 | IMPT-01..06: dead filterAndClampOrder, unvalidated /api/progress + unguarded JSON.parse, card edit endpoints without Zod, no size caps, SATA doc mismatch, override-field 500s; verified: shared schemas, atomic imports, no XSS, no id regeneration |
| 6 | Material groups / ordering / drag-drop / deletion / orphans | sa_20260806_054030_000000000_9ae1f3dfa282 | **GRP-01 Critical drift** (independent confirmation); GRP-02..07: unvalidated groupId/sortOrder, fire-and-forget reorder, sortOrder duplicates after group deletion, keyboard a11y, desc-convention fragility; verified: FKs enforced, uncategorized mapping, cascade safety |
| 7 | Frontend state / refresh / races / localStorage / boundaries | sa_20260806_054030_000000000_0e17fd155702 | FE-01..11: optimistic reorder without rollback, CRUD res.ok ignored, shared quiz reshuffle (unstable prop), finish double-submit, progress race, **FE-06 (later refuted — proxy exists)**, dashboard fetch gap, unguarded JSON.parse, effect fetches, cross-class race, collapse flash; verified: shared viewers read-only, localStorage clean, no boundary violations |
| 8 | Test quality / coverage / false confidence | sa_20260806_054030_000000000_cd3652deb3f7 | TEST-01..10: false-confidence SRS test, zero tests for scoring/auth/services/routes, no DB harness, CI never runs tests; ranked missing-test gaps by impact |
| 9 | Docker / startup / env / scripts / production / recovery | sa_20260806_054030_000000000_f49cdaacaabf | OPS-01..10: port mismatch 3726 vs 3000:3000, entrypoint fragility, session secret in prod path, dead ADMIN_PASSWORD_HASH, non-reproducible installs, no .dockerignore (win32 leak), no healthcheck/backup, override auto-merge, .gitignore gaps, CI without gates; argon2/standalone tracing verified OK |
| 10 | Adversarial review | sa_20260806_055608_000000000_c550c742d5dc | Challenged all Critical/High findings: confirmed drift, secret, retake, port, dockerignore, override; weakened OPS-02 (fresh-volume migrate succeeds) and FE-01 severity; **refuted FE-06** (proxy active — verified in compiled .next artifacts); added ADV-01..10 (retake progress pollution, activity scan, logout, dashboard crash, slugify empty slug, unguarded parses, completed-resume, localStorage key collisions, first-login race, progress validation) |
All reports are preserved in full in the session; their findings are consolidated (deduplicated, source IDs preserved) in `FINDINGS.json` and `CONFIRMED_FINDINGS.md`.

View file

@ -0,0 +1,21 @@
# Test Gaps
Current suite: 6 files / 30 tests — 20 in `src/lib/arcade/*` (out of scope) + 10 in `src/lib/spacedRepetition.test.ts` (pure scheduling math only). **No test touches a service, an API route, the database, auth, scoring, imports, or progress.** CI never runs `npm test`. Gaps ranked by user-impact × regression-likelihood.
| ID | Area | Gap | Risk it would fail to catch | Suggested test |
|---|---|---|---|---|
| TEST-GAP-01 | Auth (FIN-02/08/09/10) | `authService.ts` (login, lockout, first-login provisioning, reset token, generation bump) and session config have zero tests | Session-secret fallback regression, lockout bypass, generation-bump loss (stolen sessions stay valid) | Extract + unit-test `getLockoutDuration` thresholds, `parseResetTokenRecord`; service tests with temp SQLite + fake timers: 5 wrong passwords → 423; locked-out login rejected; success resets counter; `completePasswordReset` bumps generation |
| TEST-GAP-02 | Scoring (FIN-03/11/26/27) | `src/lib/scoring.ts` SATA partial credit has zero tests | Wrong grades silently persisted into every attempt — permanent data corruption | `scoreQuestion`: SATA 2/4 correct select both → 1; 1 correct + 1 wrong → 0; over-select → 0; MC multi-select first-wrong → 0; `scoreQuiz` with missing question keys; 0-correct SATA → 0 (not NaN) |
| TEST-GAP-03 | Quiz attempt persistence (FIN-03/12/13) | `POST /api/quizzes/[id]/attempt` + partial-retake filter + `progressService.upsertProgress` asymmetry (DECK writes cardResultsJson, QUIZ writes answersJson) have zero tests | Retake-scoring regression (FIN-03 class), duplicate attempts, progress column clobbering | Route/service test: partial retake answering 2 of 5 → maxScore 2, score from those 2; upsert round-trip preserves the respective JSON columns |
| TEST-GAP-04 | SRS service (FIN-16/35) | `spacedRepetitionService.ts` (reviewCard optimistic concurrency, findNextCard precedence, removeDeck transaction, addDeck validations) has zero tests — only the pure lib is tested | Double-review stale write, due/new-card precedence break, cross-class membership, P2002 → 500 | Seed class→deck→cards→set on temp DB: stale `expectedStateVersion` → 409; due before new before learn-ahead; new card with limit 0 rejected; removeDeck atomically deletes states |
| TEST-GAP-05 | Import/repair pipeline (FIN-28/29/30) | `jsonRepair.ts`, `importSchemas.ts`, `shuffle.ts`/`filterAndClampOrder` have zero tests | AI-import regression (fences/repair), schema constraint drift (MC exactly-one), resume clamp bugs | jsonRepair fence/trailing-comma/broken-JSON cases; schema whitespace/oversize/exactly-one cases; `filterAndClampOrder` stale-id + overflow cases |
| TEST-GAP-06 | Rate limiter (FIN-10) | `rateLimiter.ts` has zero tests | Brute-force guard silently disabled or locking the owner out | Fake timers: 10 allowed / 11th blocked with retryAfterMs; window expiry; per-key isolation; custom limits |
| TEST-GAP-07 | Integration harness (FIN-01/21/25) | No route tests, no DB-backed tests, no setup file; `src/lib/db.ts` defaults to `file:./dev.db` (a naive service test would touch the real dev DB) | Migration drift (FIN-01) — nothing would catch a schema/migration mismatch; progress/share/attempt route validation gaps | `setupFiles` + `DATABASE_URL=file:<tmp>` + `prisma migrate deploy` in globalSetup; route tests with `NextRequest`: attempt 200/400/404, reviews 409, share unknown token, login 429 |
| TEST-GAP-08 | CI gate (FIN-25) | `.forgejo/workflows/build.yml` runs no tests/lint/prisma validate; no coverage config; no drift check | Entire suite can regress with a green pipeline; FIN-01-class drift ships | Add `npm ci && npm test && npm run lint && npx prisma validate` job; `prisma migrate diff --from-migrations --to-schema-datamodel` gate; container smoke (curl /login on fresh volume); coverage floor |
| TEST-GAP-09 | False confidence (FIN-53) | `spacedRepetition.test.ts:15-23` "distinct valid state for every rating" never compares ratings | `scheduleRating` collapsing all ratings to one schedule passes the suite | Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; graduated-state lapse case |
## Test-quality positives (verified)
- `spacedRepetition.test.ts` is deterministic (fixed `now`, `enable_fuzz: false`, no `Math.random`), tests the real pure module, and asserts exact FSRS intervals.
- Arcade tests (out of scope) use seeded shuffles and meaningful assertions.
- No test uses wall-clock time, locale, or network — no flakiness found.

View file

@ -0,0 +1,35 @@
# Unverified Risks
Risks that could not be fully confirmed within audit constraints (need a browser, a container run, a live deployment, or timing-dependent reproduction). All are code-verified as plausible; the missing piece is runtime confirmation.
| ID | Title | Why unverified | Suggested verification |
|---|---|---|---|
| FIN-02 | Hardcoded fallback `SESSION_SECRET` → session forgery | Code path fully traced (seal/verify semantics verified in iron-session), but no live production deployment exists here to demonstrate an actual forged-cookie login | Boot production build without `SESSION_SECRET`, forge a cookie with the fallback password (iron-webcrypto seal script), assert full access; then with a real secret assert rejection |
| FIN-06 | No `.dockerignore` → win32 modules in Linux image | Mechanics verified (PE32+ DLL present in local node_modules; Dockerfile COPY order), exact failure mode (build error vs `ERR_DLOPEN_FAILED`) not observed | Run `docker build .` on this Windows host; inspect `better_sqlite3.node`/argon2 prebuilds in the image (must be ELF) |
| FIN-13 | Progress PATCH-after-DELETE race | Timing-dependent; needs throttled network | DevTools throttling: submit final answer + Finish immediately; inspect `StudyProgress` after |
| FIN-14 | Out-of-order progress PATCHes | Timing-dependent | DevTools throttling with rapid grading; reload and compare resume point/results |
| FIN-22 | Entrypoint migrate crash loop / runner CLI install | Requires container build + run on `node:22-slim` (docker CLI unavailable in audit environment) | `docker build` + fresh-volume run (assert 200 + `_prisma_migrations`); corrupt-volume run (assert actionable failure, not silent loop) |
| FIN-24 | Healthcheck/backup gaps | Behavior observable only in a real container run | Compose up with a corrupt volume; observe restart loop and absence of health status |
| FIN-32 | Group-delete sortOrder duplicates | Deterministic from code; visual impact needs a browser | Delete a group in the app, inspect Uncategorized order + DB sortOrders |
| FIN-33 | Keyboard cross-group moves impossible | Deterministic from code (empty `handleDragOver`), needs manual keyboard test | Tab to a drag handle, attempt keyboard cross-group move |
| FIN-34 | Restart DELETE race | Timing-dependent | Throttled network: Restart, grade first card, inspect progress row |
| FIN-36 | Previous-card animation race | Timing-dependent (350 ms window) | Grade then immediately click Previous; observe index jump |
| FIN-37 | SRS page stale on focus | Deterministic from code; browser needed to observe | Two tabs: delete deck in one, focus the other |
| FIN-39 | Proxy destroy cookie lost on redirect | Deterministic from code; browser needed to observe Set-Cookie | Stale-generation cookie → follow redirect → inspect response headers |
| FIN-40 | Secure cookie flag off | Deterministic from code (flag logic); deployment-dependent | Inspect `Set-Cookie` in a production container |
| FIN-46 | Cross-class fetch race | Timing-dependent | Throttled network + fast class switching |
| FIN-48 | Empty slug / stale slug on rename | Deterministic from code (slugify verified); UI behavior needs browser | Create class named "!!!", try to navigate to it; rename a class, check old URL |
| FIN-49 | Shared-viewer localStorage key collisions | Code-verified key construction; symptom needs browser | Open two share tokens for the same content, answer in one, reload the other |
| FIN-51 | Collapsed-groups expand flash | Cosmetic; browser-only | Visit library with saved collapsed state |
## Items verified safe (hypotheses disproven — recorded for completeness)
- SQLite relative-path split-brain between Prisma CLI and app (both cwd-relative → same `dev.db`; Docker uses absolute path) — verified safe.
- Foreign-key enforcement (better-sqlite3 compiled with `SQLITE_DEFAULT_FOREIGN_KEYS=1`) — cascades/SetNull fire; verified safe.
- Transactionality of deck/quiz/attempt/reorder writes — nested creates + `$transaction`; verified safe.
- `StudyProgress`/`ShareLink` NULL-uniqueness — service-level guards prevent duplicates via app paths; only direct API misuse (FIN-41/FIN-21) can create junk rows.
- Client/server boundary violations — none found (no client import of `@/lib/db`/services/prisma).
- XSS via Markdown — react-markdown without `rehype-raw`; no `dangerouslySetInnerHTML` on user data; verified safe.
- Password-reset nonce/token crypto (192-bit token, SHA-256 digest, `timingSafeEqual`, expiry) — sound; abuse vectors are FIN-10.
- SRS optimistic concurrency (`expectedStateVersion` → 409, rollback via refetch) — correct.
- Migrations are purely additive; no NOT NULL-without-default, no DROP — safe on populated migration-tracked DBs (except FIN-01 drift).

View file

@ -0,0 +1,38 @@
# Verification Log
Chronological record of every command run, its outcome, and classification (baseline / environment / possible defect).
## Environment notes
- OS: Windows, shell: bash (git-bash)
- `node_modules` was **absent** at audit start → `npm test` initially failed with `'vitest' is not recognized`. This is an environment condition, not a repo defect. `npm ci` completed successfully during the first audit session (518 packages present).
- No existing application database (`dev.db*`) exists anywhere in the repo — no real user data to protect; the migration-chain test uses a brand-new temp DB under `audit-results/tmp`.
- `src/generated/prisma/` (tracked) hashed BEFORE any prisma command: `audit-results/tmp/generated-before.sha256`.
## Entries
| # | Time | Command | Result | Classification |
|---|---|---|---|---|
| 1 | session start | `git status --short` | clean; only `?? .reasonix/` and `?? audit-results/` untracked | baseline |
| 2 | session start | `npm test` (first run, prior session) | FAILED: `'vitest' is not recognized` (no node_modules) | environment |
| 3 | session start | `npm ci` (background, prior session) | completed; 518 packages in node_modules | environment |
| 4 | 22:36 | `npx prisma validate` | PASS — "The schema at prisma\schema.prisma is valid" (exit 0) | baseline |
| 5 | 22:36 | `npm test` | PASS — 6 files, 30 tests, all green (arcade ×20, spacedRepetition ×10) | baseline |
| 6 | 22:37 | `npm run lint` | 28 problems: 9 errors, 19 warnings (react-hooks/set-state-in-effect errors + unused-vars warnings). All pre-existing; matches AGENTS.md known baseline. | baseline |
| 7 | 22:37 | `npm run build` (background) | PASS — route table emitted, no compile errors | baseline |
| 8 | 22:38 | `DATABASE_URL=file:./audit-results/tmp/audit-migration.db npx prisma migrate deploy` | PASS — all 4 migrations applied to brand-new temp DB | baseline |
| 9 | 22:38 | `sha256sum -c audit-results/tmp/generated-before.sha256` | PASS — every generated Prisma file byte-identical (all ": OK") | baseline |
| 10 | 22:38 | `git status --short` | only untracked `.reasonix/`, `audit-results/`; no tracked modifications | baseline |
| 11 | 22:38 | `git diff --check` | clean (no whitespace errors) | baseline |
| 12 | 22:40 | `git log --all --oneline -- prisma/migrations/` + `git show 7af0935 --stat` | Confirms drift: commit 7af0935 changed `prisma/schema.prisma` (+31) and `dev.db` (139264→155648 bytes) with **no migration**; `dev.db` present in 8 commits of history | possible defect (FIN-01, FIN-42) |
| 13 | 22:41 | `node audit-results/tmp/inspect-db.cjs audit-results/tmp/audit-migration.db` | Temp DB (built solely by `migrate deploy`) has **no** `MaterialGroup` table and **no** `groupId` column on Deck/QuizSet/ShareLink | possible defect (FIN-01) |
| 14 | 22:42 | `node audit-results/tmp/reproduce-drift.cjs` | Prisma-shaped queries fail: `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` — fresh-deploy breakage proven | possible defect (FIN-01) |
| 15 | 22:45 | `node --check audit-results/tmp/inspect-db.cjs && node --check audit-results/tmp/reproduce-drift.cjs` | diagnostic scripts syntax-valid | baseline |
| 16 | 22:50 | `node audit-results/tmp/validate-findings.cjs` | `FINDINGS.json` VALID JSON — 53 findings: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation; no duplicate ids | baseline |
| 17 | 23:00 | `git status --short` + `git diff --check` (final) | Only untracked `.reasonix/`, `audit-results/`; no tracked file modified; diff clean | baseline |
## Classification legend
- **baseline** — repository behaves as expected; recorded for reference.
- **environment** — failure caused by local machine state, not repo code.
- **possible defect** — outcome may indicate a repo defect; investigated further in findings.

Binary file not shown.

View file

@ -0,0 +1,26 @@
cb737289f5b5f6cdb8b90fc23d2c89ebd41d6602ce99e88bf6dfd751beab7103 *src/generated/prisma/browser.ts
2ea331124cc56c9c9a360b4a85f42f029992811ece7efe9db0ec1300a43f59c3 *src/generated/prisma/client.ts
f1f6280ff65c1e8aab5ce30307c8777ce1795f81bf1a513396263fc1c9a30e5c *src/generated/prisma/commonInputTypes.ts
8bcc37ae19ee9c55424b735ce5f3a0972b7e741958ef86b350c9a54dc2238a0d *src/generated/prisma/enums.ts
d52628a3dc8285d9014e2fda489badce6c88604810e2975310ab6c40ec3c708d *src/generated/prisma/internal/class.ts
91794eb9b3c395e57b96d18f2bae895149f7b70ddf0fda8b817e7aa9da17a950 *src/generated/prisma/internal/prismaNamespace.ts
6645109792ed08507d79da319aaecc2752bffa6e8bd5a3364dccc5ae93e2f23c *src/generated/prisma/internal/prismaNamespaceBrowser.ts
b8ffef1ad4428179847aafe66abee3ad5eb74a84fff46265532551b10ab2537d *src/generated/prisma/models.ts
daecbb1c94a96d19da4accfdf81fe8f11fa73c543958016bc7d6060d454f3943 *src/generated/prisma/models/AnswerOption.ts
87e18727cec17a341d32709bc0c1602c66e6c6dcc57672b9c9fbe38a6ce12801 *src/generated/prisma/models/ArcadeAttempt.ts
0c9de62bdbefafe8b5ff5300d1d764cc73235a077f8e3b3e03e6144287610c9d *src/generated/prisma/models/ArcadePack.ts
67b8ea01f0a866d14db7fdff3178a080e2879990129c5e08690463fb9940f48d *src/generated/prisma/models/AuthSecurity.ts
6885465a57019e8cba28755d9c6abaa087e913d666d02d8912abf0790c41a77b *src/generated/prisma/models/Class.ts
ea873177511c6f7354dc01ec0ad228d9f777bca2fbfb15d281d3f2ab8f817ebf *src/generated/prisma/models/Deck.ts
1082ff29bec6e4f920c93d778032ffffa324c312b05efd6c5fa7ada1be6674f7 *src/generated/prisma/models/Flashcard.ts
de94c2da405c08a0b690aeb1b0cdc03582c94ccddaf913f4c6d1b18d8c413b61 *src/generated/prisma/models/MaterialGroup.ts
941998d2c098e9fe5cd0a888bac470190108238c936c1d7cfce2742ed1e40d8a *src/generated/prisma/models/Question.ts
3d54ed93e516f4d773c200b166a84ab2c9c8b7b0d7f7ddfd61afc3ca057ee755 *src/generated/prisma/models/QuizAttempt.ts
454f19f97a82b8f37fc5c5179e87afe6ea185a79810043471d55c0a8ca26f14a *src/generated/prisma/models/QuizSet.ts
3b884aaaad311e8f22c1390cf24ebe69b74b52a97c213781d2a4cbb56288b8a7 *src/generated/prisma/models/Setting.ts
1ccf32b2f56b1d35c9b7152d2e1817b4c1b7853549a1ff187e9fb7c29fa49f2a *src/generated/prisma/models/ShareLink.ts
cba950d431c702afe67f3ac92d8397275d3aeeebbc59d357fd20dec2123e0ee2 *src/generated/prisma/models/SpacedRepetitionCardState.ts
2ef43d1c5d7e2eb0d85080fb29703ae99c1704ba8f5200703e2bb8e7a617bf04 *src/generated/prisma/models/SpacedRepetitionSet.ts
0bf9eab9f937ef2d621ccf0cea8e209eae9372a9b333dfe6b18267e563a6b2b0 *src/generated/prisma/models/SpacedRepetitionSetDeck.ts
916bda8f95647f0dc3560bc6848c4a97d4c1f37fd5ed54e6736d9d7d45e08adc *src/generated/prisma/models/StudyActivity.ts
36724157e76b49df49e7827eacbcb0ab8336917f35913777701d8a6830770500 *src/generated/prisma/models/StudyProgress.ts

View file

@ -0,0 +1,52 @@
// Read-only diagnostic: introspect the temp DB created by `prisma migrate deploy`
// to check whether the migrated schema matches schema.prisma (drift check).
// Usage: node audit-results/tmp/inspect-db.cjs <path-to-sqlite-db>
const path = require("node:path");
const Database = require("better-sqlite3");
const dbPath = process.argv[2];
if (!dbPath) {
console.error("usage: node inspect-db.cjs <db-path>");
process.exit(2);
}
const db = new Database(path.resolve(dbPath), { readonly: true });
const tables = db
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
.all()
.map((r) => r.name);
console.log("=== TABLES ===");
console.log(tables.join(", "));
console.log("Has MaterialGroup table:", tables.includes("MaterialGroup"));
for (const table of ["Deck", "QuizSet", "ShareLink", "MaterialGroup"]) {
if (tables.includes(table)) {
const cols = db
.prepare(`PRAGMA table_info('${table}')`)
.all()
.map((r) => r.name);
console.log(`--- ${table} columns ---`);
console.log(cols.join(", "));
if (table === "Deck" || table === "QuizSet" || table === "ShareLink") {
console.log(` -> has groupId:`, cols.includes("groupId"));
}
} else {
console.log(`--- ${table}: MISSING TABLE ---`);
}
}
const indexes = db
.prepare("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name")
.all()
.map((r) => `${r.tbl_name}.${r.name}`);
console.log("=== INDEXES (count) ===", indexes.length);
// Confirm migrations table
const applied = db.prepare("SELECT migration_name FROM _prisma_migrations ORDER BY started_at").all();
console.log("=== APPLIED MIGRATIONS ===");
applied.forEach((m) => console.log(" -", m.migration_name));
db.close();
console.log("DONE");

View file

@ -0,0 +1,40 @@
// Read-only diagnostic: execute the exact SQL shape the generated Prisma client
// emits for deck.findMany() / materialGroup.findMany() against a DB built purely
// from `prisma migrate deploy` (predev + Docker entrypoint flow).
// Proves DBAUD-01: migrated DB is incompatible with the shipped client.
const path = require("node:path");
const Database = require("better-sqlite3");
const dbPath = path.resolve("audit-results/tmp/audit-migration.db");
const db = new Database(dbPath, { readonly: true });
// 1. Shape of prisma.deck.findMany({ include: { class: true } }) — Prisma selects
// every scalar field, including groupId (schema.prisma:33-34, generated client).
const deckSql =
'SELECT "Deck"."id", "Deck"."classId", "Deck"."name", "Deck"."description", "Deck"."sortOrder", "Deck"."createdAt", "Deck"."groupId" FROM "Deck"';
try {
db.prepare(deckSql).all();
console.log("deck.findMany SQL: OK");
} catch (e) {
console.log("deck.findMany SQL: FAILED ->", e.message);
}
// 2. Shape of prisma.materialGroup.findMany()
const groupSql = 'SELECT "MaterialGroup"."id", "MaterialGroup"."classId", "MaterialGroup"."name", "MaterialGroup"."type", "MaterialGroup"."sortOrder", "MaterialGroup"."createdAt" FROM "MaterialGroup"';
try {
db.prepare(groupSql).all();
console.log("materialGroup.findMany SQL: OK");
} catch (e) {
console.log("materialGroup.findMany SQL: FAILED ->", e.message);
}
// 3. Shape of prisma.shareLink.findFirst() (share page validation)
const shareSql = 'SELECT "ShareLink"."id", "ShareLink"."targetType", "ShareLink"."deckId", "ShareLink"."quizSetId", "ShareLink"."groupId", "ShareLink"."createdAt" FROM "ShareLink" LIMIT 1';
try {
db.prepare(shareSql).all();
console.log("shareLink.findFirst SQL: OK");
} catch (e) {
console.log("shareLink.findFirst SQL: FAILED ->", e.message);
}
db.close();

View file

@ -0,0 +1,27 @@
// Validates audit-results/FINDINGS.json parses and reports counts per severity.
const fs = require("node:fs");
const path = require("node:path");
const file = path.resolve("audit-results/FINDINGS.json");
const raw = fs.readFileSync(file, "utf8");
const data = JSON.parse(raw); // throws if invalid
const findings = data.findings;
const bySeverity = {};
for (const f of findings) {
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
}
const byConfidence = {};
for (const f of findings) {
byConfidence[f.confidence] = (byConfidence[f.confidence] || 0) + 1;
}
const ids = findings.map((f) => f.id);
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
if (dupes.length) throw new Error("duplicate ids: " + dupes.join(","));
console.log("FINDINGS.json: VALID JSON");
console.log("total findings:", findings.length);
console.log("by severity:", JSON.stringify(bySeverity));
console.log("by confidence:", JSON.stringify(byConfidence));
console.log("ids:", ids.join(", "));

View file

@ -0,0 +1,149 @@
import { performance } from "node:perf_hooks";
import path from "node:path";
import Database from "better-sqlite3";
import { afterAll, describe, expect, it } from "vitest";
import { prisma } from "@/lib/db";
import { getActivitySummary } from "@/services/activityService";
import { listClasses } from "@/services/classService";
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
const ACTIVITY_ROWS = 100_000;
const CARD_ROWS = 10_000;
const SETS = 5;
const REQUEST_BUDGET_MS = 250;
const NOW = new Date("2026-08-07T18:00:00.000Z");
afterAll(async () => {
await prisma.$disconnect();
});
function databasePath() {
const url = process.env.DATABASE_URL;
if (!url?.startsWith("file:./")) {
throw new Error("Benchmark requires the disposable Vitest database");
}
return path.resolve(process.cwd(), url.slice("file:".length));
}
function seedRealApplicationSchema() {
const database = new Database(databasePath());
try {
database.pragma("foreign_keys = ON");
const insertActivity = database.prepare(
'INSERT INTO "StudyActivity" ("id", "type", "occurredAt") VALUES (?, ?, ?)'
);
const insertDeck = database.prepare(
'INSERT INTO "Deck" ("id", "classId", "name", "sortOrder") VALUES (?, ?, ?, ?)'
);
const insertSet = database.prepare(
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "newCardsPerDay", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)'
);
const insertMembership = database.prepare(
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
);
const insertCard = database.prepare(
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
);
const insertState = database.prepare(`
INSERT INTO "SpacedRepetitionCardState"
("id", "setId", "flashcardId", "due", "stability", "difficulty", "elapsedDays", "scheduledDays", "learningSteps", "reps", "lapses", "state", "firstReviewedAt", "lastReview", "createdAt", "updatedAt")
VALUES (?, ?, ?, ?, 1, 5, 1, ?, 0, 1, 0, ?, ?, ?, ?, ?)
`);
database.transaction(() => {
database.prepare(
'INSERT INTO "Class" ("id", "slug", "name", "sortOrder") VALUES (?, ?, ?, 0)'
).run("benchmark-class", "benchmark-class", "Benchmark Class");
for (let index = 0; index < ACTIVITY_ROWS; index += 1) {
const ageDays = index % 730;
const type = index % 3 === 0
? "FLASHCARD"
: index % 3 === 1
? "QUIZ_QUESTION"
: "ARCADE_GROUP";
insertActivity.run(
`activity-${index}`,
type,
NOW.getTime() - ageDays * 86_400_000
);
}
for (let setIndex = 0; setIndex < SETS; setIndex += 1) {
const deckId = `benchmark-deck-${setIndex}`;
const setId = `benchmark-set-${setIndex}`;
insertDeck.run(deckId, "benchmark-class", `Deck ${setIndex}`, setIndex);
insertSet.run(setId, "benchmark-class", `Set ${setIndex}`, 30, setIndex, NOW.getTime());
insertMembership.run(setId, deckId, 0);
for (let cardIndex = setIndex; cardIndex < CARD_ROWS; cardIndex += SETS) {
const cardId = `benchmark-card-${cardIndex}`;
const due = NOW.getTime() + ((cardIndex % 200) - 100) * 60_000;
const firstReviewed = NOW.getTime() - (cardIndex % 30) * 86_400_000;
const scheduledDays = cardIndex % 4 === 0 ? 1 : 0;
const state = cardIndex % 4;
insertCard.run(cardId, deckId, `Front ${cardIndex}`, `Back ${cardIndex}`, cardIndex);
insertState.run(
`benchmark-state-${cardIndex}`,
setId,
cardId,
due,
scheduledDays,
state,
firstReviewed,
firstReviewed,
firstReviewed,
NOW.getTime()
);
}
}
})();
} finally {
database.close();
}
}
async function measure<T>(label: string, operation: () => Promise<T>) {
const start = performance.now();
const value = await operation();
return {
label,
latencyMs: Number((performance.now() - start).toFixed(2)),
value,
};
}
describe("remediation performance condition", () => {
it("measures actual migrated services at the expected upper-use fixture", async () => {
seedRealApplicationSchema();
const activity = await measure("getActivitySummary", () => getActivitySummary(NOW));
const availability = await measure("getStudyAvailabilityByClass", () =>
getStudyAvailabilityByClass(NOW)
);
const classPolling = await measure("listClasses/navbar-poll", () => listClasses());
const measurements = [activity, availability, classPolling].map((result) => ({
label: result.label,
latencyMs: result.latencyMs,
}));
console.info(JSON.stringify({
fixture: { activityRows: ACTIVITY_ROWS, cards: CARD_ROWS, sets: SETS },
budgetMs: REQUEST_BUDGET_MS,
prismaServiceOperations: { activity: 1, availability: 1, classPolling: 2 },
measurements,
activity: {
displayedDays: activity.value.days.length,
currentStreak: activity.value.currentStreak,
},
readyCards: availability.value["benchmark-class"]?.readyCards,
}, null, 2));
expect(activity.value.days).toHaveLength(53 * 7);
expect(activity.value.currentStreak).toBeGreaterThan(53 * 7);
expect(availability.value["benchmark-class"]).toBeDefined();
expect(classPolling.value).toHaveLength(1);
for (const measurement of measurements) {
expect(measurement.latencyMs, measurement.label).toBeLessThanOrEqual(
REQUEST_BUDGET_MS
);
}
});
});

View file

@ -1,12 +1,15 @@
services: services:
study-app: study-app:
build: build:
context: .
target: deps target: deps
command: npm run dev command: npm run dev
ports:
- "3000:3000"
environment:
- DATABASE_URL=file:/app/data/study.db
- NODE_ENV=development
volumes: volumes:
- .:/app - .:/app
- /app/node_modules - /app/node_modules
environment: - ./data:/app/data
- NODE_ENV=development
ports:
- "3000:3000"

View file

@ -4,11 +4,13 @@ services:
container_name: study-app container_name: study-app
restart: unless-stopped restart: unless-stopped
ports: ports:
- "3000:3000" - "3000:3726"
environment: environment:
- DATABASE_URL=file:/app/data/study.db - DATABASE_URL=file:/app/data/study.db
- SESSION_SECRET=${SESSION_SECRET} - SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters}
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-}
- ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false}
- SECURE_COOKIES=${SECURE_COOKIES:-false}
- NODE_ENV=production - NODE_ENV=production
volumes: volumes:
- ./data:/app/data - ./data:/app/data

View file

@ -1,4 +1,12 @@
#!/bin/sh #!/bin/sh
set -e set -e
npx prisma migrate deploy
DEFAULT_SECRET='dev-session-secret-change-in-production-must-be-32-chars'
TRIMMED_SECRET=$(printf '%s' "${SESSION_SECRET:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SECRET" = "$DEFAULT_SECRET" ]; then
echo >&2 "SESSION_SECRET must be a non-default value of at least 32 characters"
exit 1
fi
./node_modules/.bin/prisma migrate deploy
exec node server.js exec node server.js

View file

@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
"out/**", "out/**",
"build/**", "build/**",
"next-env.d.ts", "next-env.d.ts",
"audit-results/tmp/**",
]), ]),
]); ]);

View file

@ -1 +0,0 @@
:root { --color-primary: #fff; }

625
package-lock.json generated

File diff suppressed because it is too large Load diff

View file

@ -8,7 +8,11 @@
"build": "next build", "build": "next build",
"start": "next start", "start": "next start",
"lint": "eslint", "lint": "eslint",
"test": "vitest run" "test": "vitest run",
"benchmark:remediation": "vitest run --config vitest.benchmark.config.ts",
"db:preflight": "node scripts/migration-preflight.mjs",
"db:backup": "node scripts/backup-database.mjs",
"auth:reset": "node scripts/request-password-reset.mjs"
}, },
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
@ -20,7 +24,8 @@
"better-sqlite3": "^12.11.1", "better-sqlite3": "^12.11.1",
"iron-session": "^8.0.4", "iron-session": "^8.0.4",
"jsonrepair": "^3.14.1", "jsonrepair": "^3.14.1",
"next": "16.2.9", "next": "16.3.0",
"prisma": "^7.8.0",
"react": "19.2.4", "react": "19.2.4",
"react-dom": "19.2.4", "react-dom": "19.2.4",
"react-markdown": "^10.1.0", "react-markdown": "^10.1.0",
@ -30,12 +35,12 @@
}, },
"devDependencies": { "devDependencies": {
"@tailwindcss/postcss": "^4", "@tailwindcss/postcss": "^4",
"@types/better-sqlite3": "^9.6.0",
"@types/node": "^20", "@types/node": "^20",
"@types/react": "^19", "@types/react": "^19",
"@types/react-dom": "^19", "@types/react-dom": "^19",
"eslint": "^9", "eslint": "^9",
"eslint-config-next": "16.2.9", "eslint-config-next": "16.2.9",
"prisma": "^7.8.0",
"tailwindcss": "^4", "tailwindcss": "^4",
"typescript": "^5", "typescript": "^5",
"vitest": "^4.1.10" "vitest": "^4.1.10"

View file

@ -0,0 +1,68 @@
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
-- CreateTable
CREATE TABLE "MaterialGroup" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" TEXT NOT NULL,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MaterialGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- RedefineTables
CREATE TABLE "new_Deck" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"groupId" TEXT,
CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "Deck_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_Deck" ("classId", "createdAt", "description", "id", "name", "sortOrder")
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "Deck";
DROP TABLE "Deck";
ALTER TABLE "new_Deck" RENAME TO "Deck";
CREATE TABLE "new_QuizSet" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"description" TEXT,
"sortOrder" INTEGER NOT NULL DEFAULT 0,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"groupId" TEXT,
CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "QuizSet_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
INSERT INTO "new_QuizSet" ("classId", "createdAt", "description", "id", "name", "sortOrder")
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "QuizSet";
DROP TABLE "QuizSet";
ALTER TABLE "new_QuizSet" RENAME TO "QuizSet";
CREATE TABLE "new_ShareLink" (
"id" TEXT NOT NULL PRIMARY KEY,
"targetType" TEXT NOT NULL,
"deckId" TEXT,
"quizSetId" TEXT,
"groupId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ShareLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
INSERT INTO "new_ShareLink" ("createdAt", "deckId", "id", "quizSetId", "targetType")
SELECT "createdAt", "deckId", "id", "quizSetId", "targetType" FROM "ShareLink";
DROP TABLE "ShareLink";
ALTER TABLE "new_ShareLink" RENAME TO "ShareLink";
CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId");
CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId");
CREATE UNIQUE INDEX "ShareLink_groupId_key" ON "ShareLink"("groupId");
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View file

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT;

View file

@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy';
ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;

View file

@ -99,6 +99,8 @@ model StudyProgress {
orderJson String // JSON array of card/question ids orderJson String // JSON array of card/question ids
answersJson String? // in-progress quiz answers answersJson String? // in-progress quiz answers
cardResultsJson String? // per-card grades for decks cardResultsJson String? // per-card grades for decks
sessionId String @default("legacy")
revision Int @default(0)
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade) deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade)
@ -114,6 +116,7 @@ model QuizAttempt {
score Float score Float
maxScore Int maxScore Int
answersJson String answersJson String
reviewSnapshotJson String?
isPartialRetake Boolean @default(false) isPartialRetake Boolean @default(false)
completedAt DateTime @default(now()) completedAt DateTime @default(now())

View file

@ -0,0 +1,42 @@
import { existsSync } from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { databasePathFromUrl } from "./databasePath.mjs";
function argument(name) {
const index = process.argv.indexOf(name);
return index >= 0 ? process.argv[index + 1] : undefined;
}
export async function backupDatabase(sourcePath, outputPath) {
const source = path.resolve(sourcePath);
const output = path.resolve(outputPath);
if (!existsSync(source)) throw new Error(`Source database does not exist: ${source}`);
if (existsSync(output)) throw new Error(`Backup destination already exists: ${output}`);
if (source === output) throw new Error("Backup destination must differ from source");
const database = new Database(source, { readonly: true, fileMustExist: true });
try {
await database.backup(output);
} finally {
database.close();
}
const backup = new Database(output, { readonly: true, fileMustExist: true });
try {
const integrity = backup.pragma("integrity_check", { simple: true });
if (integrity !== "ok") throw new Error(`Backup integrity check failed: ${integrity}`);
} finally {
backup.close();
}
return output;
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const source = argument("--source") ?? databasePathFromUrl(process.env.DATABASE_URL);
const output = argument("--output");
if (!output) throw new Error("Usage: npm run db:backup -- --output <new-backup.db>");
const completedPath = await backupDatabase(source, output);
console.info(`Verified SQLite backup created: ${completedPath}`);
}

10
scripts/databasePath.mjs Normal file
View file

@ -0,0 +1,10 @@
import path from "node:path";
export function databasePathFromUrl(databaseUrl, cwd = process.cwd()) {
if (!databaseUrl?.startsWith("file:")) {
throw new Error("DATABASE_URL must be a file: SQLite URL");
}
const rawPath = databaseUrl.slice("file:".length).split("?")[0];
if (!rawPath) throw new Error("DATABASE_URL does not contain a database path");
return path.resolve(cwd, rawPath);
}

View file

@ -0,0 +1,312 @@
import { existsSync, readFileSync, readdirSync } from "node:fs";
import path from "node:path";
import { spawnSync } from "node:child_process";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { databasePathFromUrl } from "./databasePath.mjs";
export const GROUP_MIGRATION = "20260807090000_add_material_groups";
function tableExists(database, table) {
return Boolean(
database
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
.get(table)
);
}
function hasColumn(database, table, column) {
if (!tableExists(database, table)) return false;
return database.pragma(`table_info(${table})`).some((item) => item.name === column);
}
function hasForeignKey(database, table, from, target, onDelete) {
return database
.pragma(`foreign_key_list(${table})`)
.some(
(item) =>
item.from === from && item.table === target && item.on_delete === onDelete
);
}
function hasUniqueIndex(database, table, indexName, column) {
const index = database
.pragma(`index_list(${table})`)
.find((item) => item.name === indexName && item.unique === 1);
if (!index) return false;
const columns = database.pragma(`index_info(${indexName})`);
return columns.length === 1 && columns[0].name === column;
}
function schemaSnapshot(database) {
const tables = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name"
)
.all()
.map((row) => row.name);
return tables.map((table) => ({
table,
columns: database.pragma(`table_info(${JSON.stringify(table)})`).map((column) => ({
name: column.name,
type: column.type,
notnull: column.notnull,
defaultValue: column.dflt_value,
primaryKey: column.pk,
})),
foreignKeys: database
.pragma(`foreign_key_list(${JSON.stringify(table)})`)
.map((foreignKey) => ({
id: foreignKey.id,
sequence: foreignKey.seq,
target: foreignKey.table,
from: foreignKey.from,
to: foreignKey.to,
onUpdate: foreignKey.on_update,
onDelete: foreignKey.on_delete,
match: foreignKey.match,
}))
.sort((left, right) => left.id - right.id || left.sequence - right.sequence),
indexes: database
.pragma(`index_list(${JSON.stringify(table)})`)
.filter((index) => !index.name.startsWith("sqlite_autoindex_"))
.map((index) => ({
name: index.name,
unique: index.unique,
partial: index.partial,
columns: database
.pragma(`index_info(${JSON.stringify(index.name)})`)
.map((column) => column.name),
}))
.sort((left, right) => left.name.localeCompare(right.name)),
}));
}
function expectedSchemaSnapshot(migrationNames) {
const database = new Database(":memory:");
try {
database.pragma("foreign_keys = ON");
for (const migrationName of migrationNames) {
database.exec(
readFileSync(
path.join(
process.cwd(),
"prisma",
"migrations",
migrationName,
"migration.sql"
),
"utf8"
)
);
}
return schemaSnapshot(database);
} finally {
database.close();
}
}
export function inspectMigrationState(databasePath) {
if (!existsSync(databasePath)) {
throw new Error(`Database does not exist: ${databasePath}`);
}
const database = new Database(databasePath, { readonly: true, fileMustExist: true });
try {
const migrationNames = tableExists(database, "_prisma_migrations")
? database
.prepare(
'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL'
)
.all()
.map((row) => row.migration_name)
: [];
const committedMigrations = readdirSync(
path.join(process.cwd(), "prisma", "migrations"),
{ withFileTypes: true }
)
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
const priorMigrations = committedMigrations.filter(
(name) => name < GROUP_MIGRATION
);
const laterMigrations = committedMigrations.filter(
(name) => name > GROUP_MIGRATION
);
const artifacts = {
materialGroup: tableExists(database, "MaterialGroup"),
deckColumn: hasColumn(database, "Deck", "groupId"),
quizColumn: hasColumn(database, "QuizSet", "groupId"),
shareColumn: hasColumn(database, "ShareLink", "groupId"),
deckForeignKey: hasForeignKey(
database,
"Deck",
"groupId",
"MaterialGroup",
"SET NULL"
),
quizForeignKey: hasForeignKey(
database,
"QuizSet",
"groupId",
"MaterialGroup",
"SET NULL"
),
shareForeignKey: hasForeignKey(
database,
"ShareLink",
"groupId",
"MaterialGroup",
"CASCADE"
),
shareUnique: hasUniqueIndex(
database,
"ShareLink",
"ShareLink_groupId_key",
"groupId"
),
};
const values = Object.values(artifacts);
const groupArtifactsPresent = values.every(Boolean);
const groupArtifactsAbsent = values.every((value) => !value);
const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION);
const priorHistoryComplete = priorMigrations.every((name) =>
migrationNames.includes(name)
);
const knownMigrationNames = new Set(committedMigrations);
const historyHasUnknown = migrationNames.some(
(name) => !knownMigrationNames.has(name)
);
const laterHistoryPresent = laterMigrations.some((name) =>
migrationNames.includes(name)
);
const actualSchema = schemaSnapshot(database);
const matches = (expected) =>
JSON.stringify(actualSchema) === JSON.stringify(expected);
const preGroupSchemaExact = matches(expectedSchemaSnapshot(priorMigrations));
const adoptedGroupSchemaExact = matches(
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
);
const appliedPrefix = committedMigrations.filter((name, index) =>
committedMigrations.slice(0, index + 1).every((candidate) =>
migrationNames.includes(candidate)
)
);
const appliedHistoryExact =
!historyHasUnknown &&
migrationNames.length === appliedPrefix.length &&
migrationNames.every((name) => appliedPrefix.includes(name));
const trackedSchemaExact =
appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix));
let classification = "CONFLICT";
if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT";
else if (
!groupMigrationApplied &&
!laterHistoryPresent &&
priorHistoryComplete &&
migrationNames.length === priorMigrations.length &&
groupArtifactsAbsent &&
preGroupSchemaExact
) classification = "APPLY";
else if (
!groupMigrationApplied &&
!laterHistoryPresent &&
priorHistoryComplete &&
migrationNames.length === priorMigrations.length &&
groupArtifactsPresent &&
adoptedGroupSchemaExact
) classification = "ADOPT";
return {
classification,
artifacts,
migrationNames,
priorHistoryComplete,
exactSchema: {
preGroup: preGroupSchemaExact,
adoptedGroup: adoptedGroupSchemaExact,
tracked: trackedSchemaExact,
},
};
} finally {
database.close();
}
}
function verifyBackup(backupPath, sourcePath) {
if (!backupPath || !existsSync(backupPath)) {
throw new Error("--resolve requires an existing verified --backup file");
}
if (path.resolve(backupPath) === path.resolve(sourcePath)) {
throw new Error("--backup must be a separate database file, not the source database");
}
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
try {
if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
throw new Error("Backup failed SQLite integrity_check");
}
if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) {
throw new Error("Backup schema does not match the source database");
}
const sourceCounts = Object.fromEntries(
schemaSnapshot(source).map(({ table }) => [
table,
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
);
const backupCounts = Object.fromEntries(
schemaSnapshot(backup).map(({ table }) => [
table,
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
);
if (JSON.stringify(sourceCounts) !== JSON.stringify(backupCounts)) {
throw new Error("Backup row counts do not match the source database");
}
} finally {
backup.close();
source.close();
}
}
function resolveMigration(databaseUrl) {
const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
const result = spawnSync(
process.execPath,
[cli, "migrate", "resolve", "--applied", GROUP_MIGRATION],
{
cwd: process.cwd(),
env: { ...process.env, DATABASE_URL: databaseUrl },
encoding: "utf8",
}
);
if (result.status !== 0) {
throw new Error(`Prisma migration resolve failed:\n${result.stdout}\n${result.stderr}`);
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const databaseUrl = process.env.DATABASE_URL;
const databasePath = databasePathFromUrl(databaseUrl);
const state = inspectMigrationState(databasePath);
console.info(JSON.stringify(state, null, 2));
if (process.argv.includes("--resolve")) {
if (state.classification !== "ADOPT") {
throw new Error(`Refusing adoption for ${state.classification} database state`);
}
const backupIndex = process.argv.indexOf("--backup");
verifyBackup(
backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
databasePath
);
resolveMigration(databaseUrl);
console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`);
} else if (state.classification === "CONFLICT") {
process.exitCode = 2;
}
}

View file

@ -0,0 +1,56 @@
import { createHash, randomBytes, randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { pathToFileURL } from "node:url";
import Database from "better-sqlite3";
import { databasePathFromUrl } from "./databasePath.mjs";
const PASSWORD_HASH_KEY = "admin_password_hash";
const RESET_TOKEN_KEY = "admin_password_reset";
const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000;
function activeRecord(value) {
try {
const record = JSON.parse(value);
return new Date(record.expiresAt).getTime() > Date.now();
} catch {
return false;
}
}
export function createResetToken(databasePath) {
if (!existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`);
const database = new Database(databasePath, { fileMustExist: true });
try {
if (!database.prepare('SELECT 1 FROM "Setting" WHERE "key" = ?').get(PASSWORD_HASH_KEY)) {
throw new Error("Password recovery is unavailable before initial setup");
}
const existing = database
.prepare('SELECT "value" FROM "Setting" WHERE "key" = ?')
.get(RESET_TOKEN_KEY);
if (existing && activeRecord(existing.value)) {
throw new Error("An unexpired reset token already exists; it was not replaced");
}
const token = randomBytes(24).toString("base64url");
const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString();
const value = JSON.stringify({
digest: createHash("sha256").update(token, "utf8").digest("hex"),
nonce: randomUUID(),
expiresAt,
});
database
.prepare(
'INSERT INTO "Setting" ("key", "value") VALUES (?, ?) ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"'
)
.run(RESET_TOKEN_KEY, value);
return { token, expiresAt };
} finally {
database.close();
}
}
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
const result = createResetToken(databasePathFromUrl(process.env.DATABASE_URL));
console.info(`Password reset token: ${result.token}`);
console.info(`Expires: ${result.expiresAt}`);
}

View file

@ -0,0 +1,197 @@
import { randomUUID } from "node:crypto";
import {
copyFileSync,
existsSync,
mkdirSync,
readFileSync,
readdirSync,
rmSync,
statSync,
} from "node:fs";
import path from "node:path";
import Database from "better-sqlite3";
import { backupDatabase } from "./backup-database.mjs";
const root = path.join(process.cwd(), ".test-databases");
const directory = path.join(root, `backup-restore-${randomUUID()}`);
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
throw new Error("Unsafe disposable backup verification path");
}
mkdirSync(directory, { recursive: true });
const sourcePath = path.join(directory, "source.test.db");
const backupPath = path.join(directory, "backup.test.db");
const restoredPath = path.join(directory, "restored.test.db");
let source;
function rowCounts(database) {
const tables = database
.prepare(
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
)
.all()
.map((row) => row.name);
return Object.fromEntries(
tables.map((table) => [
table,
database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
])
);
}
try {
source = new Database(sourcePath);
source.pragma("foreign_keys = ON");
source.pragma("journal_mode = WAL");
source.pragma("wal_autocheckpoint = 0");
const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), {
withFileTypes: true,
})
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort();
for (const migration of migrations) {
source.exec(
readFileSync(
path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"),
"utf8"
)
);
}
source.transaction(() => {
source
.prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)')
.run("backup-class", "Representative content", "representative-content", 0);
source
.prepare(
'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)'
)
.run("backup-group", "backup-class", "Backup group", "DECK", 0);
source
.prepare(
'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)'
)
.run(
"backup-deck",
"backup-class",
"backup-group",
"Backup deck",
"Restore drill content",
0
);
source
.prepare(
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
)
.run("backup-card", "backup-deck", "Representative front", "Representative back", 0);
source
.prepare(
'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)'
)
.run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0);
source
.prepare(
'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)'
)
.run(
"backup-question",
"backup-quiz",
"MULTIPLE_CHOICE",
"Representative prompt",
"Representative rationale",
"backup",
0
);
source
.prepare(
'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)'
)
.run("backup-option", "backup-question", "Correct option", 1, 0);
source
.prepare(
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)'
)
.run("backup-srs", "backup-class", "Backup SRS", 0, Date.now());
source
.prepare(
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
)
.run("backup-srs", "backup-deck", 0);
source
.prepare(
'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)'
)
.run("backup-share", "GROUP", "backup-group");
})();
const walPath = `${sourcePath}-wal`;
if (!existsSync(walPath) || statSync(walPath).size === 0) {
throw new Error("Backup drill did not create an active WAL database");
}
const sourceWalBytes = statSync(walPath).size;
const sourceCounts = rowCounts(source);
await backupDatabase(sourcePath, backupPath);
copyFileSync(backupPath, restoredPath);
source.close();
source = undefined;
const restored = new Database(restoredPath, { readonly: true, fileMustExist: true });
try {
const integrity = restored.pragma("integrity_check", { simple: true });
const foreignKeyErrors = restored.pragma("foreign_key_check");
const restoredCounts = rowCounts(restored);
const representative = restored
.prepare(`
SELECT c.name AS className, g.name AS groupName, d.description,
f.front, s.name AS setName, sl.targetType
FROM "Class" c
JOIN "MaterialGroup" g ON g.classId = c.id
JOIN "Deck" d ON d.groupId = g.id
JOIN "Flashcard" f ON f.deckId = d.id
JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id
JOIN "SpacedRepetitionSet" s ON s.id = sd.setId
JOIN "ShareLink" sl ON sl.groupId = g.id
WHERE d.id = ?
`)
.get("backup-deck");
const quizRelationship = restored
.prepare(`
SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect
FROM "QuizSet" q
JOIN "Question" question ON question.quizSetId = q.id
JOIN "AnswerOption" option ON option.questionId = question.id
WHERE q.id = ?
`)
.get("backup-quiz");
if (
integrity !== "ok" ||
foreignKeyErrors.length !== 0 ||
JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) ||
representative?.description !== "Restore drill content" ||
representative?.setName !== "Backup SRS" ||
representative?.targetType !== "GROUP" ||
quizRelationship?.isCorrect !== 1
) {
throw new Error("Restored database did not preserve integrity, counts, content, and relationships");
}
console.info(
JSON.stringify(
{
method: "better-sqlite3 online backup while the WAL source remained open",
sourceWalBytes,
integrity,
foreignKeyErrors,
counts: restoredCounts,
representative,
quizRelationship,
},
null,
2
)
);
} finally {
restored.close();
}
} finally {
source?.close();
rmSync(directory, { recursive: true, force: true });
}

View file

@ -0,0 +1,83 @@
import { randomUUID } from "node:crypto";
import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
import Database from "better-sqlite3";
const root = path.join(process.cwd(), ".test-databases");
const directory = path.join(root, `http-smoke-${randomUUID()}`);
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
throw new Error("Unsafe disposable HTTP smoke path");
}
mkdirSync(directory, { recursive: true });
const databasePath = path.join(directory, "http-smoke.test.db");
const database = new Database(databasePath);
try {
database.pragma("foreign_keys = ON");
for (const migration of readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true })
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name)
.sort()) {
database.exec(readFileSync(path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8"));
}
} finally {
database.close();
}
const port = 3789;
const server = spawn(
process.execPath,
[path.join(process.cwd(), "node_modules", "next", "dist", "bin", "next"), "start", "-p", String(port)],
{
cwd: process.cwd(),
env: {
...process.env,
NODE_ENV: "production",
DATABASE_URL: `file:${databasePath.replaceAll("\\", "/")}`,
SESSION_SECRET: "http-smoke-secret-0123456789abcdef0123456789",
ALLOW_INITIAL_SETUP: "true",
},
stdio: ["ignore", "pipe", "pipe"],
windowsHide: true,
}
);
let output = "";
server.stdout.on("data", (chunk) => { output += chunk.toString(); });
server.stderr.on("data", (chunk) => { output += chunk.toString(); });
try {
let health;
for (let attempt = 0; attempt < 30; attempt += 1) {
try {
health = await fetch(`http://127.0.0.1:${port}/api/health`);
if (health.ok) break;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 500));
}
if (!health?.ok) throw new Error(`Health check did not become ready.\n${output}`);
const login = await fetch(`http://127.0.0.1:${port}/login`);
const setup = await fetch(`http://127.0.0.1:${port}/api/auth/setup-status`);
const setupBody = await setup.json();
const protectedDot = await fetch(`http://127.0.0.1:${port}/api/decks/file.json`, { redirect: "manual" });
if (!login.ok || !setup.ok || setupBody.setupRequired !== true || setupBody.setupAllowed !== true || protectedDot.status !== 307) {
throw new Error("HTTP smoke responses did not match the production contract");
}
console.info(JSON.stringify({
health: health.status,
login: login.status,
setup: setupBody,
protectedDot: { status: protectedDot.status, location: protectedDot.headers.get("location") },
}, null, 2));
if (process.argv.includes("--stay")) {
console.info(`Browser smoke server ready at http://127.0.0.1:${port}/login`);
await new Promise(() => {});
}
} finally {
server.kill();
await new Promise((resolve) => {
if (server.exitCode !== null) return resolve();
server.once("exit", resolve);
setTimeout(resolve, 2_000);
});
rmSync(directory, { recursive: true, force: true });
}

View file

@ -25,6 +25,8 @@ interface DeckData {
currentIndex: number; currentIndex: number;
orderJson: string; orderJson: string;
cardResultsJson: string | null; cardResultsJson: string | null;
sessionId: string;
revision: number;
updatedAt: string; updatedAt: string;
}>; }>;
} }
@ -42,19 +44,24 @@ export default function DeckStudyPage() {
async function handleRestart() { async function handleRestart() {
if (!deck) return; if (!deck) return;
// Clear progress in database for both modes const responses = await Promise.all(
await Promise.all([ deck.progress.map((progress) =>
fetch("/api/progress", { fetch("/api/progress", {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) body: JSON.stringify({
}).catch(() => {}), contentType: "DECK",
fetch("/api/progress", { contentId: deck.id,
method: "DELETE", mode: progress.mode,
headers: { "Content-Type": "application/json" }, sessionId: progress.sessionId,
body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }),
}).catch(() => {}) })
]); )
).catch(() => null);
if (!responses || responses.some((response) => !response.ok)) {
window.alert("The session could not be restarted. Your saved progress was kept.");
return;
}
// Clear locally and force remount // Clear locally and force remount
setDeck({ ...deck, progress: [] }); setDeck({ ...deck, progress: [] });
@ -75,7 +82,8 @@ export default function DeckStudyPage() {
}, [deckId, classSlug, router]); }, [deckId, classSlug, router]);
useEffect(() => { useEffect(() => {
fetchDeck(); const timer = window.setTimeout(() => void fetchDeck(), 0);
return () => window.clearTimeout(timer);
}, [fetchDeck]); }, [fetchDeck]);
if (loading || !deck) { if (loading || !deck) {

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal"; import { ImportModal } from "@/components/import/ImportModal";
@ -13,7 +13,6 @@ import {
useSensor, useSensor,
useSensors, useSensors,
DragEndEvent, DragEndEvent,
DragOverEvent,
DragStartEvent, DragStartEvent,
DragOverlay, DragOverlay,
useDroppable, useDroppable,
@ -41,6 +40,8 @@ interface DeckItem {
currentIndex: number; currentIndex: number;
orderJson: string; orderJson: string;
cardResultsJson: string | null; cardResultsJson: string | null;
sessionId: string;
revision: number;
}>; }>;
} }
@ -50,15 +51,25 @@ interface MaterialGroup {
sortOrder: number; sortOrder: number;
} }
const cache: Record<string, { decks: DeckItem[]; groups: MaterialGroup[] }> = {};
function getProgressLabel(deck: DeckItem) { function getProgressLabel(deck: DeckItem) {
if (!deck.progress?.length) return null; if (!deck.progress?.length) return null;
const prog = deck.progress[0]; const prog = deck.progress[0];
const order = JSON.parse(prog.orderJson) as string[]; let order: string[];
const results = prog.cardResultsJson let results: Record<string, string>;
? (JSON.parse(prog.cardResultsJson) as Record<string, string>) try {
const parsedOrder: unknown = JSON.parse(prog.orderJson);
const parsedResults: unknown = prog.cardResultsJson
? JSON.parse(prog.cardResultsJson)
: {}; : {};
if (!Array.isArray(parsedOrder)) throw new Error("Invalid progress order");
order = parsedOrder.filter((id): id is string => typeof id === "string");
results =
typeof parsedResults === "object" && parsedResults !== null
? (parsedResults as Record<string, string>)
: {};
} catch {
return "Saved session needs repair";
}
const correctCount = Object.values(results).filter((r) => r === "correct").length; const correctCount = Object.values(results).filter((r) => r === "correct").length;
const total = order.length; const total = order.length;
const current = Math.min(prog.currentIndex + 1, total); const current = Math.min(prog.currentIndex + 1, total);
@ -70,10 +81,21 @@ interface SortableDeckCardProps {
deck: DeckItem; deck: DeckItem;
onEdit: (deck: DeckItem) => void; onEdit: (deck: DeckItem) => void;
onDelete: (deck: DeckItem) => void; onDelete: (deck: DeckItem) => void;
groups: MaterialGroup[];
onMove: (deckId: string, groupId: string | null) => void;
moveDisabled?: boolean;
classSlug: string; classSlug: string;
} }
function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) { function SortableDeckCard({
deck,
onEdit,
onDelete,
groups,
onMove,
moveDisabled = false,
classSlug,
}: SortableDeckCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@ -117,10 +139,24 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
<button <button
onClick={async () => { onClick={async () => {
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return; if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
await Promise.all([ const responses = await Promise.all(
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) }).catch(() => {}), deck.progress.map((progress) =>
fetch("/api/progress", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) }).catch(() => {}) fetch("/api/progress", {
]); method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deck.id,
mode: progress.mode,
sessionId: progress.sessionId,
}),
})
)
).catch(() => null);
if (!responses || responses.some((response) => !response.ok)) {
window.alert("The deck could not be restarted. Your saved progress was kept.");
return;
}
window.location.href = `/${classSlug}/flashcards/${deck.id}`; window.location.href = `/${classSlug}/flashcards/${deck.id}`;
}} }}
className="flex-1 text-center py-2 px-4 rounded-lg bg-bg-surface-alt border border-border text-text-heading text-sm font-medium hover:bg-border transition-all duration-200 cursor-pointer shadow-sm" className="flex-1 text-center py-2 px-4 rounded-lg bg-bg-surface-alt border border-border text-text-heading text-sm font-medium hover:bg-border transition-all duration-200 cursor-pointer shadow-sm"
@ -136,6 +172,20 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
)} )}
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<label className="sr-only" htmlFor={`move-deck-${deck.id}`}>Move {deck.name} to group</label>
<select
id={`move-deck-${deck.id}`}
value={deck.groupId ?? ""}
onChange={(event) => onMove(deck.id, event.target.value || null)}
disabled={moveDisabled}
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
aria-label={`Move ${deck.name} to group`}
>
<option value="">Uncategorized</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit"> <button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -172,10 +222,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
export default function FlashcardsPage() { export default function FlashcardsPage() {
const params = useParams(); const params = useParams();
const classSlug = params.classSlug as string; const classSlug = params.classSlug as string;
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug]?.decks || []); const [decks, setDecks] = useState<DeckItem[]>([]);
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []); const [groups, setGroups] = useState<MaterialGroup[]>([]);
const [loading, setLoading] = useState(!cache[classSlug]); const [loading, setLoading] = useState(true);
const [animate] = useState(!cache[classSlug]); const [loadError, setLoadError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const requestGeneration = useRef(0);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>(""); const [classId, setClassId] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@ -185,13 +237,17 @@ export default function FlashcardsPage() {
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState(""); const [editGroupName, setEditGroupName] = useState("");
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({}); const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => {
const saved = localStorage.getItem('flashcards_collapsed_groups'); const saved = localStorage.getItem('flashcards_collapsed_groups');
if (saved) { if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {} try { setCollapsedGroups(JSON.parse(saved)); } catch {}
} }
}, 0);
return () => window.clearTimeout(timer);
}, []); }, []);
function toggleGroup(id: string) { function toggleGroup(id: string) {
@ -202,43 +258,54 @@ export default function FlashcardsPage() {
}); });
} }
const fetchAll = useCallback(async (cId: string) => { const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
try { try {
const [deckRes, groupRes] = await Promise.all([ const [deckRes, groupRes] = await Promise.all([
fetch(`/api/decks/list?classId=${cId}`), fetch(`/api/decks/list?classId=${cId}`, { signal }),
fetch(`/api/material-groups?classId=${cId}&type=DECK`), fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }),
]); ]);
const ds = deckRes.ok ? await deckRes.json() : []; if (!deckRes.ok || !groupRes.ok) throw new Error("Unable to load flashcard library");
const gs = groupRes.ok ? await groupRes.json() : []; const ds = await deckRes.json();
const gs = await groupRes.json();
if (generation !== requestGeneration.current) return;
setDecks(ds); setDecks(ds);
setGroups(gs); setGroups(gs);
cache[classSlug] = { decks: ds, groups: gs }; setLoadError(null);
} catch { } catch (error) {
setDecks([]); if (signal?.aborted || generation !== requestGeneration.current) return;
setGroups([]); setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
} finally { } finally {
setLoading(false); if (generation === requestGeneration.current) setLoading(false);
} }
}, [classSlug]); }, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
const generation = ++requestGeneration.current;
async function init() { async function init() {
try { try {
const classRes = await fetch("/api/classes"); setLoading(true);
setLoadError(null);
const classRes = await fetch("/api/classes", { signal: controller.signal });
if (!classRes.ok) throw new Error("Unable to load class");
const classes = await classRes.json(); const classes = await classRes.json();
const cls = classes.find((c: { slug: string }) => c.slug === classSlug); const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
if (!cls) { if (!cls) throw new Error("Class not found");
setLoading(false); if (generation !== requestGeneration.current) return;
return;
}
setClassId(cls.id); setClassId(cls.id);
fetchAll(cls.id); await fetchAll(cls.id, generation, controller.signal);
} catch { } catch (error) {
if (controller.signal.aborted || generation !== requestGeneration.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
setLoading(false); setLoading(false);
} }
} }
init(); const timer = window.setTimeout(() => void init(), 0);
}, [classSlug, fetchAll]); return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [classSlug, fetchAll, reloadKey]);
// Group Management // Group Management
async function handleCreateGroup() { async function handleCreateGroup() {
@ -253,45 +320,68 @@ export default function FlashcardsPage() {
setGroups((prev) => [g, ...prev]); setGroups((prev) => [g, ...prev]);
setIsCreatingGroup(false); setIsCreatingGroup(false);
setNewGroupName(""); setNewGroupName("");
setActionMessage(`Created group ${g.name}.`);
} else {
setActionMessage("The group could not be created. Please retry.");
} }
} }
async function handleRenameGroup(id: string) { async function handleRenameGroup(id: string) {
if (!editGroupName.trim()) return; if (!editGroupName.trim()) return;
await fetch(`/api/material-groups/${id}`, { const response = await fetch(`/api/material-groups/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editGroupName.trim() }), body: JSON.stringify({ name: editGroupName.trim() }),
}); });
if (!response.ok) {
setActionMessage("The group could not be renamed. No changes were applied.");
return;
}
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g))); setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
setEditingGroupId(null); setEditingGroupId(null);
setActionMessage("Group renamed.");
} }
async function handleDeleteGroup(id: string, name: string) { async function handleDeleteGroup(id: string, name: string) {
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return; if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
await fetch(`/api/material-groups/${id}`, { method: "DELETE" }); const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
setGroups((prev) => prev.filter((g) => g.id !== id)); if (!response.ok) {
setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d))); setActionMessage("The group could not be deleted. No changes were applied.");
return;
}
await fetchAll(classId);
setActionMessage(`Deleted group ${name}; its decks are now Uncategorized.`);
} }
// Deck Management // Deck Management
async function handleDeleteDeck(deck: DeckItem) { async function handleDeleteDeck(deck: DeckItem) {
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return; if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
await fetch(`/api/decks/${deck.id}`, { method: "DELETE" }); const response = await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
if (!response.ok) {
setActionMessage("The deck could not be deleted. No changes were applied.");
return;
}
setDecks((prev) => prev.filter((d) => d.id !== deck.id)); setDecks((prev) => prev.filter((d) => d.id !== deck.id));
window.dispatchEvent(new Event("study-decks-changed"));
setActionMessage(`Deleted deck ${deck.name}.`);
} }
async function handleRenameDeck(id: string) { async function handleRenameDeck(id: string) {
if (!editName.trim()) return; if (!editName.trim()) return;
await fetch(`/api/decks/${id}`, { const response = await fetch(`/api/decks/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }), body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
}); });
if (!response.ok) {
setActionMessage("The deck could not be updated. No changes were applied.");
return;
}
setDecks((prev) => setDecks((prev) =>
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d)) prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
); );
setEditingId(null); setEditingId(null);
setActionMessage("Deck updated.");
} }
// Drag and drop setup // Drag and drop setup
@ -301,15 +391,13 @@ export default function FlashcardsPage() {
); );
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [isReordering, setIsReordering] = useState(false);
function handleDragStart(event: DragStartEvent) { function handleDragStart(event: DragStartEvent) {
if (isReordering) return;
setActiveId(event.active.id as string); setActiveId(event.active.id as string);
} }
function handleDragOver(event: DragOverEvent) {
// optional layout updates here
}
async function handleDragEnd(event: DragEndEvent) { async function handleDragEnd(event: DragEndEvent) {
setActiveId(null); setActiveId(null);
const { active, over } = event; const { active, over } = event;
@ -334,40 +422,76 @@ export default function FlashcardsPage() {
} }
} }
if (targetGroupId !== undefined) { const oldIndex = decks.findIndex((deck) => deck.id === activeId);
setDecks((items) => { const overIndex = decks.findIndex((deck) => deck.id === overId);
const oldIndex = items.findIndex((d) => d.id === activeId); let nextItems = decks.map((deck) =>
const overIndex = items.findIndex((d) => d.id === overId); deck.id === activeId ? { ...deck, groupId: targetGroupId } : { ...deck }
let newItems = [...items]; );
newItems[oldIndex].groupId = targetGroupId;
if (overIndex >= 0 && overIndex !== oldIndex) { if (overIndex >= 0 && overIndex !== oldIndex) {
newItems = arrayMove(newItems, oldIndex, overIndex); nextItems = arrayMove(nextItems, oldIndex, overIndex);
} else { } else {
const oldItem = newItems.splice(oldIndex, 1)[0]; const [moved] = nextItems.splice(oldIndex, 1);
newItems.push(oldItem); nextItems.push(moved);
}
await persistDeckReorder(
decks,
nextItems,
new Set([activeDeck.groupId, targetGroupId])
);
} }
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]); async function persistDeckReorder(
const updates: ReorderItem[] = []; previousItems: DeckItem[],
proposedItems: DeckItem[],
affectedGroups.forEach(gId => { affectedGroups: Set<string | null>
const gItems = newItems.filter(d => d.groupId === gId); ) {
gItems.forEach((item, index) => { const counters = new Map<string | null, number>();
item.sortOrder = index; const nextItems = proposedItems.map((item) => {
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId }); if (!affectedGroups.has(item.groupId)) return item;
}); const sortOrder = counters.get(item.groupId) ?? 0;
counters.set(item.groupId, sortOrder + 1);
return { ...item, sortOrder };
}); });
const updates: ReorderItem[] = nextItems
.filter((item) => affectedGroups.has(item.groupId))
.map((item) => ({ id: item.id, groupId: item.groupId }));
fetch("/api/decks/reorder", { setDecks(nextItems);
setIsReordering(true);
try {
const response = await fetch("/api/decks/reorder", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }), body: JSON.stringify({ items: updates }),
}); });
if (!response.ok) throw new Error("Reorder rejected");
return true;
} catch {
setDecks(previousItems);
window.alert("The deck move could not be saved. The library was refreshed.");
await fetchAll(classId);
return false;
} finally {
setIsReordering(false);
}
}
return newItems; async function handleMoveDeck(deckId: string, targetGroupId: string | null) {
}); if (isReordering) return;
const activeDeck = decks.find((deck) => deck.id === deckId);
if (!activeDeck || activeDeck.groupId === targetGroupId) return;
const nextItems = decks
.filter((deck) => deck.id !== deckId)
.map((deck) => ({ ...deck }));
nextItems.push({ ...activeDeck, groupId: targetGroupId });
const saved = await persistDeckReorder(
decks,
nextItems,
new Set([activeDeck.groupId, targetGroupId])
);
if (saved) {
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
setActionMessage(`Moved ${activeDeck.name} to ${destination}.`);
} }
} }
@ -381,6 +505,11 @@ export default function FlashcardsPage() {
return ( return (
<div className="pb-20"> <div className="pb-20">
{actionMessage && (
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
{actionMessage}
</p>
)}
{/* Header */} {/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div> <div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
@ -443,15 +572,23 @@ export default function FlashcardsPage() {
</div> </div>
)} )}
{!loading && decks.length === 0 && groups.length === 0 && ( {!loading && loadError && (
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
<p className="font-bold">Flashcard library could not be loaded.</p>
<p className="mt-1 text-sm">{loadError}</p>
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
</div>
)}
{!loading && !loadError && decks.length === 0 && groups.length === 0 && (
<div className="text-center py-16"> <div className="text-center py-16">
<h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3> <h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3>
<p className="text-text-secondary mb-4">Import your first deck or create a group</p> <p className="text-text-secondary mb-4">Import your first deck or create a group</p>
</div> </div>
)} )}
{!loading && ( {!loading && !loadError && (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8"> <div className="space-y-8">
{groupedDecks.map(group => ( {groupedDecks.map(group => (
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5"> <div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
@ -485,7 +622,7 @@ export default function FlashcardsPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div> <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div>
) : ( ) : (
group.decks.map(deck => ( group.decks.map(deck => (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> <SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
)) ))
)} )}
</DroppableContainer> </DroppableContainer>
@ -510,7 +647,7 @@ export default function FlashcardsPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div> <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div>
) : ( ) : (
uncategorizedDecks.map(deck => ( uncategorizedDecks.map(deck => (
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> <SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
)) ))
)} )}
</DroppableContainer> </DroppableContainer>
@ -522,7 +659,7 @@ export default function FlashcardsPage() {
<DragOverlay> <DragOverlay>
{activeDeck ? ( {activeDeck ? (
<div className="opacity-80 scale-105 shadow-xl rotate-2"> <div className="opacity-80 scale-105 shadow-xl rotate-2">
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} /> <SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
</div> </div>
) : null} ) : null}
</DragOverlay> </DragOverlay>

View file

@ -27,6 +27,7 @@ interface QuizAttempt {
score: number; score: number;
maxScore: number; maxScore: number;
answersJson: string; answersJson: string;
reviewSnapshotJson?: string | null;
isPartialRetake: boolean; isPartialRetake: boolean;
completedAt: string; completedAt: string;
} }
@ -42,6 +43,8 @@ interface QuizData {
currentIndex: number; currentIndex: number;
orderJson: string; orderJson: string;
answersJson: string | null; answersJson: string | null;
sessionId: string;
revision: number;
}>; }>;
} }
@ -60,13 +63,23 @@ export default function QuizStudyPage() {
async function handleRestart() { async function handleRestart() {
if (!quiz) return; if (!quiz) return;
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
// Clear progress in database if (progress) {
await fetch("/api/progress", { const response = await fetch("/api/progress", {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }) body: JSON.stringify({
}).catch(() => {}); contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
sessionId: progress.sessionId,
}),
}).catch(() => null);
if (!response?.ok) {
window.alert("The quiz could not be restarted. Your saved progress was kept.");
return;
}
}
// Clear locally and force remount // Clear locally and force remount
setQuiz({ ...quiz, progress: [] }); setQuiz({ ...quiz, progress: [] });
@ -104,7 +117,8 @@ export default function QuizStudyPage() {
}, [quizId, classSlug, router]); }, [quizId, classSlug, router]);
useEffect(() => { useEffect(() => {
fetchQuizAndAttempts(); const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0);
return () => window.clearTimeout(timer);
}, [fetchQuizAndAttempts]); }, [fetchQuizAndAttempts]);
const [showTopics, setShowTopics] = useState(false); const [showTopics, setShowTopics] = useState(false);
@ -221,6 +235,7 @@ export default function QuizStudyPage() {
key={restartKey} key={restartKey}
quiz={quiz} quiz={quiz}
retakeIds={retakeIds} retakeIds={retakeIds}
sessionKey={restartKey}
onFinished={() => { onFinished={() => {
router.push(`/${classSlug}/quizzes`); router.push(`/${classSlug}/quizzes`);
}} }}

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback, useRef } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams } from "next/navigation"; import { useParams } from "next/navigation";
import { ImportModal } from "@/components/import/ImportModal"; import { ImportModal } from "@/components/import/ImportModal";
@ -13,7 +13,6 @@ import {
useSensor, useSensor,
useSensors, useSensors,
DragEndEvent, DragEndEvent,
DragOverEvent,
DragStartEvent, DragStartEvent,
DragOverlay, DragOverlay,
useDroppable, useDroppable,
@ -41,6 +40,8 @@ interface QuizItem {
currentIndex: number; currentIndex: number;
orderJson: string; orderJson: string;
answersJson: string | null; answersJson: string | null;
sessionId: string;
revision: number;
}>; }>;
} }
@ -50,16 +51,25 @@ interface MaterialGroup {
sortOrder: number; sortOrder: number;
} }
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
interface SortableQuizCardProps { interface SortableQuizCardProps {
quiz: QuizItem; quiz: QuizItem;
onEdit: (quiz: QuizItem) => void; onEdit: (quiz: QuizItem) => void;
onDelete: (quiz: QuizItem) => void; onDelete: (quiz: QuizItem) => void;
groups: MaterialGroup[];
onMove: (quizId: string, groupId: string | null) => void;
moveDisabled?: boolean;
classSlug: string; classSlug: string;
} }
function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) { function SortableQuizCard({
quiz,
onEdit,
onDelete,
groups,
onMove,
moveDisabled = false,
classSlug,
}: SortableQuizCardProps) {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id }); const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@ -103,11 +113,22 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
<button <button
onClick={async () => { onClick={async () => {
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return; if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
await fetch("/api/progress", { const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
if (!progress) return;
const response = await fetch("/api/progress", {
method: "DELETE", method: "DELETE",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }), body: JSON.stringify({
}).catch(() => {}); contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
sessionId: progress.sessionId,
}),
}).catch(() => null);
if (!response?.ok) {
window.alert("The quiz could not be restarted. Your saved progress was kept.");
return;
}
window.location.href = `/${classSlug}/quizzes/${quiz.id}`; window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
}} }}
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border" className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
@ -123,6 +144,20 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
)} )}
</div> </div>
<div className="flex items-center justify-end gap-2"> <div className="flex items-center justify-end gap-2">
<label className="sr-only" htmlFor={`move-quiz-${quiz.id}`}>Move {quiz.name} to group</label>
<select
id={`move-quiz-${quiz.id}`}
value={quiz.groupId ?? ""}
onChange={(event) => onMove(quiz.id, event.target.value || null)}
disabled={moveDisabled}
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
aria-label={`Move ${quiz.name} to group`}
>
<option value="">Uncategorized</option>
{groups.map((group) => (
<option key={group.id} value={group.id}>{group.name}</option>
))}
</select>
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit"> <button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
@ -159,10 +194,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
export default function QuizzesPage() { export default function QuizzesPage() {
const params = useParams(); const params = useParams();
const classSlug = params.classSlug as string; const classSlug = params.classSlug as string;
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug]?.quizzes || []); const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []); const [groups, setGroups] = useState<MaterialGroup[]>([]);
const [loading, setLoading] = useState(!cache[classSlug]); const [loading, setLoading] = useState(true);
const [animate] = useState(!cache[classSlug]); const [loadError, setLoadError] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
const requestGeneration = useRef(0);
const [showImport, setShowImport] = useState(false); const [showImport, setShowImport] = useState(false);
const [classId, setClassId] = useState<string>(""); const [classId, setClassId] = useState<string>("");
const [editingId, setEditingId] = useState<string | null>(null); const [editingId, setEditingId] = useState<string | null>(null);
@ -172,13 +209,17 @@ export default function QuizzesPage() {
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [editingGroupId, setEditingGroupId] = useState<string | null>(null); const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
const [editGroupName, setEditGroupName] = useState(""); const [editGroupName, setEditGroupName] = useState("");
const [actionMessage, setActionMessage] = useState<string | null>(null);
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({}); const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => {
const saved = localStorage.getItem('quizzes_collapsed_groups'); const saved = localStorage.getItem('quizzes_collapsed_groups');
if (saved) { if (saved) {
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {} try { setCollapsedGroups(JSON.parse(saved)); } catch {}
} }
}, 0);
return () => window.clearTimeout(timer);
}, []); }, []);
function toggleGroup(id: string) { function toggleGroup(id: string) {
@ -189,43 +230,54 @@ export default function QuizzesPage() {
}); });
} }
const fetchAll = useCallback(async (cId: string) => { const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
try { try {
const [quizRes, groupRes] = await Promise.all([ const [quizRes, groupRes] = await Promise.all([
fetch(`/api/quizzes/list?classId=${cId}`), fetch(`/api/quizzes/list?classId=${cId}`, { signal }),
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`), fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }),
]); ]);
const qs = quizRes.ok ? await quizRes.json() : []; if (!quizRes.ok || !groupRes.ok) throw new Error("Unable to load quiz library");
const gs = groupRes.ok ? await groupRes.json() : []; const qs = await quizRes.json();
const gs = await groupRes.json();
if (generation !== requestGeneration.current) return;
setQuizzes(qs); setQuizzes(qs);
setGroups(gs); setGroups(gs);
cache[classSlug] = { quizzes: qs, groups: gs }; setLoadError(null);
} catch { } catch (error) {
setQuizzes([]); if (signal?.aborted || generation !== requestGeneration.current) return;
setGroups([]); setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
} finally { } finally {
setLoading(false); if (generation === requestGeneration.current) setLoading(false);
} }
}, [classSlug]); }, []);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
const generation = ++requestGeneration.current;
async function init() { async function init() {
try { try {
const classRes = await fetch("/api/classes"); setLoading(true);
setLoadError(null);
const classRes = await fetch("/api/classes", { signal: controller.signal });
if (!classRes.ok) throw new Error("Unable to load class");
const classes = await classRes.json(); const classes = await classRes.json();
const cls = classes.find((c: { slug: string }) => c.slug === classSlug); const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
if (!cls) { if (!cls) throw new Error("Class not found");
setLoading(false); if (generation !== requestGeneration.current) return;
return;
}
setClassId(cls.id); setClassId(cls.id);
fetchAll(cls.id); await fetchAll(cls.id, generation, controller.signal);
} catch { } catch (error) {
if (controller.signal.aborted || generation !== requestGeneration.current) return;
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
setLoading(false); setLoading(false);
} }
} }
init(); const timer = window.setTimeout(() => void init(), 0);
}, [classSlug, fetchAll]); return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [classSlug, fetchAll, reloadKey]);
// Group Management // Group Management
async function handleCreateGroup() { async function handleCreateGroup() {
@ -240,45 +292,67 @@ export default function QuizzesPage() {
setGroups((prev) => [g, ...prev]); setGroups((prev) => [g, ...prev]);
setIsCreatingGroup(false); setIsCreatingGroup(false);
setNewGroupName(""); setNewGroupName("");
setActionMessage(`Created group ${g.name}.`);
} else {
setActionMessage("The group could not be created. Please retry.");
} }
} }
async function handleRenameGroup(id: string) { async function handleRenameGroup(id: string) {
if (!editGroupName.trim()) return; if (!editGroupName.trim()) return;
await fetch(`/api/material-groups/${id}`, { const response = await fetch(`/api/material-groups/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editGroupName.trim() }), body: JSON.stringify({ name: editGroupName.trim() }),
}); });
if (!response.ok) {
setActionMessage("The group could not be renamed. No changes were applied.");
return;
}
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g))); setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
setEditingGroupId(null); setEditingGroupId(null);
setActionMessage("Group renamed.");
} }
async function handleDeleteGroup(id: string, name: string) { async function handleDeleteGroup(id: string, name: string) {
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return; if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
await fetch(`/api/material-groups/${id}`, { method: "DELETE" }); const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
setGroups((prev) => prev.filter((g) => g.id !== id)); if (!response.ok) {
setQuizzes((prev) => prev.map((q) => (q.groupId === id ? { ...q, groupId: null } : q))); setActionMessage("The group could not be deleted. No changes were applied.");
return;
}
await fetchAll(classId);
setActionMessage(`Deleted group ${name}; its quizzes are now Uncategorized.`);
} }
// Quiz Management // Quiz Management
async function handleDeleteQuiz(quiz: QuizItem) { async function handleDeleteQuiz(quiz: QuizItem) {
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return; if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" }); const response = await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
if (!response.ok) {
setActionMessage("The quiz could not be deleted. No changes were applied.");
return;
}
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id)); setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
setActionMessage(`Deleted quiz ${quiz.name}.`);
} }
async function handleRenameQuiz(id: string) { async function handleRenameQuiz(id: string) {
if (!editName.trim()) return; if (!editName.trim()) return;
await fetch(`/api/quizzes/${id}`, { const response = await fetch(`/api/quizzes/${id}`, {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }), body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
}); });
if (!response.ok) {
setActionMessage("The quiz could not be updated. No changes were applied.");
return;
}
setQuizzes((prev) => setQuizzes((prev) =>
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q)) prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
); );
setEditingId(null); setEditingId(null);
setActionMessage("Quiz updated.");
} }
// Drag and drop setup // Drag and drop setup
@ -288,26 +362,13 @@ export default function QuizzesPage() {
); );
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [isReordering, setIsReordering] = useState(false);
function handleDragStart(event: DragStartEvent) { function handleDragStart(event: DragStartEvent) {
if (isReordering) return;
setActiveId(event.active.id as string); setActiveId(event.active.id as string);
} }
function handleDragOver(event: DragOverEvent) {
const { active, over } = event;
if (!over) return;
const activeId = active.id as string;
const overId = over.id as string;
if (activeId === overId) return;
// Is it dropping over a container directly?
const isOverContainer = over.data.current?.sortable?.containerId || overId;
// ... we handle layout updates in dragEnd to keep it simple, or here for smooth lists.
// For simplicity, we just handle the final move in dragEnd.
}
async function handleDragEnd(event: DragEndEvent) { async function handleDragEnd(event: DragEndEvent) {
setActiveId(null); setActiveId(null);
const { active, over } = event; const { active, over } = event;
@ -333,43 +394,76 @@ export default function QuizzesPage() {
} }
} }
if (targetGroupId !== undefined) { const oldIndex = quizzes.findIndex((quiz) => quiz.id === activeId);
setQuizzes((items) => { const overIndex = quizzes.findIndex((quiz) => quiz.id === overId);
const oldIndex = items.findIndex((q) => q.id === activeId); let nextItems = quizzes.map((quiz) =>
const overIndex = items.findIndex((q) => q.id === overId); quiz.id === activeId ? { ...quiz, groupId: targetGroupId } : { ...quiz }
let newItems = [...items]; );
newItems[oldIndex].groupId = targetGroupId;
if (overIndex >= 0 && overIndex !== oldIndex) { if (overIndex >= 0 && overIndex !== oldIndex) {
newItems = arrayMove(newItems, oldIndex, overIndex); nextItems = arrayMove(nextItems, oldIndex, overIndex);
} else { } else {
// just moved to end of a group const [moved] = nextItems.splice(oldIndex, 1);
const oldItem = newItems.splice(oldIndex, 1)[0]; nextItems.push(moved);
newItems.push(oldItem); }
await persistQuizReorder(
quizzes,
nextItems,
new Set([activeQuiz.groupId, targetGroupId])
);
} }
// Re-calculate sortOrder for the affected groups to persist async function persistQuizReorder(
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]); previousItems: QuizItem[],
const updates: ReorderItem[] = []; proposedItems: QuizItem[],
affectedGroups: Set<string | null>
affectedGroups.forEach(gId => { ) {
const gItems = newItems.filter(q => q.groupId === gId); const counters = new Map<string | null, number>();
gItems.forEach((item, index) => { const nextItems = proposedItems.map((item) => {
item.sortOrder = index; if (!affectedGroups.has(item.groupId)) return item;
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId }); const sortOrder = counters.get(item.groupId) ?? 0;
}); counters.set(item.groupId, sortOrder + 1);
return { ...item, sortOrder };
}); });
const updates: ReorderItem[] = nextItems
.filter((item) => affectedGroups.has(item.groupId))
.map((item) => ({ id: item.id, groupId: item.groupId }));
// Fire API setQuizzes(nextItems);
fetch("/api/quizzes/reorder", { setIsReordering(true);
try {
const response = await fetch("/api/quizzes/reorder", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ items: updates }), body: JSON.stringify({ items: updates }),
}); });
if (!response.ok) throw new Error("Reorder rejected");
return true;
} catch {
setQuizzes(previousItems);
window.alert("The quiz move could not be saved. The library was refreshed.");
await fetchAll(classId);
return false;
} finally {
setIsReordering(false);
}
}
return newItems; async function handleMoveQuiz(quizId: string, targetGroupId: string | null) {
}); if (isReordering) return;
const activeQuiz = quizzes.find((quiz) => quiz.id === quizId);
if (!activeQuiz || activeQuiz.groupId === targetGroupId) return;
const nextItems = quizzes
.filter((quiz) => quiz.id !== quizId)
.map((quiz) => ({ ...quiz }));
nextItems.push({ ...activeQuiz, groupId: targetGroupId });
const saved = await persistQuizReorder(
quizzes,
nextItems,
new Set([activeQuiz.groupId, targetGroupId])
);
if (saved) {
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
setActionMessage(`Moved ${activeQuiz.name} to ${destination}.`);
} }
} }
@ -384,6 +478,11 @@ export default function QuizzesPage() {
return ( return (
<div className="pb-20"> <div className="pb-20">
{actionMessage && (
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
{actionMessage}
</p>
)}
{/* Header */} {/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div> <div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
@ -447,15 +546,23 @@ export default function QuizzesPage() {
</div> </div>
)} )}
{!loading && quizzes.length === 0 && groups.length === 0 && ( {!loading && loadError && (
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
<p className="font-bold">Quiz library could not be loaded.</p>
<p className="mt-1 text-sm">{loadError}</p>
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
</div>
)}
{!loading && !loadError && quizzes.length === 0 && groups.length === 0 && (
<div className="text-center py-16"> <div className="text-center py-16">
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3> <h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
<p className="text-text-secondary mb-4">Import your first quiz or create a group</p> <p className="text-text-secondary mb-4">Import your first quiz or create a group</p>
</div> </div>
)} )}
{!loading && ( {!loading && !loadError && (
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}> <DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div className="space-y-8"> <div className="space-y-8">
{groupedQuizzes.map(group => ( {groupedQuizzes.map(group => (
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5"> <div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
@ -476,7 +583,7 @@ export default function QuizzesPage() {
)} )}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} /> <ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} containsQuizAnswers />
<button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button> <button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
<button onClick={() => handleDeleteGroup(group.id, group.name)} className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button> <button onClick={() => handleDeleteGroup(group.id, group.name)} className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
</div> </div>
@ -489,7 +596,7 @@ export default function QuizzesPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div> <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div>
) : ( ) : (
group.quizzes.map(quiz => ( group.quizzes.map(quiz => (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> <SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
)) ))
)} )}
</DroppableContainer> </DroppableContainer>
@ -514,7 +621,7 @@ export default function QuizzesPage() {
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div> <div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div>
) : ( ) : (
uncategorizedQuizzes.map(quiz => ( uncategorizedQuizzes.map(quiz => (
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> <SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
)) ))
)} )}
</DroppableContainer> </DroppableContainer>
@ -526,7 +633,7 @@ export default function QuizzesPage() {
<DragOverlay> <DragOverlay>
{activeQuiz ? ( {activeQuiz ? (
<div className="opacity-80 scale-105 shadow-xl rotate-2"> <div className="opacity-80 scale-105 shadow-xl rotate-2">
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} /> <SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
</div> </div>
) : null} ) : null}
</DragOverlay> </DragOverlay>

View file

@ -20,6 +20,7 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7
export default function HomePage() { export default function HomePage() {
const [classes, setClasses] = useState<ClassItem[]>([]); const [classes, setClasses] = useState<ClassItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [newName, setNewName] = useState(""); const [newName, setNewName] = useState("");
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
@ -27,13 +28,25 @@ export default function HomePage() {
const [editName, setEditName] = useState(""); const [editName, setEditName] = useState("");
const [savingEdit, setSavingEdit] = useState(false); const [savingEdit, setSavingEdit] = useState(false);
useEffect(() => { fetchClasses(); }, []); useEffect(() => {
const controller = new AbortController();
void fetchClasses(controller.signal);
return () => controller.abort();
}, []);
async function fetchClasses() { async function fetchClasses(signal?: AbortSignal) {
setLoading(true);
setLoadError(null);
try { try {
const res = await fetch("/api/classes"); const res = await fetch("/api/classes", { signal });
if (!res.ok) throw new Error("The server could not load your classes.");
setClasses(await res.json()); setClasses(await res.json());
} finally { setLoading(false); } } catch (error) {
if (signal?.aborted) return;
setLoadError(error instanceof Error ? error.message : "Unable to load classes.");
} finally {
if (!signal?.aborted) setLoading(false);
}
} }
async function handleCreate(e: React.FormEvent) { async function handleCreate(e: React.FormEvent) {
@ -42,14 +55,21 @@ export default function HomePage() {
setCreating(true); setCreating(true);
try { try {
const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) }); const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) });
if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); } if (!res.ok) throw new Error("The class could not be created.");
setNewName(""); setShowCreate(false); void fetchClasses();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "The class could not be created.");
} finally { setCreating(false); } } finally { setCreating(false); }
} }
async function handleDelete(id: string, name: string) { async function handleDelete(id: string, name: string) {
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return; if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
await fetch(`/api/classes/${id}`, { method: "DELETE" }); const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null);
fetchClasses(); if (!response?.ok) {
setLoadError("The class could not be deleted. Retry when the server is available.");
return;
}
void fetchClasses();
} }
async function handleSaveEdit(id: string) { async function handleSaveEdit(id: string) {
@ -57,7 +77,10 @@ export default function HomePage() {
setSavingEdit(true); setSavingEdit(true);
try { try {
const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) }); const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) });
if (res.ok) { setEditingId(null); fetchClasses(); } if (!res.ok) throw new Error("The class could not be renamed.");
setEditingId(null); void fetchClasses();
} catch (error) {
setLoadError(error instanceof Error ? error.message : "The class could not be renamed.");
} finally { setSavingEdit(false); } } finally { setSavingEdit(false); }
} }
@ -93,7 +116,15 @@ export default function HomePage() {
</div> </div>
)} )}
{!loading && classes.length === 0 && ( {!loading && loadError && (
<div role="alert" className="rounded-2xl border border-error/30 bg-error-bg p-6 text-error">
<p className="font-bold">Your study spaces could not be loaded.</p>
<p className="mt-1 text-sm">{loadError}</p>
<button onClick={() => void fetchClasses()} className="mt-4 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
</div>
)}
{!loading && !loadError && classes.length === 0 && (
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center"> <div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div> <div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2> <h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
@ -102,7 +133,7 @@ export default function HomePage() {
</div> </div>
)} )}
{!loading && classes.length > 0 && ( {!loading && !loadError && classes.length > 0 && (
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{classes.map((cls, index) => ( {classes.map((cls, index) => (
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]"> <article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">

View file

@ -4,8 +4,7 @@ import { checkRateLimit } from "@/lib/rateLimiter";
import { login } from "@/services/authService"; import { login } from "@/services/authService";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; if (!checkRateLimit("login:global").allowed) {
if (!checkRateLimit(`login:${ip}`).allowed) {
return NextResponse.json( return NextResponse.json(
{ error: "Too many requests. Try again shortly." }, { error: "Too many requests. Try again shortly." },
{ status: 429 } { status: 429 }

View file

@ -1,8 +1,20 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { completePasswordResetSchema } from "@/lib/validation/authSchemas"; import { completePasswordResetSchema } from "@/lib/validation/authSchemas";
import { checkRateLimit } from "@/lib/rateLimiter";
import { completePasswordReset } from "@/services/authService"; import { completePasswordReset } from "@/services/authService";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (
!checkRateLimit("password-reset-complete:global", {
windowMs: 60_000,
maxRequests: 5,
}).allowed
) {
return NextResponse.json(
{ error: "Too many attempts. Try again shortly." },
{ status: 429 }
);
}
const parsed = completePasswordResetSchema.safeParse( const parsed = completePasswordResetSchema.safeParse(
await request.json().catch(() => null) await request.json().catch(() => null)
); );

View file

@ -1,11 +1,17 @@
import { NextRequest, NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { checkRateLimit } from "@/lib/rateLimiter"; import { checkRateLimit } from "@/lib/rateLimiter";
import { requestPasswordReset } from "@/services/authService"; import { requestPasswordReset } from "@/services/authService";
export async function POST(request: NextRequest) { export async function POST() {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; if (process.env.NODE_ENV === "production") {
const rateLimit = checkRateLimit(`password-reset-request:${ip}`, { return NextResponse.json(
windowMs: 60_000, { error: "Start password recovery from the local server console." },
{ status: 403 }
);
}
const rateLimit = checkRateLimit("password-reset-request:global", {
windowMs: 15 * 60_000,
maxRequests: 1, maxRequests: 1,
}); });
if (!rateLimit.allowed) { if (!rateLimit.allowed) {
@ -15,11 +21,18 @@ export async function POST(request: NextRequest) {
); );
} }
if (!(await requestPasswordReset())) { const result = await requestPasswordReset();
if (result.status === "missing-password") {
return NextResponse.json( return NextResponse.json(
{ error: "Password recovery is unavailable before initial setup." }, { error: "Password recovery is unavailable before initial setup." },
{ status: 409 } { status: 409 }
); );
} }
return NextResponse.json({ success: true }); if (result.status === "active-token") {
return NextResponse.json(
{ error: "An unexpired reset token already exists." },
{ status: 409 }
);
}
return NextResponse.json({ success: true, token: result.token });
} }

View file

@ -4,8 +4,7 @@ import { resetTokenSchema } from "@/lib/validation/authSchemas";
import { verifyPasswordResetToken } from "@/services/authService"; import { verifyPasswordResetToken } from "@/services/authService";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; if (!checkRateLimit("password-reset-verify:global").allowed) {
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
return NextResponse.json( return NextResponse.json(
{ error: "Too many attempts. Try again shortly." }, { error: "Too many attempts. Try again shortly." },
{ status: 429 } { status: 429 }

View file

@ -1,13 +1,9 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { getSetupStatus } from "@/services/authService";
export async function GET() { export async function GET() {
try { try {
const setting = await prisma.setting.findUnique({ return NextResponse.json(await getSetupStatus());
where: { key: "admin_password_hash" },
});
return NextResponse.json({ setupRequired: !setting });
} catch (error) { } catch (error) {
console.error("Failed to check setup status:", error); console.error("Failed to check setup status:", error);
return NextResponse.json( return NextResponse.json(

View file

@ -1,22 +1,25 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as cardService from "@/services/cardService"; import * as cardService from "@/services/cardService";
import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
ctx: RouteContext<"/api/cards/[id]"> ctx: RouteContext<"/api/cards/[id]">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
const body = await request.json().catch(() => null); const parsed = cardUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid card update", details: parsed.error.issues }, { status: 400 });
}
try { try {
const updated = await cardService.updateCard(id, { return NextResponse.json(await cardService.updateCard(id, parsed.data));
front: body?.front?.trim(), } catch (error) {
back: body?.back?.trim(), if (isPrismaError(error, "P2025")) {
});
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
console.error("Failed to update card", error);
return NextResponse.json({ error: "Failed to update card" }, { status: 500 });
}
} }
export async function DELETE( export async function DELETE(
@ -24,11 +27,14 @@ export async function DELETE(
ctx: RouteContext<"/api/cards/[id]"> ctx: RouteContext<"/api/cards/[id]">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
try { try {
await cardService.deleteCard(id); await cardService.deleteCard(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch { } catch (error) {
if (isPrismaError(error, "P2025")) {
return NextResponse.json({ error: "Card not found" }, { status: 404 }); return NextResponse.json({ error: "Card not found" }, { status: 404 });
} }
console.error("Failed to delete card", error);
return NextResponse.json({ error: "Failed to delete card" }, { status: 500 });
}
} }

View file

@ -1,25 +1,25 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as classService from "@/services/classService"; import * as classService from "@/services/classService";
import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
ctx: RouteContext<"/api/classes/[id]"> ctx: RouteContext<"/api/classes/[id]">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
const body = await request.json().catch(() => null); const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
if (!body || (!body.name && body.name !== "")) { return NextResponse.json({ error: "A valid class name is required" }, { status: 400 });
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
} }
try { try {
const updated = await classService.updateClass(id, { return NextResponse.json(await classService.updateClass(id, parsed.data));
name: body.name?.trim(), } catch (error) {
}); if (isPrismaError(error, "P2025")) {
return NextResponse.json(updated);
} catch {
return NextResponse.json({ error: "Class not found" }, { status: 404 }); return NextResponse.json({ error: "Class not found" }, { status: 404 });
} }
console.error("Failed to update class", error);
return NextResponse.json({ error: "Failed to update class" }, { status: 500 });
}
} }
export async function DELETE( export async function DELETE(
@ -27,11 +27,14 @@ export async function DELETE(
ctx: RouteContext<"/api/classes/[id]"> ctx: RouteContext<"/api/classes/[id]">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
try { try {
await classService.deleteClass(id); await classService.deleteClass(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch { } catch (error) {
if (isPrismaError(error, "P2025")) {
return NextResponse.json({ error: "Class not found" }, { status: 404 }); return NextResponse.json({ error: "Class not found" }, { status: 404 });
} }
console.error("Failed to delete class", error);
return NextResponse.json({ error: "Failed to delete class" }, { status: 500 });
}
} }

View file

@ -1,20 +1,23 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as classService from "@/services/classService"; import * as classService from "@/services/classService";
import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
export async function GET() { export async function GET() {
const classes = await classService.listClasses(); return NextResponse.json(await classService.listClasses());
return NextResponse.json(classes);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null); const parsed = classCreateSchema.safeParse(await request.json().catch(() => null));
if (!body?.name || typeof body.name !== "string" || !body.name.trim()) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { status: 400 });
{ error: "Class name is required" }, }
{ status: 400 } try {
); return NextResponse.json(await classService.createClass(parsed.data.name), { status: 201 });
} catch (error) {
if (isPrismaError(error, "P2002")) {
return NextResponse.json({ error: "A class with that slug already exists" }, { status: 409 });
}
console.error("Failed to create class", error);
return NextResponse.json({ error: "Failed to create class" }, { status: 500 });
} }
const newClass = await classService.createClass(body.name.trim());
return NextResponse.json(newClass, { status: 201 });
} }

View file

@ -1,29 +1,26 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as cardService from "@/services/cardService"; import * as cardService from "@/services/cardService";
import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas";
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
ctx: RouteContext<"/api/decks/[id]/cards"> ctx: RouteContext<"/api/decks/[id]/cards">
) { ) {
const { id: deckId } = await ctx.params; const { id: deckId } = await ctx.params;
const body = await request.json().catch(() => null); const parsed = cardContentSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
if (
!body?.front ||
!body?.back ||
typeof body.front !== "string" ||
typeof body.back !== "string"
) {
return NextResponse.json( return NextResponse.json(
{ error: "front and back are required" }, { error: "Card front and back are required and must be within the content limit", details: parsed.error.issues },
{ status: 400 } { status: 400 }
); );
} }
try {
const card = await cardService.createCard(deckId, { return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 });
front: body.front.trim(), } catch (error) {
back: body.back.trim(), if (isPrismaError(error, "P2003")) {
}); return NextResponse.json({ error: "Deck not found" }, { status: 404 });
}
return NextResponse.json(card, { status: 201 }); console.error("Failed to create card", error);
return NextResponse.json({ error: "Failed to create card" }, { status: 500 });
}
} }

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService"; import * as deckService from "@/services/deckService";
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
@ -20,17 +21,22 @@ export async function PATCH(
ctx: RouteContext<"/api/decks/[id]"> ctx: RouteContext<"/api/decks/[id]">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
const body = await request.json().catch(() => null); const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid deck update", details: parsed.error.issues }, { status: 400 });
}
try { try {
const updated = await deckService.updateDeck(id, { const updated = await deckService.updateDeck(id, parsed.data);
name: body?.name?.trim(),
description: body?.description,
});
return NextResponse.json(updated); return NextResponse.json(updated);
} catch { } catch (error) {
if (isPrismaError(error, "P2025")) {
return NextResponse.json({ error: "Deck not found" }, { status: 404 }); return NextResponse.json({ error: "Deck not found" }, { status: 404 });
} }
console.error("Failed to update deck", error);
return NextResponse.json({ error: "Failed to update deck" }, { status: 500 });
}
} }
export async function DELETE( export async function DELETE(
@ -42,7 +48,11 @@ export async function DELETE(
try { try {
await deckService.deleteDeck(id); await deckService.deleteDeck(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch { } catch (error) {
if (isPrismaError(error, "P2025")) {
return NextResponse.json({ error: "Deck not found" }, { status: 404 }); return NextResponse.json({ error: "Deck not found" }, { status: 404 });
} }
console.error("Failed to delete deck", error);
return NextResponse.json({ error: "Failed to delete deck" }, { status: 500 });
}
} }

View file

@ -1,32 +1,29 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
import {
ReorderConflictError,
ReorderValidationError,
reorderContent,
} from "@/services/reorderService";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { const parsed = reorderRequestSchema.safeParse(
const parsed = reorderRequestSchema.safeParse(await request.json()); await request.json().catch(() => null)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const { items } = parsed.data;
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item) =>
prisma.deck.update({
where: { id: item.id },
data: {
sortOrder: item.sortOrder,
groupId: item.groupId,
},
})
)
); );
if (!parsed.success) {
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
}
try {
await reorderContent("DECK", parsed.data);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof ReorderValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof ReorderConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
console.error("Failed to reorder decks", error);
return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 }); return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 });
} }
} }

View file

@ -1,35 +1,40 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as deckService from "@/services/deckService"; import * as deckService from "@/services/deckService";
import { flashcardImportSchema } from "@/lib/validation/importSchemas"; import { deckImportRequestSchema } from "@/lib/validation/importSchemas";
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null); let body: unknown;
try {
if (!body) { body = await readLimitedJson(request);
return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } catch (error) {
if (error instanceof RequestTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
throw error;
} }
const { classId, data, name, groupId } = body; const parsed = deckImportRequestSchema.safeParse(body);
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
// Server-side validation — never trust client
const parsed = flashcardImportSchema.safeParse(data);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.issues }, { error: "Invalid deck import", details: parsed.error.issues },
{ status: 400 } { status: 400 }
); );
} }
try {
const deck = await deckService.createDeckFromImport( const deck = await deckService.createDeckFromImport(
classId, parsed.data.classId,
parsed.data, parsed.data.data,
name?.trim() || undefined, parsed.data.name,
groupId || null parsed.data.groupId ?? null
); );
return NextResponse.json(deck, { status: 201 }); return NextResponse.json(deck, { status: 201 });
} catch (error) {
if (error instanceof deckService.DeckValidationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error("Failed to create deck", error);
return NextResponse.json({ error: "Failed to create deck" }, { status: 500 });
}
} }

View file

@ -0,0 +1,12 @@
import { NextResponse } from "next/server";
import { checkApplicationHealth } from "@/services/healthService";
export async function GET() {
try {
await checkApplicationHealth();
return NextResponse.json({ status: "ok" });
} catch (error) {
console.error("Health check failed: database schema is unavailable", error);
return NextResponse.json({ status: "unhealthy" }, { status: 503 });
}
}

View file

@ -1,42 +1,44 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas";
import type { Prisma } from "@/generated/prisma/client"; import {
deleteMaterialGroup,
renameMaterialGroup,
} from "@/services/materialGroupService";
export async function PATCH( export async function PATCH(
request: NextRequest, request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try {
const { id } = await params; const { id } = await params;
const body = await request.json(); const parsed = materialGroupUpdateSchema.safeParse(
const { name, sortOrder } = body; await request.json().catch(() => null)
);
const updateData: Prisma.MaterialGroupUpdateInput = {}; if (!parsed.success) {
if (name !== undefined) updateData.name = name; return NextResponse.json({ error: "Invalid group update" }, { status: 400 });
if (sortOrder !== undefined) updateData.sortOrder = sortOrder; }
try {
const group = await prisma.materialGroup.update({ return NextResponse.json(await renameMaterialGroup(id, parsed.data.name));
where: { id },
data: updateData,
});
return NextResponse.json(group);
} catch (error) { } catch (error) {
return NextResponse.json({ error: "Failed to update material group" }, { status: 500 }); if (
typeof error === "object" &&
error !== null &&
"code" in error &&
error.code === "P2025"
) {
return NextResponse.json({ error: "Group not found" }, { status: 404 });
}
console.error("Failed to rename material group", error);
return NextResponse.json({ error: "Failed to rename group" }, { status: 500 });
} }
} }
export async function DELETE( export async function DELETE(
request: NextRequest, _request: NextRequest,
{ params }: { params: Promise<{ id: string }> } { params }: { params: Promise<{ id: string }> }
) { ) {
try {
const { id } = await params; const { id } = await params;
await prisma.materialGroup.delete({ if (!(await deleteMaterialGroup(id))) {
where: { id }, return NextResponse.json({ error: "Group not found" }, { status: 404 });
});
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 });
} }
return NextResponse.json({ success: true });
} }

View file

@ -1,57 +1,38 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db"; import {
import type { Prisma } from "@/generated/prisma/client"; materialGroupCreateSchema,
materialGroupQuerySchema,
} from "@/lib/validation/materialGroupSchemas";
import {
createMaterialGroup,
listMaterialGroups,
} from "@/services/materialGroupService";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const parsed = materialGroupQuerySchema.safeParse(
const classId = searchParams.get("classId"); Object.fromEntries(request.nextUrl.searchParams)
const type = searchParams.get("type"); );
if (!parsed.success) {
if (!classId) { return NextResponse.json({ error: "Invalid group query" }, { status: 400 });
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
if (type && type !== "DECK" && type !== "QUIZ") {
return NextResponse.json({ error: "Invalid type" }, { status: 400 });
}
const whereClause: Prisma.MaterialGroupWhereInput = { classId };
if (type) {
whereClause.type = type;
}
try {
const groups = await prisma.materialGroup.findMany({
where: whereClause,
orderBy: [{ sortOrder: "desc" }, { createdAt: "desc" }],
});
return NextResponse.json(groups);
} catch (error) {
return NextResponse.json({ error: "Failed to fetch material groups" }, { status: 500 });
} }
return NextResponse.json(
await listMaterialGroups(parsed.data.classId, parsed.data.type)
);
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
try { const parsed = materialGroupCreateSchema.safeParse(
const body = await request.json(); await request.json().catch(() => null)
const { classId, name, type } = body; );
if (!parsed.success) {
if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) { return NextResponse.json(
return NextResponse.json({ error: "Invalid input" }, { status: 400 }); { error: parsed.error.issues[0]?.message ?? "Invalid group" },
{ status: 400 }
);
}
const group = await createMaterialGroup(parsed.data);
if (!group) {
return NextResponse.json({ error: "Class not found" }, { status: 404 });
} }
const maxOrder = await prisma.materialGroup.aggregate({
where: { classId, type },
_max: { sortOrder: true },
});
const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1;
const group = await prisma.materialGroup.create({
data: { classId, name, type, sortOrder },
});
return NextResponse.json(group, { status: 201 }); return NextResponse.json(group, { status: 201 });
} catch (error) {
return NextResponse.json({ error: "Failed to create material group" }, { status: 500 });
}
} }

View file

@ -1,66 +1,64 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as progressService from "@/services/progressService"; import {
progressDeleteSchema,
progressPatchSchema,
progressQuerySchema,
} from "@/lib/validation/progressSchemas";
import {
ProgressConflictError,
ProgressNotFoundError,
ProgressValidationError,
clearProgress,
getProgress,
saveProgress,
} from "@/services/progressService";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const parsed = progressQuerySchema.safeParse(
const contentType = searchParams.get("contentType") as "DECK" | "QUIZ"; Object.fromEntries(request.nextUrl.searchParams)
const contentId = searchParams.get("contentId");
const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED";
if (!contentType || !contentId || !mode) {
return NextResponse.json(
{ error: "contentType, contentId, and mode are required" },
{ status: 400 }
); );
if (!parsed.success) {
return NextResponse.json({ error: "Invalid progress query" }, { status: 400 });
} }
return NextResponse.json(await getProgress(parsed.data));
const progress = await progressService.getProgress(
contentType,
contentId,
mode
);
return NextResponse.json(progress);
} }
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
const body = await request.json().catch(() => null); const parsed = progressPatchSchema.safeParse(
await request.json().catch(() => null)
if (!body?.contentType || !body?.contentId || !body?.mode) { );
if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "contentType, contentId, and mode are required" }, { error: parsed.error.issues[0]?.message ?? "Invalid progress" },
{ status: 400 } { status: 400 }
); );
} }
try {
const progress = await progressService.upsertProgress({ return NextResponse.json(await saveProgress(parsed.data));
contentType: body.contentType, } catch (error) {
contentId: body.contentId, if (error instanceof ProgressNotFoundError) {
mode: body.mode, return NextResponse.json({ error: error.message }, { status: 404 });
currentIndex: body.currentIndex ?? 0, }
orderJson: body.orderJson, if (error instanceof ProgressConflictError) {
answersJson: body.answersJson, return NextResponse.json({ error: error.message }, { status: 409 });
cardResultsJson: body.cardResultsJson, }
}); if (error instanceof ProgressValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
return NextResponse.json(progress); }
console.error("Failed to save study progress", error);
return NextResponse.json({ error: "Failed to save progress" }, { status: 500 });
}
} }
export async function DELETE(request: NextRequest) { export async function DELETE(request: NextRequest) {
const body = await request.json().catch(() => null); const parsed = progressDeleteSchema.safeParse(
await request.json().catch(() => null)
if (!body?.contentType || !body?.contentId || !body?.mode) { );
if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "contentType, contentId, and mode are required" }, { error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" },
{ status: 400 } { status: 400 }
); );
} }
return NextResponse.json({ cleared: await clearProgress(parsed.data) });
await progressService.clearProgress(
body.contentType,
body.contentId,
body.mode
);
return NextResponse.json({ success: true });
} }

View file

@ -1,65 +1,40 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService"; import {
import { scoreQuiz } from "@/lib/scoring"; QuizAttemptValidationError,
QuizNotFoundError,
listQuizAttempts,
submitQuizAttempt,
} from "@/services/quizService";
import { quizAttemptSchema } from "@/lib/validation/attemptSchemas";
export async function POST( export async function POST(
request: NextRequest, request: NextRequest,
ctx: RouteContext<"/api/quizzes/[id]/attempt"> ctx: RouteContext<"/api/quizzes/[id]/attempt">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
const body = await request.json().catch(() => null); const parsed = quizAttemptSchema.safeParse(
await request.json().catch(() => null)
if (!body || !body.answersJson) { );
if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "answersJson is required" }, { error: parsed.error.issues[0]?.message ?? "Invalid attempt" },
{ status: 400 } { status: 400 }
); );
} }
const quizSet = await getQuizSetWithQuestions(id);
if (!quizSet) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
}
// Parse answers
let answers: Record<string, string[]>;
try { try {
answers = JSON.parse(body.answersJson); return NextResponse.json(await submitQuizAttempt(id, parsed.data), {
} catch { status: 201,
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
}
// Determine which questions were included in this attempt
const isPartialRetake = body.isPartialRetake === true;
// If partial retake, we only score the questions that actually had answers provided
// or that were explicitly passed in a questionIds array.
// For simplicity, we filter the quizSet questions down to what's in the answers object
// if it's a partial retake, though the client will only show those anyway.
let questionsToScore = quizSet.questions;
if (isPartialRetake) {
const answeredIds = Object.keys(answers);
questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id));
}
// Format questions for the scoring utility
const formattedQuestions = questionsToScore.map((q) => ({
id: q.id,
type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })),
}));
const { total, maxScore } = scoreQuiz(formattedQuestions, answers);
const attempt = await createQuizAttempt({
quizSetId: id,
score: total,
maxScore: maxScore,
answersJson: body.answersJson,
isPartialRetake,
}); });
} catch (error) {
return NextResponse.json(attempt, { status: 201 }); if (error instanceof QuizNotFoundError) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
if (error instanceof QuizAttemptValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
console.error("Failed to submit quiz attempt", error);
return NextResponse.json({ error: "Failed to submit attempt" }, { status: 500 });
}
} }
export async function GET( export async function GET(

View file

@ -1,5 +1,6 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService"; import * as quizService from "@/services/quizService";
import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
export async function GET( export async function GET(
_request: NextRequest, _request: NextRequest,
@ -20,17 +21,22 @@ export async function PATCH(
ctx: RouteContext<"/api/quizzes/[id]"> ctx: RouteContext<"/api/quizzes/[id]">
) { ) {
const { id } = await ctx.params; const { id } = await ctx.params;
const body = await request.json().catch(() => null); const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) {
return NextResponse.json({ error: "Invalid quiz update", details: parsed.error.issues }, { status: 400 });
}
try { try {
const updated = await quizService.updateQuizSet(id, { const updated = await quizService.updateQuizSet(id, parsed.data);
name: body?.name?.trim(),
description: body?.description,
});
return NextResponse.json(updated); return NextResponse.json(updated);
} catch { } catch (error) {
if (isPrismaError(error, "P2025")) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
} }
console.error("Failed to update quiz", error);
return NextResponse.json({ error: "Failed to update quiz" }, { status: 500 });
}
} }
export async function DELETE( export async function DELETE(
@ -42,7 +48,11 @@ export async function DELETE(
try { try {
await quizService.deleteQuizSet(id); await quizService.deleteQuizSet(id);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch { } catch (error) {
if (isPrismaError(error, "P2025")) {
return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
} }
console.error("Failed to delete quiz", error);
return NextResponse.json({ error: "Failed to delete quiz" }, { status: 500 });
}
} }

View file

@ -1,32 +1,29 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; import { reorderRequestSchema } from "@/lib/validation/reorderSchemas";
import {
ReorderConflictError,
ReorderValidationError,
reorderContent,
} from "@/services/reorderService";
export async function PATCH(request: NextRequest) { export async function PATCH(request: NextRequest) {
try { const parsed = reorderRequestSchema.safeParse(
const parsed = reorderRequestSchema.safeParse(await request.json()); await request.json().catch(() => null)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 });
}
const { items } = parsed.data;
// items should be an array of { id, sortOrder, groupId }
// Using a transaction to perform all updates
await prisma.$transaction(
items.map((item) =>
prisma.quizSet.update({
where: { id: item.id },
data: {
sortOrder: item.sortOrder,
groupId: item.groupId,
},
})
)
); );
if (!parsed.success) {
return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 });
}
try {
await reorderContent("QUIZ", parsed.data);
return NextResponse.json({ success: true }); return NextResponse.json({ success: true });
} catch (error) { } catch (error) {
if (error instanceof ReorderValidationError) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
if (error instanceof ReorderConflictError) {
return NextResponse.json({ error: error.message }, { status: 409 });
}
console.error("Failed to reorder quizzes", error);
return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 }); return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 });
} }
} }

View file

@ -1,35 +1,43 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import * as quizService from "@/services/quizService"; import * as quizService from "@/services/quizService";
import { quizImportSchema } from "@/lib/validation/importSchemas"; import { quizImportRequestSchema } from "@/lib/validation/importSchemas";
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null); let body: unknown;
try {
if (!body) { body = await readLimitedJson(request);
return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } catch (error) {
if (error instanceof RequestTooLargeError) {
return NextResponse.json({ error: error.message }, { status: 413 });
}
throw error;
} }
const { classId, data, name, groupId } = body; const parsed = quizImportRequestSchema.safeParse(body);
if (!classId || typeof classId !== "string") {
return NextResponse.json({ error: "classId is required" }, { status: 400 });
}
// Server-side validation
const parsed = quizImportSchema.safeParse(data);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json( return NextResponse.json(
{ error: "Validation failed", details: parsed.error.issues }, {
error: "Invalid quiz import. SATA questions need at least two correct options.",
details: parsed.error.issues,
},
{ status: 400 } { status: 400 }
); );
} }
const quizSet = await quizService.createQuizSetFromImport( try {
classId, const quiz = await quizService.createQuizSetFromImport(
parsed.data, parsed.data.classId,
name?.trim() || undefined, parsed.data.data,
groupId || null parsed.data.name,
parsed.data.groupId ?? null
); );
return NextResponse.json(quiz, { status: 201 });
return NextResponse.json(quizSet, { status: 201 }); } catch (error) {
if (error instanceof quizService.QuizContentValidationError) {
return NextResponse.json({ error: error.message }, { status: error.status });
}
console.error("Failed to create quiz", error);
return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 });
}
} }

View file

@ -1,20 +1,21 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService"; import { shareTargetSchema } from "@/lib/validation/shareSchemas";
import {
ShareTargetNotFoundError,
getShareLinkForContent,
isContentSharedViaGroup,
toggleShareLink,
} from "@/services/shareService";
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const { searchParams } = new URL(request.url); const parsed = shareTargetSchema.safeParse(
const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP"; Object.fromEntries(request.nextUrl.searchParams)
const contentId = searchParams.get("contentId");
if (!targetType || !contentId) {
return NextResponse.json(
{ error: "targetType and contentId are required" },
{ status: 400 }
); );
if (!parsed.success) {
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
} }
const { targetType, contentId } = parsed.data;
const link = await getShareLinkForContent(targetType, contentId); const link = await getShareLinkForContent(targetType, contentId);
if (targetType === "DECK" || targetType === "QUIZ") { if (targetType === "DECK" || targetType === "QUIZ") {
const groupShare = await isContentSharedViaGroup(targetType, contentId); const groupShare = await isContentSharedViaGroup(targetType, contentId);
if (groupShare) { if (groupShare) {
@ -25,20 +26,27 @@ export async function GET(request: NextRequest) {
}); });
} }
} }
return NextResponse.json({ token: link?.id ?? null, isGroupShared: false });
return NextResponse.json({ token: link?.id || null, isGroupShared: false });
} }
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
const body = await request.json().catch(() => null); const parsed = shareTargetSchema.safeParse(
await request.json().catch(() => null)
if (!body?.targetType || !body?.contentId) {
return NextResponse.json(
{ error: "targetType and contentId are required" },
{ status: 400 }
); );
if (!parsed.success) {
return NextResponse.json({ error: "Invalid share target" }, { status: 400 });
}
try {
const link = await toggleShareLink(
parsed.data.targetType,
parsed.data.contentId
);
return NextResponse.json({ token: link?.id ?? null });
} catch (error) {
if (error instanceof ShareTargetNotFoundError) {
return NextResponse.json({ error: error.message }, { status: 404 });
}
console.error("Failed to update share link", error);
return NextResponse.json({ error: "Failed to update sharing" }, { status: 500 });
} }
const link = await toggleShareLink(body.targetType as "DECK" | "QUIZ" | "GROUP", body.contentId);
return NextResponse.json({ token: link?.id || null });
} }

View file

@ -5,14 +5,15 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
type LoginStage = "login" | "token" | "password" | "success"; type LoginStage = "login" | "token" | "password" | "success";
async function postJson(path: string, body?: object) { async function postJson<T extends object = Record<string, never>>(path: string, body?: object): Promise<T> {
const response = await fetch(path, { const response = await fetch(path, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body ?? {}), body: JSON.stringify(body ?? {}),
}); });
const data: { error?: string } = await response.json(); const data = await response.json() as T & { error?: string };
if (!response.ok) throw new Error(data.error || "Request failed"); if (!response.ok) throw new Error(data.error || "Request failed");
return data;
} }
export default function LoginPage() { export default function LoginPage() {
@ -23,12 +24,26 @@ export default function LoginPage() {
const [error, setError] = useState(""); const [error, setError] = useState("");
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [setupRequired, setSetupRequired] = useState<boolean | null>(null); const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
const [setupAllowed, setSetupAllowed] = useState(false);
const [statusError, setStatusError] = useState("");
useEffect(() => { useEffect(() => {
fetch("/api/auth/setup-status") const controller = new AbortController();
.then((response) => response.json()) fetch("/api/auth/setup-status", { signal: controller.signal })
.then((data) => setSetupRequired(data.setupRequired)) .then((response) => {
.catch(() => setSetupRequired(false)); if (!response.ok) throw new Error("Setup status could not be loaded.");
return response.json();
})
.then((data) => {
setSetupRequired(Boolean(data.setupRequired));
setSetupAllowed(Boolean(data.setupAllowed));
})
.catch((caught) => {
if (controller.signal.aborted) return;
setStatusError(caught instanceof Error ? caught.message : "Setup status could not be loaded.");
setSetupRequired(false);
});
return () => controller.abort();
}, []); }, []);
async function run(action: () => Promise<void>) { async function run(action: () => Promise<void>) {
@ -53,8 +68,8 @@ export default function LoginPage() {
function beginReset() { function beginReset() {
void run(async () => { void run(async () => {
await postJson("/api/auth/password-reset/request"); const response = await postJson<{ token: string }>("/api/auth/password-reset/request");
setToken(""); setToken(response.token);
setStage("token"); setStage("token");
}); });
} }
@ -139,6 +154,12 @@ export default function LoginPage() {
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8"> <div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
{stage === "login" && ( {stage === "login" && (
<form onSubmit={handleLogin} className="space-y-4"> <form onSubmit={handleLogin} className="space-y-4">
{setupRequired && !setupAllowed && (
<div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">
Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server.
</div>
)}
{statusError && <ErrorMessage message={statusError} />}
<PasswordField <PasswordField
id="password" id="password"
label="Password" label="Password"
@ -148,7 +169,7 @@ export default function LoginPage() {
autoFocus autoFocus
/> />
<ErrorMessage message={error} /> <ErrorMessage message={error} />
<PrimaryButton loading={loading} disabled={password.length < 8}> <PrimaryButton loading={loading} disabled={password.length < 8 || (setupRequired && !setupAllowed)}>
{setupRequired ? "Save Password & Login" : "Sign In"} {setupRequired ? "Save Password & Login" : "Sign In"}
</PrimaryButton> </PrimaryButton>
{!setupRequired && ( {!setupRequired && (

View file

@ -6,7 +6,7 @@ import Link from "next/link";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import type { SharedGroupData } from "@/types/study"; import type { SharedGroupData } from "@/types/study";
export function SharedGroupViewer({ data }: { data: SharedGroupData }) { export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
@ -16,11 +16,12 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({}); const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
useEffect(() => { useEffect(() => {
if (typeof window === 'undefined') return; const timer = window.setTimeout(() => {
const newProgress: Record<string, { currentIndex: number, total: number }> = {}; const newProgress: Record<string, { currentIndex: number, total: number }> = {};
items.forEach((item) => { items.forEach((item) => {
const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`; const key = data.type === "DECK"
? `flashcard_progress_${token}_${item.id}`
: `quiz_progress_${token}_${item.id}`;
const saved = localStorage.getItem(key); const saved = localStorage.getItem(key);
if (saved) { if (saved) {
try { try {
@ -30,15 +31,19 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
currentIndex: parsed.currentIndex || 0, currentIndex: parsed.currentIndex || 0,
total, total,
}; };
} catch(e) {} } catch {}
} }
}); });
setProgressMap(newProgress); setProgressMap(newProgress);
}, [items, data.type]); }, 0);
return () => window.clearTimeout(timer);
}, [items, data.type, token]);
const handleRestart = (e: React.MouseEvent, itemId: string) => { const handleRestart = (e: React.MouseEvent, itemId: string) => {
e.preventDefault(); e.preventDefault();
const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`; const key = data.type === "DECK"
? `flashcard_progress_${token}_${itemId}`
: `quiz_progress_${token}_${itemId}`;
localStorage.removeItem(key); localStorage.removeItem(key);
setProgressMap(prev => { setProgressMap(prev => {
const next = { ...prev }; const next = { ...prev };

View file

@ -12,24 +12,29 @@ interface SharedViewerProps {
type: "flashcards" | "quizzes"; type: "flashcards" | "quizzes";
data: SharedStudyItem; data: SharedStudyItem;
groupMode?: boolean; groupMode?: boolean;
token: string;
} }
export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) { export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) {
const pathname = usePathname(); const pathname = usePathname();
const [restartKey, setRestartKey] = useState(0); const [restartKey, setRestartKey] = useState(0);
const [view, setView] = useState<"study" | "list">("study"); const [view, setView] = useState<"study" | "list">("study");
const [hasSavedSession, setHasSavedSession] = useState(false); const [hasSavedSession, setHasSavedSession] = useState(false);
useEffect(() => { useEffect(() => {
// Check if there's a saved session for this item const timer = window.setTimeout(() => {
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; const key = type === "flashcards"
if (localStorage.getItem(key)) { ? `flashcard_progress_${token}_${data.id}`
setHasSavedSession(true); : `quiz_progress_${token}_${data.id}`;
} setHasSavedSession(Boolean(localStorage.getItem(key)));
}, [type, data.id, restartKey]); }, 0);
return () => window.clearTimeout(timer);
}, [type, data.id, restartKey, token]);
function handleRestart() { function handleRestart() {
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; const key = type === "flashcards"
? `flashcard_progress_${token}_${data.id}`
: `quiz_progress_${token}_${data.id}`;
localStorage.removeItem(key); localStorage.removeItem(key);
setHasSavedSession(false); setHasSavedSession(false);
setRestartKey(k => k + 1); setRestartKey(k => k + 1);
@ -141,6 +146,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
cards={data.cards ?? []} cards={data.cards ?? []}
deckId={data.id} deckId={data.id}
isShared={true} isShared={true}
storageNamespace={token}
/> />
) : ( ) : (
<CardList cards={data.cards ?? []} /> <CardList cards={data.cards ?? []} />
@ -156,6 +162,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
}} }}
retakeIds={null} retakeIds={null}
isShared={true} isShared={true}
storageNamespace={token}
/> />
)} )}
</div> </div>

View file

@ -29,7 +29,7 @@ export async function generateMetadata(
} }
const link = await getShareLinkMeta(token); const link = await getShareLinkMeta(token);
return buildShareMetadata(link, { classSlug, itemId }); return buildShareMetadata(link, { classSlug, itemId, pathType: type });
} }
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) { export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
@ -71,18 +71,29 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes"; let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes";
if (type === "groups" && link.group) { if (type === "groups" && link.group) {
const groupClassSlug = link.group.class.slug;
const validDecks = link.group.decks.filter(
(deck) => link.group?.type === "DECK" && deck.class.slug === groupClassSlug
);
const validQuizSets = link.group.quizSets.filter(
(quiz) => link.group?.type === "QUIZ" && quiz.class.slug === groupClassSlug
);
if (itemId) { if (itemId) {
// Find the specific item inside the group // Find the specific item inside the group
if (link.group.type === "DECK") { if (link.group.type === "DECK") {
targetData = link.group.decks.find(d => d.id === itemId) ?? null; targetData = validDecks.find(d => d.id === itemId) ?? null;
targetType = "flashcards"; targetType = "flashcards";
} else { } else {
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null; targetData = validQuizSets.find(q => q.id === itemId) ?? null;
targetType = "quizzes"; targetType = "quizzes";
} }
if (!targetData) notFound(); if (!targetData) notFound();
} else { } else {
targetData = link.group; targetData = {
...link.group,
decks: validDecks,
quizSets: validQuizSets,
};
} }
} else { } else {
targetData = type === "flashcards" ? link.deck : link.quizSet; targetData = type === "flashcards" ? link.deck : link.quizSet;
@ -126,12 +137,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
<main className="flex-1 overflow-y-auto"> <main className="flex-1 overflow-y-auto">
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8"> <div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
{type === "groups" && !itemId ? ( {type === "groups" && !itemId ? (
<SharedGroupViewer data={targetData as SharedGroupData} /> <SharedGroupViewer data={targetData as SharedGroupData} token={token} />
) : ( ) : (
<SharedViewer <SharedViewer
type={targetType} type={targetType}
data={targetData as SharedStudyItem} data={targetData as SharedStudyItem}
groupMode={type === "groups"} groupMode={type === "groups"}
token={token}
/> />
)} )}
</div> </div>

View file

@ -41,6 +41,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
body: JSON.stringify({ front: editFront, back: editBack }), body: JSON.stringify({ front: editFront, back: editBack }),
}); });
setEditingId(null); setEditingId(null);
window.dispatchEvent(new Event("study-decks-changed"));
onCardsChanged(); onCardsChanged();
} finally { } finally {
setSaving(false); setSaving(false);
@ -50,6 +51,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
async function deleteCard(id: string) { async function deleteCard(id: string) {
if (!confirm("Delete this card?")) return; if (!confirm("Delete this card?")) return;
await fetch(`/api/cards/${id}`, { method: "DELETE" }); await fetch(`/api/cards/${id}`, { method: "DELETE" });
window.dispatchEvent(new Event("study-decks-changed"));
onCardsChanged(); onCardsChanged();
} }
@ -65,6 +67,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
setNewFront(""); setNewFront("");
setNewBack(""); setNewBack("");
setShowAdd(false); setShowAdd(false);
window.dispatchEvent(new Event("study-decks-changed"));
onCardsChanged(); onCardsChanged();
} finally { } finally {
setSaving(false); setSaving(false);
@ -89,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
</div> </div>
{/* Card list */} {/* Card list */}
{cards.map((card, index) => ( {cards.map((card) => (
<div <div
key={card.id} key={card.id}
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]" className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"

View file

@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import ReactMarkdown from "react-markdown"; import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm"; import remarkGfm from "remark-gfm";
import { recordStudyActivity } from "@/lib/activityClient"; import { recordStudyActivity } from "@/lib/activityClient";
import { normalizeProgress } from "@/lib/progressNormalization";
import { import {
STREAK_FLAME_THRESHOLD, STREAK_FLAME_THRESHOLD,
StreakBurst, StreakBurst,
@ -25,47 +26,78 @@ interface ProgressData {
currentIndex: number; currentIndex: number;
orderJson: string; orderJson: string;
cardResultsJson: string | null; cardResultsJson: string | null;
sessionId: string;
revision: number;
} }
interface FlashcardViewerProps { interface FlashcardViewerProps {
cards: Card[]; cards: Card[];
deckId: string; deckId: string;
isShared?: boolean; isShared?: boolean;
storageNamespace?: string;
initialProgress?: ProgressData | null; initialProgress?: ProgressData | null;
} }
type CardResult = "correct" | "missed"; type CardResult = "correct" | "missed";
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) { function initialFlashcardState(cards: Card[], progress?: ProgressData | null) {
if (!progress) {
return {
order: cards.map((card) => card.id),
currentIndex: 0,
data: {} as Record<string, CardResult>,
completed: cards.length === 0,
wasRecovered: false,
};
}
return normalizeProgress({
orderJson: progress.orderJson,
currentIndex: progress.currentIndex,
dataJson: progress.cardResultsJson,
liveIds: cards.map((card) => card.id),
isValidValue: (value): value is CardResult =>
value === "correct" || value === "missed",
});
}
export function FlashcardViewer({
cards,
deckId,
isShared = false,
storageNamespace,
initialProgress,
}: FlashcardViewerProps) {
const sharedStorageKey = `flashcard_progress_${storageNamespace ?? "shared"}_${deckId}`;
const [initialState] = useState(() => initialFlashcardState(cards, initialProgress));
const [isLoaded, setIsLoaded] = useState(!isShared); const [isLoaded, setIsLoaded] = useState(!isShared);
const [order, setOrder] = useState<string[]>( const [order, setOrder] = useState<string[]>(initialState.order);
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id) const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex);
);
const [currentIndex, setCurrentIndex] = useState(
initialProgress ? initialProgress.currentIndex : 0
);
const [isFlipped, setIsFlipped] = useState(false); const [isFlipped, setIsFlipped] = useState(false);
const [hasFlippedOnce, setHasFlippedOnce] = useState(false); const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
const [results, setResults] = useState<Record<string, CardResult>>( const [results, setResults] = useState<Record<string, CardResult>>(initialState.data);
initialProgress && initialProgress.cardResultsJson
? JSON.parse(initialProgress.cardResultsJson)
: {}
);
const [isShuffled, setIsShuffled] = useState<boolean>( const [isShuffled, setIsShuffled] = useState<boolean>(
initialProgress ? initialProgress.mode === "SHUFFLED" : false initialProgress ? initialProgress.mode === "SHUFFLED" : false
); );
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const [restoreWarning, setRestoreWarning] = useState(
initialState.wasRecovered
? "Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
: null
);
const [isTransitioning, setIsTransitioning] = useState(false);
// If progress is provided and index is already at or past the end, it means completed // If progress is provided and index is already at or past the end, it means completed
const [completed, setCompleted] = useState( const [completed, setCompleted] = useState(initialState.completed);
initialProgress
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
: false
);
const [swipeClass, setSwipeClass] = useState(""); const [swipeClass, setSwipeClass] = useState("");
const cardRef = useRef<HTMLDivElement>(null); const cardRef = useRef<HTMLDivElement>(null);
const gradingRef = useRef(false); const gradingRef = useRef(false);
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const sessionIdRef = useRef(
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
);
const revisionRef = useRef(initialProgress?.revision ?? 0);
const saveQueueRef = useRef(Promise.resolve());
// Touch/drag state // Touch/drag state
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false }); const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
@ -73,7 +105,6 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null; const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
const correctCount = Object.values(results).filter((r) => r === "correct").length; const correctCount = Object.values(results).filter((r) => r === "correct").length;
const missedCount = Object.values(results).filter((r) => r === "missed").length; const missedCount = Object.values(results).filter((r) => r === "missed").length;
const totalGraded = correctCount + missedCount;
// Current run of consecutive correct cards, derived by walking back from the // Current run of consecutive correct cards, derived by walking back from the
// current position. Deriving it (rather than storing it) keeps resumed // current position. Deriving it (rather than storing it) keeps resumed
@ -127,29 +158,104 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Load from localStorage if shared // Load from localStorage if shared
useEffect(() => { useEffect(() => {
if (isShared) { if (!isShared) return;
const timer = window.setTimeout(() => {
try { try {
const saved = localStorage.getItem(`flashcard_progress_${deckId}`); const saved = localStorage.getItem(sharedStorageKey);
if (saved) { if (saved) {
const parsed = JSON.parse(saved); const parsed: unknown = JSON.parse(saved);
setOrder(parsed.order || cards.map(c => c.id)); const record =
setCurrentIndex(parsed.currentIndex || 0); typeof parsed === "object" && parsed !== null
setResults(parsed.results || {}); ? (parsed as Record<string, unknown>)
setIsShuffled(parsed.mode === "SHUFFLED"); : {};
const restored = normalizeProgress({
if (parsed.currentIndex >= (parsed.order?.length || cards.length)) { orderJson: record.order,
setCompleted(true); currentIndex: record.currentIndex,
dataJson: record.results,
liveIds: cards.map((card) => card.id),
isValidValue: (value): value is CardResult =>
value === "correct" || value === "missed",
});
setOrder(restored.order);
setCurrentIndex(restored.currentIndex);
setResults(restored.data);
setIsShuffled(record.mode === "SHUFFLED");
setCompleted(restored.completed);
if (restored.wasRecovered) {
setRestoreWarning(
"Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
);
} }
setToastMessage("Session restored"); setToastMessage("Session restored");
} }
} catch (e) { } catch {
// Fallback to defaults setRestoreWarning(
"Saved progress could not be read. A fresh session was started safely."
);
} }
setIsLoaded(true); setIsLoaded(true);
}, 0);
return () => window.clearTimeout(timer);
}, [isShared, sharedStorageKey, cards]);
useEffect(() => {
return () => {
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
};
}, []);
const saveProgress = useCallback(
(
orderArr: string[],
index: number,
cardResults: Record<string, CardResult>,
mode: "SEQUENTIAL" | "SHUFFLED"
) => {
if (isShared) {
localStorage.setItem(
sharedStorageKey,
JSON.stringify({
mode,
currentIndex: index,
order: orderArr,
results: cardResults,
})
);
return;
} }
}, [isShared, deckId, cards]);
const revision = ++revisionRef.current;
saveQueueRef.current = saveQueueRef.current
.then(async () => {
const response = await fetch("/api/progress", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deckId,
mode,
currentIndex: index,
order: orderArr,
cardResults,
sessionId: sessionIdRef.current,
revision,
}),
});
if (response.status === 409) {
setToastMessage("Progress changed in another session; reload to continue safely.");
return;
}
if (!response.ok) throw new Error("Progress save failed");
})
.catch(() => {
setToastMessage("Progress could not be saved. Your current view is unchanged.");
});
},
[deckId, isShared, sharedStorageKey]
);
function toggleShuffle() { function toggleShuffle() {
if (isTransitioning) return;
const newShuffled = !isShuffled; const newShuffled = !isShuffled;
setIsShuffled(newShuffled); setIsShuffled(newShuffled);
@ -175,6 +281,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
(grade: CardResult) => { (grade: CardResult) => {
if (!currentCard || !hasFlippedOnce || gradingRef.current) return; if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
gradingRef.current = true; gradingRef.current = true;
setIsTransitioning(true);
if (!isShared) { if (!isShared) {
recordStudyActivity("FLASHCARD").catch(() => {}); recordStudyActivity("FLASHCARD").catch(() => {});
@ -187,15 +294,17 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Animate // Animate
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left"); setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
setTimeout(() => { transitionTimerRef.current = setTimeout(() => {
gradingRef.current = false; gradingRef.current = false;
setIsTransitioning(false);
setSwipeClass(""); setSwipeClass("");
setIsFlipped(false); setIsFlipped(false);
setHasFlippedOnce(false); setHasFlippedOnce(false);
if (currentIndex + 1 >= order.length) { if (currentIndex + 1 >= order.length) {
setCompleted(true); setCompleted(true);
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); setCurrentIndex(order.length);
saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
} else { } else {
const nextIndex = currentIndex + 1; const nextIndex = currentIndex + 1;
setCurrentIndex(nextIndex); setCurrentIndex(nextIndex);
@ -203,13 +312,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
} }
}, 350); }, 350);
}, },
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared] [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress]
); );
// Keyboard shortcuts // Keyboard shortcuts
useEffect(() => { useEffect(() => {
function handleKeyDown(e: KeyboardEvent) { function handleKeyDown(e: KeyboardEvent) {
if (completed) return; if (completed || isTransitioning) return;
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") { if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault(); e.preventDefault();
@ -227,7 +336,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
window.addEventListener("keydown", handleKeyDown); window.addEventListener("keydown", handleKeyDown);
return () => window.removeEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown);
}, [completed, isFlipped, gradeCard]); }, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]);
// Touch handlers for swipe // Touch handlers for swipe
function handleTouchStart(e: React.TouchEvent) { function handleTouchStart(e: React.TouchEvent) {
@ -262,39 +371,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
} }
} }
// Save progress (debounced / fire-and-forget)
function saveProgress(
orderArr: string[],
index: number,
cardResults: Record<string, CardResult>,
m: "SEQUENTIAL" | "SHUFFLED"
) {
if (isShared) {
localStorage.setItem(`flashcard_progress_${deckId}`, JSON.stringify({
mode: m,
currentIndex: index,
order: orderArr,
results: cardResults,
}));
return;
}
fetch("/api/progress", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "DECK",
contentId: deckId,
mode: m,
currentIndex: index,
orderJson: JSON.stringify(orderArr),
cardResultsJson: JSON.stringify(cardResults),
}),
}).catch(() => {}); // fire-and-forget
}
// Restart handlers // Restart handlers
function restartFullSet() { function restartFullSet() {
if (isTransitioning) return;
gradingRef.current = false; gradingRef.current = false;
let newOrder: string[]; let newOrder: string[];
if (isShuffled) { if (isShuffled) {
@ -312,6 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
} }
function redoMissed() { function redoMissed() {
if (isTransitioning) return;
const missedIds = Object.entries(results) const missedIds = Object.entries(results)
.filter(([, r]) => r === "missed") .filter(([, r]) => r === "missed")
.map(([id]) => id); .map(([id]) => id);
@ -337,6 +417,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
if (completed) { if (completed) {
return ( return (
<div className="flex flex-col items-center justify-center py-16"> <div className="flex flex-col items-center justify-center py-16">
{restoreWarning && (
<div className="mb-4 w-full max-w-md rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
{restoreWarning}
</div>
)}
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]"> <div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4"> <div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@ -393,6 +478,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
// Study view // Study view
return ( return (
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
{restoreWarning && (
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
{restoreWarning}
</div>
)}
{/* Running tally */} {/* Running tally */}
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3"> <div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm"> <div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
@ -421,6 +511,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
ref={cardRef} ref={cardRef}
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`} className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
onClick={() => { onClick={() => {
if (isTransitioning) return;
setToastMessage(null); setToastMessage(null);
setIsFlipped(!isFlipped); setIsFlipped(!isFlipped);
if (!isFlipped) setHasFlippedOnce(true); if (!isFlipped) setHasFlippedOnce(true);
@ -506,7 +597,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto"> <div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
<button <button
onClick={() => gradeCard("missed")} onClick={() => gradeCard("missed")}
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7" disabled={isTransitioning}
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
@ -515,7 +607,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
</button> </button>
<button <button
onClick={() => gradeCard("correct")} onClick={() => gradeCard("correct")}
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7" disabled={isTransitioning}
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
@ -531,6 +624,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
{currentIndex > 0 ? ( {currentIndex > 0 ? (
<button <button
onClick={() => { onClick={() => {
if (isTransitioning) return;
const prevIndex = currentIndex - 1; const prevIndex = currentIndex - 1;
setCurrentIndex(prevIndex); setCurrentIndex(prevIndex);
setIsFlipped(false); setIsFlipped(false);
@ -542,6 +636,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
setResults(newResults); setResults(newResults);
saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
}} }}
disabled={isTransitioning}
className="p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer bg-bg-base/50" className="p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer bg-bg-base/50"
title="Previous Card" title="Previous Card"
> >
@ -553,6 +648,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
<button <button
onClick={toggleShuffle} onClick={toggleShuffle}
disabled={isTransitioning}
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${ className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
isShuffled isShuffled
? "text-primary hover:bg-primary/10" ? "text-primary hover:bg-primary/10"

View file

@ -43,9 +43,11 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
return; return;
} }
const validCards = cards.filter((c) => c.front.trim() && c.back.trim()); const incompleteRows = cards.flatMap((card, index) =>
if (validCards.length === 0) { card.front.trim() && card.back.trim() ? [] : [index + 1]
setError("Please add at least one complete card."); );
if (incompleteRows.length > 0) {
setError(`Complete or remove card row${incompleteRows.length === 1 ? "" : "s"}: ${incompleteRows.join(", ")}.`);
return; return;
} }
@ -61,7 +63,7 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
type: "flashcards", type: "flashcards",
deckName: deckName, deckName: deckName,
description: description || undefined, description: description || undefined,
cards: validCards.map((c) => ({ cards: cards.map((c) => ({
front: c.front, front: c.front,
back: c.back, back: c.back,
})), })),
@ -70,9 +72,11 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
}); });
if (!res.ok) { if (!res.ok) {
throw new Error("Failed to create deck"); const response = await res.json().catch(() => null) as { error?: string } | null;
throw new Error(response?.error ?? "Failed to create deck");
} }
window.dispatchEvent(new Event("study-decks-changed"));
onCreated(); onCreated();
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to create deck"); setError(err instanceof Error ? err.message : "Failed to create deck");

View file

@ -1,6 +1,6 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect, useCallback } from "react";
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) { export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
const [instructions, setInstructions] = useState(""); const [instructions, setInstructions] = useState("");
@ -9,19 +9,39 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null); const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [packCount, setPackCount] = useState(1); const [packCount, setPackCount] = useState(1);
const [loadError, setLoadError] = useState<string | null>(null);
const batchOverride = importType === "connections" && packCount > 1 const batchOverride = importType === "connections" && packCount > 1
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n` ? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
: ""; : "";
const displayedInstructions = `${batchOverride}${instructions}`; const displayedInstructions = `${batchOverride}${instructions}`;
useEffect(() => { const loadInstructions = useCallback(async (signal?: AbortSignal) => {
fetch(`/api/settings/llm-instructions?type=${importType}`) setLoading(true);
.then((res) => res.json()) setLoadError(null);
.then((data) => setInstructions(data.value)) try {
.finally(() => setLoading(false)); const response = await fetch(`/api/settings/llm-instructions?type=${importType}`, { signal });
if (!response.ok) throw new Error("Instructions could not be loaded.");
const data = await response.json() as { value?: unknown };
if (typeof data.value !== "string") throw new Error("The server returned invalid instructions.");
setInstructions(data.value);
} catch (error) {
if (signal?.aborted) return;
setLoadError(error instanceof Error ? error.message : "Instructions could not be loaded.");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [importType]); }, [importType]);
useEffect(() => {
const controller = new AbortController();
const timer = window.setTimeout(() => void loadInstructions(controller.signal), 0);
return () => {
window.clearTimeout(timer);
controller.abort();
};
}, [loadInstructions]);
async function handleSave() { async function handleSave() {
setSaving(true); setSaving(true);
setSaveResult(null); setSaveResult(null);
@ -50,25 +70,44 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ value: "__RESET__" }), body: JSON.stringify({ value: "__RESET__" }),
}); });
if (!res.ok) throw new Error("Failed to reset instructions");
const data = await res.json(); const data = await res.json();
if (typeof data.value !== "string") throw new Error("Invalid reset response");
setInstructions(data.value); setInstructions(data.value);
setSaveResult("success");
} catch {
setSaveResult("error");
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
async function handleCopy() { async function handleCopy() {
try {
await navigator.clipboard.writeText(displayedInstructions); await navigator.clipboard.writeText(displayedInstructions);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch {
setLoadError("Clipboard access failed. Select and copy the instructions manually.");
}
} }
if (loading) { if (loading) {
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>; return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
} }
if (loadError && !instructions) {
return (
<div role="alert" className="rounded-xl border border-error/30 bg-error-bg p-4 text-error">
<p>{loadError}</p>
<button onClick={() => void loadInstructions()} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
</div>
);
}
return ( return (
<div className="space-y-4"> <div className="space-y-4">
{loadError && <p role="alert" className="rounded-lg bg-error-bg p-3 text-sm text-error">{loadError}</p>}
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab. Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
</p> </p>

View file

@ -134,6 +134,9 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) {
return; return;
} }
if (importType === "flashcards") {
window.dispatchEvent(new Event("study-decks-changed"));
}
onImported(); onImported();
} catch { } catch {
setError("Network error during import"); setError("Network error during import");

View file

@ -5,6 +5,7 @@ import remarkGfm from "remark-gfm";
import { CategoryBreakdown } from "./CategoryBreakdown"; import { CategoryBreakdown } from "./CategoryBreakdown";
import { scoreQuestion } from "@/lib/scoring"; import { scoreQuestion } from "@/lib/scoring";
import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study"; import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study";
import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots";
interface QuizResultsProps { interface QuizResultsProps {
quiz: QuizSummary; quiz: QuizSummary;
@ -20,17 +21,28 @@ interface ReviewItem {
} }
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) { export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson);
let answers: Record<string, string[]> = {}; let answers: Record<string, string[]> = {};
try { try {
answers = JSON.parse(attempt.answersJson); answers = JSON.parse(attempt.answersJson);
} catch {} } catch {}
if (snapshot) {
answers = Object.fromEntries(
snapshot.questions.map((question) => [question.id, question.selections])
);
}
// Determine non-full-credit questions for review list // Determine non-full-credit questions for review list
const reviewItems: ReviewItem[] = []; const reviewItems: ReviewItem[] = [];
const missedIds: string[] = []; const missedIds: string[] = [];
const answeredIds = Object.keys(answers); const answeredIds = Object.keys(answers);
const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id)); const questionsToReview = snapshot?.questions ??
quiz.questions.filter((q) => answeredIds.includes(q.id));
const snapshotPoints = new Map(
snapshot?.questions.map((question) => [question.id, question.points]) ?? []
);
questionsToReview.forEach((q) => { questionsToReview.forEach((q) => {
const formattedQ = { const formattedQ = {
@ -38,7 +50,8 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
type: q.type as "MULTIPLE_CHOICE" | "SATA", type: q.type as "MULTIPLE_CHOICE" | "SATA",
options: q.options, options: q.options,
}; };
const score = scoreQuestion(formattedQ, answers[q.id] || []); const score = snapshotPoints.get(q.id) ??
scoreQuestion(formattedQ, answers[q.id] || []);
// If not full credit (1.0), add to review and missed arrays // If not full credit (1.0), add to review and missed arrays
if (score < 1) { if (score < 1) {
@ -50,6 +63,9 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
}); });
} }
}); });
const liveQuestionIds = new Set(quiz.questions.map((question) => question.id));
const retakeableMissedIds = missedIds.filter((id) => liveQuestionIds.has(id));
const reviewQuiz = { ...quiz, questions: questionsToReview };
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0; const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
@ -88,12 +104,12 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
</button> </button>
)} )}
{missedIds.length > 0 && ( {retakeableMissedIds.length > 0 && (
<button <button
onClick={() => onRetake(missedIds)} onClick={() => onRetake(retakeableMissedIds)}
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer" className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
> >
Retake Missed ({missedIds.length}) Retake Missed ({retakeableMissedIds.length})
</button> </button>
)} )}
@ -112,7 +128,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
{/* Category Breakdown */} {/* Category Breakdown */}
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8"> <div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3> <h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} /> <CategoryBreakdown quiz={reviewQuiz} answeredIds={answeredIds} answers={answers} />
</div> </div>
{/* Review List */} {/* Review List */}

View file

@ -7,6 +7,7 @@ import { scoreQuestion } from "@/lib/scoring";
import { QuizResults } from "./QuizResults"; import { QuizResults } from "./QuizResults";
import { recordStudyActivity } from "@/lib/activityClient"; import { recordStudyActivity } from "@/lib/activityClient";
import type { QuizAttempt } from "@/types/study"; import type { QuizAttempt } from "@/types/study";
import { normalizeProgress } from "@/lib/progressNormalization";
interface Option { interface Option {
id: string; id: string;
@ -33,16 +34,39 @@ interface QuizViewerProps {
currentIndex: number; currentIndex: number;
orderJson: string; orderJson: string;
answersJson: string | null; answersJson: string | null;
sessionId: string;
revision: number;
}>; }>;
}; };
retakeIds: string[] | null; retakeIds: string[] | null;
sessionKey?: string | number;
storageNamespace?: string;
isShared?: boolean; isShared?: boolean;
onFinished?: () => void; onFinished?: () => void;
} }
export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) { export function QuizViewer({
quiz,
retakeIds,
sessionKey = "default",
storageNamespace,
isShared = false,
onFinished,
}: QuizViewerProps) {
const submittingQuestionRef = useRef<string | null>(null); const submittingQuestionRef = useRef<string | null>(null);
const finishingRef = useRef(false);
const [initialQuiz] = useState(() => quiz);
const [initialRetakeIds] = useState(() => retakeIds);
const [initialProgress] = useState(() =>
quiz.progress?.find((progress) => progress.mode === "SEQUENTIAL")
);
const progressSessionIdRef = useRef(
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
);
const progressRevisionRef = useRef(initialProgress?.revision ?? 0);
const saveQueueRef = useRef(Promise.resolve());
const [order, setOrder] = useState<string[]>([]); const [order, setOrder] = useState<string[]>([]);
const [attemptScope, setAttemptScope] = useState<string[]>([]);
const [currentIndex, setCurrentIndex] = useState(0); const [currentIndex, setCurrentIndex] = useState(0);
const [answers, setAnswers] = useState<Record<string, string[]>>({}); const [answers, setAnswers] = useState<Record<string, string[]>>({});
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]); const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
@ -51,6 +75,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null); const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
const [toastMessage, setToastMessage] = useState<string | null>(null); const [toastMessage, setToastMessage] = useState<string | null>(null);
const [isFinishing, setIsFinishing] = useState(false);
const [finishError, setFinishError] = useState<string | null>(null);
const [restoreWarning, setRestoreWarning] = useState<string | null>(null);
const sessionIdentity = `${quiz.id}:${sessionKey}`;
const sharedStorageKey = `quiz_progress_${storageNamespace ?? "shared"}_${quiz.id}`;
// Auto-hide toast // Auto-hide toast
useEffect(() => { useEffect(() => {
@ -62,16 +91,18 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
// Initialize session // Initialize session
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => {
// Generate shuffled options once per session mount // Generate shuffled options once per session mount
const newShuffledOpts: Record<string, Option[]> = {}; const newShuffledOpts: Record<string, Option[]> = {};
for (const q of quiz.questions) { for (const q of initialQuiz.questions) {
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5); newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
} }
setShuffledOptions(newShuffledOpts); setShuffledOptions(newShuffledOpts);
// If retaking specific questions // If retaking specific questions
if (retakeIds && retakeIds.length > 0) { if (initialRetakeIds && initialRetakeIds.length > 0) {
setOrder(retakeIds); setOrder(initialRetakeIds);
setAttemptScope(initialRetakeIds);
setCurrentIndex(0); setCurrentIndex(0);
setAnswers({}); setAnswers({});
setSubmittedAnswers([]); setSubmittedAnswers([]);
@ -82,62 +113,120 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
// Shared links: load from localStorage // Shared links: load from localStorage
if (isShared) { if (isShared) {
try { try {
const saved = localStorage.getItem(`quiz_progress_${quiz.id}`); const saved = localStorage.getItem(sharedStorageKey);
if (saved) { if (saved) {
const parsed = JSON.parse(saved); const parsed: unknown = JSON.parse(saved);
setOrder(parsed.order || quiz.questions.map(q => q.id)); const record =
setCurrentIndex(parsed.currentIndex || 0); typeof parsed === "object" && parsed !== null
setAnswers(parsed.answers || {}); ? (parsed as Record<string, unknown>)
setSubmittedAnswers(Object.keys(parsed.answers || {})); : {};
const liveIds = initialQuiz.questions.map((question) => question.id);
const optionsByQuestion = new Map(
initialQuiz.questions.map((question) => [
question.id,
new Set(question.options.map((option) => option.id)),
])
);
const restored = normalizeProgress({
orderJson: record.order,
currentIndex: record.currentIndex,
dataJson: record.answers,
liveIds,
isValidValue: (value, questionId): value is string[] =>
Array.isArray(value) &&
new Set(value).size === value.length &&
value.every(
(optionId) =>
typeof optionId === "string" &&
optionsByQuestion.get(questionId)?.has(optionId) === true
),
});
const restoredSet = new Set(restored.order);
const restoredOrder = [
...restored.order,
...liveIds.filter((id) => !restoredSet.has(id)),
];
setOrder(restoredOrder);
setAttemptScope(restoredOrder);
setCurrentIndex(
restoredOrder.length === 0
? 0
: Math.min(restored.currentIndex, restoredOrder.length - 1)
);
setAnswers(restored.data);
setSubmittedAnswers(Object.keys(restored.data));
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
setRestoreWarning(
"Some saved quiz progress was invalid or referenced changed questions, so it was safely repaired."
);
}
setToastMessage("Session restored"); setToastMessage("Session restored");
setLoading(false); setLoading(false);
return; return;
} }
} catch (e) { } catch {
// Fallback to defaults // Fallback to defaults
} }
} }
// Otherwise, normal sequential logic checking for progress // Otherwise, normal sequential logic checking for progress
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL"); const prog = initialProgress;
if (prog) { if (prog) {
let savedOrder: string[] = []; const restored = normalizeProgress({
try { orderJson: prog.orderJson,
savedOrder = JSON.parse(prog.orderJson); currentIndex: prog.currentIndex,
} catch { dataJson: prog.answersJson,
savedOrder = quiz.questions.map((q) => q.id); liveIds: initialQuiz.questions.map((question) => question.id),
isValidValue: (value, questionId): value is string[] => {
const question = initialQuiz.questions.find((item) => item.id === questionId);
const optionIds = new Set(question?.options.map((option) => option.id) ?? []);
return Array.isArray(value) &&
new Set(value).size === value.length &&
value.every((id) => typeof id === "string" && optionIds.has(id));
},
});
const liveIds = initialQuiz.questions.map((question) => question.id);
const restoredSet = new Set(restored.order);
const restoredOrder = [
...restored.order,
...liveIds.filter((id) => !restoredSet.has(id)),
];
setOrder(restoredOrder);
setAttemptScope(restoredOrder);
setCurrentIndex(
restoredOrder.length === 0
? 0
: Math.min(restored.currentIndex, restoredOrder.length - 1)
);
setAnswers(restored.data);
setSubmittedAnswers(Object.keys(restored.data));
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
setRestoreWarning(
"Some saved quiz progress was invalid or referenced deleted questions, so it was safely repaired."
);
} }
let savedAnswers: Record<string, string[]> = {};
if (prog.answersJson) {
try {
savedAnswers = JSON.parse(prog.answersJson);
} catch {}
}
setOrder(savedOrder);
setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1));
setAnswers(savedAnswers);
setSubmittedAnswers(Object.keys(savedAnswers));
} else { } else {
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5); const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
setOrder(freshOrder); setOrder(freshOrder);
setAttemptScope(freshOrder);
setCurrentIndex(0); setCurrentIndex(0);
setAnswers({}); setAnswers({});
setSubmittedAnswers([]); setSubmittedAnswers([]);
} }
setLoading(false); setLoading(false);
}, [quiz, retakeIds, isShared]); }, 0);
return () => window.clearTimeout(timer);
}, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]);
// Persist progress as you go // Persist progress as you go
function saveProgress( function saveProgress(
idx: number, idx: number,
ans: Record<string, string[]> ans: Record<string, string[]>
) { ) {
if (retakeIds) return; // Don't persist retake partial state if (attemptScope.length !== quiz.questions.length) return;
if (isShared) { if (isShared) {
localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({ localStorage.setItem(sharedStorageKey, JSON.stringify({
currentIndex: idx, currentIndex: idx,
order: order, order: order,
answers: ans, answers: ans,
@ -145,7 +234,10 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
return; return;
} }
fetch("/api/progress", { const revision = ++progressRevisionRef.current;
saveQueueRef.current = saveQueueRef.current
.then(async () => {
const response = await fetch("/api/progress", {
method: "PATCH", method: "PATCH",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@ -153,10 +245,21 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
contentId: quiz.id, contentId: quiz.id,
mode: "SEQUENTIAL", mode: "SEQUENTIAL",
currentIndex: idx, currentIndex: idx,
orderJson: JSON.stringify(order), order,
answersJson: JSON.stringify(ans), answers: ans,
sessionId: progressSessionIdRef.current,
revision,
}), }),
}).catch(() => {}); });
if (response.status === 409) {
setToastMessage("Progress changed in another session; reload to continue safely.");
return;
}
if (!response.ok) throw new Error("Progress save failed");
})
.catch(() => {
setToastMessage("Progress could not be saved. Your answers remain on screen.");
});
} }
if (loading) return <div>Loading quiz...</div>; if (loading) return <div>Loading quiz...</div>;
@ -217,18 +320,21 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
} }
async function handleFinish() { async function handleFinish() {
if (finishingRef.current) return;
finishingRef.current = true;
setIsFinishing(true);
setFinishError(null);
if (isShared) { if (isShared) {
localStorage.removeItem(`quiz_progress_${quiz.id}`); localStorage.removeItem(sharedStorageKey);
} }
// If shared, grade it locally and show results, don't ping backend // If shared, grade it locally and show results, don't ping backend
if (isShared) { if (isShared) {
// Simulate an attempt object // Simulate an attempt object
let runningScore = 0; let runningScore = 0;
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically const maxScore = attemptScope.length;
if (retakeIds) maxScore = retakeIds.length;
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => { const scoredItems = attemptScope.map(qId => {
const q = quiz.questions.find((x) => x.id === qId); const q = quiz.questions.find((x) => x.id === qId);
if (!q) return 0; if (!q) return 0;
const formattedQ = { const formattedQ = {
@ -245,45 +351,40 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
score: runningScore, score: runningScore,
maxScore: maxScore, maxScore: maxScore,
answersJson: JSON.stringify(answers), answersJson: JSON.stringify(answers),
isPartialRetake: false, isPartialRetake: attemptScope.length !== quiz.questions.length,
completedAt: new Date().toISOString(), completedAt: new Date().toISOString(),
}; };
setResultsData(attempt); setResultsData(attempt);
finishingRef.current = false;
setIsFinishing(false);
return; return;
} }
// Send to backend
const isPartialRetake = !!retakeIds;
try { try {
await saveQueueRef.current;
const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, { const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
answersJson: JSON.stringify(answers), questionIds: attemptScope,
isPartialRetake, answers,
}), }),
}); });
if (res.ok) { if (!res.ok) {
const attempt = await res.json(); const payload = (await res.json().catch(() => null)) as
| { error?: string }
// Clear progress | null;
if (!isPartialRetake) { throw new Error(payload?.error ?? "Could not save this attempt");
await fetch("/api/progress", {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
contentType: "QUIZ",
contentId: quiz.id,
mode: "SEQUENTIAL",
}),
});
} }
setResultsData(await res.json());
setResultsData(attempt); } catch (error) {
} setFinishError(
} catch (e) { error instanceof Error ? error.message : "Could not save this attempt"
console.error(e); );
} finally {
finishingRef.current = false;
setIsFinishing(false);
} }
} }
@ -295,11 +396,13 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
onRetake={(missedIds: string[]) => { onRetake={(missedIds: string[]) => {
// Restart this component with retakeIds // Restart this component with retakeIds
setOrder(missedIds); setOrder(missedIds);
setAttemptScope(missedIds);
setCurrentIndex(0); setCurrentIndex(0);
setAnswers({}); setAnswers({});
setSubmittedAnswers([]); setSubmittedAnswers([]);
submittingQuestionRef.current = null; submittingQuestionRef.current = null;
setResultsData(null); setResultsData(null);
setFinishError(null);
// Re-shuffle options for the new attempt // Re-shuffle options for the new attempt
const newShuffledOpts: Record<string, Option[]> = {}; const newShuffledOpts: Record<string, Option[]> = {};
@ -319,6 +422,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
return ( return (
<div className="flex flex-col items-center relative"> <div className="flex flex-col items-center relative">
{restoreWarning && (
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
{restoreWarning}
</div>
)}
{/* Toast Notification */} {/* Toast Notification */}
{toastMessage && ( {toastMessage && (
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none"> <div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
@ -504,12 +612,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
Next Question Next Question
</button> </button>
) : ( ) : (
<div className="w-full">
{finishError && (
<div className="mb-3 rounded-xl bg-error-bg px-4 py-3 text-sm text-error" role="alert">
{finishError} Your answers are still here; try again.
</div>
)}
<button <button
onClick={handleFinish} onClick={handleFinish}
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm" disabled={isFinishing}
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm disabled:cursor-not-allowed disabled:opacity-60"
> >
Finish Quiz {isFinishing ? "Saving attempt..." : "Finish Quiz"}
</button> </button>
</div>
)} )}
</div> </div>

View file

@ -188,7 +188,16 @@ export function SpacedRepetitionSets() {
useEffect(() => { useEffect(() => {
const timer = window.setTimeout(() => void load(), 0); const timer = window.setTimeout(() => void load(), 0);
return () => window.clearTimeout(timer); function refreshWhenVisible() {
if (document.visibilityState === "visible") void load();
}
window.addEventListener("focus", refreshWhenVisible);
window.addEventListener("study-decks-changed", refreshWhenVisible);
return () => {
window.clearTimeout(timer);
window.removeEventListener("focus", refreshWhenVisible);
window.removeEventListener("study-decks-changed", refreshWhenVisible);
};
}, [load]); }, [load]);
async function createSet() { async function createSet() {

View file

@ -21,6 +21,8 @@ export function Navbar() {
const parts = pathname.split("/").filter(Boolean); const parts = pathname.split("/").filter(Boolean);
const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null; const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null;
const [hasReadyCards, setHasReadyCards] = useState(false); const [hasReadyCards, setHasReadyCards] = useState(false);
const [loggingOut, setLoggingOut] = useState(false);
const [logoutError, setLogoutError] = useState<string | null>(null);
const refreshDueCards = useCallback(async () => { const refreshDueCards = useCallback(async () => {
if (!classSlug) return; if (!classSlug) return;
@ -53,9 +55,18 @@ export function Navbar() {
}, [refreshDueCards]); }, [refreshDueCards]);
async function handleLogout() { async function handleLogout() {
await fetch("/api/auth/logout", { method: "POST" }); setLoggingOut(true);
setLogoutError(null);
try {
const response = await fetch("/api/auth/logout", { method: "POST" });
if (!response.ok) throw new Error("Sign out failed. Your session is still active.");
router.push("/login"); router.push("/login");
router.refresh(); router.refresh();
} catch (error) {
setLogoutError(error instanceof Error ? error.message : "Sign out failed. Please retry.");
} finally {
setLoggingOut(false);
}
} }
const nav = ( const nav = (
@ -110,7 +121,8 @@ export function Navbar() {
<span className="text-xs font-semibold text-text-muted">Appearance</span> <span className="text-xs font-semibold text-text-muted">Appearance</span>
<ThemeToggle /> <ThemeToggle />
</div> </div>
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error"> {logoutError && <p role="alert" className="mt-2 rounded-lg bg-error-bg p-2 text-xs text-error">{logoutError}</p>}
<button aria-label={loggingOut ? "Signing out" : logoutError ? "Retry sign out" : "Sign out"} disabled={loggingOut} onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error disabled:opacity-50">
<span aria-hidden></span> Sign out <span aria-hidden></span> Sign out
</button> </button>
</div> </div>

View file

@ -1,21 +1,29 @@
"use client"; "use client";
import { useState, useEffect, useRef } from "react"; import { useState, useEffect, useRef, useCallback } from "react";
interface ShareMenuProps { interface ShareMenuProps {
targetType: "DECK" | "QUIZ" | "GROUP"; targetType: "DECK" | "QUIZ" | "GROUP";
contentId: string; contentId: string;
classSlug: string; classSlug: string;
compact?: boolean; compact?: boolean;
containsQuizAnswers?: boolean;
} }
export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) { export function ShareMenu({
targetType,
contentId,
classSlug,
compact = false,
containsQuizAnswers = targetType === "QUIZ",
}: ShareMenuProps) {
const [token, setToken] = useState<string | null>(null); const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [isGroupShared, setIsGroupShared] = useState(false); const [isGroupShared, setIsGroupShared] = useState(false);
const [groupName, setGroupName] = useState<string | null>(null); const [groupName, setGroupName] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const buttonRef = useRef<HTMLButtonElement>(null); const buttonRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@ -35,28 +43,55 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
return () => document.removeEventListener("pointerdown", handlePointerDown); return () => document.removeEventListener("pointerdown", handlePointerDown);
}, [open]); }, [open]);
const loadShareInfo = useCallback(async (signal?: AbortSignal) => {
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/share?targetType=${targetType}&contentId=${contentId}`, { signal });
if (!response.ok) throw new Error("Sharing settings could not be loaded.");
const data = await response.json() as {
token?: string | null;
isGroupShared?: boolean;
groupName?: string | null;
};
setToken(data.token ?? null);
setIsGroupShared(Boolean(data.isGroupShared));
setGroupName(data.groupName ?? null);
} catch (loadError) {
if (signal?.aborted) return;
setError(loadError instanceof Error ? loadError.message : "Sharing settings could not be loaded.");
} finally {
if (!signal?.aborted) setLoading(false);
}
}, [targetType, contentId]);
useEffect(() => { useEffect(() => {
if (!open) return; if (!open) return;
fetch(`/api/share?targetType=${targetType}&contentId=${contentId}`) const controller = new AbortController();
.then(res => res.json()) const timer = window.setTimeout(() => void loadShareInfo(controller.signal), 0);
.then(data => { return () => {
setToken(data.token); window.clearTimeout(timer);
setIsGroupShared(data.isGroupShared || false); controller.abort();
setGroupName(data.groupName || null); };
}) }, [open, loadShareInfo]);
.finally(() => setLoading(false));
}, [open, targetType, contentId]);
async function handleToggle() { async function handleToggle() {
setLoading(true); setLoading(true);
setError(null);
try { try {
const res = await fetch("/api/share", { const res = await fetch("/api/share", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ targetType, contentId }), body: JSON.stringify({ targetType, contentId }),
}); });
if (!res.ok) {
const response = await res.json().catch(() => null) as { error?: string } | null;
throw new Error(response?.error ?? "Sharing could not be updated.");
}
const data = await res.json(); const data = await res.json();
setToken(data.token); setToken(data.token);
} catch (toggleError) {
setError(toggleError instanceof Error ? toggleError.message : "Sharing could not be updated.");
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -73,9 +108,13 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
: "quizzes"; : "quizzes";
const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : ""; const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : "";
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`; const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`;
try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
} catch {
setError("Clipboard access failed. Retry or copy the URL from your browser.");
}
} }
return ( return (
@ -85,6 +124,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
onClick={() => setOpen(!open)} onClick={() => setOpen(!open)}
className={`${compact ? "h-8 w-8 rounded-lg" : "h-10 w-10 rounded-xl"} grid place-items-center text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary`} className={`${compact ? "h-8 w-8 rounded-lg" : "h-10 w-10 rounded-xl"} grid place-items-center text-text-muted transition-colors hover:bg-bg-surface-alt hover:text-primary`}
title="Share" title="Share"
aria-label="Share settings"
> >
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" /> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
@ -99,6 +139,11 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
{loading ? ( {loading ? (
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" /> <div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
) : error ? (
<div role="alert" className="rounded-lg bg-error-bg p-3 text-sm text-error">
<p>{error}</p>
<button onClick={() => void loadShareInfo()} className="mt-2 min-h-9 rounded-lg border border-error/40 px-3 font-bold">Retry</button>
</div>
) : ( ) : (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -106,6 +151,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
<button <button
onClick={handleToggle} onClick={handleToggle}
disabled={isGroupShared} disabled={isGroupShared}
aria-label={token || isGroupShared ? "Disable public sharing" : "Enable public sharing"}
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${ className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
token || isGroupShared ? 'bg-primary' : 'bg-border' token || isGroupShared ? 'bg-primary' : 'bg-border'
} ${isGroupShared ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`} } ${isGroupShared ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
@ -127,8 +173,13 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
{(token || isGroupShared) && ( {(token || isGroupShared) && (
<div className="space-y-2 pt-2 border-t border-border-light"> <div className="space-y-2 pt-2 border-t border-border-light">
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
Anyone with the link can view and study this {targetType === "GROUP" ? "group" : (targetType === "DECK" ? "deck" : "quiz")}. No progress is saved. Anyone with the link can view and study this {targetType === "GROUP" ? "group" : (targetType === "DECK" ? "deck" : "quiz")}. Progress is stored only in that recipient&apos;s browser.
</p> </p>
{containsQuizAnswers && (
<p className="rounded-lg bg-badge-bg px-3 py-2 text-xs text-text-secondary">
Shared quizzes send correct answers and rationales to the recipient&apos;s browser for local grading, so the answer key is inspectable.
</p>
)}
<button <button
onClick={handleCopy} onClick={handleCopy}
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium cursor-pointer" className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium cursor-pointer"

File diff suppressed because one or more lines are too long

View file

@ -80,12 +80,12 @@ export type PrismaVersion = {
} }
/** /**
* Prisma Client JS version: 7.8.0 * Prisma Client JS version: 7.9.1
* Query Engine version: 3c6e192761c0362d496ed980de936e2f3cebcd3a * Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
*/ */
export const prismaVersion: PrismaVersion = { export const prismaVersion: PrismaVersion = {
client: "7.8.0", client: "7.9.1",
engine: "3c6e192761c0362d496ed980de936e2f3cebcd3a" engine: "e922089b7d7502aff4249d5da3420f6fa55fc6ad"
} }
/** /**
@ -155,6 +155,19 @@ export type Subset<T, U> = {
[key in keyof T]: key extends keyof U ? T[key] : never; [key in keyof T]: key extends keyof U ? T[key] : never;
}; };
/**
* Resolved type of the argument passed to the `PrismaClient` constructor.
*
* When called without a narrower options type (the common case), this resolves
* to `PrismaClientOptions` directly, which produces a clear TypeScript error
* message (`not assignable to parameter of type 'PrismaClientOptions'`) when
* the argument is missing or incomplete. When the user supplies a narrower
* options type (e.g. via a literal), it falls back to `Subset` to keep
* filtering out unknown properties.
*/
export type PrismaClientConstructorArgs<Options extends PrismaClientOptions> =
[PrismaClientOptions] extends [Options] ? PrismaClientOptions : Subset<Options, PrismaClientOptions>;
/** /**
* SelectSubset * SelectSubset
* @desc From `T` pick properties that exist in `U`. Simple version of Intersection. * @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
@ -187,7 +200,7 @@ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
export type XOR<T, U> = export type XOR<T, U> =
T extends object ? T extends object ?
U extends object ? U extends object ?
(Without<T, U> & U) | (Without<U, T> & T) ((Without<T, U> & U) | (Without<U, T> & T)) & object
: U : T : U : T
@ -1871,6 +1884,8 @@ export const StudyProgressScalarFieldEnum = {
orderJson: 'orderJson', orderJson: 'orderJson',
answersJson: 'answersJson', answersJson: 'answersJson',
cardResultsJson: 'cardResultsJson', cardResultsJson: 'cardResultsJson',
sessionId: 'sessionId',
revision: 'revision',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const } as const
@ -1883,6 +1898,7 @@ export const QuizAttemptScalarFieldEnum = {
score: 'score', score: 'score',
maxScore: 'maxScore', maxScore: 'maxScore',
answersJson: 'answersJson', answersJson: 'answersJson',
reviewSnapshotJson: 'reviewSnapshotJson',
isPartialRetake: 'isPartialRetake', isPartialRetake: 'isPartialRetake',
completedAt: 'completedAt' completedAt: 'completedAt'
} as const } as const
@ -2090,19 +2106,10 @@ export type BatchPayload = {
export const defineExtension = runtime.Extensions.defineExtension as unknown as runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs> export const defineExtension = runtime.Extensions.defineExtension as unknown as runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs>
export type DefaultPrismaClient = PrismaClient export type DefaultPrismaClient = PrismaClient
export type ErrorFormat = 'pretty' | 'colorless' | 'minimal' export type ErrorFormat = 'pretty' | 'colorless' | 'minimal'
export type PrismaClientOptions = ({ /**
/** * Options common to all variants of `PrismaClientOptions`, regardless of whether you connect to your database through a driver adapter or through Prisma Accelerate.
* Instance of a Driver Adapter, e.g., like one provided by `@prisma/adapter-pg`.
*/ */
adapter: runtime.SqlDriverAdapterFactory export interface PrismaClientBaseOptions {
accelerateUrl?: never
} | {
/**
* Prisma Accelerate URL allowing the client to connect through Accelerate instead of a direct database.
*/
accelerateUrl: string
adapter?: never
}) & {
/** /**
* @default "colorless" * @default "colorless"
*/ */
@ -2189,6 +2196,56 @@ export type PrismaClientOptions = ({
*/ */
queryPlanCacheMaxSize?: number queryPlanCacheMaxSize?: number
} }
/**
* `PrismaClient` options for connecting to your database through Prisma Accelerate instead of a driver adapter.
*
* Learn more: https://pris.ly/d/accelerate
*/
export interface PrismaClientOptionsWithAccelerateUrl extends PrismaClientBaseOptions {
/**
* The Prisma Accelerate connection URL. Use this option to connect to your database through Prisma Accelerate instead of using a driver adapter to connect directly.
*
* Learn more: https://pris.ly/d/accelerate
*/
accelerateUrl: string
adapter?: never
}
/**
* `PrismaClient` options for connecting to your database through a driver adapter. This is the common case in Prisma 7.
*
* Learn more: https://pris.ly/d/driver-adapters
*/
export interface PrismaClientOptionsWithAdapter extends PrismaClientBaseOptions {
/**
* A driver adapter that PrismaClient uses to connect to your database, such as the ones provided by `@prisma/adapter-pg`, `@prisma/adapter-libsql`, `@prisma/adapter-planetscale`, etc.
*
* A driver adapter is **required** unless you connect to your database through Prisma Accelerate (in which case use `accelerateUrl` instead).
*
* Learn more: https://pris.ly/d/driver-adapters
*
* @example
* ```ts
* import { PrismaPg } from '@prisma/adapter-pg'
* import { PrismaClient } from './generated/prisma/client'
*
* const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
* const prisma = new PrismaClient({ adapter })
* ```
*/
adapter: runtime.SqlDriverAdapterFactory
accelerateUrl?: never
}
/**
* Options passed to the `PrismaClient` constructor.
*
* A driver adapter (or, alternatively, a Prisma Accelerate URL) is **required**. See {@link PrismaClientOptionsWithAdapter} and {@link PrismaClientOptionsWithAccelerateUrl} for the two variants. All other properties live in {@link PrismaClientBaseOptions} and are optional.
*
* Learn more about driver adapters: https://pris.ly/d/driver-adapters
*/
export type PrismaClientOptions = PrismaClientOptionsWithAccelerateUrl | PrismaClientOptionsWithAdapter
export type GlobalOmitConfig = { export type GlobalOmitConfig = {
class?: Prisma.ClassOmit class?: Prisma.ClassOmit
deck?: Prisma.DeckOmit deck?: Prisma.DeckOmit

View file

@ -166,6 +166,8 @@ export const StudyProgressScalarFieldEnum = {
orderJson: 'orderJson', orderJson: 'orderJson',
answersJson: 'answersJson', answersJson: 'answersJson',
cardResultsJson: 'cardResultsJson', cardResultsJson: 'cardResultsJson',
sessionId: 'sessionId',
revision: 'revision',
updatedAt: 'updatedAt' updatedAt: 'updatedAt'
} as const } as const
@ -178,6 +180,7 @@ export const QuizAttemptScalarFieldEnum = {
score: 'score', score: 'score',
maxScore: 'maxScore', maxScore: 'maxScore',
answersJson: 'answersJson', answersJson: 'answersJson',
reviewSnapshotJson: 'reviewSnapshotJson',
isPartialRetake: 'isPartialRetake', isPartialRetake: 'isPartialRetake',
completedAt: 'completedAt' completedAt: 'completedAt'
} as const } as const

View file

@ -42,6 +42,7 @@ export type QuizAttemptMinAggregateOutputType = {
score: number | null score: number | null
maxScore: number | null maxScore: number | null
answersJson: string | null answersJson: string | null
reviewSnapshotJson: string | null
isPartialRetake: boolean | null isPartialRetake: boolean | null
completedAt: Date | null completedAt: Date | null
} }
@ -52,6 +53,7 @@ export type QuizAttemptMaxAggregateOutputType = {
score: number | null score: number | null
maxScore: number | null maxScore: number | null
answersJson: string | null answersJson: string | null
reviewSnapshotJson: string | null
isPartialRetake: boolean | null isPartialRetake: boolean | null
completedAt: Date | null completedAt: Date | null
} }
@ -62,6 +64,7 @@ export type QuizAttemptCountAggregateOutputType = {
score: number score: number
maxScore: number maxScore: number
answersJson: number answersJson: number
reviewSnapshotJson: number
isPartialRetake: number isPartialRetake: number
completedAt: number completedAt: number
_all: number _all: number
@ -84,6 +87,7 @@ export type QuizAttemptMinAggregateInputType = {
score?: true score?: true
maxScore?: true maxScore?: true
answersJson?: true answersJson?: true
reviewSnapshotJson?: true
isPartialRetake?: true isPartialRetake?: true
completedAt?: true completedAt?: true
} }
@ -94,6 +98,7 @@ export type QuizAttemptMaxAggregateInputType = {
score?: true score?: true
maxScore?: true maxScore?: true
answersJson?: true answersJson?: true
reviewSnapshotJson?: true
isPartialRetake?: true isPartialRetake?: true
completedAt?: true completedAt?: true
} }
@ -104,6 +109,7 @@ export type QuizAttemptCountAggregateInputType = {
score?: true score?: true
maxScore?: true maxScore?: true
answersJson?: true answersJson?: true
reviewSnapshotJson?: true
isPartialRetake?: true isPartialRetake?: true
completedAt?: true completedAt?: true
_all?: true _all?: true
@ -201,6 +207,7 @@ export type QuizAttemptGroupByOutputType = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson: string | null
isPartialRetake: boolean isPartialRetake: boolean
completedAt: Date completedAt: Date
_count: QuizAttemptCountAggregateOutputType | null _count: QuizAttemptCountAggregateOutputType | null
@ -234,6 +241,7 @@ export type QuizAttemptWhereInput = {
score?: Prisma.FloatFilter<"QuizAttempt"> | number score?: Prisma.FloatFilter<"QuizAttempt"> | number
maxScore?: Prisma.IntFilter<"QuizAttempt"> | number maxScore?: Prisma.IntFilter<"QuizAttempt"> | number
answersJson?: Prisma.StringFilter<"QuizAttempt"> | string answersJson?: Prisma.StringFilter<"QuizAttempt"> | string
reviewSnapshotJson?: Prisma.StringNullableFilter<"QuizAttempt"> | string | null
isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean
completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string
quizSet?: Prisma.XOR<Prisma.QuizSetScalarRelationFilter, Prisma.QuizSetWhereInput> quizSet?: Prisma.XOR<Prisma.QuizSetScalarRelationFilter, Prisma.QuizSetWhereInput>
@ -245,6 +253,7 @@ export type QuizAttemptOrderByWithRelationInput = {
score?: Prisma.SortOrder score?: Prisma.SortOrder
maxScore?: Prisma.SortOrder maxScore?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
reviewSnapshotJson?: Prisma.SortOrderInput | Prisma.SortOrder
isPartialRetake?: Prisma.SortOrder isPartialRetake?: Prisma.SortOrder
completedAt?: Prisma.SortOrder completedAt?: Prisma.SortOrder
quizSet?: Prisma.QuizSetOrderByWithRelationInput quizSet?: Prisma.QuizSetOrderByWithRelationInput
@ -259,6 +268,7 @@ export type QuizAttemptWhereUniqueInput = Prisma.AtLeast<{
score?: Prisma.FloatFilter<"QuizAttempt"> | number score?: Prisma.FloatFilter<"QuizAttempt"> | number
maxScore?: Prisma.IntFilter<"QuizAttempt"> | number maxScore?: Prisma.IntFilter<"QuizAttempt"> | number
answersJson?: Prisma.StringFilter<"QuizAttempt"> | string answersJson?: Prisma.StringFilter<"QuizAttempt"> | string
reviewSnapshotJson?: Prisma.StringNullableFilter<"QuizAttempt"> | string | null
isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean
completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string
quizSet?: Prisma.XOR<Prisma.QuizSetScalarRelationFilter, Prisma.QuizSetWhereInput> quizSet?: Prisma.XOR<Prisma.QuizSetScalarRelationFilter, Prisma.QuizSetWhereInput>
@ -270,6 +280,7 @@ export type QuizAttemptOrderByWithAggregationInput = {
score?: Prisma.SortOrder score?: Prisma.SortOrder
maxScore?: Prisma.SortOrder maxScore?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
reviewSnapshotJson?: Prisma.SortOrderInput | Prisma.SortOrder
isPartialRetake?: Prisma.SortOrder isPartialRetake?: Prisma.SortOrder
completedAt?: Prisma.SortOrder completedAt?: Prisma.SortOrder
_count?: Prisma.QuizAttemptCountOrderByAggregateInput _count?: Prisma.QuizAttemptCountOrderByAggregateInput
@ -288,6 +299,7 @@ export type QuizAttemptScalarWhereWithAggregatesInput = {
score?: Prisma.FloatWithAggregatesFilter<"QuizAttempt"> | number score?: Prisma.FloatWithAggregatesFilter<"QuizAttempt"> | number
maxScore?: Prisma.IntWithAggregatesFilter<"QuizAttempt"> | number maxScore?: Prisma.IntWithAggregatesFilter<"QuizAttempt"> | number
answersJson?: Prisma.StringWithAggregatesFilter<"QuizAttempt"> | string answersJson?: Prisma.StringWithAggregatesFilter<"QuizAttempt"> | string
reviewSnapshotJson?: Prisma.StringNullableWithAggregatesFilter<"QuizAttempt"> | string | null
isPartialRetake?: Prisma.BoolWithAggregatesFilter<"QuizAttempt"> | boolean isPartialRetake?: Prisma.BoolWithAggregatesFilter<"QuizAttempt"> | boolean
completedAt?: Prisma.DateTimeWithAggregatesFilter<"QuizAttempt"> | Date | string completedAt?: Prisma.DateTimeWithAggregatesFilter<"QuizAttempt"> | Date | string
} }
@ -297,6 +309,7 @@ export type QuizAttemptCreateInput = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson?: string | null
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: Date | string completedAt?: Date | string
quizSet: Prisma.QuizSetCreateNestedOneWithoutAttemptsInput quizSet: Prisma.QuizSetCreateNestedOneWithoutAttemptsInput
@ -308,6 +321,7 @@ export type QuizAttemptUncheckedCreateInput = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson?: string | null
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: Date | string completedAt?: Date | string
} }
@ -317,6 +331,7 @@ export type QuizAttemptUpdateInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSet?: Prisma.QuizSetUpdateOneRequiredWithoutAttemptsNestedInput quizSet?: Prisma.QuizSetUpdateOneRequiredWithoutAttemptsNestedInput
@ -328,6 +343,7 @@ export type QuizAttemptUncheckedUpdateInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -338,6 +354,7 @@ export type QuizAttemptCreateManyInput = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson?: string | null
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: Date | string completedAt?: Date | string
} }
@ -347,6 +364,7 @@ export type QuizAttemptUpdateManyMutationInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -357,6 +375,7 @@ export type QuizAttemptUncheckedUpdateManyInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -377,6 +396,7 @@ export type QuizAttemptCountOrderByAggregateInput = {
score?: Prisma.SortOrder score?: Prisma.SortOrder
maxScore?: Prisma.SortOrder maxScore?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
reviewSnapshotJson?: Prisma.SortOrder
isPartialRetake?: Prisma.SortOrder isPartialRetake?: Prisma.SortOrder
completedAt?: Prisma.SortOrder completedAt?: Prisma.SortOrder
} }
@ -392,6 +412,7 @@ export type QuizAttemptMaxOrderByAggregateInput = {
score?: Prisma.SortOrder score?: Prisma.SortOrder
maxScore?: Prisma.SortOrder maxScore?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
reviewSnapshotJson?: Prisma.SortOrder
isPartialRetake?: Prisma.SortOrder isPartialRetake?: Prisma.SortOrder
completedAt?: Prisma.SortOrder completedAt?: Prisma.SortOrder
} }
@ -402,6 +423,7 @@ export type QuizAttemptMinOrderByAggregateInput = {
score?: Prisma.SortOrder score?: Prisma.SortOrder
maxScore?: Prisma.SortOrder maxScore?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
reviewSnapshotJson?: Prisma.SortOrder
isPartialRetake?: Prisma.SortOrder isPartialRetake?: Prisma.SortOrder
completedAt?: Prisma.SortOrder completedAt?: Prisma.SortOrder
} }
@ -466,6 +488,7 @@ export type QuizAttemptCreateWithoutQuizSetInput = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson?: string | null
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: Date | string completedAt?: Date | string
} }
@ -475,6 +498,7 @@ export type QuizAttemptUncheckedCreateWithoutQuizSetInput = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson?: string | null
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: Date | string completedAt?: Date | string
} }
@ -513,6 +537,7 @@ export type QuizAttemptScalarWhereInput = {
score?: Prisma.FloatFilter<"QuizAttempt"> | number score?: Prisma.FloatFilter<"QuizAttempt"> | number
maxScore?: Prisma.IntFilter<"QuizAttempt"> | number maxScore?: Prisma.IntFilter<"QuizAttempt"> | number
answersJson?: Prisma.StringFilter<"QuizAttempt"> | string answersJson?: Prisma.StringFilter<"QuizAttempt"> | string
reviewSnapshotJson?: Prisma.StringNullableFilter<"QuizAttempt"> | string | null
isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean
completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string
} }
@ -522,6 +547,7 @@ export type QuizAttemptCreateManyQuizSetInput = {
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson?: string | null
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: Date | string completedAt?: Date | string
} }
@ -531,6 +557,7 @@ export type QuizAttemptUpdateWithoutQuizSetInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -540,6 +567,7 @@ export type QuizAttemptUncheckedUpdateWithoutQuizSetInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -549,6 +577,7 @@ export type QuizAttemptUncheckedUpdateManyWithoutQuizSetInput = {
score?: Prisma.FloatFieldUpdateOperationsInput | number score?: Prisma.FloatFieldUpdateOperationsInput | number
maxScore?: Prisma.IntFieldUpdateOperationsInput | number maxScore?: Prisma.IntFieldUpdateOperationsInput | number
answersJson?: Prisma.StringFieldUpdateOperationsInput | string answersJson?: Prisma.StringFieldUpdateOperationsInput | string
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -561,6 +590,7 @@ export type QuizAttemptSelect<ExtArgs extends runtime.Types.Extensions.InternalA
score?: boolean score?: boolean
maxScore?: boolean maxScore?: boolean
answersJson?: boolean answersJson?: boolean
reviewSnapshotJson?: boolean
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: boolean completedAt?: boolean
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs> quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
@ -572,6 +602,7 @@ export type QuizAttemptSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
score?: boolean score?: boolean
maxScore?: boolean maxScore?: boolean
answersJson?: boolean answersJson?: boolean
reviewSnapshotJson?: boolean
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: boolean completedAt?: boolean
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs> quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
@ -583,6 +614,7 @@ export type QuizAttemptSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
score?: boolean score?: boolean
maxScore?: boolean maxScore?: boolean
answersJson?: boolean answersJson?: boolean
reviewSnapshotJson?: boolean
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: boolean completedAt?: boolean
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs> quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
@ -594,11 +626,12 @@ export type QuizAttemptSelectScalar = {
score?: boolean score?: boolean
maxScore?: boolean maxScore?: boolean
answersJson?: boolean answersJson?: boolean
reviewSnapshotJson?: boolean
isPartialRetake?: boolean isPartialRetake?: boolean
completedAt?: boolean completedAt?: boolean
} }
export type QuizAttemptOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "quizSetId" | "score" | "maxScore" | "answersJson" | "isPartialRetake" | "completedAt", ExtArgs["result"]["quizAttempt"]> export type QuizAttemptOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "quizSetId" | "score" | "maxScore" | "answersJson" | "reviewSnapshotJson" | "isPartialRetake" | "completedAt", ExtArgs["result"]["quizAttempt"]>
export type QuizAttemptInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type QuizAttemptInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs> quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
} }
@ -620,6 +653,7 @@ export type $QuizAttemptPayload<ExtArgs extends runtime.Types.Extensions.Interna
score: number score: number
maxScore: number maxScore: number
answersJson: string answersJson: string
reviewSnapshotJson: string | null
isPartialRetake: boolean isPartialRetake: boolean
completedAt: Date completedAt: Date
}, ExtArgs["result"]["quizAttempt"]> }, ExtArgs["result"]["quizAttempt"]>
@ -1051,6 +1085,7 @@ export interface QuizAttemptFieldRefs {
readonly score: Prisma.FieldRef<"QuizAttempt", 'Float'> readonly score: Prisma.FieldRef<"QuizAttempt", 'Float'>
readonly maxScore: Prisma.FieldRef<"QuizAttempt", 'Int'> readonly maxScore: Prisma.FieldRef<"QuizAttempt", 'Int'>
readonly answersJson: Prisma.FieldRef<"QuizAttempt", 'String'> readonly answersJson: Prisma.FieldRef<"QuizAttempt", 'String'>
readonly reviewSnapshotJson: Prisma.FieldRef<"QuizAttempt", 'String'>
readonly isPartialRetake: Prisma.FieldRef<"QuizAttempt", 'Boolean'> readonly isPartialRetake: Prisma.FieldRef<"QuizAttempt", 'Boolean'>
readonly completedAt: Prisma.FieldRef<"QuizAttempt", 'DateTime'> readonly completedAt: Prisma.FieldRef<"QuizAttempt", 'DateTime'>
} }

View file

@ -28,10 +28,12 @@ export type AggregateStudyProgress = {
export type StudyProgressAvgAggregateOutputType = { export type StudyProgressAvgAggregateOutputType = {
currentIndex: number | null currentIndex: number | null
revision: number | null
} }
export type StudyProgressSumAggregateOutputType = { export type StudyProgressSumAggregateOutputType = {
currentIndex: number | null currentIndex: number | null
revision: number | null
} }
export type StudyProgressMinAggregateOutputType = { export type StudyProgressMinAggregateOutputType = {
@ -44,6 +46,8 @@ export type StudyProgressMinAggregateOutputType = {
orderJson: string | null orderJson: string | null
answersJson: string | null answersJson: string | null
cardResultsJson: string | null cardResultsJson: string | null
sessionId: string | null
revision: number | null
updatedAt: Date | null updatedAt: Date | null
} }
@ -57,6 +61,8 @@ export type StudyProgressMaxAggregateOutputType = {
orderJson: string | null orderJson: string | null
answersJson: string | null answersJson: string | null
cardResultsJson: string | null cardResultsJson: string | null
sessionId: string | null
revision: number | null
updatedAt: Date | null updatedAt: Date | null
} }
@ -70,6 +76,8 @@ export type StudyProgressCountAggregateOutputType = {
orderJson: number orderJson: number
answersJson: number answersJson: number
cardResultsJson: number cardResultsJson: number
sessionId: number
revision: number
updatedAt: number updatedAt: number
_all: number _all: number
} }
@ -77,10 +85,12 @@ export type StudyProgressCountAggregateOutputType = {
export type StudyProgressAvgAggregateInputType = { export type StudyProgressAvgAggregateInputType = {
currentIndex?: true currentIndex?: true
revision?: true
} }
export type StudyProgressSumAggregateInputType = { export type StudyProgressSumAggregateInputType = {
currentIndex?: true currentIndex?: true
revision?: true
} }
export type StudyProgressMinAggregateInputType = { export type StudyProgressMinAggregateInputType = {
@ -93,6 +103,8 @@ export type StudyProgressMinAggregateInputType = {
orderJson?: true orderJson?: true
answersJson?: true answersJson?: true
cardResultsJson?: true cardResultsJson?: true
sessionId?: true
revision?: true
updatedAt?: true updatedAt?: true
} }
@ -106,6 +118,8 @@ export type StudyProgressMaxAggregateInputType = {
orderJson?: true orderJson?: true
answersJson?: true answersJson?: true
cardResultsJson?: true cardResultsJson?: true
sessionId?: true
revision?: true
updatedAt?: true updatedAt?: true
} }
@ -119,6 +133,8 @@ export type StudyProgressCountAggregateInputType = {
orderJson?: true orderJson?: true
answersJson?: true answersJson?: true
cardResultsJson?: true cardResultsJson?: true
sessionId?: true
revision?: true
updatedAt?: true updatedAt?: true
_all?: true _all?: true
} }
@ -219,6 +235,8 @@ export type StudyProgressGroupByOutputType = {
orderJson: string orderJson: string
answersJson: string | null answersJson: string | null
cardResultsJson: string | null cardResultsJson: string | null
sessionId: string
revision: number
updatedAt: Date updatedAt: Date
_count: StudyProgressCountAggregateOutputType | null _count: StudyProgressCountAggregateOutputType | null
_avg: StudyProgressAvgAggregateOutputType | null _avg: StudyProgressAvgAggregateOutputType | null
@ -255,6 +273,8 @@ export type StudyProgressWhereInput = {
orderJson?: Prisma.StringFilter<"StudyProgress"> | string orderJson?: Prisma.StringFilter<"StudyProgress"> | string
answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
sessionId?: Prisma.StringFilter<"StudyProgress"> | string
revision?: Prisma.IntFilter<"StudyProgress"> | number
updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string
deck?: Prisma.XOR<Prisma.DeckNullableScalarRelationFilter, Prisma.DeckWhereInput> | null deck?: Prisma.XOR<Prisma.DeckNullableScalarRelationFilter, Prisma.DeckWhereInput> | null
quizSet?: Prisma.XOR<Prisma.QuizSetNullableScalarRelationFilter, Prisma.QuizSetWhereInput> | null quizSet?: Prisma.XOR<Prisma.QuizSetNullableScalarRelationFilter, Prisma.QuizSetWhereInput> | null
@ -270,6 +290,8 @@ export type StudyProgressOrderByWithRelationInput = {
orderJson?: Prisma.SortOrder orderJson?: Prisma.SortOrder
answersJson?: Prisma.SortOrderInput | Prisma.SortOrder answersJson?: Prisma.SortOrderInput | Prisma.SortOrder
cardResultsJson?: Prisma.SortOrderInput | Prisma.SortOrder cardResultsJson?: Prisma.SortOrderInput | Prisma.SortOrder
sessionId?: Prisma.SortOrder
revision?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
deck?: Prisma.DeckOrderByWithRelationInput deck?: Prisma.DeckOrderByWithRelationInput
quizSet?: Prisma.QuizSetOrderByWithRelationInput quizSet?: Prisma.QuizSetOrderByWithRelationInput
@ -290,6 +312,8 @@ export type StudyProgressWhereUniqueInput = Prisma.AtLeast<{
orderJson?: Prisma.StringFilter<"StudyProgress"> | string orderJson?: Prisma.StringFilter<"StudyProgress"> | string
answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
sessionId?: Prisma.StringFilter<"StudyProgress"> | string
revision?: Prisma.IntFilter<"StudyProgress"> | number
updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string
deck?: Prisma.XOR<Prisma.DeckNullableScalarRelationFilter, Prisma.DeckWhereInput> | null deck?: Prisma.XOR<Prisma.DeckNullableScalarRelationFilter, Prisma.DeckWhereInput> | null
quizSet?: Prisma.XOR<Prisma.QuizSetNullableScalarRelationFilter, Prisma.QuizSetWhereInput> | null quizSet?: Prisma.XOR<Prisma.QuizSetNullableScalarRelationFilter, Prisma.QuizSetWhereInput> | null
@ -305,6 +329,8 @@ export type StudyProgressOrderByWithAggregationInput = {
orderJson?: Prisma.SortOrder orderJson?: Prisma.SortOrder
answersJson?: Prisma.SortOrderInput | Prisma.SortOrder answersJson?: Prisma.SortOrderInput | Prisma.SortOrder
cardResultsJson?: Prisma.SortOrderInput | Prisma.SortOrder cardResultsJson?: Prisma.SortOrderInput | Prisma.SortOrder
sessionId?: Prisma.SortOrder
revision?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
_count?: Prisma.StudyProgressCountOrderByAggregateInput _count?: Prisma.StudyProgressCountOrderByAggregateInput
_avg?: Prisma.StudyProgressAvgOrderByAggregateInput _avg?: Prisma.StudyProgressAvgOrderByAggregateInput
@ -326,6 +352,8 @@ export type StudyProgressScalarWhereWithAggregatesInput = {
orderJson?: Prisma.StringWithAggregatesFilter<"StudyProgress"> | string orderJson?: Prisma.StringWithAggregatesFilter<"StudyProgress"> | string
answersJson?: Prisma.StringNullableWithAggregatesFilter<"StudyProgress"> | string | null answersJson?: Prisma.StringNullableWithAggregatesFilter<"StudyProgress"> | string | null
cardResultsJson?: Prisma.StringNullableWithAggregatesFilter<"StudyProgress"> | string | null cardResultsJson?: Prisma.StringNullableWithAggregatesFilter<"StudyProgress"> | string | null
sessionId?: Prisma.StringWithAggregatesFilter<"StudyProgress"> | string
revision?: Prisma.IntWithAggregatesFilter<"StudyProgress"> | number
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"StudyProgress"> | Date | string updatedAt?: Prisma.DateTimeWithAggregatesFilter<"StudyProgress"> | Date | string
} }
@ -337,6 +365,8 @@ export type StudyProgressCreateInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
deck?: Prisma.DeckCreateNestedOneWithoutProgressInput deck?: Prisma.DeckCreateNestedOneWithoutProgressInput
quizSet?: Prisma.QuizSetCreateNestedOneWithoutProgressInput quizSet?: Prisma.QuizSetCreateNestedOneWithoutProgressInput
@ -352,6 +382,8 @@ export type StudyProgressUncheckedCreateInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
} }
@ -363,6 +395,8 @@ export type StudyProgressUpdateInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
deck?: Prisma.DeckUpdateOneWithoutProgressNestedInput deck?: Prisma.DeckUpdateOneWithoutProgressNestedInput
quizSet?: Prisma.QuizSetUpdateOneWithoutProgressNestedInput quizSet?: Prisma.QuizSetUpdateOneWithoutProgressNestedInput
@ -378,6 +412,8 @@ export type StudyProgressUncheckedUpdateInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -391,6 +427,8 @@ export type StudyProgressCreateManyInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
} }
@ -402,6 +440,8 @@ export type StudyProgressUpdateManyMutationInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -415,6 +455,8 @@ export type StudyProgressUncheckedUpdateManyInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -448,11 +490,14 @@ export type StudyProgressCountOrderByAggregateInput = {
orderJson?: Prisma.SortOrder orderJson?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
cardResultsJson?: Prisma.SortOrder cardResultsJson?: Prisma.SortOrder
sessionId?: Prisma.SortOrder
revision?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
export type StudyProgressAvgOrderByAggregateInput = { export type StudyProgressAvgOrderByAggregateInput = {
currentIndex?: Prisma.SortOrder currentIndex?: Prisma.SortOrder
revision?: Prisma.SortOrder
} }
export type StudyProgressMaxOrderByAggregateInput = { export type StudyProgressMaxOrderByAggregateInput = {
@ -465,6 +510,8 @@ export type StudyProgressMaxOrderByAggregateInput = {
orderJson?: Prisma.SortOrder orderJson?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
cardResultsJson?: Prisma.SortOrder cardResultsJson?: Prisma.SortOrder
sessionId?: Prisma.SortOrder
revision?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
@ -478,11 +525,14 @@ export type StudyProgressMinOrderByAggregateInput = {
orderJson?: Prisma.SortOrder orderJson?: Prisma.SortOrder
answersJson?: Prisma.SortOrder answersJson?: Prisma.SortOrder
cardResultsJson?: Prisma.SortOrder cardResultsJson?: Prisma.SortOrder
sessionId?: Prisma.SortOrder
revision?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder updatedAt?: Prisma.SortOrder
} }
export type StudyProgressSumOrderByAggregateInput = { export type StudyProgressSumOrderByAggregateInput = {
currentIndex?: Prisma.SortOrder currentIndex?: Prisma.SortOrder
revision?: Prisma.SortOrder
} }
export type StudyProgressCreateNestedManyWithoutDeckInput = { export type StudyProgressCreateNestedManyWithoutDeckInput = {
@ -577,6 +627,8 @@ export type StudyProgressCreateWithoutDeckInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
quizSet?: Prisma.QuizSetCreateNestedOneWithoutProgressInput quizSet?: Prisma.QuizSetCreateNestedOneWithoutProgressInput
} }
@ -590,6 +642,8 @@ export type StudyProgressUncheckedCreateWithoutDeckInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
} }
@ -631,6 +685,8 @@ export type StudyProgressScalarWhereInput = {
orderJson?: Prisma.StringFilter<"StudyProgress"> | string orderJson?: Prisma.StringFilter<"StudyProgress"> | string
answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
sessionId?: Prisma.StringFilter<"StudyProgress"> | string
revision?: Prisma.IntFilter<"StudyProgress"> | number
updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string
} }
@ -642,6 +698,8 @@ export type StudyProgressCreateWithoutQuizSetInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
deck?: Prisma.DeckCreateNestedOneWithoutProgressInput deck?: Prisma.DeckCreateNestedOneWithoutProgressInput
} }
@ -655,6 +713,8 @@ export type StudyProgressUncheckedCreateWithoutQuizSetInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
} }
@ -692,6 +752,8 @@ export type StudyProgressCreateManyDeckInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
} }
@ -703,6 +765,8 @@ export type StudyProgressUpdateWithoutDeckInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
quizSet?: Prisma.QuizSetUpdateOneWithoutProgressNestedInput quizSet?: Prisma.QuizSetUpdateOneWithoutProgressNestedInput
} }
@ -716,6 +780,8 @@ export type StudyProgressUncheckedUpdateWithoutDeckInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -728,6 +794,8 @@ export type StudyProgressUncheckedUpdateManyWithoutDeckInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -740,6 +808,8 @@ export type StudyProgressCreateManyQuizSetInput = {
orderJson: string orderJson: string
answersJson?: string | null answersJson?: string | null
cardResultsJson?: string | null cardResultsJson?: string | null
sessionId?: string
revision?: number
updatedAt?: Date | string updatedAt?: Date | string
} }
@ -751,6 +821,8 @@ export type StudyProgressUpdateWithoutQuizSetInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
deck?: Prisma.DeckUpdateOneWithoutProgressNestedInput deck?: Prisma.DeckUpdateOneWithoutProgressNestedInput
} }
@ -764,6 +836,8 @@ export type StudyProgressUncheckedUpdateWithoutQuizSetInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -776,6 +850,8 @@ export type StudyProgressUncheckedUpdateManyWithoutQuizSetInput = {
orderJson?: Prisma.StringFieldUpdateOperationsInput | string orderJson?: Prisma.StringFieldUpdateOperationsInput | string
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
revision?: Prisma.IntFieldUpdateOperationsInput | number
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
} }
@ -791,6 +867,8 @@ export type StudyProgressSelect<ExtArgs extends runtime.Types.Extensions.Interna
orderJson?: boolean orderJson?: boolean
answersJson?: boolean answersJson?: boolean
cardResultsJson?: boolean cardResultsJson?: boolean
sessionId?: boolean
revision?: boolean
updatedAt?: boolean updatedAt?: boolean
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs> deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs> quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
@ -806,6 +884,8 @@ export type StudyProgressSelectCreateManyAndReturn<ExtArgs extends runtime.Types
orderJson?: boolean orderJson?: boolean
answersJson?: boolean answersJson?: boolean
cardResultsJson?: boolean cardResultsJson?: boolean
sessionId?: boolean
revision?: boolean
updatedAt?: boolean updatedAt?: boolean
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs> deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs> quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
@ -821,6 +901,8 @@ export type StudyProgressSelectUpdateManyAndReturn<ExtArgs extends runtime.Types
orderJson?: boolean orderJson?: boolean
answersJson?: boolean answersJson?: boolean
cardResultsJson?: boolean cardResultsJson?: boolean
sessionId?: boolean
revision?: boolean
updatedAt?: boolean updatedAt?: boolean
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs> deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs> quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
@ -836,10 +918,12 @@ export type StudyProgressSelectScalar = {
orderJson?: boolean orderJson?: boolean
answersJson?: boolean answersJson?: boolean
cardResultsJson?: boolean cardResultsJson?: boolean
sessionId?: boolean
revision?: boolean
updatedAt?: boolean updatedAt?: boolean
} }
export type StudyProgressOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "contentType" | "deckId" | "quizSetId" | "mode" | "currentIndex" | "orderJson" | "answersJson" | "cardResultsJson" | "updatedAt", ExtArgs["result"]["studyProgress"]> export type StudyProgressOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "contentType" | "deckId" | "quizSetId" | "mode" | "currentIndex" | "orderJson" | "answersJson" | "cardResultsJson" | "sessionId" | "revision" | "updatedAt", ExtArgs["result"]["studyProgress"]>
export type StudyProgressInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = { export type StudyProgressInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs> deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs> quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
@ -869,6 +953,8 @@ export type $StudyProgressPayload<ExtArgs extends runtime.Types.Extensions.Inter
orderJson: string orderJson: string
answersJson: string | null answersJson: string | null
cardResultsJson: string | null cardResultsJson: string | null
sessionId: string
revision: number
updatedAt: Date updatedAt: Date
}, ExtArgs["result"]["studyProgress"]> }, ExtArgs["result"]["studyProgress"]>
composites: {} composites: {}
@ -1304,6 +1390,8 @@ export interface StudyProgressFieldRefs {
readonly orderJson: Prisma.FieldRef<"StudyProgress", 'String'> readonly orderJson: Prisma.FieldRef<"StudyProgress", 'String'>
readonly answersJson: Prisma.FieldRef<"StudyProgress", 'String'> readonly answersJson: Prisma.FieldRef<"StudyProgress", 'String'>
readonly cardResultsJson: Prisma.FieldRef<"StudyProgress", 'String'> readonly cardResultsJson: Prisma.FieldRef<"StudyProgress", 'String'>
readonly sessionId: Prisma.FieldRef<"StudyProgress", 'String'>
readonly revision: Prisma.FieldRef<"StudyProgress", 'Int'>
readonly updatedAt: Prisma.FieldRef<"StudyProgress", 'DateTime'> readonly updatedAt: Prisma.FieldRef<"StudyProgress", 'DateTime'>
} }

View file

@ -1,5 +1,9 @@
import { getIronSession, type SessionOptions } from "iron-session"; import { getIronSession } from "iron-session";
import { cookies } from "next/headers"; import { cookies } from "next/headers";
import {
getPasswordResetSessionOptions,
getSessionOptions,
} from "@/lib/sessionConfig";
export interface SessionData { export interface SessionData {
isAuthenticated: boolean; isAuthenticated: boolean;
@ -11,31 +15,9 @@ interface PasswordResetSessionData {
expiresAt?: string; expiresAt?: string;
} }
const sessionOptions: SessionOptions = {
password: process.env.SESSION_SECRET || "dev-session-secret-change-in-production-must-be-32-chars",
cookieName: "study-app-session",
cookieOptions: {
secure: process.env.NODE_ENV === "production" && process.env.SECURE_COOKIES === "true",
httpOnly: true,
sameSite: "lax" as const,
},
};
const passwordResetSessionOptions: SessionOptions = {
password: sessionOptions.password,
cookieName: "study-app-password-reset",
cookieOptions: {
secure: sessionOptions.cookieOptions?.secure,
httpOnly: true,
sameSite: "lax" as const,
maxAge: 15 * 60,
path: "/",
},
};
export async function getSession() { export async function getSession() {
const cookieStore = await cookies(); const cookieStore = await cookies();
return getIronSession<SessionData>(cookieStore, sessionOptions); return getIronSession<SessionData>(cookieStore, getSessionOptions());
} }
export async function createSession(sessionGeneration: number) { export async function createSession(sessionGeneration: number) {
@ -59,7 +41,7 @@ export async function getPasswordResetSession() {
const cookieStore = await cookies(); const cookieStore = await cookies();
return getIronSession<PasswordResetSessionData>( return getIronSession<PasswordResetSessionData>(
cookieStore, cookieStore,
passwordResetSessionOptions getPasswordResetSessionOptions()
); );
} }

View file

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { NextRequest } from "next/server";
import { readLimitedJson, RequestTooLargeError } from "./limitedJson";
describe("readLimitedJson", () => {
it("rejects declared and measured oversized request bodies", async () => {
const declared = new NextRequest("http://localhost/api/decks", {
method: "POST",
headers: { "content-length": "100" },
body: "{}",
});
await expect(readLimitedJson(declared, 10)).rejects.toBeInstanceOf(RequestTooLargeError);
const measured = new NextRequest("http://localhost/api/decks", {
method: "POST",
body: JSON.stringify({ value: "oversized" }),
});
await expect(readLimitedJson(measured, 10)).rejects.toBeInstanceOf(RequestTooLargeError);
});
});

24
src/lib/limitedJson.ts Normal file
View file

@ -0,0 +1,24 @@
import type { NextRequest } from "next/server";
import { CONTENT_LIMITS } from "@/lib/validation/contentSchemas";
export class RequestTooLargeError extends Error {}
export async function readLimitedJson(
request: NextRequest,
maximumBytes = CONTENT_LIMITS.requestBytes
): Promise<unknown> {
const declaredLength = Number(request.headers.get("content-length"));
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
throw new RequestTooLargeError(`Request body must not exceed ${maximumBytes} bytes`);
}
const body = await request.text();
if (new TextEncoder().encode(body).byteLength > maximumBytes) {
throw new RequestTooLargeError(`Request body must not exceed ${maximumBytes} bytes`);
}
try {
return JSON.parse(body) as unknown;
} catch {
return null;
}
}

View file

@ -0,0 +1,70 @@
import { describe, expect, it } from "vitest";
import { normalizeProgress } from "./progressNormalization";
const isGrade = (value: unknown): value is "correct" | "missed" =>
value === "correct" || value === "missed";
function normalize(order: string[], currentIndex: number, liveIds: string[]) {
return normalizeProgress({
orderJson: JSON.stringify(order),
currentIndex,
dataJson: JSON.stringify({ a: "correct", deleted: "missed" }),
liveIds,
isValidValue: isGrade,
});
}
describe("normalizeProgress", () => {
it("preserves the logical current card when a prior card was deleted", () => {
expect(normalize(["a", "b", "c"], 1, ["b", "c"]).currentIndex).toBe(0);
});
it("uses the next survivor when the current card was deleted", () => {
expect(normalize(["a", "b", "c"], 1, ["a", "c"]).currentIndex).toBe(1);
});
it("uses the previous survivor when no later card remains", () => {
expect(normalize(["a", "b", "c"], 2, ["a", "b"]).currentIndex).toBe(1);
});
it("preserves completed state after stale IDs are removed", () => {
const result = normalize(["a", "b", "c"], 3, ["a", "c"]);
expect(result.currentIndex).toBe(2);
expect(result.completed).toBe(true);
});
it("recovers malformed JSON and filters stale result keys", () => {
const result = normalizeProgress({
orderJson: "not-json",
currentIndex: -1,
dataJson: '{"a":"correct","b":"invalid"}',
liveIds: ["a", "b"],
isValidValue: isGrade,
});
expect(result).toMatchObject({
order: ["a", "b"],
currentIndex: 0,
data: { a: "correct" },
completed: false,
wasRecovered: true,
});
});
it("returns a clear completed state when all saved cards were deleted", () => {
const result = normalize(["deleted"], 0, []);
expect(result).toMatchObject({ order: [], currentIndex: 0, completed: true });
});
it("lets content-specific validation reject a value owned by another item", () => {
const result = normalizeProgress({
orderJson: '["q1","q2"]',
currentIndex: 0,
dataJson: '{"q1":["q2-option"]}',
liveIds: ["q1", "q2"],
isValidValue: (value, id): value is string[] =>
Array.isArray(value) && value.every((optionId) => optionId === `${id}-option`),
});
expect(result.data).toEqual({});
expect(result.wasRecovered).toBe(true);
});
});

View file

@ -0,0 +1,108 @@
type JsonRecord = Record<string, unknown>;
interface NormalizeProgressInput<T> {
orderJson: unknown;
currentIndex: unknown;
dataJson?: unknown;
liveIds: string[];
isValidValue: (value: unknown, id: string) => value is T;
}
export interface NormalizedProgress<T> {
order: string[];
currentIndex: number;
data: Record<string, T>;
completed: boolean;
wasRecovered: boolean;
}
function parseJson(value: unknown): { value: unknown; invalid: boolean } {
if (typeof value !== "string") return { value, invalid: false };
try {
return { value: JSON.parse(value), invalid: false };
} catch {
return { value: undefined, invalid: true };
}
}
function isRecord(value: unknown): value is JsonRecord {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function normalizeProgress<T>({
orderJson,
currentIndex,
dataJson,
liveIds,
isValidValue,
}: NormalizeProgressInput<T>): NormalizedProgress<T> {
const uniqueLiveIds = [...new Set(liveIds)];
const liveIdSet = new Set(uniqueLiveIds);
const parsedOrder = parseJson(orderJson);
const hasValidSavedOrder =
Array.isArray(parsedOrder.value) &&
parsedOrder.value.every((id) => typeof id === "string");
const savedOrder = hasValidSavedOrder
? (parsedOrder.value as string[])
: uniqueLiveIds;
let wasRecovered = parsedOrder.invalid || !hasValidSavedOrder;
const seen = new Set<string>();
const order = savedOrder.filter((id) => {
const keep = liveIdSet.has(id) && !seen.has(id);
if (!keep) wasRecovered = true;
seen.add(id);
return keep;
});
const savedIndex =
typeof currentIndex === "number" &&
Number.isSafeInteger(currentIndex) &&
currentIndex >= 0
? currentIndex
: 0;
if (savedIndex !== currentIndex) wasRecovered = true;
const savedCompleted =
hasValidSavedOrder && savedOrder.length > 0 && savedIndex >= savedOrder.length;
let normalizedIndex = 0;
if (order.length === 0 || savedCompleted) {
normalizedIndex = order.length;
} else {
const currentId = savedOrder[savedIndex];
if (currentId && order.includes(currentId)) {
normalizedIndex = order.indexOf(currentId);
} else {
const nextId = savedOrder
.slice(savedIndex + 1)
.find((id) => order.includes(id));
const previousId = savedOrder
.slice(0, savedIndex)
.reverse()
.find((id) => order.includes(id));
const replacementId = nextId ?? previousId;
normalizedIndex = replacementId ? order.indexOf(replacementId) : 0;
if (currentId !== order[normalizedIndex]) wasRecovered = true;
}
}
const parsedData = parseJson(dataJson ?? {});
const rawData = isRecord(parsedData.value) ? parsedData.value : {};
if (parsedData.invalid || !isRecord(parsedData.value)) wasRecovered = true;
const data: Record<string, T> = {};
for (const [id, value] of Object.entries(rawData)) {
if (liveIdSet.has(id) && isValidValue(value, id)) {
data[id] = value;
} else {
wasRecovered = true;
}
}
return {
order,
currentIndex: normalizedIndex,
data,
completed: order.length === 0 || normalizedIndex === order.length,
wasRecovered,
};
}

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from "vitest";
import { isPublicPath } from "./publicPaths";
describe("isPublicPath", () => {
it.each([
"/login",
"/api/auth/login",
"/api/health",
"/shared/class/quizzes/token",
"/_next/static/chunk.js",
"/favicon.ico",
])("allows intentional public path %s", (pathname) => {
expect(isPublicPath(pathname)).toBe(true);
});
it.each([
"/private/file.txt",
"/api/decks/file.json",
"/api/authentication/fake",
"/shared-secret",
"/private/%2e%2e/data",
])("does not bypass authentication for %s", (pathname) => {
expect(isPublicPath(pathname)).toBe(false);
});
});

17
src/lib/publicPaths.ts Normal file
View file

@ -0,0 +1,17 @@
const EXACT_PUBLIC_PATHS = new Set([
"/login",
"/api/health",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
"/icon.svg",
]);
export function isPublicPath(pathname: string) {
return (
EXACT_PUBLIC_PATHS.has(pathname) ||
pathname.startsWith("/_next/") ||
pathname.startsWith("/shared/") ||
pathname.startsWith("/api/auth/")
);
}

33
src/lib/quizSnapshots.ts Normal file
View file

@ -0,0 +1,33 @@
import type { QuizQuestion } from "@/types/study";
export interface SnapshotQuestion extends QuizQuestion {
selections: string[];
points: number;
}
export interface QuizReviewSnapshot {
version: 1;
questions: SnapshotQuestion[];
}
export function parseQuizReviewSnapshot(
value: string | null | undefined
): QuizReviewSnapshot | null {
if (!value) return null;
try {
const parsed: unknown = JSON.parse(value);
if (
typeof parsed !== "object" ||
parsed === null ||
!("version" in parsed) ||
parsed.version !== 1 ||
!("questions" in parsed) ||
!Array.isArray(parsed.questions)
) {
return null;
}
return parsed as QuizReviewSnapshot;
} catch {
return null;
}
}

View file

@ -0,0 +1,30 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { checkRateLimit, clearRateLimits } from "./rateLimiter";
describe("checkRateLimit", () => {
beforeEach(() => {
clearRateLimits();
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-08-07T12:00:00Z"));
});
afterEach(() => {
vi.useRealTimers();
});
it("blocks after the configured global request limit", () => {
expect(checkRateLimit("reset", { maxRequests: 1 }).allowed).toBe(true);
expect(checkRateLimit("reset", { maxRequests: 1 })).toMatchObject({
allowed: false,
retryAfterMs: 60_000,
});
});
it("allows a request after the window expires", () => {
checkRateLimit("reset", { windowMs: 1_000, maxRequests: 1 });
vi.advanceTimersByTime(1_000);
expect(
checkRateLimit("reset", { windowMs: 1_000, maxRequests: 1 }).allowed
).toBe(true);
});
});

View file

@ -12,6 +12,10 @@ const store = new Map<string, RateLimitEntry>();
const WINDOW_MS = 60 * 1000; // 1 minute const WINDOW_MS = 60 * 1000; // 1 minute
const MAX_REQUESTS = 10; const MAX_REQUESTS = 10;
export function clearRateLimits() {
store.clear();
}
export function checkRateLimit( export function checkRateLimit(
key: string, key: string,
options: { windowMs?: number; maxRequests?: number } = {} options: { windowMs?: number; maxRequests?: number } = {}

48
src/lib/scoring.test.ts Normal file
View file

@ -0,0 +1,48 @@
import { describe, expect, it } from "vitest";
import { scoreQuestion, scoreQuiz } from "./scoring";
describe("quiz scoring", () => {
it("returns zero rather than NaN for invalid zero-correct SATA data", () => {
expect(
scoreQuestion(
{
id: "q1",
type: "SATA",
options: [{ id: "o1", isCorrect: false }],
},
["o1"]
)
).toBe(0);
});
it("does not award duplicate SATA selections more than once", () => {
expect(
scoreQuestion(
{
id: "q1",
type: "SATA",
options: [
{ id: "o1", isCorrect: true },
{ id: "o2", isCorrect: true },
],
},
["o1", "o1"]
)
).toBe(0.5);
});
it("keeps totals finite when legacy questions are malformed", () => {
const result = scoreQuiz(
[
{
id: "q1",
type: "SATA",
options: [{ id: "o1", isCorrect: false }],
},
],
{ q1: [] }
);
expect(Number.isFinite(result.total)).toBe(true);
expect(result).toMatchObject({ total: 0, maxScore: 1 });
});
});

View file

@ -9,19 +9,22 @@ export function scoreQuestion(
question: QuestionLite, question: QuestionLite,
selectedIds: string[] selectedIds: string[]
): number { ): number {
const uniqueSelectedIds = [...new Set(selectedIds)];
const correctIds = question.options const correctIds = question.options
.filter((o) => o.isCorrect) .filter((o) => o.isCorrect)
.map((o) => o.id); .map((o) => o.id);
if (question.type === "MULTIPLE_CHOICE") { if (question.type === "MULTIPLE_CHOICE") {
return correctIds.includes(selectedIds[0]) ? 1 : 0; return correctIds.includes(uniqueSelectedIds[0]) ? 1 : 0;
} }
if (correctIds.length === 0) return 0;
// SATA: partial credit // SATA: partial credit
const correctSelected = selectedIds.filter((id) => const correctSelected = uniqueSelectedIds.filter((id) =>
correctIds.includes(id) correctIds.includes(id)
).length; ).length;
const incorrectSelected = selectedIds.filter( const incorrectSelected = uniqueSelectedIds.filter(
(id) => !correctIds.includes(id) (id) => !correctIds.includes(id)
).length; ).length;
return Math.max(0, correctSelected - incorrectSelected) / correctIds.length; return Math.max(0, correctSelected - incorrectSelected) / correctIds.length;

View file

@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { sealData, unsealData } from "iron-session";
import {
DEVELOPMENT_SESSION_SECRET,
getSessionOptions,
} from "./sessionConfig";
describe("session configuration", () => {
it.each([undefined, "", "short", DEVELOPMENT_SESSION_SECRET])(
"rejects invalid production secret %s",
(secret) => {
expect(() =>
getSessionOptions({ NODE_ENV: "production", SESSION_SECRET: secret })
).toThrow(/SESSION_SECRET/);
}
);
it("allows the labelled fallback only outside production", () => {
expect(getSessionOptions({ NODE_ENV: "development" }).password).toBe(
DEVELOPMENT_SESSION_SECRET
);
});
it("honors secure cookies only when explicitly enabled in production", () => {
const options = getSessionOptions({
NODE_ENV: "production",
SESSION_SECRET: "a-secure-production-secret-that-is-long-enough",
SECURE_COOKIES: "true",
});
expect(options.cookieOptions?.secure).toBe(true);
});
it("does not authenticate data sealed with the development fallback under a real secret", async () => {
const sealed = await sealData(
{ isAuthenticated: true },
{ password: DEVELOPMENT_SESSION_SECRET, ttl: 60 }
);
await expect(unsealData(sealed, {
password: "a-secure-production-secret-that-is-long-enough",
ttl: 60,
})).resolves.toEqual({});
});
});

58
src/lib/sessionConfig.ts Normal file
View file

@ -0,0 +1,58 @@
import type { SessionOptions } from "iron-session";
export const DEVELOPMENT_SESSION_SECRET =
"dev-session-secret-change-in-production-must-be-32-chars";
function sessionPassword(env: NodeJS.ProcessEnv) {
const configured = env.SESSION_SECRET?.trim();
if (env.NODE_ENV === "production") {
if (
!configured ||
configured.length < 32 ||
configured === DEVELOPMENT_SESSION_SECRET
) {
throw new Error(
"SESSION_SECRET must be a non-default value of at least 32 characters in production"
);
}
return configured;
}
if (!configured) return DEVELOPMENT_SESSION_SECRET;
if (configured.length < 32) {
throw new Error("SESSION_SECRET must be at least 32 characters");
}
return configured;
}
export function getSessionOptions(
env: NodeJS.ProcessEnv = process.env
): SessionOptions {
return {
password: sessionPassword(env),
cookieName: "study-app-session",
cookieOptions: {
secure: env.NODE_ENV === "production" && env.SECURE_COOKIES === "true",
httpOnly: true,
sameSite: "lax",
path: "/",
},
};
}
export function getPasswordResetSessionOptions(
env: NodeJS.ProcessEnv = process.env
): SessionOptions {
const sessionOptions = getSessionOptions(env);
return {
password: sessionOptions.password,
cookieName: "study-app-password-reset",
cookieOptions: {
secure: sessionOptions.cookieOptions?.secure,
httpOnly: true,
sameSite: "lax",
maxAge: 15 * 60,
path: "/",
},
};
}

View file

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { buildShareMetadata, type ShareMetaLink } from "./shareMetadata";
describe("buildShareMetadata", () => {
const deckLink: ShareMetaLink = {
targetType: "DECK",
deck: {
name: "Deck",
description: null,
class: { slug: "class", name: "Class" },
_count: { cards: 3 },
},
quizSet: null,
group: null,
};
it("does not describe a token under a mismatched shared route type", () => {
expect(
buildShareMetadata(deckLink, {
classSlug: "class",
pathType: "quizzes",
}).title
).toBe("Study Desk");
});
it("describes the target only when token, class, and route type agree", () => {
expect(
buildShareMetadata(deckLink, {
classSlug: "class",
pathType: "flashcards",
}).title
).toContain("Deck");
});
});

View file

@ -22,6 +22,7 @@ interface MetaItem {
} }
export interface ShareMetaLink { export interface ShareMetaLink {
targetType: string;
deck: { deck: {
name: string; name: string;
description: string | null; description: string | null;
@ -38,8 +39,8 @@ export interface ShareMetaLink {
name: string; name: string;
type: string; type: string;
class: MetaClass; class: MetaClass;
decks: { id: string; name: string; description: string | null; _count: { cards: number } }[]; decks: { id: string; name: string; description: string | null; class: MetaClass; _count: { cards: number } }[];
quizSets: { id: string; name: string; description: string | null; _count: { questions: number } }[]; quizSets: { id: string; name: string; description: string | null; class: MetaClass; _count: { questions: number } }[];
} | null; } | null;
} }
@ -95,16 +96,30 @@ function itemMetadata(
*/ */
export function buildShareMetadata( export function buildShareMetadata(
link: ShareMetaLink | null, link: ShareMetaLink | null,
options: { classSlug?: string; itemId?: string } = {}, options: {
classSlug?: string;
itemId?: string;
pathType?: "flashcards" | "quizzes" | "groups";
} = {},
): Metadata { ): Metadata {
const { classSlug, itemId } = options; const { classSlug, itemId, pathType } = options;
if (!link) return toMetadata(SITE_NAME, SITE_DESCRIPTION); if (!link) return toMetadata(SITE_NAME, SITE_DESCRIPTION);
// Never describe content whose URL does not agree with the stored class, so // Never describe content whose URL does not agree with the stored class, so
// previews stay silent for the same requests the page itself rejects. // previews stay silent for the same requests the page itself rejects.
const targets = [link.deck, link.quizSet, link.group].filter(Boolean);
const target = link.deck ?? link.quizSet ?? link.group; const target = link.deck ?? link.quizSet ?? link.group;
if (!target || (classSlug && target.class.slug !== classSlug)) { const targetAgrees =
(link.targetType === "DECK" && Boolean(link.deck) && pathType !== "quizzes" && pathType !== "groups") ||
(link.targetType === "QUIZ" && Boolean(link.quizSet) && pathType !== "flashcards" && pathType !== "groups") ||
(link.targetType === "GROUP" && Boolean(link.group) && pathType !== "flashcards" && pathType !== "quizzes");
if (
!target ||
targets.length !== 1 ||
!targetAgrees ||
(classSlug && target.class.slug !== classSlug)
) {
return toMetadata(SITE_NAME, SITE_DESCRIPTION); return toMetadata(SITE_NAME, SITE_DESCRIPTION);
} }
@ -129,8 +144,12 @@ export function buildShareMetadata(
const isDeckGroup = group.type === "DECK"; const isDeckGroup = group.type === "DECK";
if (itemId) { if (itemId) {
const deck = isDeckGroup ? group.decks.find((d) => d.id === itemId) : undefined; const deck = isDeckGroup
const quizSet = isDeckGroup ? undefined : group.quizSets.find((q) => q.id === itemId); ? group.decks.find((d) => d.id === itemId && d.class.slug === group.class.slug)
: undefined;
const quizSet = isDeckGroup
? undefined
: group.quizSets.find((q) => q.id === itemId && q.class.slug === group.class.slug);
if (deck) { if (deck) {
return itemMetadata( return itemMetadata(

Some files were not shown because too many files have changed in this diff Show more