diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 088cd7e..0000000 --- a/.dockerignore +++ /dev/null @@ -1,20 +0,0 @@ -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 diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 7abe151..4ed4ca5 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1,4 +1,4 @@ -name: Verify and publish container +name: Automated Container Build on: push: @@ -16,41 +16,10 @@ jobs: run: | echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin - - name: Install locked dependencies - run: npm ci - - - name: Run tests - run: npm test - - - name: Validate Prisma schema and migration drift + - name: Build and Push Image 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" + # 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" . + docker push "$IMAGE_PATH" \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index f1f1a79..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output. -src/generated/prisma/** -whitespace diff --git a/.gitignore b/.gitignore index 4da595d..03f032b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,18 +12,12 @@ # testing /coverage -/.test-databases/ # local SQLite study data /dev.db /dev.db-journal /dev.db-shm /dev.db-wal -/data/ -/study.db -/study.db-journal -/study.db-shm -/study.db-wal # next.js /.next/ diff --git a/Dockerfile b/Dockerfile index 90c938b..1c5addf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,11 @@ -# ---- base ---- -FROM node:22-slim AS base -RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* - # ---- deps ---- -FROM base AS deps +FROM node:22-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci - -# ---- production dependencies ---- -FROM deps AS runtime-deps -RUN npm prune --omit=dev +RUN npm install # ---- builder ---- -FROM base AS builder +FROM node:22-slim AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -21,13 +13,13 @@ RUN npx prisma generate RUN npm run build # ---- runner ---- -FROM base AS runner +FROM node:22-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3726 -ENV HOSTNAME=0.0.0.0 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 COPY --from=builder /app/.next/standalone ./ @@ -35,13 +27,10 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./ -COPY --from=builder /app/scripts ./scripts -COPY --from=runtime-deps /app/node_modules ./node_modules +RUN npm install prisma@^7.8.0 COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh # Running as root to avoid permission denied on Unraid host-mounted volumes EXPOSE 3726 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"] diff --git a/README.md b/README.md index 17eac58..e215bc4 100644 --- a/README.md +++ b/README.md @@ -1,77 +1,4 @@ -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 = '' -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 `. -- `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. +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). ## Getting Started diff --git a/audit-results/CONFIRMED_FINDINGS.md b/audit-results/CONFIRMED_FINDINGS.md deleted file mode 100644 index 4e08f96..0000000 --- a/audit-results/CONFIRMED_FINDINGS.md +++ /dev/null @@ -1,67 +0,0 @@ -# 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). diff --git a/audit-results/COVERAGE_MAP.md b/audit-results/COVERAGE_MAP.md deleted file mode 100644 index 467bbc0..0000000 --- a/audit-results/COVERAGE_MAP.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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). diff --git a/audit-results/EXECUTIVE_SUMMARY.md b/audit-results/EXECUTIVE_SUMMARY.md deleted file mode 100644 index a45f275..0000000 --- a/audit-results/EXECUTIVE_SUMMARY.md +++ /dev/null @@ -1,69 +0,0 @@ -# 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. diff --git a/audit-results/FINDINGS.json b/audit-results/FINDINGS.json deleted file mode 100644 index 437d5eb..0000000 --- a/audit-results/FINDINGS.json +++ /dev/null @@ -1,1024 +0,0 @@ -{ - "audit": "Study Desk comprehensive read-only audit", - "generatedAt": "2026-08-06T00:00:00Z", - "method": "10 specialist subagent audits (DB, quiz, flashcards/SRS, auth/sharing, imports, groups, frontend, tests, ops, adversarial) + parent verification (baseline commands, migration chain on temp DB, drift reproduction, code-path re-tracing). Read-only; no tracked files modified.", - "outOfScope": "Arcade feature internals (may be removed) except where they affect the main app, shared dependencies, database integrity, build, or deployment.", - "findings": [ - { - "id": "FIN-01", - "sourceIds": ["DBAUD-01", "GRP-01"], - "title": "Schema/migration drift: MaterialGroup table and Deck/QuizSet/ShareLink.groupId columns were never migrated — every fresh deployment's database is incompatible with the app", - "severity": "Critical", - "confidence": "Confirmed", - "files": ["prisma/schema.prisma:33-34,61-62,128,133,196-208", "prisma/migrations/20260628011409_init/migration.sql:10-19,32-40,92-101", "src/generated/prisma/models/Deck.ts:44,54,64,201", "src/app/api/material-groups/route.ts:24,43,49", "package.json:6", "docker-entrypoint.sh:3"], - "functions": ["createDeckFromImport", "createQuizSetFromImport", "toggleShareLink", "isContentSharedViaGroup", "MaterialGroup CRUD routes", "predev", "docker-entrypoint.sh"], - "scenario": "Fresh clone → npm run dev (predev runs prisma migrate deploy) or docker-compose with empty volume → open any class page. GET /api/decks/list fails with 'no such column: Deck.groupId'; GET /api/material-groups fails with 'no such table: MaterialGroup'. Verified: temp DB built from the 4 migrations has no MaterialGroup table and no groupId columns; Prisma-shaped SELECTs fail with the exact errors above.", - "expected": "migrate deploy produces a DB matching schema.prisma (groups feature present).", - "actual": "The migration chain (the only DB-init path in dev and Docker) produces a DB missing the entire groups feature; even Deck/QuizSet/ShareLink reads fail because the generated client selects groupId.", - "rootCause": "Commit 7af0935 changed schema.prisma (+31 lines) and updated the committed dev.db binary (a prisma db push artifact) but created no migration. The developer's db-push-synced local DB masks the drift; AGENTS.md rule 'change schema, then create a new migration' was violated.", - "impact": "On any fresh install/deploy (new machine, new container volume, CI), the core app is non-functional: library pages fail, group/import/reorder/share operations error. No data loss on migration-tracked DBs; a db-push DB without _prisma_migrations would make migrate deploy fail with 'table already exists'.", - "evidence": "git show 7af0935 --stat (dev.db 139264→155648 bytes, schema +31, no migration); git log --all -- prisma/migrations (only 4 migration commits); audit-results/tmp/inspect-db.cjs + reproduce-drift.cjs runs (no such column/table errors); grep MaterialGroup|groupId across migrations = 0 hits.", - "testCoverage": "None. Only arcade + spacedRepetition tests exist; no migration/drift/integration test.", - "fixDirection": "Create a new migration (prisma migrate dev --name add_material_groups) adding MaterialGroup table, groupId columns with FKs (SET NULL for Deck/QuizSet, CASCADE for ShareLink), and the ShareLink_groupId_key unique index; verify with prisma migrate diff --from-migrations --to-schema-datamodel (empty diff); add CI drift gate. Do NOT edit applied migrations.", - "fixComplexity": "M", - "regressionTest": "CI: migrate deploy on fresh temp DB → prisma migrate diff empty; smoke query prisma.deck.findFirst() + prisma.materialGroup.count().", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-02", - "sourceIds": ["AUTH-01", "OPS-03"], - "title": "Hardcoded fallback SESSION_SECRET enables full session-cookie forgery and authentication bypass when the env var is unset (the shipped docker-compose default)", - "severity": "Critical", - "confidence": "High-confidence inference", - "files": ["src/lib/auth.ts:14-22", "src/proxy.ts:6-11,31-43", "docker-compose.yml:10", "src/services/authService.ts:61-67,128"], - "functions": ["getSession", "createSession", "isAuthenticated", "proxy()", "login", "completePasswordReset"], - "scenario": "docker compose up without SESSION_SECRET in .env (no .env.example exists, README silent) → compose interpolates an empty string → both lib/auth.ts and proxy.ts fall back to the public constant 'dev-session-secret-change-in-production-must-be-32-chars'. Anyone with the source seals {isAuthenticated:true, sessionGeneration:1} with that password (iron-session seal) and sets cookie study-app-session=; the proxy decrypts it successfully and grants full access to every protected route and API.", - "expected": "Missing production secret should fail hard at startup.", - "actual": "Silent fallback to a publicly-known secret; sessionGeneration defaults to '1' (only password reset bumps it), so a forged generation:1 cookie is valid in the default state.", - "rootCause": "process.env.SESSION_SECRET || in two places with no startup validation; compose passes possibly-empty variable; no documented secret provisioning.", - "impact": "Complete loss of confidentiality, integrity, availability of the entire single-user dataset (decks, cards, quizzes incl. rationales, attempts, progress, settings) on any deployment that omits the variable — read, modify, or wipe everything.", - "evidence": "auth.ts:15 and proxy.ts:8-9 use the identical fallback; compose:10 passes ${SESSION_SECRET}; grep shows no other consumption or validation; iron-session seals with the password (verified in node_modules/iron-session).", - "testCoverage": "None (no auth tests).", - "fixDirection": "Refuse to boot in production when SESSION_SECRET is unset or equals the known fallback (throw at module load); compose: SESSION_SECRET: ${SESSION_SECRET:?must be set}; ship .env.example; document openssl rand -hex 32.", - "fixComplexity": "S", - "regressionTest": "Unit: sessionOptions/proxy reject missing/known secret under NODE_ENV=production; integration: cookie sealed with fallback rejected when a real secret is set.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-03", - "sourceIds": ["QUIZ-01", "ADV-01"], - "title": "In-viewer 'Retake Missed' is persisted as a full attempt with a wrong, lower score and pollutes SEQUENTIAL progress", - "severity": "High", - "confidence": "Confirmed", - "files": ["src/components/quizzes/QuizViewer.tsx:137,219-288,295-310", "src/app/api/quizzes/[id]/attempt/route.ts:33-43", "src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx:213-223"], - "functions": ["QuizViewer.handleFinish", "QuizViewer.onRetake", "saveProgress", "POST /api/quizzes/[id]/attempt"], - "scenario": "Finish a 10-question quiz missing 4; click 'Retake Missed (4)'; answer the 4 questions; Finish. The retake resets local state but never sets the retakeIds prop; handleFinish computes isPartialRetake=!!retakeIds=false; the server scores ALL questions (6 unanswered → 0) and persists e.g. 3/10 instead of 3/4. saveProgress also runs during the retake (guard reads the prop), writing retake-only answers into SEQUENTIAL progress.", - "expected": "Partial retake scored only over submitted questions (domain invariant), maxScore = submitted count, no progress pollution.", - "actual": "Full-attempt scoring with wrong low score; abandoned retakes leave progress pointing at the retake subset.", - "rootCause": "Retake mode is tracked via a prop (set only by the page-level AttemptHistory flow), but the results-screen retake keeps state local and never lifts it; all three retake-dependent behaviors (saveProgress guard, isPartialRetake, progress DELETE) read the prop.", - "impact": "Wrong persisted scores and history entries for the most common retake path; percentage drops and compounds on further retakes; resume can show only the retake subset.", - "evidence": "QuizViewer.tsx:295-310 has no retakeIds setter (verified by reading); attempt/route.ts:40-43 filters only when isPartialRetake===true (verified); server cannot detect partial answers itself (adversarial review confirmed).", - "testCoverage": "None.", - "fixDirection": "Track retake mode in component state initialized from the prop (useState(!!retakeIds)), set it in onRetake; use it in saveProgress/isPartialRetake/DELETE; defense-in-depth: server validates isPartialRetake consistency with answer-set size.", - "fixComplexity": "M", - "regressionTest": "Component/integration test: full quiz → onRetake(missedIds) → answer → finish → assert POST body isPartialRetake===true and maxScore equals submitted question count.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-04", - "sourceIds": ["CARD-01", "IMPT-01", "QUIZ-06"], - "title": "Stale card ids in saved progress are never filtered on resume (filterAndClampOrder is dead code) — deleting a card mid-session bricks the flashcard study session", - "severity": "High", - "confidence": "Confirmed", - "files": ["src/lib/shuffle.ts:27-35", "src/components/flashcards/FlashcardViewer.tsx:42-44,73,174-207", "src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:157-172", "src/services/cardService.ts:33-38"], - "functions": ["filterAndClampOrder", "generateOrder", "FlashcardViewer (resume init)", "deleteCard", "GET /api/decks/[id]"], - "scenario": "Study a deck to card N; open Manage; delete the current or any upcoming card; return to Study/reload. order still contains the deleted id; currentCard is undefined; the card area renders nothing, gradeCard early-returns, no skip UI exists → permanently stuck until Restart (which wipes progress). Quiz resume has the same latent gap (currently unreachable since question editing doesn't exist).", - "expected": "Resume filters deleted ids and clamps the index — the cardService.ts:34-35 comment claims this.", - "actual": "filterAndClampOrder has zero call sites (grep-verified); resume copies orderJson verbatim.", - "rootCause": "The cleanup helper was implemented but never wired into the viewer's resume path; viewer initializes order once from props and never reconciles with the live cards list.", - "impact": "Session becomes unusable after any card deletion affecting the current/upcoming position; user must restart, losing in-progress results. No data corruption.", - "evidence": "grep filterAndClampOrder → definition + comment only; FlashcardViewer.tsx:42-44 parses raw orderJson (verified); gradeCard guard at :176.", - "testCoverage": "None.", - "fixDirection": "Call filterAndClampOrder on resume with (orderJson, existing card ids, currentIndex); also filter redoMissed/restartFullSet and results; add skip affordance as defense.", - "fixComplexity": "S", - "regressionTest": "Unit test filterAndClampOrder; component test: resume with stale id at ≤ currentIndex → filtered order, clamped index, card renders.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-05", - "sourceIds": ["OPS-01"], - "title": "Port mismatch: container listens on 3726 but docker-compose publishes 3000:3000 — production deployment is unreachable", - "severity": "High", - "confidence": "Confirmed", - "files": ["Dockerfile:19,34", "docker-compose.yml:6-7", ".next/standalone/server.js:8"], - "functions": ["Next standalone startServer (PORT env)", "compose port publishing"], - "scenario": "docker compose -f docker-compose.yml up on a server → entrypoint exec node server.js → listens on 0.0.0.0:3726 (ENV PORT=3726) → compose publishes host 3000 → container 3000 where nothing listens → curl http://server:3000 → connection refused. Dev path works only because the override runs npm run dev (PORT unset → 3000).", - "expected": "Published port reaches the app.", - "actual": "No listener on container port 3000; AGENTS.md already flags the mismatch ('verify the mapping') but the repo ships broken.", - "rootCause": "Dockerfile PORT=3726 chosen for the image; compose port mapping never updated; EXPOSE is metadata only.", - "impact": "Production deployment totally unreachable via the documented path — complete service outage.", - "evidence": "server.js:8 (parseInt(process.env.PORT,10)||3000); Dockerfile:19; compose:6-7 (verified by reading).", - "testCoverage": "None (CI builds but never runs the image).", - "fixDirection": "Map '3726:3726' in compose (or drop ENV PORT); add healthcheck on the real port; document in README.", - "fixComplexity": "S", - "regressionTest": "CI smoke: run image with compose mapping, curl the published port, expect HTTP 200.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-06", - "sourceIds": ["OPS-06"], - "title": "No .dockerignore: building on a Windows host injects win32 native modules (better-sqlite3, argon2) into the Linux image", - "severity": "High", - "confidence": "High-confidence inference", - "files": ["Dockerfile:10-11,25", "(missing .dockerignore)", "next.config.ts:3-6"], - "functions": ["docker build context", "COPY . ."], - "scenario": "On this Windows machine: docker build . → context includes local node_modules (win32 better_sqlite3.node PE32+ DLL verified) and .next → builder's COPY . . overwrites the Linux deps from the deps stage → next build traces win32 natives into .next/standalone → the Linux runner image contains Windows binaries → ERR_DLOPEN_FAILED at runtime or build failure. CI (Linux) masks it entirely.", - "expected": "Linux-built natives in the Linux image.", - "actual": "Build-host platform leaks into the image when building from Windows.", - "rootCause": "No .dockerignore; COPY . . after COPY --from=deps node_modules.", - "impact": "Locally built production image is broken or fails to build; context bloat (node_modules, .next, .git).", - "evidence": "Dockerfile COPY order (verified); local node_modules contains win32 binaries (adversarial review verified PE32+ via file); no .dockerignore (glob).", - "testCoverage": "None.", - "fixDirection": "Add .dockerignore (node_modules, .next, .git, .env*, data, audit-results, *.db*).", - "fixComplexity": "S", - "regressionTest": "CI assertion: container's better_sqlite3.node is ELF, not PE32+.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-07", - "sourceIds": ["OPS-08"], - "title": "docker-compose.override.yml auto-merges on plain 'docker compose up' — the production command silently runs dev mode", - "severity": "High", - "confidence": "Confirmed", - "files": ["docker-compose.override.yml:3-12", "docker-compose.yml:2-14"], - "functions": ["Compose override auto-merge", "npm run dev"], - "scenario": "Operator on the server runs the conventional 'docker compose up -d' with the repo present → Compose v2 auto-merges docker-compose.override.yml → build target deps, command npm run dev, NODE_ENV=development, bind mount → production runs dev server with hot reload and dev semantics. Running with -f docker-compose.yml instead hits FIN-05 (port mismatch). Every compose invocation is misconfigured.", - "expected": "One documented production command.", - "actual": "Two broken paths: dev-mode-in-production (silent) or unreachable (port mismatch).", - "rootCause": "Dev override committed under the conventional auto-merge filename with no -f documentation.", - "impact": "Unpredictable runtime behavior in production; dev-mode performance/logging; production bugs masked.", - "evidence": "Override file contents (verified); standard Compose v2 auto-merge semantics; no README instructions (stock create-next-app).", - "testCoverage": "None.", - "fixDirection": "Rename to docker-compose.dev.yml and document COMPOSE_FILE/-f usage; document the exact production command.", - "fixComplexity": "S", - "regressionTest": "docker compose config for the documented production invocation must show production settings.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-08", - "sourceIds": ["AUTH-04", "ADV-09"], - "title": "First-login provisioning takeover: whoever logs in first becomes admin; ADMIN_PASSWORD_HASH env is dead config", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/services/authService.ts:91-108", "src/app/api/auth/setup-status/route.ts:6-17", "docker-compose.yml:11", "src/lib/validation/authSchemas.ts"], - "functions": ["login", "GET /api/auth/setup-status"], - "scenario": "App reachable before the owner's first login: attacker polls the public /api/auth/setup-status until setupRequired:true, then POSTs any 8+ char password → becomes admin; owner's first login later fails. ADMIN_PASSWORD_HASH passed in compose is never read by any code (grep-verified).", - "expected": "Password seeded from env or setup bound to the deployment; env var consumed.", - "actual": "Provisioning is 'whoever logs in first'; the intended seeding env var is ignored.", - "impact": "Full account takeover if the instance is exposed pre-setup; misleading deployment config.", - "evidence": "authService.ts:97-101 first-login provisioning; setup-status public; grep ADMIN_PASSWORD_HASH → compose only.", - "testCoverage": "None.", - "fixDirection": "Consume ADMIN_PASSWORD_HASH at startup (seed Setting + mark setupRequired false) or require a one-time setup token; document firewall-until-setup.", - "fixComplexity": "S-M", - "regressionTest": "Boot with ADMIN_PASSWORD_HASH set → setup-status false, only that password works.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-09", - "sourceIds": ["AUTH-02"], - "title": "Proxy auth bypass for any path containing a dot — latent authorization gap in every API route", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/proxy.ts:22-28,48-49"], - "functions": ["proxy()"], - "scenario": "GET /api/decks/.json → proxy sees '.' → passes through without session check → the [id] segment routes to the handler. Today it 404s (UUID lookups miss); any future route with non-UUID keys (numeric ids, slugs) becomes unauthenticated instantly.", - "expected": "Every protected path requires a valid session.", - "actual": "pathname.includes('.') bypasses the check entirely (asset convenience rule written too broadly).", - "rootCause": "Static-asset rule not scoped to asset paths or an allowlist.", - "impact": "Latent authorization bypass primitive; inconsistent 404-vs-redirect UX masking the gap.", - "evidence": "proxy.ts:25 (verified by reading); compiled middleware contains the same logic (.next artifacts, adversarial review).", - "testCoverage": "None.", - "fixDirection": "Replace includes('.') with an explicit allowlist (favicon, known assets) or exclude /api/* from the bypass.", - "fixComplexity": "S", - "regressionTest": "Unauthenticated requests with dot-suffixed ids (.json, .rsc, %2E) → redirect/401, not handler execution.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-10", - "sourceIds": ["AUTH-03"], - "title": "Password-reset endpoints abuseable: token overwrite DoS, spoofable x-forwarded-for rate limits, unthrottled complete", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/app/api/auth/password-reset/request/route.ts:5-24", "src/app/api/auth/password-reset/verify/route.ts:6-25", "src/app/api/auth/password-reset/complete/route.ts:5-24", "src/services/authService.ts:132-154", "src/lib/rateLimiter.ts:10-36"], - "functions": ["requestPasswordReset", "verifyPasswordResetToken", "completePasswordReset", "checkRateLimit"], - "scenario": "Owner requests reset → token T valid 15 min. Attacker POSTs request repeatedly with rotating x-forwarded-for headers → each call upserts the token record, killing T; log flooded with token lines; verify is rate-limited per spoofable IP; complete has no rate limit at all.", - "expected": "Token issuance throttled globally, outstanding token preserved or invalidated with delay.", - "actual": "Per-IP in-memory limits keyed on a client-controlled header; token overwrite per request; complete unlimited.", - "impact": "Account-recovery DoS (matters once FIN-02 is fixed); log flooding; CPU/DB load.", - "evidence": "authService.ts:146-150 upsert; routes' header trust (verified).", - "testCoverage": "None.", - "fixDirection": "DB-backed global rate limits; refuse minting while one is outstanding; rate-limit complete; document trusted-proxy requirement.", - "fixComplexity": "M", - "regressionTest": "Spoofed-IP parallel requests → only N tokens; outstanding token survives; complete 429 past threshold.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-11", - "sourceIds": ["QUIZ-02"], - "title": "Attempt route stores unvalidated answersJson; duplicate option ids inflate SATA credit; malformed shapes cause 500s", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/app/api/quizzes/[id]/attempt/route.ts:24-60", "src/lib/scoring.ts:21-27"], - "functions": ["POST /api/quizzes/[id]/attempt", "scoreQuestion"], - "scenario": "Crafted request: SATA with correct [A,B], submit answersJson {\"qid\":[\"A\",\"A\"]} → correctSelected=2 → full credit for one distinct option selected twice; answersJson 'null' → Object.keys(null) throws → 500; raw body persisted verbatim.", - "expected": "Server validates ids against the quiz set, dedupes, rejects malformed shapes with 4xx.", - "actual": "Only JSON parseability checked; duplicates double-count; garbage persisted forever.", - "impact": "Score integrity only exploitable via crafted HTTP (single-user app → low practical impact); corrupt attempt data persists.", - "evidence": "attempt/route.ts:27-30,58; scoring.ts counts occurrences (verified).", - "testCoverage": "None.", - "fixDirection": "Zod schema for answersJson (Record, ids verified, dedupe, reject unknown); persist validated object.", - "fixComplexity": "M", - "regressionTest": "scoreQuestion with duplicate selections; API tests with unknown ids, 'null', arrays → 400/422.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-12", - "sourceIds": ["QUIZ-03", "FE-04"], - "title": "Quiz Finish double-submit creates duplicate attempts; failure path gives no user feedback", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/components/quizzes/QuizViewer.tsx:219-288,506-513"], - "functions": ["QuizViewer.handleFinish"], - "scenario": "Double-click 'Finish Quiz' on the last question → two identical QuizAttempt rows; attempt history inflated. On POST failure only console.error runs; the button stays active.", - "expected": "One attempt per completion with in-flight guard.", - "actual": "No guard (per-question submit has submittingQuestionRef; finish has none); error path silent.", - "impact": "Duplicate history entries, confusing retake stats.", - "evidence": "QuizViewer.tsx:257-287 no guard ref; button not disabled (verified).", - "testCoverage": "None.", - "fixDirection": "finishingRef/isFinishing state; disable button in flight; visible error state on failure.", - "fixComplexity": "S", - "regressionTest": "Double-click Finish with delayed mocked POST → exactly one POST.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-13", - "sourceIds": ["QUIZ-04"], - "title": "Progress PATCH can land after Finish's DELETE — stale 'Continue' row reappears for a completed quiz", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/components/quizzes/QuizViewer.tsx:148-159,271-281", "src/app/api/progress/route.ts:49-66"], - "functions": ["QuizViewer.saveProgress", "QuizViewer.handleFinish", "DELETE /api/progress"], - "scenario": "On the final question a non-awaited PATCH fires; user immediately clicks Finish; POST attempt succeeds; DELETE progress runs. If the PATCH lands after the DELETE (or DELETE fails), StudyProgress is recreated → quiz list shows 'Continue' for a completed quiz → resume → re-finish → duplicate attempt.", - "expected": "Attempt persistence and progress cleanup coordinated.", - "actual": "Three uncoordinated HTTP calls; no transaction, no sequencing.", - "impact": "Intermittent stale progress, duplicate attempts.", - "evidence": "QuizViewer.tsx:159 .catch(() => {}) swallows PATCH failure; DELETE is a separate request (verified).", - "testCoverage": "None.", - "fixDirection": "POST /attempt deletes the matching SEQUENTIAL progress row in the same Prisma transaction server-side.", - "fixComplexity": "M", - "regressionTest": "API test: POST attempt then assert progress row gone (transactional).", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-14", - "sourceIds": ["CARD-02", "FE-05", "QUIZ-09"], - "title": "Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins loses answers", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/components/quizzes/QuizViewer.tsx:133-160", "src/components/flashcards/FlashcardViewer.tsx:265-294", "src/services/progressService.ts:36-64", "src/app/api/progress/route.ts:26-47"], - "functions": ["saveProgress", "upsertProgress", "PATCH /api/progress"], - "scenario": "On a slow connection, grade cards or submit answers rapidly: each action fires a non-awaited full-state PATCH; if PATCH(n) resolves after PATCH(n+1) the server stores the older index/results → resume point and tallies regress; closing the tab mid-flight loses the last grade. Two tabs editing the same quiz → later write clobbers the other tab's answers.", - "expected": "Latest state wins; writes ordered.", - "actual": "Last arrival wins; no sequence/version; no beforeunload flush.", - "impact": "Silent resume-point regressions, lost answers, possibly wrong final scores.", - "evidence": "FlashcardViewer.tsx:282-293 fetch().catch(() => {}); progressService unconditional update (verified).", - "testCoverage": "None.", - "fixDirection": "Serialize/queue saves (latest snapshot), add monotonic sequence or updatedAt compare-and-set server-side, flush on pagehide.", - "fixComplexity": "M", - "regressionTest": "Out-of-order PATCH simulation → only latest sequence persisted.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-15", - "sourceIds": ["CARD-03", "ADV-07"], - "title": "Completed flashcard session is never persisted as complete — resume re-shows the last (already graded) card instead of the summary", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/components/flashcards/FlashcardViewer.tsx:61-65,196-203"], - "functions": ["FlashcardViewer completed init", "gradeCard completion path"], - "scenario": "Finish the last card → saveProgress stores currentIndex = order.length - 1 → reload: completed = currentIndex >= order.length is false → the last card is re-presented; re-grading it overwrites the recorded result.", - "expected": "Reload of a completed session shows the 'Set complete' summary.", - "actual": "Last card re-presented.", - "impact": "Confusing resume; minor result-map overwrite on re-grade.", - "evidence": "Lines 61-65 vs 196-203 (verified).", - "testCoverage": "None.", - "fixDirection": "Persist an explicit completed flag or save currentIndex = order.length on completion.", - "fixComplexity": "S", - "regressionTest": "Viewer init with currentIndex === orderJson.length shows summary.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-16", - "sourceIds": ["CARD-04"], - "title": "SRS review endpoint enforces membership only — no due-date or newCardsPerDay enforcement server-side", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/services/spacedRepetitionService.ts:406-447,388-404", "src/app/api/spaced-repetition-sets/[id]/reviews/route.ts:6-22"], - "functions": ["reviewCard", "POST /api/spaced-repetition-sets/[id]/reviews"], - "scenario": "Direct POST can (a) introduce a brand-new card after the daily limit is exhausted (firstReviewedAt counts toward introducedToday), and (b) review a not-yet-due card early (FSRS reschedules, compressing the interval). The UI enforces limits only through queue construction.", - "expected": "Server enforces the same queue rules the UI shows.", - "actual": "reviewCard checks only set membership and expectedStateVersion.", - "impact": "'New cards per day' guarantee is not data-level; future-dated cards can be advanced early (single-user: crafted requests/other tabs).", - "evidence": "reviewCard 414-420 membership-only (verified).", - "testCoverage": "None.", - "fixDirection": "Reject if no state and newCardsAvailable<=0, or state exists and not due (outside learn-ahead).", - "fixComplexity": "M", - "regressionTest": "Service test: new card with limit 0 → 400/409; not-due review → 400/409.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-17", - "sourceIds": ["CARD-05", "ADV-02"], - "title": "Unbounded DB scans: every activity fetch reads the entire StudyActivity table; class polling loads all SRS card states every 60 seconds", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/services/activityService.ts:65-68", "src/services/spacedRepetitionService.ts:54-142,153-220", "src/components/ui/Navbar.tsx:42-53", "src/services/classService.ts:11-22"], - "functions": ["getActivitySummary", "getSetSummary", "getStudyAvailabilityByClass", "listClasses", "Navbar.refreshDueCards"], - "scenario": "Every grade/review/quiz submit inserts a StudyActivity row; GET /api/activity reads the whole table; every page load and every 60 s the Navbar polls /api/classes which loads all sets + all card states for all classes. Query cost grows linearly forever.", - "expected": "Bounded, date-filtered queries.", - "actual": "Client-side aggregation over unfiltered findMany; N+1 per set.", - "impact": "Progressive page-load/nav lag in a long-lived self-hosted DB.", - "evidence": "activityService.ts:65-68 no where clause; Navbar interval 60s (verified).", - "testCoverage": "None.", - "fixDirection": "Date-filter StudyActivity, SQL-side aggregation, limit state scans to needed fields, cache availability.", - "fixComplexity": "M", - "regressionTest": "Activity summary with >371-day-old rows returns same result; query-shape assertion.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-18", - "sourceIds": ["FE-01", "GRP-03", "FE-02", "GRP-05"], - "title": "Library optimistic mutations are fire-and-forget: reorder/delete/rename never check res.ok, never roll back; cross-group reorder omits the origin group from the payload", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:244-295,313-372", "src/app/(protected)/[classSlug]/quizzes/page.tsx:231-282,311-374", "src/components/flashcards/CardManager.tsx:34-72"], - "functions": ["handleDragEnd", "handleDeleteGroup", "handleDeleteDeck", "handleRenameDeck", "saveEdit", "deleteCard"], - "scenario": "Drag a deck while offline → list keeps the new order; reload → order reverts silently (no error). Cross-group drag: newItems[oldIndex].groupId = targetGroupId mutates the object shared with activeDeck before affectedGroups is computed → origin group excluded from the PATCH payload → sortOrder gaps. Rename/delete fetches ignore res.ok; CardManager clears the editor even on failure.", - "expected": "Rollback to server truth + error message on failure.", - "actual": "Fire-and-forget fetches; in-place mutation; silent divergence until reload.", - "impact": "Lost operations without feedback; sortOrder gaps; phantom state. (Adversarial review: no visible break for single user → downgraded from High; the mutation-before-read payload bug is real.)", - "evidence": "flashcards/page.tsx:363-367 no await/catch; :343 mutation before :352 read (verified); SpacedRepetitionSets.tsx:277 shows the correct rollback pattern.", - "testCoverage": "None.", - "fixDirection": "Compute payload before mutating; await PATCH; on failure refetch or revert; check res.ok everywhere; disable drags while saving.", - "fixComplexity": "M", - "regressionTest": "Mock failing reorder → state refetched/reverted; unit test affectedGroups includes origin group.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-19", - "sourceIds": ["GRP-02", "CARD-07", "AUTH-06"], - "title": "Reorder/create endpoints trust client groupId and sortOrder — no class/type/existence/complete-set validation; cross-class group membership can expose content in another class's share link", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/lib/validation/reorderSchemas.ts:3-11", "src/app/api/decks/reorder/route.ts:7-26", "src/app/api/quizzes/reorder/route.ts:7-26", "src/app/api/decks/route.ts:12,31", "src/services/deckService.ts:39-48", "src/services/quizService.ts:44-53", "src/app/shared/[classSlug]/[type]/[token]/page.tsx:64-83"], - "functions": ["PATCH /api/decks/reorder", "PATCH /api/quizzes/reorder", "createDeckFromImport", "createQuizSetFromImport", "SharedPage"], - "scenario": "(a) Crafted reorder assigns a deck from class A to a QUIZ-type group (or a class-B group) — FK passes, deck disappears from the library. (b) Partial reorder payloads leave duplicate sortOrders. (c) A deck assigned to another class's group appears in that group's public share link; the shared page validates only the group's class.", - "expected": "Server validates membership/type/class and renumbers authoritatively.", - "actual": "Blind write-through of client-computed values; shape-only Zod schema.", - "impact": "Content becomes invisible; ambiguous ordering; cross-class content can leak into a group share link (privacy nuance for a single user).", - "evidence": "reorderSchemas shape-only; routes' unconditional $transaction (verified).", - "testCoverage": "None.", - "fixDirection": "Server-side validation in reorder routes (same class, existing group of matching type, renumber 0..n-1, reject duplicates); validate groupId on create; per-item class check in SharedPage.", - "fixComplexity": "M", - "regressionTest": "Cross-class groupId rejected; QUIZ group for deck rejected; partial set renumbered; shared group page with cross-class item → notFound.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-20", - "sourceIds": ["FE-03"], - "title": "Shared quiz session resets and options reshuffle on any parent re-render (unstable inline quiz prop object)", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:149-159", "src/components/quizzes/QuizViewer.tsx:64-130"], - "functions": ["SharedViewer render", "QuizViewer init effect"], - "scenario": "Open a shared quiz link; any SharedViewer re-render (auto after setHasSavedSession, topics toggle, view switch) creates a new quiz object → QuizViewer's init effect re-runs → shuffledOptions regenerate (options move mid-question) and, without a saved localStorage session, a fresh random question order is generated (the current question changes).", - "expected": "Stable question/option order for the whole session.", - "actual": "Options (and possibly the current question) reshuffle after unrelated UI toggles.", - "impact": "Confusing UX; mis-reading moved options; answers tracked by id so scores stay correct.", - "evidence": "SharedViewer.tsx:151-156 inline object literal (verified); QuizViewer deps [quiz, retakeIds, isShared].", - "testCoverage": "None.", - "fixDirection": "Memoize the quiz object (useMemo on data.id) or make init mount-only/state-lazy.", - "fixComplexity": "S", - "regressionTest": "Render shared QuizViewer, answer, re-render parent → option order array unchanged.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-21", - "sourceIds": ["IMPT-02", "FE-08", "QUIZ-05", "ADV-10"], - "title": "/api/progress PATCH is completely unvalidated and client JSON.parse of persisted progress is unguarded — malformed rows crash pages", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/app/api/progress/route.ts:26-47", "src/services/progressService.ts:18-65", "src/app/(protected)/[classSlug]/flashcards/page.tsx:55-67", "src/components/flashcards/FlashcardViewer.tsx:42-44,50-54,61-65"], - "functions": ["PATCH /api/progress", "upsertProgress", "getProgressLabel", "FlashcardViewer init"], - "scenario": "PATCH with contentType:'QUIZZ', mode:'weird', orderJson:'not json', currentIndex:-5 — all accepted and persisted (no Zod anywhere in the route). Malformed orderJson then crashes the flashcards list page at render (getProgressLabel JSON.parse) and the viewer at mount (unguarded useState initializers); bogus contentId → FK violation → uncaught 500. QuizViewer guards the same parse (QuizViewer.tsx:104-116); the flashcard paths do not.", - "expected": "400 on invalid payloads; safe fallback on read.", - "actual": "Garbage persisted; render-time SyntaxError white-screens pages.", - "impact": "Page crash on the flashcards tab; latent trap for any future writer bug.", - "evidence": "progress/route.ts imports no validator (verified); flashcards/page.tsx:58-61 bare JSON.parse; FlashcardViewer.tsx:43,52,63 bare parses.", - "testCoverage": "None.", - "fixDirection": "Zod schema for progress PATCH (enums, JSON-string validity, index >= 0); wrap client parses in try/catch with fallback (mirror QuizViewer).", - "fixComplexity": "S", - "regressionTest": "API: malformed orderJson → 400; render: corrupt progress row → page renders with fallback.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-22", - "sourceIds": ["OPS-02"], - "title": "Entrypoint migrate-deploy chain is fragile: failure = crash loop with no recovery path; runner prisma CLI install is unverified and unpinned", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["docker-entrypoint.sh:2-4", "Dockerfile:16-36", "prisma.config.ts:7"], - "functions": ["docker-entrypoint.sh", "RUN npm install prisma@^7.8.0"], - "scenario": "set -e + npx prisma migrate deploy + exec node server.js. On a FRESH volume migrate succeeds (no drift detection — the DB is then incompatible per FIN-01, so the app 500s but the container 'runs'). On conflicting DB state (db-push DB, manual tampering, locked file) migrate fails → container exits → restart: unless-stopped → infinite crash loop, no documented recovery. The runner's unpinned npm install prisma@^7.8.0 (no lockfile, re-resolves the whole tree, runtime network dependency for npx) is plausible per Prisma 7 docs but never exercised by CI.", - "expected": "Migration failures are observable and recoverable; image contents reproducible.", - "actual": "Silent crash loop / broken-but-running container; no smoke test anywhere.", - "impact": "Prolonged outage without diagnostics on secondary triggers; nondeterministic images.", - "evidence": "entrypoint set -e (verified); adversarial review weakened the fresh-volume crash-loop framing (migrate succeeds there).", - "testCoverage": "None (CI never runs the image).", - "fixDirection": "Retry-with-backoff or one-shot init job; clear failure logs + documented recovery; install prisma pinned from the lockfile (or copy from builder); CI container smoke test.", - "fixComplexity": "M", - "regressionTest": "Container smoke: fresh volume → 200 and _prisma_migrations populated; corrupt volume → actionable log, no silent loop.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-23", - "sourceIds": ["OPS-05"], - "title": "Non-reproducible installs: npm install (not ci) in deps; unpinned prisma re-install re-resolving the whole tree in the runner", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["Dockerfile:5,30", "package.json:38"], - "functions": ["docker build stages"], - "scenario": "deps stage npm install can silently mutate the lockfile; the runner's npm install prisma@^7.8.0 against the standalone package.json re-resolves all caret ranges, downloads native modules, and floats image content with the registry.", - "expected": "Images reproducible from package-lock.json.", - "actual": "Runner image content not a function of the repo lockfile.", - "impact": "Nondeterministic images; invisible dependency drift (incl. prisma patch releases).", - "evidence": "Dockerfile:5,30 (verified); standalone package.json is a full copy.", - "testCoverage": "None.", - "fixDirection": "npm ci in deps; pin prisma exact version or copy CLI from builder stage.", - "fixComplexity": "M", - "regressionTest": "Two builds from same commit → prisma --version identical.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-24", - "sourceIds": ["OPS-07"], - "title": "No healthcheck, no backup, no documented recovery — outages are invisible to the orchestrator and the DB is a single point of failure", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["docker-compose.yml:5", "docker-entrypoint.sh:3", "Dockerfile:35"], - "functions": ["compose orchestration", "entrypoint"], - "scenario": "A hung-but-running app shows 'running' in docker ps while /login 500s; a corrupt study.db means years of study data with no backup and no documented recovery procedure; no WAL mode (default rollback journal).", - "expected": "Observable health + backup + recovery path.", - "actual": "None of the three.", - "impact": "Prolonged undetected outages; permanent data loss on disk failure.", - "evidence": "compose has no healthcheck; entrypoint no retry (verified).", - "testCoverage": "None.", - "fixDirection": "Add healthcheck (wget -qO- http://127.0.0.1:3726/login); document volume backup; consider WAL + backup script.", - "fixComplexity": "S-M", - "regressionTest": "Compose healthcheck flips unhealthy when entrypoint fails.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-25", - "sourceIds": ["OPS-10", "TEST-10"], - "title": "CI builds and pushes the image without running tests, lint, prisma validate, or a container smoke test — every deployment-breaking defect ships with a green pipeline", - "severity": "Medium", - "confidence": "Confirmed", - "files": [".forgejo/workflows/build.yml:12-25", "package.json:6-11", "vitest.config.ts"], - "functions": ["CI workflow"], - "scenario": "Any of FIN-01/02/05/06/07 land on main → CI is green (docker build && push only) → broken image tagged latest. No PR pipeline, no test step, no coverage gate.", - "expected": "Tests, lint, prisma validate, and a container smoke gate the image.", - "actual": "None run; pipeline reports success while running zero tests.", - "impact": "Broken production artifacts shipped; regressions invisible until deployment.", - "evidence": "build.yml steps (verified); package.json test script never referenced by any workflow.", - "testCoverage": "The gap itself.", - "fixDirection": "Add npm ci && npm test && lint && prisma validate jobs plus a container smoke (curl /login on fresh volume); tag by commit SHA.", - "fixComplexity": "M", - "regressionTest": "CI fails when the container doesn't return 200 on a fresh volume (catches FIN-01/FIN-05).", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-26", - "sourceIds": ["QUIZ-07"], - "title": "SATA scoring divides by correctIds.length with no zero-guard → NaN scores", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/scoring.ts:27", "src/lib/validation/importSchemas.ts:16-18"], - "functions": ["scoreQuestion (SATA branch)"], - "scenario": "A SATA question with zero correct options → 0/0 → NaN; scoreQuiz sums NaN; persisted score NaN. Unreachable through the current import path (Zod enforces >=1 correct) but unguarded at score time — any future content-edit feature or DB drift corrupts scores silently.", - "expected": "Defensive 0 for degenerate questions.", - "actual": "NaN propagates.", - "impact": "Latent corruption risk; none today.", - "evidence": "scoring.ts:27 no guard (verified).", - "testCoverage": "None.", - "fixDirection": "if (correctIds.length === 0) return 0;", - "fixComplexity": "S", - "regressionTest": "scoreQuestion with 0-correct SATA → 0; scoreQuiz finite.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-27", - "sourceIds": ["QUIZ-08"], - "title": "Historical review and category breakdown recompute scores from current content, not the stored attempt", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/quizzes/QuizResults.tsx:32-52", "src/components/quizzes/CategoryBreakdown.tsx:17-35"], - "functions": ["QuizResults", "CategoryBreakdown"], - "scenario": "If option correctness/type/set ever differs from attempt time (content editing is added later), per-question review scores and category bars diverge from the persisted total; 'Retake Missed' ids derive from current content. Today content is immutable so the recompute matches.", - "expected": "Render the attempt as scored.", - "actual": "Only total + answersJson stored; everything per-question recomputed.", - "impact": "Latent historical-corruption display bug; benign today.", - "evidence": "QuizResults.tsx:35-52 recompute vs :54 persisted banner (verified).", - "testCoverage": "None.", - "fixDirection": "Persist per-question points snapshot at submit time; render stored points with fallback for legacy rows.", - "fixComplexity": "M", - "regressionTest": "Score quiz, flip an option's isCorrect, re-render → per-question scores still match stored attempt.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-28", - "sourceIds": ["IMPT-03"], - "title": "Card add/edit endpoints bypass Zod: empty/whitespace strings storable; CreateTab silently drops incomplete cards", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/api/cards/[id]/route.ts:11-16", "src/app/api/decks/[id]/cards/route.ts:11-25", "src/components/flashcards/CardManager.tsx:34-48", "src/components/import/CreateTab.tsx:46-50"], - "functions": ["PATCH /api/cards/[id]", "POST /api/decks/[id]/cards", "saveEdit", "handleCreate"], - "scenario": "Edit a card, clear both textareas, Save → 200 with blank front persisted; PATCH with {} → silent no-op 200; CreateTab with 3 cards where one is blank → 2-card deck created with no warning.", - "expected": "400 on empty content; no silent drops.", - "actual": "Empty strings persisted; silent card dropping.", - "impact": "Blank/garbage cards in decks; misleading save success.", - "evidence": "cards/[id]/route.ts:11-16 (verified); CreateTab.tsx:46 filter.", - "testCoverage": "None.", - "fixDirection": "Shared cardContentSchema with safeParse in both endpoints; surface incomplete-card errors in CreateTab.", - "fixComplexity": "S", - "regressionTest": "API: PATCH card with '' → 400; CreateTab 3-cards-1-blank → error, nothing created.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-29", - "sourceIds": ["IMPT-04"], - "title": "No payload size/string-length/array-length caps on import schemas and routes; whitespace-only names accepted", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/validation/importSchemas.ts:3-45", "src/app/api/decks/route.ts:5-25", "src/app/api/quizzes/route.ts:5-25", "next.config.ts:3-6"], - "functions": ["flashcardImportSchema", "quizImportSchema", "POST /api/decks|quizzes"], - "scenario": "A 20 MB JSON with 100k cards → minutes-long UI freeze (per-keystroke parse+repair+validate pipeline in ImportTab), huge transaction; ' ' as deckName passes (no trim); very long strings bloat rows. SRS schemas cap at 120/5000 chars — import schemas don't.", - "expected": "Bounded inputs consistent with SRS schemas.", - "actual": "Unbounded everything; no body-size guard.", - "impact": "Self-DoS, DB bloat, visually-empty names.", - "evidence": "importSchemas.ts has no .max()/.trim() (verified); spacedRepetitionSchemas.ts:4-5 has caps.", - "testCoverage": "None.", - "fixDirection": "Add trim/min/max to strings, .max() on arrays, description caps, content-length guard, debounce ImportTab.", - "fixComplexity": "S-M", - "regressionTest": "Schema tests: whitespace names rejected, oversized arrays/strings rejected.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-30", - "sourceIds": ["IMPT-05"], - "title": "SATA constraint mismatch: LLM instructions require >=2 correct options, schema enforces only >=1", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/validation/importSchemas.ts:16-24", "src/services/settingsService.ts:38-39"], - "functions": ["quizImportSchema", "getDefaultInstructions"], - "scenario": "LLM returns a SATA question with exactly one correct:true → import succeeds despite instructions saying 'two or more options with correct: true'. Scoring still works (single correct), so no corruption — doc/schema drift.", - "expected": "Rejected per documented rule.", - "actual": "Accepted.", - "impact": "Semantic drift; 1-correct SATA graded with partial-credit formula.", - "evidence": "importSchemas refine >=1 vs settingsService.ts:38-39 (verified).", - "testCoverage": "None.", - "fixDirection": "Per-type refine: sata → correctCount >= 2 (or align instructions).", - "fixComplexity": "S", - "regressionTest": "Schema: SATA with 1 correct fails; 2+ passes; MC 0/2 fails.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-31", - "sourceIds": ["IMPT-06"], - "title": "Unvalidated name/groupId override fields on import POST routes throw 500s; PATCH routes mask all failures as 404", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/api/decks/route.ts:12,27-32", "src/app/api/quizzes/route.ts:12,27-32", "src/app/api/decks/[id]/route.ts:22-33", "src/app/api/quizzes/[id]/route.ts:22-33"], - "functions": ["POST /api/decks|quizzes", "PATCH /api/decks/[id]|quizzes/[id]"], - "scenario": "POST with name:123 → TypeError: name.trim is not a function → unhandled 500; PATCH with wrong-typed body → 404 'Deck not found' for an existing deck (misleading diagnostics).", - "expected": "400 with a validation message.", - "actual": "500 / misleading 404.", - "impact": "Error-handling quality only (UI never sends wrong types).", - "evidence": "decks/route.ts:12,27-32 (verified).", - "testCoverage": "None.", - "fixDirection": "Validate the whole request envelope with Zod; narrow PATCH catch to P2025 for 404.", - "fixComplexity": "S", - "regressionTest": "POST numeric name → 400; PATCH wrong-typed body → 400 not 404.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-32", - "sourceIds": ["GRP-04"], - "title": "Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint and no tie-break → ambiguous, unstable order", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:270-275", "src/app/(protected)/[classSlug]/quizzes/page.tsx:257-262", "prisma/schema.prisma:24-39,52-67", "src/services/deckService.ts:5-7"], - "functions": ["handleDeleteGroup", "DELETE /api/material-groups/[id]", "listDecksByClass", "listQuizSetsByClass"], - "scenario": "Group A has decks sortOrder 0,1,2; Uncategorized has 0,1. Delete group A → SetNull moves rows without renumbering → duplicates; rendering ties resolve to SQLite scan order which can change after VACUUM. Dragging self-heals; imports into Uncategorized use max+1 leaving old collisions.", - "expected": "Renumbered sequential sortOrders after delete.", - "actual": "Stale group-relative values kept.", - "impact": "Cosmetic unstable ordering after the common 'delete group' operation.", - "evidence": "DELETE route plain delete; orderBy single-key (verified).", - "testCoverage": "None.", - "fixDirection": "Renumber in the delete route transaction; add deterministic orderBy tie-break (createdAt).", - "fixComplexity": "S", - "regressionTest": "Integration: delete group → all Uncategorized sortOrders unique and sequential.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-33", - "sourceIds": ["GRP-06"], - "title": "Keyboard users cannot move items between groups; no drag announcements", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:298-311", "src/app/(protected)/[classSlug]/quizzes/page.tsx:285-309"], - "functions": ["handleDragOver", "sensors"], - "scenario": "KeyboardSensor allows within-group reorder, but cross-container keyboard moves require onDragOver to move the active item between containers; handleDragOver is a no-op → cross-group moves are pointer-only. Screen readers get no live announcements.", - "expected": "Full DnD parity for keyboard.", - "actual": "Within-group only.", - "impact": "Accessibility gap; no data corruption.", - "evidence": "Empty handleDragOver bodies (verified).", - "testCoverage": "None.", - "fixDirection": "Implement handleDragOver for keyboard container switches or add explicit 'Move to group' actions.", - "fixComplexity": "M", - "regressionTest": "Keyboard test moving an item across two groups.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-34", - "sourceIds": ["CARD-08"], - "title": "Restart deletes progress fire-and-forget; a slow DELETE can remove the fresh session's newly created progress row", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:42-62", "src/app/(protected)/[classSlug]/flashcards/page.tsx:117-125"], - "functions": ["handleRestart", "DELETE /api/progress"], - "scenario": "Click Restart → two unawaited DELETEs fire → viewer remounts → first grade PATCHes a new progress row → if a DELETE lands after the PATCH, the new row is deleted → next load shows no progress (session silently restarted).", - "expected": "Restart clears old progress only.", - "actual": "Delete-after-create race can remove the new row.", - "impact": "Occasional unexpected session reset (small window).", - "evidence": "[deckId]/page.tsx:46-61 .catch(() => {}) (verified).", - "testCoverage": "None.", - "fixDirection": "Await DELETEs before remount or use per-deck session ids so late DELETEs can't touch newer rows.", - "fixComplexity": "S", - "regressionTest": "Route-order simulation.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-35", - "sourceIds": ["CARD-09"], - "title": "Concurrent first review of the same new card hits the unique constraint → unhandled generic 500 instead of 409", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/services/spacedRepetitionService.ts:426-442", "src/lib/spacedRepetitionApi.ts:4-9"], - "functions": ["reviewCard", "spacedRepetitionErrorResponse"], - "scenario": "Two rapid reviews of a never-reviewed card (double-tap or two tabs): both see current=null, both create; loser hits P2002 → generic 500 'Spaced repetition request failed' (the update path maps this to 409 via optimistic concurrency; the create path doesn't).", - "expected": "409 'already reviewed'.", - "actual": "500 with no guidance.", - "impact": "Confusing error in an edge double-submit; review still recorded by the winner.", - "evidence": "reviewCard create path lacks the updateMany guard (verified).", - "testCoverage": "None.", - "fixDirection": "Catch P2002 → 409, or upsert-guard with retry.", - "fixComplexity": "S", - "regressionTest": "Two concurrent reviewCard calls → one 409.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-36", - "sourceIds": ["CARD-10"], - "title": "'Previous card' during the 350 ms grade animation races the pending timeout — index jumps forward, result map inconsistent", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/components/flashcards/FlashcardViewer.tsx:190-204,531-544"], - "functions": ["gradeCard", "previous-card handler"], - "scenario": "Grade a card, click Previous within 350 ms: click sets currentIndex-1; the pending timeout then fires setCurrentIndex(currentIndex+1) from a stale closure → jumps forward past the card the user navigated to; saveProgress writes results inconsistent with the visible card.", - "expected": "Navigation wins.", - "actual": "Timeout overwrites navigation.", - "impact": "Transient wrong position/result map; self-corrects on next action.", - "evidence": "Timeout uses closure currentIndex (verified).", - "testCoverage": "None.", - "fixDirection": "Disable prev during swipe or use functional setState/refs in the timeout.", - "fixComplexity": "S", - "regressionTest": "Component test simulating grade→previous→timeout sequence.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-37", - "sourceIds": ["CARD-11"], - "title": "SRS set page never refreshes on window focus — stale membership after deck deletion in another tab", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/spaced-repetition/SpacedRepetitionSets.tsx:167-192"], - "functions": ["SpacedRepetitionSets.load"], - "scenario": "Keep the SRS page open; delete a member deck in another tab → the UI still lists the deck (DB is cascade-clean); removing/dragging it yields 404 errors until manual reload. Navbar refreshes on focus; the sets page doesn't.", - "expected": "UI reflects DB state.", - "actual": "Mount-only load.", - "impact": "Stale list; harmless 404 messages.", - "evidence": "useEffect mount-only (verified); contrast Navbar.tsx:45.", - "testCoverage": "None.", - "fixDirection": "Add focus listener / deck-changed event to re-run load.", - "fixComplexity": "S", - "regressionTest": "Component-level focus-refresh test.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-38", - "sourceIds": ["CARD-06"], - "title": "Day boundary hardcoded to Arizona (UTC-7): 'today', new-card limit, and streak roll over at 07:00 UTC for non-Arizona users", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/spacedRepetition.ts:157-161", "src/services/activityService.ts:28-30", "src/lib/spacedRepetition.test.ts:56-61"], - "functions": ["getArizonaDayBounds", "getEffectiveDue", "toArizonaDateKey"], - "scenario": "For a UTC+8 user the day flips at 15:00 local; due 'today' and the daily new-card allowance become available mid-afternoon and reset mid-afternoon. Internally consistent (tests assert the design); the label 'Arizona time' in ActivityBanner confirms intent — but it's never a setting.", - "expected": "Configurable timezone.", - "actual": "Fixed UTC-7 offset.", - "impact": "Confusing day boundaries for non-Arizona users.", - "evidence": "spacedRepetition.ts:157-161 (verified); tests codify it.", - "testCoverage": "Yes — unit tests assert the current behavior.", - "fixDirection": "Make the timezone a Setting (default America/Phoenix) threaded through the pure functions.", - "fixComplexity": "M", - "regressionTest": "Parameterize with another offset and assert boundaries.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-39", - "sourceIds": ["AUTH-05"], - "title": "Proxy destroy() cookie-clear header is lost on redirect; page-level check ignores sessionGeneration", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/proxy.ts:30-43", "src/app/(protected)/layout.tsx:10-13", "src/lib/auth.ts:53-56"], - "functions": ["proxy()", "ProtectedLayout", "isAuthenticated"], - "scenario": "Stale/tampered cookie → proxy redirects to /login, but session.destroy() attaches Set-Cookie to the discarded NextResponse.next() response, not the redirect → the browser keeps the bad cookie → every navigation redirects again until re-login. Layout backstop checks only isAuthenticated, not generation.", - "expected": "Invalid cookie cleared on redirect.", - "actual": "Clear header dropped; defense-in-depth inconsistency.", - "impact": "Minor UX (redirect loop until re-login).", - "evidence": "proxy.ts:30-42 (verified); iron-session destroy writes to the passed response.", - "testCoverage": "None.", - "fixDirection": "Build the redirect first, then getIronSession(request, redirectResponse) before returning; make isAuthenticated generation-aware.", - "fixComplexity": "S", - "regressionTest": "Integration: stale-generation cookie → redirect response carries Set-Cookie clearing study-app-session.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-40", - "sourceIds": ["AUTH-08"], - "title": "Session cookie Secure flag off in the shipped compose (plain-HTTP deployment, no TLS story)", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/auth.ts:17-21", "docker-compose.yml:8-12"], - "functions": ["sessionOptions"], - "scenario": "Default docker compose up → secure=false → session cookie and login POST travel in cleartext on the LAN; any host can sniff and replay. SECURE_COOKIES=true on plain HTTP would break login entirely — the flag logic is correct; the deployment lacks TLS.", - "expected": "HTTPS with secure cookies.", - "actual": "Plain HTTP, no HSTS.", - "impact": "Credential/session sniffing on shared networks; critical if port-forwarded.", - "evidence": "auth.ts:18 (verified); compose sets no SECURE_COOKIES.", - "testCoverage": "None.", - "fixDirection": "Document/require a TLS proxy (Caddy/Traefik) + SECURE_COOKIES=true.", - "fixComplexity": "S", - "regressionTest": "Unit test flag computation across env combos.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-41", - "sourceIds": ["DBAUD-02", "AUTH-07"], - "title": "/api/share accepts arbitrary targetType — repeatable junk all-NULL ShareLink rows; no CHECK constraints or enum validation anywhere", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/api/share/route.ts:32-44", "src/services/shareService.ts:105-123", "prisma/schema.prisma:92-109,123-134"], - "functions": ["toggleShareLink", "POST /api/share"], - "scenario": "POST /api/share {targetType:'FOO', contentId:'x'} → toggleShareLink matches no branch → creates ShareLink with all three FKs null; SQLite unique indexes treat NULLs as distinct → unlimited junk rows; no cleanup path. StudyProgress has the same unvalidated contentType (FIN-21).", - "expected": "Only DECK/QUIZ/GROUP with exactly one target.", - "actual": "Any string accepted; junk rows persist.", - "impact": "Data hygiene (owner-controlled); no privilege boundary crossed.", - "evidence": "toggleShareLink fall-through create (verified).", - "testCoverage": "None.", - "fixDirection": "Zod enum validation at the route; optional CHECK constraints via raw migration.", - "fixComplexity": "S", - "regressionTest": "POST invalid targetType → 400, zero rows; GROUP twice → toggles to one row.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-42", - "sourceIds": ["DBAUD-03"], - "title": "SQLite dev.db was committed in git history (8 commits) and remains in blobs on the LAN remote", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["git history (dev.db blobs in b7ce314..7af0935)", ".git/config:8-10", ".gitignore:17"], - "functions": ["git history"], - "scenario": "Anyone with repo/server access can git show 7af0935:dev.db — a real SQLite DB containing StudyProgress rows (verified blob markers; no password hash found in the checked blobs, but content not fully enumerable read-only).", - "expected": "DB files never in VCS.", - "actual": "Present in history incl. the db-push drift variant.", - "impact": "Personal study content exposure to anyone with repo/server access; conditional (no credentials found).", - "evidence": "git log --all -- dev.db → 8 commits (verified).", - "testCoverage": "n/a.", - "fixDirection": "If the remote is ever shared: git filter-repo --path dev.db --invert-paths + force-push.", - "fixComplexity": "S-M", - "regressionTest": "git log --all -- dev.db empty after rewrite.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-43", - "sourceIds": ["OPS-09"], - "title": ".gitignore gaps: production data/ directory and study.db* not ignored; scratch files (out.css, temp.css, test.css) tracked", - "severity": "Low", - "confidence": "Confirmed", - "files": [".gitignore:16-20", "docker-compose.yml:13-14", "git ls-files"], - "functions": ["git", "compose volume"], - "scenario": "Running production compose from the repo creates ./data/study.db — not ignored → a careless 'git add .' commits the real production database. temp.css/test.css/out.css are tracked junk.", - "expected": "Database artifacts never committable.", - "actual": "Only /dev.db* guarded.", - "impact": "Risk of leaking the entire study database to the Forgejo remote.", - "evidence": ".gitignore contents vs compose volume path (verified).", - "testCoverage": "n/a.", - "fixDirection": "Add data/, /study.db*, audit-results/, .reasonix/; untrack the junk files.", - "fixComplexity": "S", - "regressionTest": "git check-ignore data/study.db returns the path.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-44", - "sourceIds": ["FE-07", "ADV-04"], - "title": "Dashboard fetchClasses has no .catch and no res.ok check — unhandled rejection and misleading empty state (or render crash on error JSON)", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/(protected)/page.tsx:30-37"], - "functions": ["fetchClasses"], - "scenario": "Network/server error on the dashboard → unhandled promise rejection → 'Your desk is ready' empty state despite existing classes; a 500 JSON error body stored into classes makes classes.map crash the render.", - "expected": "Error banner + retry.", - "actual": "Silent empty state / crash.", - "impact": "Misleading UI after transient failures.", - "evidence": "page.tsx:33-36 try/finally without catch (verified).", - "testCoverage": "None.", - "fixDirection": "Add catch → error state with retry; check res.ok before parsing.", - "fixComplexity": "S", - "regressionTest": "Reject /api/classes → error UI, not empty state.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-45", - "sourceIds": ["FE-09"], - "title": "Effect fetches without .catch in GenerateTab and ShareMenu — unhandled rejections, degraded UI states", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/import/GenerateTab.tsx:18-23", "src/components/ui/ShareMenu.tsx:38-48"], - "functions": ["GenerateTab effect", "ShareMenu effect"], - "scenario": "Settings/share fetch fails → unhandled rejection; GenerateTab textarea stays empty with no error (setInstructions(undefined) makes a controlled textarea temporarily uncontrolled); ShareMenu shows 'Enable link sharing' even when a share token exists server-side.", - "expected": "Error state.", - "actual": "Silent degraded UI.", - "impact": "Minor UX confusion; console noise.", - "evidence": "Missing .catch in both (verified).", - "testCoverage": "None.", - "fixDirection": "Add .catch → error message/retry; validate data.value before setState.", - "fixComplexity": "S", - "regressionTest": "Reject fetches → no unhandled rejection, error message shown.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-46", - "sourceIds": ["FE-10"], - "title": "Cross-class navigation fetch race: slow response from class A can render class A's data under class B's header", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:205-241", "src/app/(protected)/[classSlug]/quizzes/page.tsx:192-228"], - "functions": ["fetchAll", "init effect"], - "scenario": "Navigate quickly between two classes: class A's in-flight fetch resolves after class B's → setDecks(A-data) renders wrong-class content until the next action. Module-level cache never invalidated (brief stale flash on revisits).", - "expected": "Only the current class's data applies.", - "actual": "Last-resolved-wins with no guard.", - "impact": "Wrong-class content displayed transiently.", - "evidence": "No abort/stale-response guard (verified).", - "testCoverage": "None.", - "fixDirection": "AbortController or sequence check per classSlug; invalidate/drop the module cache.", - "fixComplexity": "S-M", - "regressionTest": "Out-of-order resolution of two fetchAll calls → only latest class's data set.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-47", - "sourceIds": ["ADV-03"], - "title": "Logout has no error handling — failed logout strands the user with an unhandled rejection", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/ui/Navbar.tsx:55-59"], - "functions": ["handleLogout"], - "scenario": "Network failure during POST /api/auth/logout → router.push('/login') never runs; unhandled rejection; the user appears logged out in UI but the session cookie persists.", - "expected": "Graceful failure handling.", - "actual": "None.", - "impact": "Stranded session on transient errors.", - "evidence": "Navbar.tsx:55-58 (verified).", - "testCoverage": "None.", - "fixDirection": "try/catch + user feedback; force navigation regardless.", - "fixComplexity": "S", - "regressionTest": "Mock failed logout → navigation still occurs or error shown.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-48", - "sourceIds": ["ADV-05"], - "title": "slugify can yield an empty slug (unreachable class) and renames never update the URL slug", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/services/classService.ts:4-10,47-64,66-71"], - "functions": ["slugify", "createClass", "updateClass"], - "scenario": "Create a class named '!!!' → slug '' → class created but unreachable via /[classSlug] (empty segment). Rename a class → slug stays the old value; bookmarked/old URLs keep pointing at the old slug while the header shows the new name.", - "expected": "Always-nonempty unique slugs; rename updates the slug (or redirects).", - "actual": "Empty slug possible; rename never touches slug.", - "impact": "Unreachable content; stale URLs after rename.", - "evidence": "classService.ts:4-9 slugify drops all non-alphanumerics (verified); updateClass updates name only.", - "testCoverage": "None.", - "fixDirection": "Fallback slug (e.g. 'class' + counter) when slugify returns ''; decide rename-vs-slug policy (update slug or 301 old → new).", - "fixComplexity": "S", - "regressionTest": "createClass('!!!') → non-empty unique slug; rename → old slug resolves or redirects.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-49", - "sourceIds": ["ADV-08"], - "title": "Shared-viewer localStorage session keys are item-scoped, not token-scoped — progress collides across share tokens and with the owner's own sessions", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:26-33", "src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx:24-41", "src/components/flashcards/FlashcardViewer.tsx:129-150"], - "functions": ["localStorage session save/load"], - "scenario": "Two share links for the same deck/quiz (or the owner studying locally) share one localStorage key (flashcard_progress_ / quiz_progress_): progress from one token's session restores into another's; restored orders can reference cards not in this payload → stuck blank viewer (recoverable via restart).", - "expected": "Session scoped to the share token.", - "actual": "Scoped to content id only.", - "impact": "Cross-token session bleed; stuck viewers in edge cases.", - "evidence": "Key construction in SharedViewer/SharedGroupViewer (verified).", - "testCoverage": "None.", - "fixDirection": "Include the token (or a per-viewer random id) in the localStorage key.", - "fixComplexity": "S", - "regressionTest": "Two tokens for the same content → independent sessions.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-50", - "sourceIds": ["GRP-07"], - "title": "Group sortOrder uses an inverted desc convention (newest-first) with an unvalidated PATCH — latent trap for future group reordering", - "severity": "Observation", - "confidence": "Confirmed", - "files": ["src/app/api/material-groups/route.ts:26,43-47", "src/app/api/material-groups/[id]/route.ts:11-21"], - "functions": ["GET/POST /api/material-groups", "PATCH /api/material-groups/[id]"], - "scenario": "Groups order by sortOrder desc (newest first) while every other sortOrder in the codebase is asc; PATCH accepts arbitrary/duplicate sortOrder values with no validation. Internally consistent today (no group drag UI); the convention is fragile and the PATCH is a footgun.", - "expected": "Consistent, validated semantics.", - "actual": "Inverted convention, unbounded PATCH values.", - "impact": "Latent ordering trap; self-harm only via crafted requests.", - "evidence": "material-groups/route.ts:26 (verified).", - "testCoverage": "None.", - "fixDirection": "Remove sortOrder from the group PATCH contract or validate/normalize it; document the convention.", - "fixComplexity": "S", - "regressionTest": "PATCH with sortOrder 'abc' → 400.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-51", - "sourceIds": ["FE-11"], - "title": "Collapsed-groups state read in an effect — brief expand flash on every library visit", - "severity": "Observation", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:190-195", "src/app/(protected)/[classSlug]/quizzes/page.tsx:177-182"], - "functions": ["collapsed-groups localStorage init"], - "scenario": "Collapsed groups render expanded for one frame after navigation, then collapse (effect runs post-paint). Cosmetic; source of a known lint warning.", - "expected": "Collapsed from first paint.", - "actual": "One-frame flash.", - "impact": "Cosmetic layout jump.", - "evidence": "useState {} + effect sync (verified).", - "testCoverage": "None.", - "fixDirection": "Lazy useState initializer or useSyncExternalStore.", - "fixComplexity": "S", - "regressionTest": "Render with saved collapsed state → collapsed on first paint.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-52", - "sourceIds": ["AUTH-10"], - "title": "Shared quiz links ship the full answer key (isCorrect, rationale) to anonymous viewers — by design, but worth an explicit warning", - "severity": "Observation", - "confidence": "Confirmed", - "files": ["src/services/shareService.ts:13-23", "src/components/quizzes/QuizViewer.tsx:377-487", "src/components/ui/ShareMenu.tsx:130"], - "functions": ["getShareLink", "QuizViewer shared grading"], - "scenario": "Anyone with a shared quiz token can read correct answers and rationales from the payload — inherent to the client-side self-grading design and documented ('Anyone with the link can view and study'). The token IS the answer key.", - "expected": "Documented sharing contract.", - "actual": "Exactly that.", - "impact": "None beyond the documented contract; consider a confirmation in the Share menu.", - "evidence": "shareService include without select-sanitization (verified).", - "testCoverage": "None.", - "fixDirection": "Optional: warning text in ShareMenu; a future 'practice-only' share mode without isCorrect.", - "fixComplexity": "S", - "regressionTest": "Assert shared payload omits attempts/progress/answersJson.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-53", - "sourceIds": ["TEST-01"], - "title": "False-confidence test: 'distinct valid state for every rating' never asserts distinctness — scheduleRating rating-dependence is untested", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/spacedRepetition.test.ts:15-23"], - "functions": ["scheduleRating (test)"], - "scenario": "The loop over AGAIN/HARD/GOOD/EASY asserts only reps===1, lastReview===now, due>now per rating — no cross-rating comparison. If ratingMap broke (all ratings → Good), the test still passes. previewRatings (a different function) is tested separately; scheduleRating's rating dependence is never asserted for learning/review states.", - "expected": "Ratings produce distinct schedules.", - "actual": "Distinctness never asserted.", - "impact": "False confidence in the SRS scheduling core.", - "evidence": "spacedRepetition.test.ts:15-23 (verified).", - "testCoverage": "The finding.", - "fixDirection": "Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; add a graduated-state lapse case.", - "fixComplexity": "S", - "regressionTest": "The improved test itself.", - "needsRuntimeConfirmation": false - } - ], - "notes": "Severity: Critical = destructive data loss / credential exposure / unauthorized access / unrecoverable migration failure / app-wide failure; High = major feature failure / persistent state corruption / deployment failure; Medium = meaningful incorrect behavior; Low = limited concrete defect; Observation = useful concern without confirmed defect. Confidence: Confirmed / High-confidence inference / Unverified risk. FIN-01..FIN-53 are the consolidated, deduplicated findings; source IDs from specialist auditors are preserved in sourceIds. FE-06 ('no auth on API routes') was refuted by the adversarial review and is recorded in REJECTED_FINDINGS.md. Test-gap findings beyond FIN-53 (scoring/auth/SRS-service/attempt/jsonRepair/shuffle/rateLimiter missing tests, no integration harness) are detailed in TEST_GAPS.md." -} diff --git a/audit-results/REJECTED_FINDINGS.md b/audit-results/REJECTED_FINDINGS.md deleted file mode 100644 index 32d0415..0000000 --- a/audit-results/REJECTED_FINDINGS.md +++ /dev/null @@ -1,15 +0,0 @@ -# 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. | diff --git a/audit-results/REMEDIATION_PLAN.md b/audit-results/REMEDIATION_PLAN.md deleted file mode 100644 index 6719e4a..0000000 --- a/audit-results/REMEDIATION_PLAN.md +++ /dev/null @@ -1,340 +0,0 @@ -# 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-05–10, FIN-23–25, 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-11–13, 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 group’s 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 recipient’s 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-28–31, FIN-35, FIN-37, FIN-43–48, 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-` 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 audit’s 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. diff --git a/audit-results/SUBAGENT_REPORTS.md b/audit-results/SUBAGENT_REPORTS.md deleted file mode 100644 index 7fe4973..0000000 --- a/audit-results/SUBAGENT_REPORTS.md +++ /dev/null @@ -1,20 +0,0 @@ -# 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`. diff --git a/audit-results/TEST_GAPS.md b/audit-results/TEST_GAPS.md deleted file mode 100644 index b77408b..0000000 --- a/audit-results/TEST_GAPS.md +++ /dev/null @@ -1,21 +0,0 @@ -# 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:` + `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. diff --git a/audit-results/UNVERIFIED_RISKS.md b/audit-results/UNVERIFIED_RISKS.md deleted file mode 100644 index 371613b..0000000 --- a/audit-results/UNVERIFIED_RISKS.md +++ /dev/null @@ -1,35 +0,0 @@ -# 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). diff --git a/audit-results/VERIFICATION_LOG.md b/audit-results/VERIFICATION_LOG.md deleted file mode 100644 index da881a2..0000000 --- a/audit-results/VERIFICATION_LOG.md +++ /dev/null @@ -1,38 +0,0 @@ -# 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. diff --git a/audit-results/tmp/audit-migration.db b/audit-results/tmp/audit-migration.db deleted file mode 100644 index 49fb7a2..0000000 Binary files a/audit-results/tmp/audit-migration.db and /dev/null differ diff --git a/audit-results/tmp/generated-before.sha256 b/audit-results/tmp/generated-before.sha256 deleted file mode 100644 index ec530fd..0000000 --- a/audit-results/tmp/generated-before.sha256 +++ /dev/null @@ -1,26 +0,0 @@ -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 diff --git a/audit-results/tmp/inspect-db.cjs b/audit-results/tmp/inspect-db.cjs deleted file mode 100644 index 7e4af48..0000000 --- a/audit-results/tmp/inspect-db.cjs +++ /dev/null @@ -1,52 +0,0 @@ -// 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 -const path = require("node:path"); -const Database = require("better-sqlite3"); - -const dbPath = process.argv[2]; -if (!dbPath) { - console.error("usage: node inspect-db.cjs "); - 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"); diff --git a/audit-results/tmp/reproduce-drift.cjs b/audit-results/tmp/reproduce-drift.cjs deleted file mode 100644 index ba3d99f..0000000 --- a/audit-results/tmp/reproduce-drift.cjs +++ /dev/null @@ -1,40 +0,0 @@ -// 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(); diff --git a/audit-results/tmp/validate-findings.cjs b/audit-results/tmp/validate-findings.cjs deleted file mode 100644 index ffc724a..0000000 --- a/audit-results/tmp/validate-findings.cjs +++ /dev/null @@ -1,27 +0,0 @@ -// 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(", ")); diff --git a/benchmarks/remediationBenchmark.test.ts b/benchmarks/remediationBenchmark.test.ts deleted file mode 100644 index 25eb390..0000000 --- a/benchmarks/remediationBenchmark.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -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(label: string, operation: () => Promise) { - 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 - ); - } - }); -}); diff --git a/docker-compose.dev.yml b/docker-compose.override.yml similarity index 70% rename from docker-compose.dev.yml rename to docker-compose.override.yml index 7721dc8..224ac4a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.override.yml @@ -1,15 +1,12 @@ services: study-app: build: - context: . target: deps command: npm run dev - ports: - - "3000:3000" - environment: - - DATABASE_URL=file:/app/data/study.db - - NODE_ENV=development volumes: - .:/app - /app/node_modules - - ./data:/app/data + environment: + - NODE_ENV=development + ports: + - "3000:3000" diff --git a/docker-compose.yml b/docker-compose.yml index 3ca99f6..6270daf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,11 @@ services: container_name: study-app restart: unless-stopped ports: - - "3000:3726" + - "3000:3000" environment: - DATABASE_URL=file:/app/data/study.db - - SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters} - - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-} - - ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false} - - SECURE_COOKIES=${SECURE_COOKIES:-false} + - SESSION_SECRET=${SESSION_SECRET} + - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} - NODE_ENV=production volumes: - ./data:/app/data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 0678c2c..5e93fe8 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,12 +1,4 @@ #!/bin/sh set -e - -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 +npx prisma migrate deploy exec node server.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 3d021d4..05e726d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,7 +12,6 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", - "audit-results/tmp/**", ]), ]); diff --git a/out.css b/out.css new file mode 100644 index 0000000..c837bac --- /dev/null +++ b/out.css @@ -0,0 +1 @@ +:root { --color-primary: #fff; } diff --git a/package-lock.json b/package-lock.json index c9163a8..f81787d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,8 +17,7 @@ "better-sqlite3": "^12.11.1", "iron-session": "^8.0.4", "jsonrepair": "^3.14.1", - "next": "16.3.0", - "prisma": "^7.8.0", + "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", @@ -28,12 +27,12 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.9", + "prisma": "^7.8.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" @@ -83,6 +82,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -309,6 +309,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -349,12 +350,15 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "license": "Apache-2.0" + "devOptional": true, + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -367,46 +371,12 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -572,6 +542,7 @@ "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -657,9 +628,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -669,19 +640,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -691,38 +662,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -736,9 +688,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -752,9 +704,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -768,9 +720,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -784,9 +736,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -800,9 +752,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ "riscv64" ], @@ -816,9 +768,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -832,9 +784,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -848,9 +800,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -864,9 +816,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -880,9 +832,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -892,19 +844,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -914,19 +866,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -936,19 +888,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ "riscv64" ], @@ -958,19 +910,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -980,19 +932,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -1002,19 +954,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -1024,19 +976,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1046,54 +998,38 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@emnapi/runtime": "^1.7.0" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1103,16 +1039,16 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1122,16 +1058,16 @@ "win32" ], "engines": { - "node": "^20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1141,7 +1077,7 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1201,6 +1137,7 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { @@ -1223,9 +1160,9 @@ } }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1239,9 +1176,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", "cpu": [ "arm64" ], @@ -1255,9 +1192,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", "cpu": [ "x64" ], @@ -1271,9 +1208,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", "cpu": [ "arm64" ], @@ -1287,9 +1224,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", "cpu": [ "arm64" ], @@ -1303,9 +1240,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", "cpu": [ "x64" ], @@ -1319,9 +1256,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", "cpu": [ "x64" ], @@ -1335,9 +1272,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", "cpu": [ "arm64" ], @@ -1351,9 +1288,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", "cpu": [ "x64" ], @@ -1477,6 +1414,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.3.4", @@ -1495,6 +1433,7 @@ "version": "0.24.3", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.4.1", @@ -1529,6 +1468,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1542,12 +1482,14 @@ "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1557,6 +1499,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0", @@ -1568,6 +1511,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1577,6 +1521,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -1586,18 +1531,21 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -1614,6 +1562,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1630,12 +1579,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, "license": "MIT" }, "node_modules/@prisma/studio-core": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", @@ -1655,12 +1606,14 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1676,6 +1629,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -1699,6 +1653,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -1717,6 +1672,7 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -1742,6 +1698,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", @@ -1761,6 +1718,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" @@ -1779,6 +1737,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -2088,6 +2047,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2322,72 +2282,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", @@ -2447,16 +2341,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/better-sqlite3": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", - "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -2543,6 +2427,7 @@ "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2552,6 +2437,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2560,8 +2446,9 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2617,6 +2504,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -3339,6 +3227,7 @@ "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3629,6 +3518,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -3707,6 +3597,7 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, "license": "MIT" }, "node_modules/better-sqlite3": { @@ -3787,6 +3678,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3829,6 +3721,7 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -4014,6 +3907,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -4026,6 +3920,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -4090,6 +3985,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4271,6 +4167,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4316,12 +4213,14 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, "license": "MIT" }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -4340,6 +4239,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -4381,6 +4281,7 @@ "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4408,6 +4309,7 @@ "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4432,6 +4334,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -4464,6 +4367,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -4707,6 +4611,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4892,6 +4797,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5150,6 +5056,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "devOptional": true, "license": "MIT" }, "node_modules/extend": { @@ -5162,6 +5069,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, "funding": [ { "type": "individual", @@ -5184,6 +5092,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5234,6 +5143,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -5346,6 +5256,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5427,6 +5338,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -5481,6 +5393,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -5532,6 +5445,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -5603,18 +5517,21 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -5772,7 +5689,9 @@ "version": "4.12.27", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -5791,12 +5710,14 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6269,6 +6190,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -6451,6 +6373,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -6890,6 +6813,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/longest-streak": { @@ -6929,6 +6853,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -7889,6 +7814,7 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -7909,6 +7835,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -7918,9 +7845,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -7965,16 +7892,16 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", + "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.5.23", + "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "bin": { @@ -7984,15 +7911,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8018,9 +7945,9 @@ } }, "node_modules/next/node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -8037,9 +7964,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8259,6 +8186,7 @@ "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, "license": "MIT" }, "node_modules/once": { @@ -8406,12 +8334,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8437,6 +8367,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -8487,6 +8418,7 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -8537,8 +8469,10 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -8582,6 +8516,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8593,6 +8528,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, "license": "ISC" }, "node_modules/property-information": { @@ -8629,6 +8565,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, "funding": [ { "type": "individual", @@ -8690,6 +8627,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.6", @@ -8701,6 +8639,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8710,6 +8649,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8769,6 +8709,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8892,6 +8833,7 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -8901,6 +8843,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8954,6 +8897,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -9107,6 +9051,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -9128,7 +9073,8 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -9180,53 +9126,48 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.1.0", + "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "semver": "^7.7.3" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/sharp/node_modules/semver": { @@ -9350,6 +9291,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -9426,6 +9368,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9449,6 +9392,7 @@ "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -9799,6 +9743,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -10012,6 +9957,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10252,6 +10198,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -10681,6 +10628,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", @@ -10692,6 +10640,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 66dbfd0..b7bc5b1 100644 --- a/package.json +++ b/package.json @@ -8,11 +8,7 @@ "build": "next build", "start": "next start", "lint": "eslint", - "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" + "test": "vitest run" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -24,8 +20,7 @@ "better-sqlite3": "^12.11.1", "iron-session": "^8.0.4", "jsonrepair": "^3.14.1", - "next": "16.3.0", - "prisma": "^7.8.0", + "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", @@ -35,12 +30,12 @@ }, "devDependencies": { "@tailwindcss/postcss": "^4", - "@types/better-sqlite3": "^9.6.0", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.2.9", + "prisma": "^7.8.0", "tailwindcss": "^4", "typescript": "^5", "vitest": "^4.1.10" diff --git a/prisma/migrations/20260807090000_add_material_groups/migration.sql b/prisma/migrations/20260807090000_add_material_groups/migration.sql deleted file mode 100644 index 2227e4f..0000000 --- a/prisma/migrations/20260807090000_add_material_groups/migration.sql +++ /dev/null @@ -1,68 +0,0 @@ -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; diff --git a/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql b/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql deleted file mode 100644 index 289d17a..0000000 --- a/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT; diff --git a/prisma/migrations/20260807092000_add_progress_revisions/migration.sql b/prisma/migrations/20260807092000_add_progress_revisions/migration.sql deleted file mode 100644 index 792da5c..0000000 --- a/prisma/migrations/20260807092000_add_progress_revisions/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy'; -ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 45d61ec..1e031d9 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -99,8 +99,6 @@ model StudyProgress { orderJson String // JSON array of card/question ids answersJson String? // in-progress quiz answers cardResultsJson String? // per-card grades for decks - sessionId String @default("legacy") - revision Int @default(0) updatedAt DateTime @updatedAt deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade) @@ -111,14 +109,13 @@ model StudyProgress { } model QuizAttempt { - id String @id @default(uuid()) - quizSetId String - score Float - maxScore Int - answersJson String - reviewSnapshotJson String? - isPartialRetake Boolean @default(false) - completedAt DateTime @default(now()) + id String @id @default(uuid()) + quizSetId String + score Float + maxScore Int + answersJson String + isPartialRetake Boolean @default(false) + completedAt DateTime @default(now()) quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade) } diff --git a/scripts/backup-database.mjs b/scripts/backup-database.mjs deleted file mode 100644 index 5f0b8fb..0000000 --- a/scripts/backup-database.mjs +++ /dev/null @@ -1,42 +0,0 @@ -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 "); - const completedPath = await backupDatabase(source, output); - console.info(`Verified SQLite backup created: ${completedPath}`); -} diff --git a/scripts/databasePath.mjs b/scripts/databasePath.mjs deleted file mode 100644 index 86b9564..0000000 --- a/scripts/databasePath.mjs +++ /dev/null @@ -1,10 +0,0 @@ -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); -} diff --git a/scripts/migration-preflight.mjs b/scripts/migration-preflight.mjs deleted file mode 100644 index a058181..0000000 --- a/scripts/migration-preflight.mjs +++ /dev/null @@ -1,312 +0,0 @@ -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; - } -} diff --git a/scripts/request-password-reset.mjs b/scripts/request-password-reset.mjs deleted file mode 100644 index 1847eb2..0000000 --- a/scripts/request-password-reset.mjs +++ /dev/null @@ -1,56 +0,0 @@ -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}`); -} diff --git a/scripts/verify-backup-restore.mjs b/scripts/verify-backup-restore.mjs deleted file mode 100644 index b67c924..0000000 --- a/scripts/verify-backup-restore.mjs +++ /dev/null @@ -1,197 +0,0 @@ -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 }); -} diff --git a/scripts/verify-http-smoke.mjs b/scripts/verify-http-smoke.mjs deleted file mode 100644 index ba76b12..0000000 --- a/scripts/verify-http-smoke.mjs +++ /dev/null @@ -1,83 +0,0 @@ -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 }); -} diff --git a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx index 8743b87..726de4e 100644 --- a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx @@ -25,8 +25,6 @@ interface DeckData { currentIndex: number; orderJson: string; cardResultsJson: string | null; - sessionId: string; - revision: number; updatedAt: string; }>; } @@ -44,24 +42,19 @@ export default function DeckStudyPage() { async function handleRestart() { if (!deck) return; - const responses = await Promise.all( - deck.progress.map((progress) => - 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 session could not be restarted. Your saved progress was kept."); - return; - } + // Clear progress in database for both modes + await Promise.all([ + fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) + }).catch(() => {}), + fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) + }).catch(() => {}) + ]); // Clear locally and force remount setDeck({ ...deck, progress: [] }); @@ -82,8 +75,7 @@ export default function DeckStudyPage() { }, [deckId, classSlug, router]); useEffect(() => { - const timer = window.setTimeout(() => void fetchDeck(), 0); - return () => window.clearTimeout(timer); + fetchDeck(); }, [fetchDeck]); if (loading || !deck) { diff --git a/src/app/(protected)/[classSlug]/flashcards/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index d5ce426..5ff4316 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; @@ -13,6 +13,7 @@ import { useSensor, useSensors, DragEndEvent, + DragOverEvent, DragStartEvent, DragOverlay, useDroppable, @@ -40,8 +41,6 @@ interface DeckItem { currentIndex: number; orderJson: string; cardResultsJson: string | null; - sessionId: string; - revision: number; }>; } @@ -51,25 +50,15 @@ interface MaterialGroup { sortOrder: number; } +const cache: Record = {}; + function getProgressLabel(deck: DeckItem) { if (!deck.progress?.length) return null; const prog = deck.progress[0]; - let order: string[]; - let results: Record; - 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) - : {}; - } catch { - return "Saved session needs repair"; - } + const order = JSON.parse(prog.orderJson) as string[]; + const results = prog.cardResultsJson + ? (JSON.parse(prog.cardResultsJson) as Record) + : {}; const correctCount = Object.values(results).filter((r) => r === "correct").length; const total = order.length; const current = Math.min(prog.currentIndex + 1, total); @@ -81,21 +70,10 @@ interface SortableDeckCardProps { deck: DeckItem; onEdit: (deck: DeckItem) => void; onDelete: (deck: DeckItem) => void; - groups: MaterialGroup[]; - onMove: (deckId: string, groupId: string | null) => void; - moveDisabled?: boolean; classSlug: string; } -function SortableDeckCard({ - deck, - onEdit, - onDelete, - groups, - onMove, - moveDisabled = false, - classSlug, -}: SortableDeckCardProps) { +function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const style = { transform: CSS.Transform.toString(transform), @@ -139,24 +117,10 @@ function SortableDeckCard({ - - )} - - {!loading && !loadError && decks.length === 0 && groups.length === 0 && ( + {!loading && decks.length === 0 && groups.length === 0 && (

No decks yet

Import your first deck or create a group

)} - {!loading && !loadError && ( - + {!loading && ( +
{groupedDecks.map(group => (
@@ -622,7 +485,7 @@ export default function FlashcardsPage() {
Drop decks here
) : ( group.decks.map(deck => ( - { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> )) )} @@ -647,7 +510,7 @@ export default function FlashcardsPage() {
No uncategorized decks
) : ( uncategorizedDecks.map(deck => ( - { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> )) )} @@ -659,7 +522,7 @@ export default function FlashcardsPage() { {activeDeck ? (
- {}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} /> + {}} onDelete={()=>{}} classSlug={classSlug} />
) : null}
diff --git a/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx b/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx index 2055011..9c57430 100644 --- a/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx @@ -27,7 +27,6 @@ interface QuizAttempt { score: number; maxScore: number; answersJson: string; - reviewSnapshotJson?: string | null; isPartialRetake: boolean; completedAt: string; } @@ -43,8 +42,6 @@ interface QuizData { currentIndex: number; orderJson: string; answersJson: string | null; - sessionId: string; - revision: number; }>; } @@ -63,23 +60,13 @@ export default function QuizStudyPage() { async function handleRestart() { if (!quiz) return; - const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL"); - if (progress) { - const response = await fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - 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 progress in database + await fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }) + }).catch(() => {}); // Clear locally and force remount setQuiz({ ...quiz, progress: [] }); @@ -117,8 +104,7 @@ export default function QuizStudyPage() { }, [quizId, classSlug, router]); useEffect(() => { - const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0); - return () => window.clearTimeout(timer); + fetchQuizAndAttempts(); }, [fetchQuizAndAttempts]); const [showTopics, setShowTopics] = useState(false); @@ -235,7 +221,6 @@ export default function QuizStudyPage() { key={restartKey} quiz={quiz} retakeIds={retakeIds} - sessionKey={restartKey} onFinished={() => { router.push(`/${classSlug}/quizzes`); }} diff --git a/src/app/(protected)/[classSlug]/quizzes/page.tsx b/src/app/(protected)/[classSlug]/quizzes/page.tsx index 6ae9949..0509c3b 100644 --- a/src/app/(protected)/[classSlug]/quizzes/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; @@ -13,6 +13,7 @@ import { useSensor, useSensors, DragEndEvent, + DragOverEvent, DragStartEvent, DragOverlay, useDroppable, @@ -40,8 +41,6 @@ interface QuizItem { currentIndex: number; orderJson: string; answersJson: string | null; - sessionId: string; - revision: number; }>; } @@ -51,25 +50,16 @@ interface MaterialGroup { sortOrder: number; } +const cache: Record = {}; + interface SortableQuizCardProps { quiz: QuizItem; onEdit: (quiz: QuizItem) => void; onDelete: (quiz: QuizItem) => void; - groups: MaterialGroup[]; - onMove: (quizId: string, groupId: string | null) => void; - moveDisabled?: boolean; classSlug: string; } -function SortableQuizCard({ - quiz, - onEdit, - onDelete, - groups, - onMove, - moveDisabled = false, - classSlug, -}: SortableQuizCardProps) { +function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCardProps) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id }); const style = { transform: CSS.Transform.toString(transform), @@ -113,22 +103,11 @@ function SortableQuizCard({
- - -
- )} - - {!loading && !loadError && quizzes.length === 0 && groups.length === 0 && ( + {!loading && quizzes.length === 0 && groups.length === 0 && (

No quizzes yet

Import your first quiz or create a group

)} - {!loading && !loadError && ( - + {!loading && ( +
{groupedQuizzes.map(group => (
@@ -583,7 +476,7 @@ export default function QuizzesPage() { )}
- +
@@ -596,7 +489,7 @@ export default function QuizzesPage() {
Drop quizzes here
) : ( group.quizzes.map(quiz => ( - { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> )) )} @@ -621,7 +514,7 @@ export default function QuizzesPage() {
No uncategorized quizzes
) : ( uncategorizedQuizzes.map(quiz => ( - { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> )) )} @@ -633,7 +526,7 @@ export default function QuizzesPage() { {activeQuiz ? (
- {}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} /> + {}} onDelete={()=>{}} classSlug={classSlug} />
) : null}
diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index d411e93..5d37387 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -20,7 +20,6 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7 export default function HomePage() { const [classes, setClasses] = useState([]); const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); @@ -28,25 +27,13 @@ export default function HomePage() { const [editName, setEditName] = useState(""); const [savingEdit, setSavingEdit] = useState(false); - useEffect(() => { - const controller = new AbortController(); - void fetchClasses(controller.signal); - return () => controller.abort(); - }, []); + useEffect(() => { fetchClasses(); }, []); - async function fetchClasses(signal?: AbortSignal) { - setLoading(true); - setLoadError(null); + async function fetchClasses() { try { - const res = await fetch("/api/classes", { signal }); - if (!res.ok) throw new Error("The server could not load your classes."); + const res = await fetch("/api/classes"); setClasses(await res.json()); - } catch (error) { - if (signal?.aborted) return; - setLoadError(error instanceof Error ? error.message : "Unable to load classes."); - } finally { - if (!signal?.aborted) setLoading(false); - } + } finally { setLoading(false); } } async function handleCreate(e: React.FormEvent) { @@ -55,21 +42,14 @@ export default function HomePage() { setCreating(true); try { const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) }); - 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."); + if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); } } finally { setCreating(false); } } async function handleDelete(id: string, name: string) { if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return; - const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null); - if (!response?.ok) { - setLoadError("The class could not be deleted. Retry when the server is available."); - return; - } - void fetchClasses(); + await fetch(`/api/classes/${id}`, { method: "DELETE" }); + fetchClasses(); } async function handleSaveEdit(id: string) { @@ -77,10 +57,7 @@ export default function HomePage() { setSavingEdit(true); try { const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) }); - 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."); + if (res.ok) { setEditingId(null); fetchClasses(); } } finally { setSavingEdit(false); } } @@ -116,15 +93,7 @@ export default function HomePage() {
)} - {!loading && loadError && ( -
-

Your study spaces could not be loaded.

-

{loadError}

- -
- )} - - {!loading && !loadError && classes.length === 0 && ( + {!loading && classes.length === 0 && (
+

Your desk is ready

@@ -133,7 +102,7 @@ export default function HomePage() {
)} - {!loading && !loadError && classes.length > 0 && ( + {!loading && classes.length > 0 && (
{classes.map((cls, index) => (
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f557aaa..d609732 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -4,7 +4,8 @@ import { checkRateLimit } from "@/lib/rateLimiter"; import { login } from "@/services/authService"; export async function POST(request: NextRequest) { - if (!checkRateLimit("login:global").allowed) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + if (!checkRateLimit(`login:${ip}`).allowed) { return NextResponse.json( { error: "Too many requests. Try again shortly." }, { status: 429 } diff --git a/src/app/api/auth/password-reset/complete/route.ts b/src/app/api/auth/password-reset/complete/route.ts index 8368cbe..e614085 100644 --- a/src/app/api/auth/password-reset/complete/route.ts +++ b/src/app/api/auth/password-reset/complete/route.ts @@ -1,20 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { completePasswordResetSchema } from "@/lib/validation/authSchemas"; -import { checkRateLimit } from "@/lib/rateLimiter"; import { completePasswordReset } from "@/services/authService"; 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( await request.json().catch(() => null) ); diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts index 9cb71bf..5ac61d3 100644 --- a/src/app/api/auth/password-reset/request/route.ts +++ b/src/app/api/auth/password-reset/request/route.ts @@ -1,17 +1,11 @@ -import { NextResponse } from "next/server"; +import { NextRequest, NextResponse } from "next/server"; import { checkRateLimit } from "@/lib/rateLimiter"; import { requestPasswordReset } from "@/services/authService"; -export async function POST() { - if (process.env.NODE_ENV === "production") { - return NextResponse.json( - { error: "Start password recovery from the local server console." }, - { status: 403 } - ); - } - - const rateLimit = checkRateLimit("password-reset-request:global", { - windowMs: 15 * 60_000, +export async function POST(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + const rateLimit = checkRateLimit(`password-reset-request:${ip}`, { + windowMs: 60_000, maxRequests: 1, }); if (!rateLimit.allowed) { @@ -21,18 +15,11 @@ export async function POST() { ); } - const result = await requestPasswordReset(); - if (result.status === "missing-password") { + if (!(await requestPasswordReset())) { return NextResponse.json( { error: "Password recovery is unavailable before initial setup." }, { status: 409 } ); } - 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 }); + return NextResponse.json({ success: true }); } diff --git a/src/app/api/auth/password-reset/verify/route.ts b/src/app/api/auth/password-reset/verify/route.ts index 231232e..fc05690 100644 --- a/src/app/api/auth/password-reset/verify/route.ts +++ b/src/app/api/auth/password-reset/verify/route.ts @@ -4,7 +4,8 @@ import { resetTokenSchema } from "@/lib/validation/authSchemas"; import { verifyPasswordResetToken } from "@/services/authService"; export async function POST(request: NextRequest) { - if (!checkRateLimit("password-reset-verify:global").allowed) { + const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) { return NextResponse.json( { error: "Too many attempts. Try again shortly." }, { status: 429 } diff --git a/src/app/api/auth/setup-status/route.ts b/src/app/api/auth/setup-status/route.ts index 99ed0e9..24816e0 100644 --- a/src/app/api/auth/setup-status/route.ts +++ b/src/app/api/auth/setup-status/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from "next/server"; -import { getSetupStatus } from "@/services/authService"; +import { prisma } from "@/lib/db"; export async function GET() { try { - return NextResponse.json(await getSetupStatus()); + const setting = await prisma.setting.findUnique({ + where: { key: "admin_password_hash" }, + }); + + return NextResponse.json({ setupRequired: !setting }); } catch (error) { console.error("Failed to check setup status:", error); return NextResponse.json( diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index bc7e4f9..a0a5100 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -1,24 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import * as cardService from "@/services/cardService"; -import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function PATCH( request: NextRequest, ctx: RouteContext<"/api/cards/[id]"> ) { const { id } = await ctx.params; - 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 }); - } + const body = await request.json().catch(() => null); + try { - return NextResponse.json(await cardService.updateCard(id, parsed.data)); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + const updated = await cardService.updateCard(id, { + front: body?.front?.trim(), + back: body?.back?.trim(), + }); + return NextResponse.json(updated); + } catch { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); } } @@ -27,14 +24,11 @@ export async function DELETE( ctx: RouteContext<"/api/cards/[id]"> ) { const { id } = await ctx.params; + try { await cardService.deleteCard(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + } catch { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); } } diff --git a/src/app/api/classes/[id]/route.ts b/src/app/api/classes/[id]/route.ts index 89aad57..dc66b93 100644 --- a/src/app/api/classes/[id]/route.ts +++ b/src/app/api/classes/[id]/route.ts @@ -1,24 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import * as classService from "@/services/classService"; -import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function PATCH( request: NextRequest, ctx: RouteContext<"/api/classes/[id]"> ) { const { id } = await ctx.params; - const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "A valid class name is required" }, { status: 400 }); + const body = await request.json().catch(() => null); + + if (!body || (!body.name && body.name !== "")) { + return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); } + try { - return NextResponse.json(await classService.updateClass(id, parsed.data)); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + const updated = await classService.updateClass(id, { + name: body.name?.trim(), + }); + return NextResponse.json(updated); + } catch { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); } } @@ -27,14 +27,11 @@ export async function DELETE( ctx: RouteContext<"/api/classes/[id]"> ) { const { id } = await ctx.params; + try { await classService.deleteClass(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + } catch { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); } } diff --git a/src/app/api/classes/route.ts b/src/app/api/classes/route.ts index 2be6d0d..9a5b335 100644 --- a/src/app/api/classes/route.ts +++ b/src/app/api/classes/route.ts @@ -1,23 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import * as classService from "@/services/classService"; -import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET() { - return NextResponse.json(await classService.listClasses()); + const classes = await classService.listClasses(); + return NextResponse.json(classes); } export async function POST(request: NextRequest) { - const parsed = classCreateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { 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 body = await request.json().catch(() => null); + if (!body?.name || typeof body.name !== "string" || !body.name.trim()) { + return NextResponse.json( + { error: "Class name is required" }, + { status: 400 } + ); } + + const newClass = await classService.createClass(body.name.trim()); + return NextResponse.json(newClass, { status: 201 }); } diff --git a/src/app/api/decks/[id]/cards/route.ts b/src/app/api/decks/[id]/cards/route.ts index 7416c0b..4aa5fea 100644 --- a/src/app/api/decks/[id]/cards/route.ts +++ b/src/app/api/decks/[id]/cards/route.ts @@ -1,26 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; import * as cardService from "@/services/cardService"; -import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function POST( request: NextRequest, ctx: RouteContext<"/api/decks/[id]/cards"> ) { const { id: deckId } = await ctx.params; - const parsed = cardContentSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if ( + !body?.front || + !body?.back || + typeof body.front !== "string" || + typeof body.back !== "string" + ) { return NextResponse.json( - { error: "Card front and back are required and must be within the content limit", details: parsed.error.issues }, + { error: "front and back are required" }, { status: 400 } ); } - try { - return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 }); - } catch (error) { - if (isPrismaError(error, "P2003")) { - return NextResponse.json({ error: "Deck not found" }, { status: 404 }); - } - console.error("Failed to create card", error); - return NextResponse.json({ error: "Failed to create card" }, { status: 500 }); - } + + const card = await cardService.createCard(deckId, { + front: body.front.trim(), + back: body.back.trim(), + }); + + return NextResponse.json(card, { status: 201 }); } diff --git a/src/app/api/decks/[id]/route.ts b/src/app/api/decks/[id]/route.ts index 7fdd14f..b415c00 100644 --- a/src/app/api/decks/[id]/route.ts +++ b/src/app/api/decks/[id]/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import * as deckService from "@/services/deckService"; -import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET( _request: NextRequest, @@ -21,21 +20,16 @@ export async function PATCH( ctx: RouteContext<"/api/decks/[id]"> ) { const { id } = await ctx.params; - 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 }); - } + const body = await request.json().catch(() => null); try { - const updated = await deckService.updateDeck(id, parsed.data); + const updated = await deckService.updateDeck(id, { + name: body?.name?.trim(), + description: body?.description, + }); return NextResponse.json(updated); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + } catch { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); } } @@ -48,11 +42,7 @@ export async function DELETE( try { await deckService.deleteDeck(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + } catch { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); } } diff --git a/src/app/api/decks/reorder/route.ts b/src/app/api/decks/reorder/route.ts index 9c090fe..c7bcc43 100644 --- a/src/app/api/decks/reorder/route.ts +++ b/src/app/api/decks/reorder/route.ts @@ -1,29 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; -import { - ReorderConflictError, - ReorderValidationError, - reorderContent, -} from "@/services/reorderService"; export async function PATCH(request: NextRequest) { - const parsed = reorderRequestSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 }); - } try { - await reorderContent("DECK", parsed.data); + const parsed = reorderRequestSchema.safeParse(await request.json()); + 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, + }, + }) + ) + ); + return NextResponse.json({ success: true }); } 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 }); } } diff --git a/src/app/api/decks/route.ts b/src/app/api/decks/route.ts index dbc6c62..b33fdbf 100644 --- a/src/app/api/decks/route.ts +++ b/src/app/api/decks/route.ts @@ -1,40 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import * as deckService from "@/services/deckService"; -import { deckImportRequestSchema } from "@/lib/validation/importSchemas"; -import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson"; +import { flashcardImportSchema } from "@/lib/validation/importSchemas"; export async function POST(request: NextRequest) { - let body: unknown; - try { - body = await readLimitedJson(request); - } catch (error) { - if (error instanceof RequestTooLargeError) { - return NextResponse.json({ error: error.message }, { status: 413 }); - } - throw error; + const body = await request.json().catch(() => null); + + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const parsed = deckImportRequestSchema.safeParse(body); + const { classId, data, name, groupId } = 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) { return NextResponse.json( - { error: "Invalid deck import", details: parsed.error.issues }, + { error: "Validation failed", details: parsed.error.issues }, { status: 400 } ); } - try { - const deck = await deckService.createDeckFromImport( - parsed.data.classId, - parsed.data.data, - parsed.data.name, - parsed.data.groupId ?? null - ); - 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 }); - } + const deck = await deckService.createDeckFromImport( + classId, + parsed.data, + name?.trim() || undefined, + groupId || null + ); + + return NextResponse.json(deck, { status: 201 }); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts deleted file mode 100644 index cf75f33..0000000 --- a/src/app/api/health/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -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 }); - } -} diff --git a/src/app/api/material-groups/[id]/route.ts b/src/app/api/material-groups/[id]/route.ts index 4e74abd..bbd03cf 100644 --- a/src/app/api/material-groups/[id]/route.ts +++ b/src/app/api/material-groups/[id]/route.ts @@ -1,44 +1,42 @@ import { NextRequest, NextResponse } from "next/server"; -import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas"; -import { - deleteMaterialGroup, - renameMaterialGroup, -} from "@/services/materialGroupService"; +import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const parsed = materialGroupUpdateSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid group update" }, { status: 400 }); - } try { - return NextResponse.json(await renameMaterialGroup(id, parsed.data.name)); + const { id } = await params; + const body = await request.json(); + const { name, sortOrder } = body; + + const updateData: Prisma.MaterialGroupUpdateInput = {}; + if (name !== undefined) updateData.name = name; + if (sortOrder !== undefined) updateData.sortOrder = sortOrder; + + const group = await prisma.materialGroup.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(group); } catch (error) { - 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 }); + return NextResponse.json({ error: "Failed to update material group" }, { status: 500 }); } } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - if (!(await deleteMaterialGroup(id))) { - return NextResponse.json({ error: "Group not found" }, { status: 404 }); + try { + const { id } = await params; + await prisma.materialGroup.delete({ + where: { id }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 }); } - return NextResponse.json({ success: true }); } diff --git a/src/app/api/material-groups/route.ts b/src/app/api/material-groups/route.ts index 8164ace..2485c6d 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -1,38 +1,57 @@ import { NextRequest, NextResponse } from "next/server"; -import { - materialGroupCreateSchema, - materialGroupQuerySchema, -} from "@/lib/validation/materialGroupSchemas"; -import { - createMaterialGroup, - listMaterialGroups, -} from "@/services/materialGroupService"; +import { prisma } from "@/lib/db"; +import type { Prisma } from "@/generated/prisma/client"; export async function GET(request: NextRequest) { - const parsed = materialGroupQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid group query" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const classId = searchParams.get("classId"); + const type = searchParams.get("type"); + + if (!classId) { + 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) { - const parsed = materialGroupCreateSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid group" }, - { status: 400 } - ); + try { + const body = await request.json(); + const { classId, name, type } = body; + + if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + 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 }); + } catch (error) { + return NextResponse.json({ error: "Failed to create material group" }, { status: 500 }); } - const group = await createMaterialGroup(parsed.data); - if (!group) { - return NextResponse.json({ error: "Class not found" }, { status: 404 }); - } - return NextResponse.json(group, { status: 201 }); } diff --git a/src/app/api/progress/route.ts b/src/app/api/progress/route.ts index 1366efb..7b072d6 100644 --- a/src/app/api/progress/route.ts +++ b/src/app/api/progress/route.ts @@ -1,64 +1,66 @@ import { NextRequest, NextResponse } from "next/server"; -import { - progressDeleteSchema, - progressPatchSchema, - progressQuerySchema, -} from "@/lib/validation/progressSchemas"; -import { - ProgressConflictError, - ProgressNotFoundError, - ProgressValidationError, - clearProgress, - getProgress, - saveProgress, -} from "@/services/progressService"; +import * as progressService from "@/services/progressService"; export async function GET(request: NextRequest) { - const parsed = progressQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid progress query" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const contentType = searchParams.get("contentType") as "DECK" | "QUIZ"; + 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 } + ); } - 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) { - const parsed = progressPatchSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if (!body?.contentType || !body?.contentId || !body?.mode) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid progress" }, + { error: "contentType, contentId, and mode are required" }, { status: 400 } ); } - try { - return NextResponse.json(await saveProgress(parsed.data)); - } catch (error) { - if (error instanceof ProgressNotFoundError) { - return NextResponse.json({ error: error.message }, { status: 404 }); - } - if (error instanceof ProgressConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }); - } - if (error instanceof ProgressValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - console.error("Failed to save study progress", error); - return NextResponse.json({ error: "Failed to save progress" }, { status: 500 }); - } + + const progress = await progressService.upsertProgress({ + contentType: body.contentType, + contentId: body.contentId, + mode: body.mode, + currentIndex: body.currentIndex ?? 0, + orderJson: body.orderJson, + answersJson: body.answersJson, + cardResultsJson: body.cardResultsJson, + }); + + return NextResponse.json(progress); } export async function DELETE(request: NextRequest) { - const parsed = progressDeleteSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if (!body?.contentType || !body?.contentId || !body?.mode) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" }, + { error: "contentType, contentId, and mode are required" }, { status: 400 } ); } - return NextResponse.json({ cleared: await clearProgress(parsed.data) }); + + await progressService.clearProgress( + body.contentType, + body.contentId, + body.mode + ); + + return NextResponse.json({ success: true }); } diff --git a/src/app/api/quizzes/[id]/attempt/route.ts b/src/app/api/quizzes/[id]/attempt/route.ts index 0b1f301..1f13839 100644 --- a/src/app/api/quizzes/[id]/attempt/route.ts +++ b/src/app/api/quizzes/[id]/attempt/route.ts @@ -1,40 +1,65 @@ import { NextRequest, NextResponse } from "next/server"; -import { - QuizAttemptValidationError, - QuizNotFoundError, - listQuizAttempts, - submitQuizAttempt, -} from "@/services/quizService"; -import { quizAttemptSchema } from "@/lib/validation/attemptSchemas"; +import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService"; +import { scoreQuiz } from "@/lib/scoring"; export async function POST( request: NextRequest, ctx: RouteContext<"/api/quizzes/[id]/attempt"> ) { const { id } = await ctx.params; - const parsed = quizAttemptSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if (!body || !body.answersJson) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid attempt" }, + { error: "answersJson is required" }, { status: 400 } ); } - try { - return NextResponse.json(await submitQuizAttempt(id, parsed.data), { - status: 201, - }); - } catch (error) { - 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 }); + + const quizSet = await getQuizSetWithQuestions(id); + if (!quizSet) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); } + + // Parse answers + let answers: Record; + try { + answers = JSON.parse(body.answersJson); + } catch { + 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, + }); + + return NextResponse.json(attempt, { status: 201 }); } export async function GET( diff --git a/src/app/api/quizzes/[id]/route.ts b/src/app/api/quizzes/[id]/route.ts index f912d7d..070bb2c 100644 --- a/src/app/api/quizzes/[id]/route.ts +++ b/src/app/api/quizzes/[id]/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import * as quizService from "@/services/quizService"; -import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET( _request: NextRequest, @@ -21,21 +20,16 @@ export async function PATCH( ctx: RouteContext<"/api/quizzes/[id]"> ) { const { id } = await ctx.params; - 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 }); - } + const body = await request.json().catch(() => null); try { - const updated = await quizService.updateQuizSet(id, parsed.data); + const updated = await quizService.updateQuizSet(id, { + name: body?.name?.trim(), + description: body?.description, + }); return NextResponse.json(updated); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + } catch { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); } } @@ -48,11 +42,7 @@ export async function DELETE( try { await quizService.deleteQuizSet(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - 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 }); + } catch { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); } } diff --git a/src/app/api/quizzes/reorder/route.ts b/src/app/api/quizzes/reorder/route.ts index a6c3e01..7ad7087 100644 --- a/src/app/api/quizzes/reorder/route.ts +++ b/src/app/api/quizzes/reorder/route.ts @@ -1,29 +1,32 @@ import { NextRequest, NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; -import { - ReorderConflictError, - ReorderValidationError, - reorderContent, -} from "@/services/reorderService"; export async function PATCH(request: NextRequest) { - const parsed = reorderRequestSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 }); - } try { - await reorderContent("QUIZ", parsed.data); + const parsed = reorderRequestSchema.safeParse(await request.json()); + 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, + }, + }) + ) + ); + return NextResponse.json({ success: true }); } 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 }); } } diff --git a/src/app/api/quizzes/route.ts b/src/app/api/quizzes/route.ts index b14c02f..c6f1564 100644 --- a/src/app/api/quizzes/route.ts +++ b/src/app/api/quizzes/route.ts @@ -1,43 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import * as quizService from "@/services/quizService"; -import { quizImportRequestSchema } from "@/lib/validation/importSchemas"; -import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson"; +import { quizImportSchema } from "@/lib/validation/importSchemas"; export async function POST(request: NextRequest) { - let body: unknown; - try { - body = await readLimitedJson(request); - } catch (error) { - if (error instanceof RequestTooLargeError) { - return NextResponse.json({ error: error.message }, { status: 413 }); - } - throw error; + const body = await request.json().catch(() => null); + + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const parsed = quizImportRequestSchema.safeParse(body); + const { classId, data, name, groupId } = 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) { return NextResponse.json( - { - error: "Invalid quiz import. SATA questions need at least two correct options.", - details: parsed.error.issues, - }, + { error: "Validation failed", details: parsed.error.issues }, { status: 400 } ); } - try { - const quiz = await quizService.createQuizSetFromImport( - parsed.data.classId, - parsed.data.data, - parsed.data.name, - parsed.data.groupId ?? null - ); - return NextResponse.json(quiz, { 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 }); - } + const quizSet = await quizService.createQuizSetFromImport( + classId, + parsed.data, + name?.trim() || undefined, + groupId || null + ); + + return NextResponse.json(quizSet, { status: 201 }); } diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index c6642e5..f29f093 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -1,21 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { shareTargetSchema } from "@/lib/validation/shareSchemas"; -import { - ShareTargetNotFoundError, - getShareLinkForContent, - isContentSharedViaGroup, - toggleShareLink, -} from "@/services/shareService"; +import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService"; export async function GET(request: NextRequest) { - const parsed = shareTargetSchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid share target" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP"; + const contentId = searchParams.get("contentId"); + + if (!targetType || !contentId) { + return NextResponse.json( + { error: "targetType and contentId are required" }, + { status: 400 } + ); } - const { targetType, contentId } = parsed.data; + const link = await getShareLinkForContent(targetType, contentId); + if (targetType === "DECK" || targetType === "QUIZ") { const groupShare = await isContentSharedViaGroup(targetType, contentId); if (groupShare) { @@ -26,27 +25,20 @@ 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) { - const parsed = shareTargetSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid share target" }, { status: 400 }); - } - try { - const link = await toggleShareLink( - parsed.data.targetType, - parsed.data.contentId + const body = await request.json().catch(() => null); + + if (!body?.targetType || !body?.contentId) { + return NextResponse.json( + { error: "targetType and contentId are required" }, + { status: 400 } ); - 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 }); } diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1dea071..32ce56c 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -5,15 +5,14 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle"; type LoginStage = "login" | "token" | "password" | "success"; -async function postJson>(path: string, body?: object): Promise { +async function postJson(path: string, body?: object) { const response = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body ?? {}), }); - const data = await response.json() as T & { error?: string }; + const data: { error?: string } = await response.json(); if (!response.ok) throw new Error(data.error || "Request failed"); - return data; } export default function LoginPage() { @@ -24,26 +23,12 @@ export default function LoginPage() { const [error, setError] = useState(""); const [loading, setLoading] = useState(false); const [setupRequired, setSetupRequired] = useState(null); - const [setupAllowed, setSetupAllowed] = useState(false); - const [statusError, setStatusError] = useState(""); useEffect(() => { - const controller = new AbortController(); - fetch("/api/auth/setup-status", { signal: controller.signal }) - .then((response) => { - 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(); + fetch("/api/auth/setup-status") + .then((response) => response.json()) + .then((data) => setSetupRequired(data.setupRequired)) + .catch(() => setSetupRequired(false)); }, []); async function run(action: () => Promise) { @@ -68,8 +53,8 @@ export default function LoginPage() { function beginReset() { void run(async () => { - const response = await postJson<{ token: string }>("/api/auth/password-reset/request"); - setToken(response.token); + await postJson("/api/auth/password-reset/request"); + setToken(""); setStage("token"); }); } @@ -154,12 +139,6 @@ export default function LoginPage() {
{stage === "login" && (
- {setupRequired && !setupAllowed && ( -
- Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server. -
- )} - {statusError && } - + {setupRequired ? "Save Password & Login" : "Sign In"} {!setupRequired && ( diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx index 62711f1..5381446 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx @@ -6,7 +6,7 @@ import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; import type { SharedGroupData } from "@/types/study"; -export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) { +export function SharedGroupViewer({ data }: { data: SharedGroupData }) { const pathname = usePathname(); const router = useRouter(); @@ -16,34 +16,29 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke const [progressMap, setProgressMap] = useState>({}); useEffect(() => { - const timer = window.setTimeout(() => { - const newProgress: Record = {}; - items.forEach((item) => { - const key = data.type === "DECK" - ? `flashcard_progress_${token}_${item.id}` - : `quiz_progress_${token}_${item.id}`; - const saved = localStorage.getItem(key); - if (saved) { - try { - const parsed = JSON.parse(saved); - const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0); - newProgress[item.id] = { - currentIndex: parsed.currentIndex || 0, - total, - }; - } catch {} - } - }); - setProgressMap(newProgress); - }, 0); - return () => window.clearTimeout(timer); - }, [items, data.type, token]); + if (typeof window === 'undefined') return; + + const newProgress: Record = {}; + items.forEach((item) => { + const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`; + const saved = localStorage.getItem(key); + if (saved) { + try { + const parsed = JSON.parse(saved); + const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0); + newProgress[item.id] = { + currentIndex: parsed.currentIndex || 0, + total, + }; + } catch(e) {} + } + }); + setProgressMap(newProgress); + }, [items, data.type]); const handleRestart = (e: React.MouseEvent, itemId: string) => { e.preventDefault(); - const key = data.type === "DECK" - ? `flashcard_progress_${token}_${itemId}` - : `quiz_progress_${token}_${itemId}`; + const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`; localStorage.removeItem(key); setProgressMap(prev => { const next = { ...prev }; diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx index 0c39134..5aa6c63 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx @@ -12,29 +12,24 @@ interface SharedViewerProps { type: "flashcards" | "quizzes"; data: SharedStudyItem; groupMode?: boolean; - token: string; } -export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) { +export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) { const pathname = usePathname(); const [restartKey, setRestartKey] = useState(0); const [view, setView] = useState<"study" | "list">("study"); const [hasSavedSession, setHasSavedSession] = useState(false); useEffect(() => { - const timer = window.setTimeout(() => { - const key = type === "flashcards" - ? `flashcard_progress_${token}_${data.id}` - : `quiz_progress_${token}_${data.id}`; - setHasSavedSession(Boolean(localStorage.getItem(key))); - }, 0); - return () => window.clearTimeout(timer); - }, [type, data.id, restartKey, token]); + // Check if there's a saved session for this item + const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; + if (localStorage.getItem(key)) { + setHasSavedSession(true); + } + }, [type, data.id, restartKey]); function handleRestart() { - const key = type === "flashcards" - ? `flashcard_progress_${token}_${data.id}` - : `quiz_progress_${token}_${data.id}`; + const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; localStorage.removeItem(key); setHasSavedSession(false); setRestartKey(k => k + 1); @@ -146,7 +141,6 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie cards={data.cards ?? []} deckId={data.id} isShared={true} - storageNamespace={token} /> ) : ( @@ -162,7 +156,6 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie }} retakeIds={null} isShared={true} - storageNamespace={token} /> )}
diff --git a/src/app/shared/[classSlug]/[type]/[token]/page.tsx b/src/app/shared/[classSlug]/[type]/[token]/page.tsx index e62a7e9..c3ec969 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/page.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/page.tsx @@ -29,7 +29,7 @@ export async function generateMetadata( } const link = await getShareLinkMeta(token); - return buildShareMetadata(link, { classSlug, itemId, pathType: type }); + return buildShareMetadata(link, { classSlug, itemId }); } export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) { @@ -71,29 +71,18 @@ export default async function SharedPage(props: SharedPageProps & { searchParams let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes"; 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) { // Find the specific item inside the group if (link.group.type === "DECK") { - targetData = validDecks.find(d => d.id === itemId) ?? null; + targetData = link.group.decks.find(d => d.id === itemId) ?? null; targetType = "flashcards"; } else { - targetData = validQuizSets.find(q => q.id === itemId) ?? null; + targetData = link.group.quizSets.find(q => q.id === itemId) ?? null; targetType = "quizzes"; } if (!targetData) notFound(); } else { - targetData = { - ...link.group, - decks: validDecks, - quizSets: validQuizSets, - }; + targetData = link.group; } } else { targetData = type === "flashcards" ? link.deck : link.quizSet; @@ -137,13 +126,12 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{type === "groups" && !itemId ? ( - + ) : ( )}
diff --git a/src/components/flashcards/CardManager.tsx b/src/components/flashcards/CardManager.tsx index b372669..f98bcb7 100644 --- a/src/components/flashcards/CardManager.tsx +++ b/src/components/flashcards/CardManager.tsx @@ -41,7 +41,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) body: JSON.stringify({ front: editFront, back: editBack }), }); setEditingId(null); - window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } finally { setSaving(false); @@ -51,7 +50,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) async function deleteCard(id: string) { if (!confirm("Delete this card?")) return; await fetch(`/api/cards/${id}`, { method: "DELETE" }); - window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } @@ -67,7 +65,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) setNewFront(""); setNewBack(""); setShowAdd(false); - window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } finally { setSaving(false); @@ -92,7 +89,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
{/* Card list */} - {cards.map((card) => ( + {cards.map((card, index) => (
card.id), - currentIndex: 0, - data: {} as Record, - 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)); +export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) { const [isLoaded, setIsLoaded] = useState(!isShared); - const [order, setOrder] = useState(initialState.order); - const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex); + const [order, setOrder] = useState( + initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id) + ); + const [currentIndex, setCurrentIndex] = useState( + initialProgress ? initialProgress.currentIndex : 0 + ); const [isFlipped, setIsFlipped] = useState(false); const [hasFlippedOnce, setHasFlippedOnce] = useState(false); - const [results, setResults] = useState>(initialState.data); + const [results, setResults] = useState>( + initialProgress && initialProgress.cardResultsJson + ? JSON.parse(initialProgress.cardResultsJson) + : {} + ); const [isShuffled, setIsShuffled] = useState( initialProgress ? initialProgress.mode === "SHUFFLED" : false ); const [toastMessage, setToastMessage] = useState(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 - const [completed, setCompleted] = useState(initialState.completed); + const [completed, setCompleted] = useState( + initialProgress + ? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length + : false + ); const [swipeClass, setSwipeClass] = useState(""); const cardRef = useRef(null); const gradingRef = useRef(false); - const transitionTimerRef = useRef | null>(null); - const sessionIdRef = useRef( - initialProgress?.sessionId ?? globalThis.crypto.randomUUID() - ); - const revisionRef = useRef(initialProgress?.revision ?? 0); - const saveQueueRef = useRef(Promise.resolve()); // Touch/drag state const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false }); @@ -105,6 +73,7 @@ export function FlashcardViewer({ const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null; const correctCount = Object.values(results).filter((r) => r === "correct").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 position. Deriving it (rather than storing it) keeps resumed @@ -158,104 +127,29 @@ export function FlashcardViewer({ // Load from localStorage if shared useEffect(() => { - if (!isShared) return; - const timer = window.setTimeout(() => { + if (isShared) { try { - const saved = localStorage.getItem(sharedStorageKey); + const saved = localStorage.getItem(`flashcard_progress_${deckId}`); if (saved) { - const parsed: unknown = JSON.parse(saved); - const record = - typeof parsed === "object" && parsed !== null - ? (parsed as Record) - : {}; - const restored = normalizeProgress({ - orderJson: record.order, - 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." - ); + const parsed = JSON.parse(saved); + setOrder(parsed.order || cards.map(c => c.id)); + setCurrentIndex(parsed.currentIndex || 0); + setResults(parsed.results || {}); + setIsShuffled(parsed.mode === "SHUFFLED"); + + if (parsed.currentIndex >= (parsed.order?.length || cards.length)) { + setCompleted(true); } setToastMessage("Session restored"); } - } catch { - setRestoreWarning( - "Saved progress could not be read. A fresh session was started safely." - ); + } catch (e) { + // Fallback to defaults } 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, - mode: "SEQUENTIAL" | "SHUFFLED" - ) => { - if (isShared) { - localStorage.setItem( - sharedStorageKey, - JSON.stringify({ - mode, - currentIndex: index, - order: orderArr, - results: cardResults, - }) - ); - return; - } - - 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] - ); + } + }, [isShared, deckId, cards]); function toggleShuffle() { - if (isTransitioning) return; const newShuffled = !isShuffled; setIsShuffled(newShuffled); @@ -281,7 +175,6 @@ export function FlashcardViewer({ (grade: CardResult) => { if (!currentCard || !hasFlippedOnce || gradingRef.current) return; gradingRef.current = true; - setIsTransitioning(true); if (!isShared) { recordStudyActivity("FLASHCARD").catch(() => {}); @@ -294,17 +187,15 @@ export function FlashcardViewer({ // Animate setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left"); - transitionTimerRef.current = setTimeout(() => { + setTimeout(() => { gradingRef.current = false; - setIsTransitioning(false); setSwipeClass(""); setIsFlipped(false); setHasFlippedOnce(false); if (currentIndex + 1 >= order.length) { setCompleted(true); - setCurrentIndex(order.length); - saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); + saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); } else { const nextIndex = currentIndex + 1; setCurrentIndex(nextIndex); @@ -312,13 +203,13 @@ export function FlashcardViewer({ } }, 350); }, - [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress] + [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared] ); // Keyboard shortcuts useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (completed || isTransitioning) return; + if (completed) return; if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); @@ -336,7 +227,7 @@ export function FlashcardViewer({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]); + }, [completed, isFlipped, gradeCard]); // Touch handlers for swipe function handleTouchStart(e: React.TouchEvent) { @@ -371,9 +262,39 @@ export function FlashcardViewer({ } } + // Save progress (debounced / fire-and-forget) + function saveProgress( + orderArr: string[], + index: number, + cardResults: Record, + 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 function restartFullSet() { - if (isTransitioning) return; gradingRef.current = false; let newOrder: string[]; if (isShuffled) { @@ -391,7 +312,6 @@ export function FlashcardViewer({ } function redoMissed() { - if (isTransitioning) return; const missedIds = Object.entries(results) .filter(([, r]) => r === "missed") .map(([id]) => id); @@ -417,11 +337,6 @@ export function FlashcardViewer({ if (completed) { return (
- {restoreWarning && ( -
- {restoreWarning} -
- )}
@@ -478,11 +393,6 @@ export function FlashcardViewer({ // Study view return (
- {restoreWarning && ( -
- {restoreWarning} -
- )} {/* Running tally */}
@@ -511,7 +421,6 @@ export function FlashcardViewer({ ref={cardRef} className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`} onClick={() => { - if (isTransitioning) return; setToastMessage(null); setIsFlipped(!isFlipped); if (!isFlipped) setHasFlippedOnce(true); @@ -597,8 +506,7 @@ export function FlashcardViewer({
-
- ); - } - return (
- {loadError &&

{loadError}

}

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.

diff --git a/src/components/import/ImportTab.tsx b/src/components/import/ImportTab.tsx index 81b4c3b..13c3b56 100644 --- a/src/components/import/ImportTab.tsx +++ b/src/components/import/ImportTab.tsx @@ -134,9 +134,6 @@ export function ImportTab({ classId, importType, onImported }: ImportTabProps) { return; } - if (importType === "flashcards") { - window.dispatchEvent(new Event("study-decks-changed")); - } onImported(); } catch { setError("Network error during import"); diff --git a/src/components/quizzes/QuizResults.tsx b/src/components/quizzes/QuizResults.tsx index 31b5ce7..95f0baa 100644 --- a/src/components/quizzes/QuizResults.tsx +++ b/src/components/quizzes/QuizResults.tsx @@ -5,7 +5,6 @@ import remarkGfm from "remark-gfm"; import { CategoryBreakdown } from "./CategoryBreakdown"; import { scoreQuestion } from "@/lib/scoring"; import type { QuizAttempt, QuizQuestion, QuizSummary } from "@/types/study"; -import { parseQuizReviewSnapshot } from "@/lib/quizSnapshots"; interface QuizResultsProps { quiz: QuizSummary; @@ -21,28 +20,17 @@ interface ReviewItem { } export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) { - const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson); let answers: Record = {}; try { answers = JSON.parse(attempt.answersJson); } catch {} - if (snapshot) { - answers = Object.fromEntries( - snapshot.questions.map((question) => [question.id, question.selections]) - ); - } - // Determine non-full-credit questions for review list const reviewItems: ReviewItem[] = []; const missedIds: string[] = []; const answeredIds = Object.keys(answers); - const questionsToReview = snapshot?.questions ?? - quiz.questions.filter((q) => answeredIds.includes(q.id)); - const snapshotPoints = new Map( - snapshot?.questions.map((question) => [question.id, question.points]) ?? [] - ); + const questionsToReview = quiz.questions.filter((q) => answeredIds.includes(q.id)); questionsToReview.forEach((q) => { const formattedQ = { @@ -50,8 +38,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro type: q.type as "MULTIPLE_CHOICE" | "SATA", options: q.options, }; - const score = snapshotPoints.get(q.id) ?? - scoreQuestion(formattedQ, answers[q.id] || []); + const score = scoreQuestion(formattedQ, answers[q.id] || []); // If not full credit (1.0), add to review and missed arrays if (score < 1) { @@ -63,9 +50,6 @@ 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; @@ -104,12 +88,12 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro )} - {retakeableMissedIds.length > 0 && ( + {missedIds.length > 0 && ( )} @@ -128,7 +112,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro {/* Category Breakdown */}

Category Breakdown

- +
{/* Review List */} diff --git a/src/components/quizzes/QuizViewer.tsx b/src/components/quizzes/QuizViewer.tsx index 94cbaf0..204701e 100644 --- a/src/components/quizzes/QuizViewer.tsx +++ b/src/components/quizzes/QuizViewer.tsx @@ -7,7 +7,6 @@ import { scoreQuestion } from "@/lib/scoring"; import { QuizResults } from "./QuizResults"; import { recordStudyActivity } from "@/lib/activityClient"; import type { QuizAttempt } from "@/types/study"; -import { normalizeProgress } from "@/lib/progressNormalization"; interface Option { id: string; @@ -34,39 +33,16 @@ interface QuizViewerProps { currentIndex: number; orderJson: string; answersJson: string | null; - sessionId: string; - revision: number; }>; }; retakeIds: string[] | null; - sessionKey?: string | number; - storageNamespace?: string; isShared?: boolean; onFinished?: () => void; } -export function QuizViewer({ - quiz, - retakeIds, - sessionKey = "default", - storageNamespace, - isShared = false, - onFinished, -}: QuizViewerProps) { +export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: QuizViewerProps) { const submittingQuestionRef = useRef(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([]); - const [attemptScope, setAttemptScope] = useState([]); const [currentIndex, setCurrentIndex] = useState(0); const [answers, setAnswers] = useState>({}); const [submittedAnswers, setSubmittedAnswers] = useState([]); @@ -75,11 +51,6 @@ export function QuizViewer({ const [loading, setLoading] = useState(true); const [resultsData, setResultsData] = useState(null); const [toastMessage, setToastMessage] = useState(null); - const [isFinishing, setIsFinishing] = useState(false); - const [finishError, setFinishError] = useState(null); - const [restoreWarning, setRestoreWarning] = useState(null); - const sessionIdentity = `${quiz.id}:${sessionKey}`; - const sharedStorageKey = `quiz_progress_${storageNamespace ?? "shared"}_${quiz.id}`; // Auto-hide toast useEffect(() => { @@ -91,18 +62,16 @@ export function QuizViewer({ // Initialize session useEffect(() => { - const timer = window.setTimeout(() => { // Generate shuffled options once per session mount const newShuffledOpts: Record = {}; - for (const q of initialQuiz.questions) { + for (const q of quiz.questions) { newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5); } setShuffledOptions(newShuffledOpts); // If retaking specific questions - if (initialRetakeIds && initialRetakeIds.length > 0) { - setOrder(initialRetakeIds); - setAttemptScope(initialRetakeIds); + if (retakeIds && retakeIds.length > 0) { + setOrder(retakeIds); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); @@ -113,120 +82,62 @@ export function QuizViewer({ // Shared links: load from localStorage if (isShared) { try { - const saved = localStorage.getItem(sharedStorageKey); + const saved = localStorage.getItem(`quiz_progress_${quiz.id}`); if (saved) { - const parsed: unknown = JSON.parse(saved); - const record = - typeof parsed === "object" && parsed !== null - ? (parsed as Record) - : {}; - 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." - ); - } + const parsed = JSON.parse(saved); + setOrder(parsed.order || quiz.questions.map(q => q.id)); + setCurrentIndex(parsed.currentIndex || 0); + setAnswers(parsed.answers || {}); + setSubmittedAnswers(Object.keys(parsed.answers || {})); setToastMessage("Session restored"); setLoading(false); return; } - } catch { + } catch (e) { // Fallback to defaults } } // Otherwise, normal sequential logic checking for progress - const prog = initialProgress; + const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL"); if (prog) { - const restored = normalizeProgress({ - orderJson: prog.orderJson, - currentIndex: prog.currentIndex, - dataJson: prog.answersJson, - 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 savedOrder: string[] = []; + try { + savedOrder = JSON.parse(prog.orderJson); + } catch { + savedOrder = quiz.questions.map((q) => q.id); } + + let savedAnswers: Record = {}; + 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 { - const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5); + const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5); setOrder(freshOrder); - setAttemptScope(freshOrder); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); } setLoading(false); - }, 0); - return () => window.clearTimeout(timer); - }, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]); + }, [quiz, retakeIds, isShared]); // Persist progress as you go function saveProgress( idx: number, ans: Record ) { - if (attemptScope.length !== quiz.questions.length) return; + if (retakeIds) return; // Don't persist retake partial state if (isShared) { - localStorage.setItem(sharedStorageKey, JSON.stringify({ + localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({ currentIndex: idx, order: order, answers: ans, @@ -234,32 +145,18 @@ export function QuizViewer({ return; } - const revision = ++progressRevisionRef.current; - saveQueueRef.current = saveQueueRef.current - .then(async () => { - const response = await fetch("/api/progress", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "QUIZ", - contentId: quiz.id, - mode: "SEQUENTIAL", - currentIndex: idx, - order, - answers: ans, - sessionId: progressSessionIdRef.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 answers remain on screen."); - }); + fetch("/api/progress", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "QUIZ", + contentId: quiz.id, + mode: "SEQUENTIAL", + currentIndex: idx, + orderJson: JSON.stringify(order), + answersJson: JSON.stringify(ans), + }), + }).catch(() => {}); } if (loading) return
Loading quiz...
; @@ -320,21 +217,18 @@ export function QuizViewer({ } async function handleFinish() { - if (finishingRef.current) return; - finishingRef.current = true; - setIsFinishing(true); - setFinishError(null); if (isShared) { - localStorage.removeItem(sharedStorageKey); + localStorage.removeItem(`quiz_progress_${quiz.id}`); } // If shared, grade it locally and show results, don't ping backend if (isShared) { // Simulate an attempt object let runningScore = 0; - const maxScore = attemptScope.length; + let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically + if (retakeIds) maxScore = retakeIds.length; - const scoredItems = attemptScope.map(qId => { + const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => { const q = quiz.questions.find((x) => x.id === qId); if (!q) return 0; const formattedQ = { @@ -351,40 +245,45 @@ export function QuizViewer({ score: runningScore, maxScore: maxScore, answersJson: JSON.stringify(answers), - isPartialRetake: attemptScope.length !== quiz.questions.length, + isPartialRetake: false, completedAt: new Date().toISOString(), }; setResultsData(attempt); - finishingRef.current = false; - setIsFinishing(false); return; } + // Send to backend + const isPartialRetake = !!retakeIds; try { - await saveQueueRef.current; const res = await fetch(`/api/quizzes/${quiz.id}/attempt`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - questionIds: attemptScope, - answers, + answersJson: JSON.stringify(answers), + isPartialRetake, }), }); - if (!res.ok) { - const payload = (await res.json().catch(() => null)) as - | { error?: string } - | null; - throw new Error(payload?.error ?? "Could not save this attempt"); + if (res.ok) { + const attempt = await res.json(); + + // Clear progress + if (!isPartialRetake) { + await fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "QUIZ", + contentId: quiz.id, + mode: "SEQUENTIAL", + }), + }); + } + + setResultsData(attempt); } - setResultsData(await res.json()); - } catch (error) { - setFinishError( - error instanceof Error ? error.message : "Could not save this attempt" - ); - } finally { - finishingRef.current = false; - setIsFinishing(false); + } catch (e) { + console.error(e); } } @@ -396,13 +295,11 @@ export function QuizViewer({ onRetake={(missedIds: string[]) => { // Restart this component with retakeIds setOrder(missedIds); - setAttemptScope(missedIds); setCurrentIndex(0); setAnswers({}); setSubmittedAnswers([]); submittingQuestionRef.current = null; setResultsData(null); - setFinishError(null); // Re-shuffle options for the new attempt const newShuffledOpts: Record = {}; @@ -422,11 +319,6 @@ export function QuizViewer({ return (
- {restoreWarning && ( -
- {restoreWarning} -
- )} {/* Toast Notification */} {toastMessage && (
@@ -612,20 +504,12 @@ export function QuizViewer({ Next Question ) : ( -
- {finishError && ( -
- {finishError} Your answers are still here; try again. -
- )} - -
+ )}
diff --git a/src/components/spaced-repetition/SpacedRepetitionSets.tsx b/src/components/spaced-repetition/SpacedRepetitionSets.tsx index 574afc9..af0b9b3 100644 --- a/src/components/spaced-repetition/SpacedRepetitionSets.tsx +++ b/src/components/spaced-repetition/SpacedRepetitionSets.tsx @@ -188,16 +188,7 @@ export function SpacedRepetitionSets() { useEffect(() => { const timer = window.setTimeout(() => void load(), 0); - 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); - }; + return () => window.clearTimeout(timer); }, [load]); async function createSet() { diff --git a/src/components/ui/Navbar.tsx b/src/components/ui/Navbar.tsx index 55e0c85..fb9a6c3 100644 --- a/src/components/ui/Navbar.tsx +++ b/src/components/ui/Navbar.tsx @@ -21,8 +21,6 @@ export function Navbar() { const parts = pathname.split("/").filter(Boolean); const classSlug = parts[0] && parts[0] !== "shared" ? parts[0] : null; const [hasReadyCards, setHasReadyCards] = useState(false); - const [loggingOut, setLoggingOut] = useState(false); - const [logoutError, setLogoutError] = useState(null); const refreshDueCards = useCallback(async () => { if (!classSlug) return; @@ -55,18 +53,9 @@ export function Navbar() { }, [refreshDueCards]); async function handleLogout() { - 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.refresh(); - } catch (error) { - setLogoutError(error instanceof Error ? error.message : "Sign out failed. Please retry."); - } finally { - setLoggingOut(false); - } + await fetch("/api/auth/logout", { method: "POST" }); + router.push("/login"); + router.refresh(); } const nav = ( @@ -121,8 +110,7 @@ export function Navbar() { Appearance
- {logoutError &&

{logoutError}

} -
diff --git a/src/components/ui/ShareMenu.tsx b/src/components/ui/ShareMenu.tsx index d1a57b3..1aa85b5 100644 --- a/src/components/ui/ShareMenu.tsx +++ b/src/components/ui/ShareMenu.tsx @@ -1,29 +1,21 @@ "use client"; -import { useState, useEffect, useRef, useCallback } from "react"; +import { useState, useEffect, useRef } from "react"; interface ShareMenuProps { targetType: "DECK" | "QUIZ" | "GROUP"; contentId: string; classSlug: string; compact?: boolean; - containsQuizAnswers?: boolean; } -export function ShareMenu({ - targetType, - contentId, - classSlug, - compact = false, - containsQuizAnswers = targetType === "QUIZ", -}: ShareMenuProps) { +export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) { const [token, setToken] = useState(null); const [loading, setLoading] = useState(true); const [open, setOpen] = useState(false); const [copied, setCopied] = useState(false); const [isGroupShared, setIsGroupShared] = useState(false); const [groupName, setGroupName] = useState(null); - const [error, setError] = useState(null); const buttonRef = useRef(null); const menuRef = useRef(null); @@ -43,55 +35,28 @@ export function ShareMenu({ return () => document.removeEventListener("pointerdown", handlePointerDown); }, [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(() => { if (!open) return; - const controller = new AbortController(); - const timer = window.setTimeout(() => void loadShareInfo(controller.signal), 0); - return () => { - window.clearTimeout(timer); - controller.abort(); - }; - }, [open, loadShareInfo]); + fetch(`/api/share?targetType=${targetType}&contentId=${contentId}`) + .then(res => res.json()) + .then(data => { + setToken(data.token); + setIsGroupShared(data.isGroupShared || false); + setGroupName(data.groupName || null); + }) + .finally(() => setLoading(false)); + }, [open, targetType, contentId]); async function handleToggle() { setLoading(true); - setError(null); try { const res = await fetch("/api/share", { method: "POST", headers: { "Content-Type": "application/json" }, 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(); setToken(data.token); - } catch (toggleError) { - setError(toggleError instanceof Error ? toggleError.message : "Sharing could not be updated."); } finally { setLoading(false); } @@ -108,13 +73,9 @@ export function ShareMenu({ : "quizzes"; const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : ""; const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`; - try { - await navigator.clipboard.writeText(url); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch { - setError("Clipboard access failed. Retry or copy the URL from your browser."); - } + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } return ( @@ -124,7 +85,6 @@ export function ShareMenu({ 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`} title="Share" - aria-label="Share settings" > @@ -139,11 +99,6 @@ export function ShareMenu({ {loading ? (
- ) : error ? ( -
-

{error}

- -
) : (
@@ -151,7 +106,6 @@ export function ShareMenu({