Refactor Study Desk application structure
This commit is contained in:
parent
faaccf8a7e
commit
089439ed90
145 changed files with 8087 additions and 3412 deletions
20
.dockerignore
Normal file
20
.dockerignore
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
node_modules
|
||||
.next
|
||||
.git
|
||||
.forgejo
|
||||
.github
|
||||
.env*
|
||||
!.env.example
|
||||
dev.db*
|
||||
study.db*
|
||||
data
|
||||
coverage
|
||||
.test-databases
|
||||
.npm-cache
|
||||
.prisma-cache
|
||||
audit-results
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
out.css
|
||||
temp.css
|
||||
test.css
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Automated Container Build
|
||||
name: Verify and publish container
|
||||
|
||||
on:
|
||||
push:
|
||||
|
|
@ -16,10 +16,41 @@ jobs:
|
|||
run: |
|
||||
echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin
|
||||
|
||||
- name: Build and Push Image
|
||||
- name: Install locked dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Run tests
|
||||
run: npm test
|
||||
|
||||
- name: Validate Prisma schema and migration drift
|
||||
run: |
|
||||
# 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"
|
||||
npx prisma validate
|
||||
DATABASE_URL=file:./ci-migration.test.db npx prisma migrate deploy
|
||||
npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code
|
||||
|
||||
- name: Build application
|
||||
run: npm run build
|
||||
|
||||
- name: Lint changed source files
|
||||
run: |
|
||||
FILES=$(git diff --name-only HEAD^ HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs')
|
||||
if [ -n "$FILES" ]; then npx eslint $FILES; fi
|
||||
|
||||
- name: Build and smoke production image
|
||||
run: |
|
||||
IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE_SHA="$IMAGE_PATH:${{ github.sha }}"
|
||||
CI_SECRET=$(openssl rand -hex 32)
|
||||
docker build -t "$IMAGE_SHA" .
|
||||
docker run -d --name study-smoke -p 3000:3726 -e SESSION_SECRET="$CI_SECRET" -e ALLOW_INITIAL_SETUP=true "$IMAGE_SHA"
|
||||
for attempt in $(seq 1 30); do
|
||||
if [ "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy" ]; then break; fi
|
||||
sleep 2
|
||||
done
|
||||
test "$(docker inspect --format='{{.State.Health.Status}}' study-smoke)" = "healthy"
|
||||
curl --fail http://127.0.0.1:3000/login > /dev/null
|
||||
curl --fail http://127.0.0.1:3000/api/auth/setup-status | grep '"setupRequired":true'
|
||||
docker rm -f study-smoke
|
||||
docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest"
|
||||
docker push "$IMAGE_SHA"
|
||||
docker push "$IMAGE_PATH:latest"
|
||||
|
|
|
|||
2
.gitattributes
vendored
Normal file
2
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output.
|
||||
src/generated/prisma/** -whitespace
|
||||
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -12,12 +12,18 @@
|
|||
|
||||
# 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/
|
||||
|
|
|
|||
23
Dockerfile
23
Dockerfile
|
|
@ -1,11 +1,19 @@
|
|||
# ---- base ----
|
||||
FROM node:22-slim AS base
|
||||
RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ---- deps ----
|
||||
FROM node:22-slim AS deps
|
||||
FROM base AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm install
|
||||
RUN npm ci
|
||||
|
||||
# ---- production dependencies ----
|
||||
FROM deps AS runtime-deps
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# ---- builder ----
|
||||
FROM node:22-slim AS builder
|
||||
FROM base AS builder
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
|
@ -13,13 +21,13 @@ RUN npx prisma generate
|
|||
RUN npm run build
|
||||
|
||||
# ---- runner ----
|
||||
FROM node:22-slim AS runner
|
||||
FROM base 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 ./
|
||||
|
|
@ -27,10 +35,13 @@ 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 ./
|
||||
RUN npm install prisma@^7.8.0
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
COPY --from=runtime-deps /app/node_modules ./node_modules
|
||||
COPY docker-entrypoint.sh ./
|
||||
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"]
|
||||
|
|
|
|||
75
README.md
75
README.md
|
|
@ -1,4 +1,77 @@
|
|||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
Study Desk is a self-hosted, single-user study application.
|
||||
|
||||
## Local development
|
||||
|
||||
```powershell
|
||||
npm.cmd ci
|
||||
npx.cmd prisma generate
|
||||
npm.cmd run dev
|
||||
```
|
||||
|
||||
The automated test harness always creates a uniquely named database under
|
||||
`.test-databases/`, applies committed migrations, and removes the database after
|
||||
the run. It refuses `dev.db`, `/app/data/study.db`, existing databases, and paths
|
||||
that are not explicitly test-named.
|
||||
|
||||
## Production container
|
||||
|
||||
Set a unique session secret of at least 32 characters. Production startup fails
|
||||
before migrations or the HTTP server when the secret is missing, short, or the
|
||||
published development fallback.
|
||||
|
||||
```powershell
|
||||
$env:SESSION_SECRET = '<at-least-32-random-characters>'
|
||||
docker compose -f docker-compose.yml up --build
|
||||
```
|
||||
|
||||
The host listens on `http://localhost:3000`; the container listens on port 3726.
|
||||
Development Compose is intentionally explicit and is never auto-merged:
|
||||
|
||||
```powershell
|
||||
docker compose -f docker-compose.dev.yml up --build
|
||||
```
|
||||
|
||||
For first-time production setup, either provide an Argon2 encoded
|
||||
`ADMIN_PASSWORD_HASH`, or temporarily set `ALLOW_INITIAL_SETUP=true` for the
|
||||
one-time setup request. Remove the flag after setup. Production HTTP password
|
||||
reset initiation is unavailable; run this on the server instead:
|
||||
|
||||
```powershell
|
||||
npm.cmd run auth:reset
|
||||
```
|
||||
|
||||
If TLS terminates at a trusted reverse proxy, forward requests only from that
|
||||
proxy and set `SECURE_COOKIES=true`. Leave it false for intentional plain HTTP;
|
||||
Secure cookies cannot be used over plain HTTP.
|
||||
|
||||
## Database backup, migration adoption, and restore
|
||||
|
||||
Never run migration tests against the only copy of a study database. Create a
|
||||
lock-safe SQLite backup through the backup API and verify it before deployment:
|
||||
|
||||
```powershell
|
||||
$env:DATABASE_URL = 'file:./data/study.db'
|
||||
npm.cmd run db:backup -- --output ./backups/study-before-upgrade.db
|
||||
npm.cmd run db:preflight
|
||||
npx.cmd prisma migrate deploy
|
||||
```
|
||||
|
||||
The material-group preflight reports one of four states:
|
||||
|
||||
- `APPLY`: migration history is complete and the group schema is absent; run
|
||||
`prisma migrate deploy` normally.
|
||||
- `ADOPT`: prior migrations are tracked and the database exactly matches the
|
||||
intended group schema. After reviewing the verified backup, explicitly run
|
||||
`npm run db:preflight -- --resolve --backup <backup-path>`.
|
||||
- `CURRENT`: migration and schema already agree.
|
||||
- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use
|
||||
`db push`, reset, edit applied migrations, or mark anything applied.
|
||||
|
||||
Restore drill: stop Study Desk, keep the damaged database as evidence, restore
|
||||
the verified backup to a new path, run SQLite `integrity_check` plus
|
||||
`npm run db:preflight`, start the same prior application image against the
|
||||
restored path, and compare class/deck/quiz counts plus representative content.
|
||||
Only swap the production path after those checks pass.
|
||||
|
||||
## Getting Started
|
||||
|
||||
|
|
|
|||
67
audit-results/CONFIRMED_FINDINGS.md
Normal file
67
audit-results/CONFIRMED_FINDINGS.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Confirmed Findings
|
||||
|
||||
Severity: Critical / High / Medium / Low / Observation. Confidence: Confirmed / High-confidence inference / Unverified risk.
|
||||
Full structured detail for every finding (files, functions, scenario, expected/actual, root cause, impact, evidence, fix direction, complexity, regression test, runtime-confirmation flag) is in `FINDINGS.json` (validated). Source IDs from specialist auditors are in parentheses.
|
||||
|
||||
| ID | Severity | Confidence | Title | Area |
|
||||
|---|---|---|---|---|
|
||||
| FIN-01 (DBAUD-01, GRP-01) | **Critical** | Confirmed | Schema/migration drift: `MaterialGroup` table + `groupId` columns never migrated — fresh deployments' databases are incompatible with the app (verified: `no such column: Deck.groupId`, `no such table: MaterialGroup`) | DB/migrations |
|
||||
| FIN-02 (AUTH-01, OPS-03) | **Critical** | High-confidence inference | Hardcoded fallback `SESSION_SECRET` (`"dev-session-secret-change-in-production-must-be-32-chars"`) in `src/lib/auth.ts:15` + `src/proxy.ts:8-9`; empty env var in shipped compose → forgeable session cookie → full auth bypass | Auth/deploy |
|
||||
| FIN-03 (QUIZ-01, ADV-01) | **High** | Confirmed | In-viewer "Retake Missed" never sets `retakeIds` prop → persisted as a full attempt with a wrong, lower score; pollutes SEQUENTIAL progress | Quiz |
|
||||
| FIN-04 (CARD-01, IMPT-01, QUIZ-06) | **High** | Confirmed | Stale card ids in saved progress never filtered on resume (`filterAndClampOrder` dead code) → deleting a card mid-session bricks the flashcard study session | Flashcards |
|
||||
| FIN-05 (OPS-01) | **High** | Confirmed | Port mismatch: container listens on 3726, compose publishes 3000:3000 → production deployment unreachable | Deploy |
|
||||
| FIN-06 (OPS-06) | **High** | High-confidence inference | No `.dockerignore`: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image | Deploy |
|
||||
| FIN-07 (OPS-08) | **High** | Confirmed | `docker-compose.override.yml` auto-merges on plain `docker compose up` → production command silently runs dev mode | Deploy |
|
||||
| FIN-08 (AUTH-04, ADV-09) | Medium | High-confidence inference | First-login provisioning takeover; `ADMIN_PASSWORD_HASH` env is dead config | Auth |
|
||||
| FIN-09 (AUTH-02) | Medium | High-confidence inference | Proxy auth bypass for any path containing `.` (latent today; would expose any future non-UUID-id route) | Auth |
|
||||
| FIN-10 (AUTH-03) | Medium | High-confidence inference | Password-reset abuse: token overwrite DoS, spoofable `x-forwarded-for` rate limits, unthrottled `complete` | Auth |
|
||||
| FIN-11 (QUIZ-02) | Medium | High-confidence inference | Attempt route stores unvalidated `answersJson`; duplicate option ids inflate SATA credit; malformed shapes → 500 | Quiz |
|
||||
| FIN-12 (QUIZ-03, FE-04) | Medium | Confirmed | Quiz Finish double-submit creates duplicate attempts; failure path silent | Quiz/FE |
|
||||
| FIN-13 (QUIZ-04) | Medium | High-confidence inference | Progress PATCH can land after Finish's DELETE → stale "Continue" row for a completed quiz | Quiz |
|
||||
| FIN-14 (CARD-02, FE-05, QUIZ-09) | Medium | High-confidence inference | Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins | Flashcards/Quiz |
|
||||
| FIN-15 (CARD-03, ADV-07) | Medium | Confirmed | Completed flashcard session never persisted as complete → resume re-shows the last graded card | Flashcards |
|
||||
| FIN-16 (CARD-04) | Medium | Confirmed | SRS review endpoint enforces membership only — no due-date or `newCardsPerDay` enforcement server-side | SRS |
|
||||
| FIN-17 (CARD-05, ADV-02) | Medium | Confirmed | Unbounded DB scans: whole `StudyActivity` table per fetch; all SRS card states per set; Navbar polls every 60 s | Perf/DB |
|
||||
| FIN-18 (FE-01, GRP-03, FE-02, GRP-05) | Medium | Confirmed | Library optimistic mutations are fire-and-forget (no `res.ok`, no rollback); cross-group reorder omits origin group from payload | FE/Groups |
|
||||
| FIN-19 (GRP-02, CARD-07, AUTH-06) | Medium | Confirmed | Reorder/create endpoints trust client `groupId`/`sortOrder` — no class/type/existence validation; cross-class group membership can leak content into another class's share link | Groups |
|
||||
| FIN-20 (FE-03) | Medium | Confirmed | Shared quiz session resets and options reshuffle on any parent re-render (unstable inline `quiz` prop) | FE |
|
||||
| FIN-21 (IMPT-02, FE-08, QUIZ-05, ADV-10) | Medium | Confirmed | `/api/progress` PATCH completely unvalidated; unguarded client `JSON.parse` of persisted progress crashes pages | Quiz/FE |
|
||||
| FIN-22 (OPS-02) | Medium | High-confidence inference | Entrypoint migrate-deploy chain fragile: crash loop on conflicting DB state; runner prisma CLI install unverified/unpinned | Deploy |
|
||||
| FIN-23 (OPS-05) | Medium | Confirmed | Non-reproducible installs: `npm install` (not `ci`); unpinned `prisma@^7.8.0` re-install re-resolves the whole tree in the runner | Deploy |
|
||||
| FIN-24 (OPS-07) | Medium | Confirmed | No healthcheck, no backup, no documented recovery | Deploy |
|
||||
| FIN-25 (OPS-10, TEST-10) | Medium | Confirmed | CI builds/pushes without tests, lint, prisma validate, or container smoke test — deployment-breaking defects ship green | CI |
|
||||
| FIN-26 (QUIZ-07) | Low | Confirmed | SATA scoring divides by `correctIds.length` with no zero-guard → NaN | Quiz |
|
||||
| FIN-27 (QUIZ-08) | Low | Confirmed | Historical review/category breakdown recompute scores from current content, not stored attempt | Quiz |
|
||||
| FIN-28 (IMPT-03) | Low | Confirmed | Card add/edit endpoints bypass Zod: empty strings storable; CreateTab silently drops incomplete cards | Imports |
|
||||
| FIN-29 (IMPT-04) | Low | Confirmed | No payload size/string-length/array-length caps on import schemas/routes; whitespace-only names accepted | Imports |
|
||||
| FIN-30 (IMPT-05) | Low | Confirmed | SATA constraint mismatch: instructions say ≥2 correct, schema enforces ≥1 | Imports |
|
||||
| FIN-31 (IMPT-06) | Low | Confirmed | Unvalidated `name`/`groupId` override fields on import POST routes → 500; PATCH routes mask failures as 404 | Imports |
|
||||
| FIN-32 (GRP-04) | Low | Confirmed | Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint/tie-break | Groups |
|
||||
| FIN-33 (GRP-06) | Low | Confirmed | Keyboard users cannot move items between groups (empty `handleDragOver`) | Groups/a11y |
|
||||
| FIN-34 (CARD-08) | Low | High-confidence inference | Restart deletes progress fire-and-forget; slow DELETE can remove the fresh session's progress row | Flashcards |
|
||||
| FIN-35 (CARD-09) | Low | Confirmed | Concurrent first review of same new card → P2002 → generic 500 instead of 409 | SRS |
|
||||
| FIN-36 (CARD-10) | Low | High-confidence inference | "Previous card" during the 350 ms grade animation races the pending timeout | Flashcards |
|
||||
| FIN-37 (CARD-11) | Low | Confirmed | SRS set page never refreshes on focus → stale membership after deck deletion in another tab | SRS/FE |
|
||||
| FIN-38 (CARD-06) | Low | Confirmed | Day boundary hardcoded to Arizona (UTC-7): "today"/new-card limit/streak roll over at 07:00 UTC | SRS |
|
||||
| FIN-39 (AUTH-05) | Low | Confirmed | Proxy `destroy()` cookie-clear lost on redirect; layout check ignores `sessionGeneration` | Auth |
|
||||
| FIN-40 (AUTH-08) | Low | Confirmed | Session cookie Secure flag off in shipped compose (plain HTTP, no TLS story) | Auth/deploy |
|
||||
| FIN-41 (DBAUD-02, AUTH-07) | Low | Confirmed | `/api/share` accepts arbitrary `targetType` → repeatable junk all-NULL `ShareLink` rows | Auth/DB |
|
||||
| FIN-42 (DBAUD-03) | Low | High-confidence inference | `dev.db` committed in git history (8 commits), remains in blobs on the LAN remote | Repo hygiene |
|
||||
| FIN-43 (OPS-09) | Low | Confirmed | `.gitignore` gaps (`data/`, `study.db*`); scratch files `out.css`/`temp.css`/`test.css` tracked | Repo hygiene |
|
||||
| FIN-44 (FE-07, ADV-04) | Low | Confirmed | Dashboard `fetchClasses` no `.catch`/`res.ok` → unhandled rejection, misleading empty state (or render crash) | FE |
|
||||
| FIN-45 (FE-09) | Low | Confirmed | Effect fetches without `.catch` in GenerateTab and ShareMenu | FE |
|
||||
| FIN-46 (FE-10) | Low | High-confidence inference | Cross-class navigation fetch race renders wrong-class data; module cache never invalidated | FE |
|
||||
| FIN-47 (ADV-03) | Low | Confirmed | Logout has no error handling — failed logout strands the user | FE |
|
||||
| FIN-48 (ADV-05) | Low | Confirmed | `slugify` can yield an empty slug (unreachable class); renames never update the URL slug | Classes |
|
||||
| FIN-49 (ADV-08) | Low | High-confidence inference | Shared-viewer localStorage keys item-scoped, not token-scoped → session bleed across tokens | Sharing/FE |
|
||||
| FIN-50 (GRP-07) | Observation | Confirmed | Group `sortOrder` uses inverted desc convention with unvalidated PATCH — latent trap | Groups |
|
||||
| FIN-51 (FE-11) | Observation | Confirmed | Collapsed-groups state read in effect → one-frame expand flash | FE |
|
||||
| FIN-52 (AUTH-10) | Observation | Confirmed | Shared quiz links ship the full answer key to anonymous viewers — by design, worth a warning | Sharing |
|
||||
| FIN-53 (TEST-01) | Low | Confirmed | False-confidence test: "distinct valid state for every rating" never asserts distinctness | Tests |
|
||||
|
||||
## Notes on consolidation
|
||||
|
||||
- Duplicate findings across specialist reports were merged under one root cause (see `sourceIds`).
|
||||
- FE-06 ("all /api/* routes unauthenticated") was **refuted** by the adversarial review — `src/proxy.ts` IS the compiled middleware and enforces session checks; the residual truth is FIN-09 (dot-bypass). See REJECTED_FINDINGS.md.
|
||||
- OPS-02's fresh-volume crash-loop framing was weakened by the adversarial review (on a fresh volume `migrate deploy` succeeds; the app then fails per FIN-01) — reframed as FIN-22.
|
||||
- FIN-18 severity downgraded from the frontend auditor's High to Medium after adversarial review (no visible break for a single user; silent divergence self-heals on reload).
|
||||
38
audit-results/COVERAGE_MAP.md
Normal file
38
audit-results/COVERAGE_MAP.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Coverage Map
|
||||
|
||||
Mapping of repository areas → audit status → specialist report reference. Statuses: done / done+verified (parent-verified with runtime evidence) / partial.
|
||||
|
||||
| Area | Files | Auditor | Status | Report ref |
|
||||
|---|---|---|---|---|
|
||||
| Prisma schema/migrations/transactions/cascades | prisma/schema.prisma, prisma/migrations/*, prisma.config.ts, src/lib/db.ts, services | DB specialist (task-1) | done+verified (drift reproduced) | sa_20260806_054030_000000000_5ed877563a0d |
|
||||
| Quiz scoring/attempts/history/retakes/progress | src/lib/scoring.ts, quizService, progressService, quizzes/*, api/quizzes/**, api/progress | Quiz specialist (task-2) | done+verified (server scoring path re-checked) | sa_20260806_054030_000000000_e75a8ce575f9 |
|
||||
| Flashcards/SRS/progress/deletion/ordering | flashcards/*, spaced-repetition/*, cardService, deckService, spacedRepetitionService, api/cards|decks|spaced-repetition-sets|progress | Flashcard specialist (task-3) | done | sa_20260806_054030_000000000_a222804c133c |
|
||||
| Auth/protected routes/API authz/sharing | src/proxy.ts, lib/auth.ts, authService, shareService, shareMetadata, api/auth/**, api/share, shared/*, all api routes | Auth specialist (task-4) | done+verified (proxy + fallback secret read directly) | sa_20260806_054030_000000000_aab0c59dbad3 |
|
||||
| Imports/exports/generation/Zod/malformed input | lib/validation/*, jsonRepair, components/import/*, api/decks|quizzes|material-groups|settings | Import specialist (task-5) | done | sa_20260806_054030_000000000_5e72c2713989 |
|
||||
| Material groups/library/drag-drop/deletion/orphans | api/material-groups/**, reorder routes, library pages, classService | Groups specialist (task-6) | done | sa_20260806_054030_000000000_9ae1f3dfa282 |
|
||||
| Frontend state/refresh/races/localStorage/boundaries | all components + pages + shared viewers | Frontend specialist (task-7) | done | sa_20260806_054030_000000000_0e17fd155702 |
|
||||
| Test quality/coverage gaps | *.test.ts, vitest.config.ts, package.json, CI workflow | Test specialist (task-8) | done | sa_20260806_054030_000000000_cd3652deb3f7 |
|
||||
| Docker/startup/env/scripts/production/recovery | Dockerfile, docker-entrypoint.sh, compose files, package.json, prisma.config.ts, next.config.ts, .gitignore, CI | Ops specialist (task-9) | done | sa_20260806_054030_000000000_f49cdaacaabf |
|
||||
| Adversarial review (challenge Critical/High + missed issues) | (all of the above + lightly-covered files) | Adversarial specialist (task-10) | done — 9/10 challenged findings confirmed/weakened, FE-06 refuted, 10 new issues added | sa_20260806_055608_000000000_c550c742d5dc |
|
||||
|
||||
## Parent-level verification performed (see VERIFICATION_LOG.md and audit-results/tmp/)
|
||||
|
||||
- `npx prisma validate` — PASS
|
||||
- `npm test` — PASS (30 tests: 20 arcade + 10 spacedRepetition)
|
||||
- `npm run lint` — 28 baseline problems (9 errors / 19 warnings), all pre-existing
|
||||
- `npm run build` — PASS (route table incl. "ƒ Proxy (Middleware)")
|
||||
- `prisma migrate deploy` on fresh temp DB — PASS (creates DB **missing** MaterialGroup/groupId → FIN-01 drift confirmed)
|
||||
- Temp DB introspection (`audit-results/tmp/inspect-db.cjs`) — `MaterialGroup` table and `groupId` columns absent
|
||||
- Prisma-shaped SQL against temp DB (`audit-results/tmp/reproduce-drift.cjs`) — `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId`
|
||||
- `FINDINGS.json` validity — PASS (53 findings; validator `audit-results/tmp/validate-findings.cjs`)
|
||||
|
||||
## Coverage gaps (areas NOT fully audited)
|
||||
|
||||
1. **Arcade feature internals** — intentionally out of scope (may be removed). Only checked for shared-DB/build/deploy impact (Arcade models/indexes verified in migrations).
|
||||
2. **Real browser interaction** — no browser available in the audit environment: drag-drop, resume flows, share pages, keyboard a11y, theme behavior were code-verified only; findings needing runtime confirmation are flagged in UNVERIFIED_RISKS.md.
|
||||
3. **Container build/run** — docker CLI unavailable: OPS-02/FIN-22, FIN-06, FIN-24 need a container run to observe exact failure modes.
|
||||
4. **git history deep-dive** — limited to drift commit (7af0935), dev.db commits, and migration commits; other commits not diffed line-by-line.
|
||||
5. **Committed dev.db blob contents** — could not be fully enumerated (no sqlite3 CLI; blob grep only; no admin_password hash found in checked blobs).
|
||||
6. **Live dev.db** — none exists in the workspace; runtime behavior on a populated database (perf findings FIN-17) not measured.
|
||||
7. **Network/remote behavior** — the Forgejo remote and CI execution were not reachable; CI findings are static analysis of `.forgejo/workflows/build.yml`.
|
||||
8. **External services** — none (app calls no LLM/third-party APIs).
|
||||
69
audit-results/EXECUTIVE_SUMMARY.md
Normal file
69
audit-results/EXECUTIVE_SUMMARY.md
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
# Study Desk — Overnight Read-Only Audit: Executive Summary
|
||||
|
||||
Status: **COMPLETE** (2026-08-06)
|
||||
|
||||
## Scope and method
|
||||
|
||||
Comprehensive read-only audit of the Study repository (Next.js 16 / React 19 / Prisma 7 / SQLite, self-hosted single-user study app). Ten specialist subagents audited: (1) Prisma schema/migrations/transactions/cascades/drift, (2) quiz scoring/attempts/retakes/progress, (3) flashcards/SRS/progress/ordering, (4) auth/protected routes/API authorization/sharing, (5) imports/exports/generation/Zod validation, (6) material groups/library/drag-drop/deletion, (7) frontend state/races/localStorage/boundaries, (8) test quality, (9) Docker/env/production/recovery, (10) adversarial review challenging every Critical/High finding and hunting missed issues. The parent agent independently re-verified all Critical/High claims (code tracing, git history, temp-DB migration chain, DB introspection, Prisma-shaped SQL probes). The Arcade feature set was excluded per scope unless it affects the main app, shared dependencies, database integrity, build, or deployment.
|
||||
|
||||
## Constraints honored
|
||||
|
||||
- **No source code, tests, migrations, config, package files, lockfiles, docs, or databases modified.** Final `git status --short`: only untracked `.reasonix/` and `audit-results/`; `git diff --check` clean; `src/generated/prisma/` byte-identical before/after all prisma commands (sha256).
|
||||
- All reports and diagnostic artifacts live under `audit-results/` (reports + `tmp/`: `audit-migration.db`, `inspect-db.cjs`, `reproduce-drift.cjs`, `validate-findings.cjs`, `generated-before.sha256`).
|
||||
- Existing databases: none found in the workspace; the migration-chain test used a brand-new temp DB.
|
||||
|
||||
## Baseline verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `npm test` | ✅ PASS — 30 tests / 6 files (20 arcade out of scope, 10 spacedRepetition) |
|
||||
| `npm run lint` | ⚠️ 28 problems (9 errors, 19 warnings) — all pre-existing baseline |
|
||||
| `npm run build` | ✅ PASS — full route table incl. "ƒ Proxy (Middleware)" |
|
||||
| `npx prisma validate` | ✅ PASS |
|
||||
| Migration chain on fresh temp DB | ✅ PASS — 4/4 migrations apply, but the resulting DB is **missing** `MaterialGroup` + `groupId` columns (drift, see below) |
|
||||
|
||||
## Findings (53 total: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation)
|
||||
|
||||
Full detail in `FINDINGS.json` (validated) and `CONFIRMED_FINDINGS.md`.
|
||||
|
||||
### Critical
|
||||
|
||||
1. **FIN-01 — Schema/migration drift (Confirmed, runtime-verified).** `prisma/schema.prisma` defines `MaterialGroup` and `Deck/QuizSet/ShareLink.groupId` (added by commit 7af0935, which also rewrote a committed `dev.db` via `prisma db push`) but **no migration creates them**. Every DB built by `prisma migrate deploy` — the `predev` hook and the Docker entrypoint — lacks these objects; the generated client then fails every Deck/QuizSet/ShareLink/MaterialGroup query (`no such column: Deck.groupId`, `no such table: MaterialGroup` — reproduced against a temp DB). **Any fresh install/deploy is non-functional.** The app only works on the developer's db-push-synced local database.
|
||||
2. **FIN-02 — Hardcoded session-secret fallback (High-confidence inference).** `src/lib/auth.ts:15` and `src/proxy.ts:8-9` fall back to the public constant `"dev-session-secret-change-in-production-must-be-32-chars"` whenever `SESSION_SECRET` is unset — and the shipped `docker-compose.yml` passes `${SESSION_SECRET}`, which is empty by default. Anyone with the source can forge an iron-session cookie (`{isAuthenticated:true, sessionGeneration:1}`) and fully bypass authentication on such deployments.
|
||||
|
||||
### High
|
||||
|
||||
3. **FIN-03 — "Retake Missed" persists as a full attempt with a wrong lower score** (in-viewer retake never sets the `retakeIds` prop; server scores all unanswered questions as 0) and pollutes SEQUENTIAL progress.
|
||||
4. **FIN-04 — Deleting a card mid-session bricks the flashcard study session** (`filterAndClampOrder` is dead code; resume never filters stale ids; no skip UI).
|
||||
5. **FIN-05 — Production compose is unreachable**: container listens on 3726 (`ENV PORT=3726`), compose publishes 3000:3000.
|
||||
6. **FIN-06 — No `.dockerignore`**: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image.
|
||||
7. **FIN-07 — `docker-compose.override.yml` auto-merges**: plain `docker compose up` silently runs dev mode in production.
|
||||
|
||||
### Medium (selected)
|
||||
|
||||
First-login provisioning takeover + dead `ADMIN_PASSWORD_HASH` (FIN-08); proxy auth bypass for dot-containing paths (FIN-09); password-reset abuse (FIN-10); unvalidated attempt `answersJson` (FIN-11); finish double-submit duplicates attempts (FIN-12); progress PATCH/DELETE race (FIN-13); unsequenced progress autosaves (FIN-14); completed session resumes on last card (FIN-15); SRS review endpoint lacks due/new-card-limit enforcement (FIN-16); unbounded DB scans (FIN-17); library optimistic mutations without rollback (FIN-18); unvalidated `groupId`/`sortOrder` incl. cross-class group membership (FIN-19); shared-quiz reshuffle on re-render (FIN-20); unvalidated `/api/progress` + unguarded `JSON.parse` crashes (FIN-21); entrypoint migrate fragility (FIN-22); non-reproducible installs (FIN-23); no healthcheck/backup (FIN-24); CI ships without tests/lint/prisma/smoke gates (FIN-25).
|
||||
|
||||
### Low / Observation
|
||||
|
||||
25 Low (e.g. SATA div-by-zero NaN, historical score recompute, card-edit endpoints without Zod, no import size caps, sortOrder duplicates after group deletion, keyboard cross-group moves impossible, restart DELETE race, P2002 → 500, git history containing `dev.db`, `.gitignore` gaps, dashboard fetch crash, empty slug from `slugify`, shared-viewer localStorage key collisions) and 3 Observations (group sortOrder desc convention, collapse flash, shared quizzes ship the answer key by design).
|
||||
|
||||
## Verification highlights
|
||||
|
||||
- FIN-01 disproved attempts: checked whether the client avoids selecting `groupId` (it doesn't — generated `Deck.ts` includes it in every payload), whether pages swallow errors (they don't), and whether any migration creates the objects (grep = 0 hits). Confirmed via live probes instead: see `audit-results/tmp/reproduce-drift.cjs` output in VERIFICATION_LOG.md.
|
||||
- Adversarial review **refuted** the frontend auditor's "no auth on API routes" claim (proxy is compiled middleware — verified in `.next` artifacts), **weakened** the entrypoint crash-loop framing (fresh-volume `migrate deploy` succeeds), and downgraded the optimistic-reorder severity. It independently confirmed all other Critical/High findings and added 10 missed issues, merged into the final list.
|
||||
|
||||
## Repository areas NOT fully audited
|
||||
|
||||
1. Arcade feature internals (out of scope; shared-DB/build/deploy impact only — verified in migrations).
|
||||
2. Real browser interaction (no browser available): drag-drop, resume flows, share pages, keyboard a11y, themes — code-verified; flagged for runtime confirmation in UNVERIFIED_RISKS.md.
|
||||
3. Container build/run (docker CLI unavailable): FIN-06, FIN-22, FIN-24 need a container run.
|
||||
4. Deep git-history diff of every commit; full content audit of the committed `dev.db` blobs (no sqlite3 CLI; no credentials found in the checked blobs).
|
||||
5. Runtime performance on a populated database (FIN-17 not measured).
|
||||
6. CI/remote execution (Forgejo pipeline analyzed statically).
|
||||
|
||||
## Recommended priority (when fixes are authorized)
|
||||
|
||||
1. **FIN-01** — generate and commit the missing migration (`prisma migrate dev --name add_material_groups`); add a CI drift gate. Everything else depends on a working fresh deployment.
|
||||
2. **FIN-02 / FIN-05 / FIN-06 / FIN-07** — harden the deployment path: require `SESSION_SECRET`, fix the port mapping, add `.dockerignore`, rename the dev override, and smoke-test the image in CI.
|
||||
3. **FIN-03 / FIN-04** — the two most user-visible application bugs (wrong retake scores; stuck flashcard sessions).
|
||||
4. Then the Medium cluster (validation, races, SRS enforcement, progress handling) and finally Low items + TEST_GAPS.md.
|
||||
1024
audit-results/FINDINGS.json
Normal file
1024
audit-results/FINDINGS.json
Normal file
File diff suppressed because it is too large
Load diff
15
audit-results/REJECTED_FINDINGS.md
Normal file
15
audit-results/REJECTED_FINDINGS.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# Rejected Findings
|
||||
|
||||
Findings that were investigated and disproven, or removed as duplicate/stylistic/insignificant, with the reason.
|
||||
|
||||
| ID | Original claim | Rejection reason |
|
||||
|---|---|---|
|
||||
| FE-06 | "All `/api/*` routes are unauthenticated (no middleware, no per-route checks) — curl without a session cookie returns data" | **Refuted by adversarial review + parent verification.** `src/proxy.ts` IS the compiled Next.js 16 middleware (verified in `.next/server/functions-config-manifest.json`, `middleware.js`, and the compiled chunk; build output shows "ƒ Proxy (Middleware)"). It decrypts the iron-session cookie and enforces `isAuthenticated` + `sessionGeneration` on every path except `/login`, `/api/auth`, `/shared`, `/_next*`, `/favicon*`, and paths containing `.`. The auditor searched for `middleware.ts` (the Next 15 name) and missed `proxy.ts`. The residual truth is the dot-bypass (FIN-09). The claim that "no frontend code handles 401s because there is no auth" is also wrong — there is no 401 because the proxy redirects to /login. |
|
||||
| QUIZ-06 (standalone) | "Quiz resume with stale question ids crashes the viewer" | Merged into FIN-04. The quiz-side path is currently unreachable (no question editing/deletion exists in the app; quiz deletion cascades progress), so it is a latent variant of the same root cause (dead `filterAndClampOrder`), not a separate live defect. |
|
||||
| OPS-02 (original framing) | "Fresh-volume deployment crash-loops because `migrate deploy` fails" | **Weakened by adversarial review.** On a fresh volume `prisma migrate deploy` *succeeds* (verified against a temp DB); the fresh-volume failure is FIN-01 (schema drift → app 500s while the container runs). The crash loop requires a secondary trigger (db-push DB, tampering, lock). Reframed as FIN-22 with the crash-loop path demoted. |
|
||||
| FE-01 (original severity) | Optimistic drag-drop reorder without rollback rated High | **Severity downgraded to Medium by adversarial review.** Mechanics confirmed (fire-and-forget fetch, in-place mutation, origin group omitted from cross-group payload) but impact analysis: sortOrder gaps only, silent divergence self-heals on reload, no visible break for a single user. Merged into FIN-18. |
|
||||
| AUTH-06 (standalone) | "Cross-class group membership via reorder API exposes another class's content" | Merged into FIN-19 (same root cause: `groupId`/`sortOrder` never validated server-side). The sharing-exposure angle is recorded as impact of FIN-19. |
|
||||
| DBAUD-04 | "Relative SQLite path resolution may cause dev dual-DB split-brain" | **Disproven.** CLI (`prisma.config.ts`) and app adapter both resolve `file:./dev.db` cwd-relative; `predev` and the app run from the project root → same file. Docker uses absolute `file:/app/data/study.db`. Recorded as verified-safe in UNVERIFIED_RISKS.md. |
|
||||
| DBAUD-05/06/07, QUIZ-08-verdicts, GRP-01-verdicts (various) | "Cascades broken / transactions partial / StudyProgress duplicates via NULL tricks / id regeneration on card edit / XSS via markdown / client-server boundary violations" | **Disproven** by the respective auditors with evidence: FKs enforced by compile default; nested creates implicit-transactional; service-level guards block duplicates; PATCH edits in place (ids preserved); no `rehype-raw`; no server-module imports in client components. Recorded as verified-safe. |
|
||||
| FE-11 (as bug) | "Collapsed-groups expand flash" | Retained only as Observation (FIN-51) — cosmetic, no data impact. |
|
||||
| Various | Style preferences, harmless duplication, generic best practices, speculative micro-optimizations, Arcade-only issues | Excluded per audit scope rules. |
|
||||
340
audit-results/REMEDIATION_PLAN.md
Normal file
340
audit-results/REMEDIATION_PLAN.md
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
# Study Desk audit evaluation and remediation plan
|
||||
|
||||
Date: 2026-08-06
|
||||
Scope: independent evaluation of `CONFIRMED_FINDINGS.md`, `FINDINGS.json`, the supporting audit reports, and the current repository source.
|
||||
Change policy for this evaluation: this plan is the only new file; no application source, tests, migrations, configuration, generated files, database, or existing audit artifact was changed.
|
||||
|
||||
## Executive verdict
|
||||
|
||||
The audit is credible and the central findings hold up against the current source. The most urgent problems are not cosmetic:
|
||||
|
||||
1. The committed migration chain does not create `MaterialGroup` or the three `groupId` columns, while the generated Prisma client selects those fields. A database created only with `prisma migrate deploy` is incompatible with the app.
|
||||
2. Production authentication can use a public fallback session secret, making a deployment with an empty `SESSION_SECRET` forgeable.
|
||||
3. The in-viewer quiz retake flow loses the retake scope and records the retake as a full, incorrectly scored attempt.
|
||||
4. Flashcard resume trusts stale IDs and unguarded JSON, so deletion or malformed progress can leave the study viewer blank or crash it.
|
||||
5. The production container path is presently unreliable: the port mapping is wrong without the development override, the conventional override silently changes normal Compose behavior, the build context can include host `node_modules`, and the runner installs a ranged Prisma CLI outside the lockfile.
|
||||
|
||||
I agree with most of the remaining findings as defects or resilience gaps, but not always with their severity or proposed remedy. In particular:
|
||||
|
||||
- FIN-04 needs more than wiring in the existing `filterAndClampOrder`: that helper does not preserve the logical current card when a stale ID occurs before the saved index, and it turns a completed index back into the last card.
|
||||
- FIN-05 should normally use `3000:3726`, preserving the documented host port while mapping it to the actual container port.
|
||||
- FIN-22 should not be fixed with an automatic migration retry loop. Schema conflicts should fail fast with actionable diagnostics and a documented recovery path.
|
||||
- FIN-27 is more important than Low because changing quiz content can rewrite the meaning of persisted history.
|
||||
- FIN-34 is only partially present: the dedicated deck page already awaits both DELETE requests, but the library start/restart path fires them without waiting.
|
||||
- FIN-38 is not currently a defect for this Arizona-based installation. It becomes a portability requirement only if the app is meant to support a configurable local day boundary.
|
||||
- FIN-42 does not justify a destructive history rewrite without evidence that the old database contained sensitive data and without coordinating every clone/remote.
|
||||
- FIN-50 is an intentional convention. Newest named groups must remain first and Uncategorized must remain last; validation should make that convention harder to corrupt.
|
||||
- FIN-52 is an architectural consequence of local grading in an anonymous read-only viewer. It merits a sharing warning, not removal of the answer key unless the product adopts server-side anonymous grading.
|
||||
|
||||
## Finding-by-finding disposition
|
||||
|
||||
Priority definitions: P0 = release/deployment blocker; P1 = next remediation batch; P2 = important hardening; P3 = worthwhile backlog; Conditional = verify a stated condition before changing behavior; No separate fix = intentional behavior or covered by another item.
|
||||
|
||||
| ID | Verdict | Worth fixing? | Priority and disposition |
|
||||
|---|---|---:|---|
|
||||
| FIN-01 | Agree | Yes | **P0.** Add the missing migration plus a safe adoption runbook for already-`db push`-synchronized databases. |
|
||||
| FIN-02 | Agree, conditional on an unset/blank production secret | Yes | **P0.** Production must fail before serving requests; development may use an explicitly development-only value. |
|
||||
| FIN-03 | Agree | Yes | **P1.** Make attempted-question scope explicit and derive partial-retake status on the server. |
|
||||
| FIN-04 | Agree; proposed helper is insufficient as written | Yes | **P1.** Normalize order, index, and results while preserving the logical current card and completed state. |
|
||||
| FIN-05 | Agree | Yes | **P0.** Map host 3000 to container 3726 and smoke-test it. |
|
||||
| FIN-06 | Agree with the mechanics; exact runtime failure is unconfirmed | Yes | **P0.** Add `.dockerignore` and prove native modules load in the Linux image. |
|
||||
| FIN-07 | Agree | Yes | **P0.** Rename the development override and document explicit dev/prod commands. |
|
||||
| FIN-08 | Partly agree: first-login setup is intentional, accidental exposure and dead config are real | Yes | **P1.** Honor a pre-provisioned admin hash or require an explicit one-time setup mode. |
|
||||
| FIN-09 | Agree; latent with current IDs | Yes | **P1.** Replace the broad dot bypass with exact public/static path rules. |
|
||||
| FIN-10 | Agree | Yes | **P1.** Prevent token overwrite, remove spoofable per-IP trust, and make reset initiation a local/admin operation. |
|
||||
| FIN-11 | Agree | Yes | **P1.** Validate the whole attempt envelope, question scope, option ownership, array uniqueness, and types. |
|
||||
| FIN-12 | Agree | Yes | **P1.** Add an in-flight guard, disable Finish, and surface failure/retry state. |
|
||||
| FIN-13 | Agree as a credible timing race | Yes | **P1.** Persist the attempt and clear full-attempt progress in one server transaction. |
|
||||
| FIN-14 | Agree as a credible timing race | Yes | **P2.** Add session identity and monotonic revisions, not only client debouncing. |
|
||||
| FIN-15 | Agree | Yes | **P1.** Persist `currentIndex === order.length` and restore the summary state. |
|
||||
| FIN-16 | Agree; impact is lower in a protected single-user app | Yes | **P2.** Accept only the queue-eligible card, including the intended learn-ahead rule. |
|
||||
| FIN-17 | Agree that the queries are unbounded; user impact is unmeasured | Conditional | **P3.** Benchmark realistic data first, then optimize queries that exceed the budget. |
|
||||
| FIN-18 | Agree | Yes | **P2.** Stop mutating shared objects, capture the origin group before mutation, await writes, and rollback/refetch on error. |
|
||||
| FIN-19 | Agree | Yes | **P1.** Enforce class/type/group membership and server-owned ordering; retain a share-page defense. |
|
||||
| FIN-20 | Agree | Yes | **P1.** Give the viewer a stable session input so unrelated parent renders cannot reset it. |
|
||||
| FIN-21 | Agree | Yes | **P1.** Validate progress writes and defensively normalize legacy/corrupt progress on reads. |
|
||||
| FIN-22 | Partly agree with the operational gap, not the retry remedy | No separate fix | Fail fast; cover CLI pinning in FIN-23 and recovery/health in FIN-24. Do not retry a conflicting migration automatically. |
|
||||
| FIN-23 | Agree | Yes | **P0.** Use `npm ci` and ship a lockfile-pinned Prisma CLI/runtime path. |
|
||||
| FIN-24 | Agree | Yes | **P1.** Add a schema-aware health check and tested backup/restore instructions. |
|
||||
| FIN-25 | Agree | Yes | **P0/P1.** Gate test, build, Prisma validation/drift, and container smoke. Stage lint until its known baseline is resolved. |
|
||||
| FIN-26 | Agree | Yes | **P1.** Return zero for an invalid zero-correct SATA question and test finite totals. |
|
||||
| FIN-27 | Agree; severity should be Medium | Yes | **P2.** Persist an immutable result/review snapshot for new attempts, with legacy fallback. |
|
||||
| FIN-28 | Agree | Yes | **P2.** Reuse a trimmed card-content schema and reject rather than silently drop incomplete Create-tab rows. |
|
||||
| FIN-29 | Agree | Yes | **P2.** Add reasonable string, array, and request-size limits with clear 400/413 errors. |
|
||||
| FIN-30 | Agree that the contract is inconsistent | Yes | **P2.** Make SATA imports require at least two correct options, matching the shipped generation instructions; preserve legacy stored data. |
|
||||
| FIN-31 | Agree | Yes | **P2.** Validate complete request envelopes and map only actual not-found errors to 404. |
|
||||
| FIN-32 | Agree | Yes | **P2.** Delete/reassign/renumber in one transaction and add deterministic item tie-breaks. |
|
||||
| FIN-33 | Agree | Yes | **P2.** Add an explicit keyboard-accessible “Move to group” action; do not depend on drag gestures. |
|
||||
| FIN-34 | Partly agree | Yes | **P2.** Fix the library path that does not await deletion; make deletion session-aware with FIN-14. |
|
||||
| FIN-35 | Agree | Yes | **P3.** Translate concurrent first-review uniqueness conflicts to 409 and refetch. |
|
||||
| FIN-36 | Agree as a credible timing race | Yes | **P2.** Disable navigation while grading and cancel/ignore stale animation callbacks. |
|
||||
| FIN-37 | Agree | Yes | **P3.** Refresh set/deck membership on focus and after local deck-change events. |
|
||||
| FIN-38 | Factually correct, but Arizona is the current intended boundary | Not now | Keep the existing behavior. Revisit only with an explicit portability requirement and a timezone setting/migration plan. |
|
||||
| FIN-39 | Agree about the lost clear-cookie response; layout impact is overstated because proxy checks generation | Yes | **P2.** Destroy the cookie on the actual redirect response and test its header. |
|
||||
| FIN-40 | Deployment-policy gap, not a bug on intentional HTTP | Yes, as documentation/config | **P1.** Document a TLS proxy profile and `SECURE_COOKIES=true`; do not enable Secure cookies on plain HTTP. |
|
||||
| FIN-41 | Agree | Yes | **P1.** Validate a strict target enum and target existence; reject impossible/null-target rows. |
|
||||
| FIN-42 | Agree that blobs exist; sensitivity is unproven | Conditional | Do not rewrite history now. Inventory the old DB offline before any public remote; rewrite only with explicit coordination if sensitive data is found. |
|
||||
| FIN-43 | Partly agree | Yes | **P3.** Ignore `data/` and production DB sidecars. Confirm ownership, then remove unreferenced CSS scratch files. Do not automatically ignore the audit plan. |
|
||||
| FIN-44 | Agree | Yes | **P2.** Add `res.ok`, error, and retry handling; never present a failed load as an empty dashboard. |
|
||||
| FIN-45 | Agree | Yes | **P3.** Add abort/error handling to settings/share fetches. |
|
||||
| FIN-46 | Agree as a credible timing race | Yes | **P2.** Use `AbortController` or a request generation tied to `classSlug`; remove or bound the module cache. |
|
||||
| FIN-47 | Agree | Yes | **P3.** Show logout failure and retry; do not falsely claim logout if the server call failed. |
|
||||
| FIN-48 | Partly agree | Yes | **P2.** Guarantee a non-empty unique slug. Keep slugs stable on rename unless redirect/history semantics are deliberately designed. |
|
||||
| FIN-49 | Agree; impact is same-browser only | Yes | **P3.** Include the share token in local session keys and migrate/ignore old keys safely. |
|
||||
| FIN-50 | Intentional newest-first convention | No separate fix | Preserve descending named-group order and final Uncategorized placement; restrict arbitrary sort-order writes under FIN-19/31. |
|
||||
| FIN-51 | Agree, cosmetic only | Not now | Defer unless it can be removed without hydration mismatch or new effect-rule violations. |
|
||||
| FIN-52 | Expected local-grading design | Warning only | **P3.** Explain in ShareMenu that recipients can inspect correct answers; do not imply answer secrecy. |
|
||||
| FIN-53 | Agree | Yes | **P1.** Replace the false-confidence assertion and add scoring/scheduling edge cases. |
|
||||
|
||||
## Remediation sequence
|
||||
|
||||
### Phase 0 — safeguards and test foundation
|
||||
|
||||
Do this before behavior or schema changes.
|
||||
|
||||
1. Create a database-backed Vitest harness that always points Prisma at a unique disposable SQLite path. It must refuse to run if the resolved path is `dev.db`, `/app/data/study.db`, or any existing non-test database.
|
||||
2. Add helpers to apply committed migrations to the disposable database and dispose of it after the test process.
|
||||
3. Record the current lint baseline separately. Do not make a failing baseline a nominally “green” CI gate, and do not mix unrelated lint cleanup into P0 fixes.
|
||||
4. Add focused pure tests first for scoring, progress normalization, import schemas, and rate limiting. These give fast feedback before route/service integration tests.
|
||||
5. Before testing any real deployment database, make and verify a restorable backup. No remediation command should run against the only copy.
|
||||
|
||||
Exit criteria:
|
||||
|
||||
- Tests demonstrably create and use only a disposable DB.
|
||||
- A deliberate attempt to point the harness at the real DB aborts.
|
||||
- Existing `npm.cmd test` remains green.
|
||||
|
||||
### Phase 1 — restore a safe, reproducible deployment path
|
||||
|
||||
Addresses FIN-01, FIN-02, FIN-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-<short-id>` fallback. Keep existing slugs stable on rename.
|
||||
10. Add `data/`, `study.db`, and sidecars to `.gitignore`. Confirm `out.css`, `temp.css`, and `test.css` are unreferenced scratch artifacts before removing them in a separate cleanup change.
|
||||
11. For performance, first seed a disposable benchmark DB approximating expected upper use (for example 100k activity rows, 10k cards, and several SRS sets). Add date predicates/aggregation and narrower SRS queries only where the measured request/poll budget is exceeded. Preserve long-streak correctness when limiting the activity window.
|
||||
|
||||
Focused verification:
|
||||
|
||||
- Whitespace-only and oversized content is rejected with an actionable client error and no partial write.
|
||||
- SATA 1-correct import fails; 2+-correct passes; multiple-choice still requires exactly one.
|
||||
- SRS cannot review a not-due/non-selected/new-limit-exhausted card; valid due and learn-ahead reviews still work.
|
||||
- Concurrent first review produces one success and one handled conflict, not a generic 500.
|
||||
- Focus refresh removes a deleted deck from the SRS set UI.
|
||||
- Failed dashboard/settings/share/logout requests have visible, retryable states and no unhandled promise rejection.
|
||||
- Rapid A→B class navigation cannot render A data under B.
|
||||
- Creating a class named only punctuation yields a navigable, unique slug; rename does not break the old URL.
|
||||
- Performance tests record query counts and latency before/after and prove the 53-week display plus longer current streak remain correct.
|
||||
|
||||
## Cross-cutting release verification
|
||||
|
||||
Run after every phase, with focused tests first and the full suite last.
|
||||
|
||||
Automated gates:
|
||||
|
||||
1. `npm.cmd test`
|
||||
2. `npx.cmd prisma validate`
|
||||
3. migration deploy + empty migration/schema diff against a fresh disposable DB whenever schema changes
|
||||
4. `npm.cmd run build`
|
||||
5. `git diff --check`
|
||||
6. production image build, fresh-volume health wait, and HTTP smoke when Docker/deployment files change
|
||||
7. lint against changed files; full `npm.cmd run lint` only becomes blocking after the known baseline is cleared
|
||||
|
||||
Manual browser matrix for affected phases:
|
||||
|
||||
1. Authenticated routes and corresponding public/shared routes.
|
||||
2. Fresh, resumed, completed, restarted, partial-retake, and stale/deleted-content sessions.
|
||||
3. Loading, empty, server-error, malformed-persisted-data, and retry states.
|
||||
4. Light and dark themes.
|
||||
5. Narrow mobile viewport with no horizontal overflow.
|
||||
6. Keyboard-only navigation, including group moves and all modified icon controls.
|
||||
7. Throttled-network checks for Finish, autosave, restart, class navigation, reorder, and logout.
|
||||
8. Two-tab checks for progress revisions, SRS conflicts, and focus refresh.
|
||||
|
||||
Release/rollback rules:
|
||||
|
||||
- Back up and restore-test the SQLite database before the first migration-bearing release.
|
||||
- Deploy schema and code as one versioned release; do not run a newer generated Prisma client against an older database.
|
||||
- Keep the prior image and verified pre-migration backup until post-deploy smoke and representative data checks pass.
|
||||
- If migration preflight sees a partial or unknown schema state, stop. Do not run `db push`, edit an applied migration, reset the database, or auto-mark the migration applied.
|
||||
|
||||
## Explicitly deferred or rejected work
|
||||
|
||||
- **FIN-38:** no timezone-setting work without a product requirement to support a non-Arizona study day.
|
||||
- **FIN-42:** no history rewrite without an offline sensitivity review and explicit remote/clone coordination.
|
||||
- **FIN-51:** no cosmetic state-initialization change unless it avoids both hydration mismatch and the existing effect-rule class of lint failures.
|
||||
- **FIN-22 retry proposal:** no automatic migration retry/backoff for schema conflicts; fail fast with health diagnostics and the adoption/recovery runbook.
|
||||
- No changes to the 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.
|
||||
20
audit-results/SUBAGENT_REPORTS.md
Normal file
20
audit-results/SUBAGENT_REPORTS.md
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
# Subagent Reports
|
||||
|
||||
Full specialist reports were produced by 10 read-only subagents. Each returned a structured markdown report with a findings table and per-finding detail blocks (ID, severity, confidence, files:lines, scenario, expected/actual, root cause, impact, evidence, test coverage, fix direction, complexity, regression test, runtime-confirmation flag). The parent agent re-verified the Critical/High claims directly (see VERIFICATION_LOG.md) and consolidated/deduplicated into `FINDINGS.json` (53 findings).
|
||||
|
||||
## Report index (all reports were reviewed in full by the parent)
|
||||
|
||||
| # | Auditor | Ref | Key output |
|
||||
|---|---|---|---|
|
||||
| 1 | DB / migrations / transactions / cascades / drift | sa_20260806_054030_000000000_5ed877563a0d | **DBAUD-01 Critical migration drift** (MaterialGroup/groupId never migrated; commit 7af0935 db-push artifact); DBAUD-02 junk ShareLink rows; DBAUD-03 dev.db in git history; verified-safe: cascades, transactions, path resolution, single-row AuthSecurity |
|
||||
| 2 | Quiz scoring / attempts / retakes / progress | sa_20260806_054030_000000000_e75a8ce575f9 | **QUIZ-01 High retake-scoring bug** (in-viewer retake never sets retakeIds); QUIZ-02..09 (unvalidated answersJson, double-submit, progress races, dead filter, div-by-zero, historical recompute, multi-tab) |
|
||||
| 3 | Flashcards / SRS / progress / deletion / ordering | sa_20260806_054030_000000000_a222804c133c | CARD-01..11: dead stale-id filter (stuck sessions), unsequenced saves, completed-session resume, SRS due/limit not enforced, unbounded scans, Arizona day boundary, restart race, P2002, animation race, focus staleness |
|
||||
| 4 | Auth / protected routes / API authz / sharing | sa_20260806_054030_000000000_aab0c59dbad3 | **AUTH-01 Critical hardcoded session secret**; AUTH-02 dot-bypass; AUTH-03 reset abuse; AUTH-04 first-login takeover + dead ADMIN_PASSWORD_HASH; AUTH-05..10 (cookie-clear loss, cross-class group membership, share validation, secure flag, no auth tests, answer-key exposure); verified proxy compiled as middleware |
|
||||
| 5 | Imports / exports / generation / Zod / malformed input | sa_20260806_054030_000000000_5e72c2713989 | IMPT-01..06: dead filterAndClampOrder, unvalidated /api/progress + unguarded JSON.parse, card edit endpoints without Zod, no size caps, SATA doc mismatch, override-field 500s; verified: shared schemas, atomic imports, no XSS, no id regeneration |
|
||||
| 6 | Material groups / ordering / drag-drop / deletion / orphans | sa_20260806_054030_000000000_9ae1f3dfa282 | **GRP-01 Critical drift** (independent confirmation); GRP-02..07: unvalidated groupId/sortOrder, fire-and-forget reorder, sortOrder duplicates after group deletion, keyboard a11y, desc-convention fragility; verified: FKs enforced, uncategorized mapping, cascade safety |
|
||||
| 7 | Frontend state / refresh / races / localStorage / boundaries | sa_20260806_054030_000000000_0e17fd155702 | FE-01..11: optimistic reorder without rollback, CRUD res.ok ignored, shared quiz reshuffle (unstable prop), finish double-submit, progress race, **FE-06 (later refuted — proxy exists)**, dashboard fetch gap, unguarded JSON.parse, effect fetches, cross-class race, collapse flash; verified: shared viewers read-only, localStorage clean, no boundary violations |
|
||||
| 8 | Test quality / coverage / false confidence | sa_20260806_054030_000000000_cd3652deb3f7 | TEST-01..10: false-confidence SRS test, zero tests for scoring/auth/services/routes, no DB harness, CI never runs tests; ranked missing-test gaps by impact |
|
||||
| 9 | Docker / startup / env / scripts / production / recovery | sa_20260806_054030_000000000_f49cdaacaabf | OPS-01..10: port mismatch 3726 vs 3000:3000, entrypoint fragility, session secret in prod path, dead ADMIN_PASSWORD_HASH, non-reproducible installs, no .dockerignore (win32 leak), no healthcheck/backup, override auto-merge, .gitignore gaps, CI without gates; argon2/standalone tracing verified OK |
|
||||
| 10 | Adversarial review | sa_20260806_055608_000000000_c550c742d5dc | Challenged all Critical/High findings: confirmed drift, secret, retake, port, dockerignore, override; weakened OPS-02 (fresh-volume migrate succeeds) and FE-01 severity; **refuted FE-06** (proxy active — verified in compiled .next artifacts); added ADV-01..10 (retake progress pollution, activity scan, logout, dashboard crash, slugify empty slug, unguarded parses, completed-resume, localStorage key collisions, first-login race, progress validation) |
|
||||
|
||||
All reports are preserved in full in the session; their findings are consolidated (deduplicated, source IDs preserved) in `FINDINGS.json` and `CONFIRMED_FINDINGS.md`.
|
||||
21
audit-results/TEST_GAPS.md
Normal file
21
audit-results/TEST_GAPS.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# Test Gaps
|
||||
|
||||
Current suite: 6 files / 30 tests — 20 in `src/lib/arcade/*` (out of scope) + 10 in `src/lib/spacedRepetition.test.ts` (pure scheduling math only). **No test touches a service, an API route, the database, auth, scoring, imports, or progress.** CI never runs `npm test`. Gaps ranked by user-impact × regression-likelihood.
|
||||
|
||||
| ID | Area | Gap | Risk it would fail to catch | Suggested test |
|
||||
|---|---|---|---|---|
|
||||
| TEST-GAP-01 | Auth (FIN-02/08/09/10) | `authService.ts` (login, lockout, first-login provisioning, reset token, generation bump) and session config have zero tests | Session-secret fallback regression, lockout bypass, generation-bump loss (stolen sessions stay valid) | Extract + unit-test `getLockoutDuration` thresholds, `parseResetTokenRecord`; service tests with temp SQLite + fake timers: 5 wrong passwords → 423; locked-out login rejected; success resets counter; `completePasswordReset` bumps generation |
|
||||
| TEST-GAP-02 | Scoring (FIN-03/11/26/27) | `src/lib/scoring.ts` SATA partial credit has zero tests | Wrong grades silently persisted into every attempt — permanent data corruption | `scoreQuestion`: SATA 2/4 correct select both → 1; 1 correct + 1 wrong → 0; over-select → 0; MC multi-select first-wrong → 0; `scoreQuiz` with missing question keys; 0-correct SATA → 0 (not NaN) |
|
||||
| TEST-GAP-03 | Quiz attempt persistence (FIN-03/12/13) | `POST /api/quizzes/[id]/attempt` + partial-retake filter + `progressService.upsertProgress` asymmetry (DECK writes cardResultsJson, QUIZ writes answersJson) have zero tests | Retake-scoring regression (FIN-03 class), duplicate attempts, progress column clobbering | Route/service test: partial retake answering 2 of 5 → maxScore 2, score from those 2; upsert round-trip preserves the respective JSON columns |
|
||||
| TEST-GAP-04 | SRS service (FIN-16/35) | `spacedRepetitionService.ts` (reviewCard optimistic concurrency, findNextCard precedence, removeDeck transaction, addDeck validations) has zero tests — only the pure lib is tested | Double-review stale write, due/new-card precedence break, cross-class membership, P2002 → 500 | Seed class→deck→cards→set on temp DB: stale `expectedStateVersion` → 409; due before new before learn-ahead; new card with limit 0 rejected; removeDeck atomically deletes states |
|
||||
| TEST-GAP-05 | Import/repair pipeline (FIN-28/29/30) | `jsonRepair.ts`, `importSchemas.ts`, `shuffle.ts`/`filterAndClampOrder` have zero tests | AI-import regression (fences/repair), schema constraint drift (MC exactly-one), resume clamp bugs | jsonRepair fence/trailing-comma/broken-JSON cases; schema whitespace/oversize/exactly-one cases; `filterAndClampOrder` stale-id + overflow cases |
|
||||
| TEST-GAP-06 | Rate limiter (FIN-10) | `rateLimiter.ts` has zero tests | Brute-force guard silently disabled or locking the owner out | Fake timers: 10 allowed / 11th blocked with retryAfterMs; window expiry; per-key isolation; custom limits |
|
||||
| TEST-GAP-07 | Integration harness (FIN-01/21/25) | No route tests, no DB-backed tests, no setup file; `src/lib/db.ts` defaults to `file:./dev.db` (a naive service test would touch the real dev DB) | Migration drift (FIN-01) — nothing would catch a schema/migration mismatch; progress/share/attempt route validation gaps | `setupFiles` + `DATABASE_URL=file:<tmp>` + `prisma migrate deploy` in globalSetup; route tests with `NextRequest`: attempt 200/400/404, reviews 409, share unknown token, login 429 |
|
||||
| TEST-GAP-08 | CI gate (FIN-25) | `.forgejo/workflows/build.yml` runs no tests/lint/prisma validate; no coverage config; no drift check | Entire suite can regress with a green pipeline; FIN-01-class drift ships | Add `npm ci && npm test && npm run lint && npx prisma validate` job; `prisma migrate diff --from-migrations --to-schema-datamodel` gate; container smoke (curl /login on fresh volume); coverage floor |
|
||||
| TEST-GAP-09 | False confidence (FIN-53) | `spacedRepetition.test.ts:15-23` "distinct valid state for every rating" never compares ratings | `scheduleRating` collapsing all ratings to one schedule passes the suite | Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; graduated-state lapse case |
|
||||
|
||||
## Test-quality positives (verified)
|
||||
|
||||
- `spacedRepetition.test.ts` is deterministic (fixed `now`, `enable_fuzz: false`, no `Math.random`), tests the real pure module, and asserts exact FSRS intervals.
|
||||
- Arcade tests (out of scope) use seeded shuffles and meaningful assertions.
|
||||
- No test uses wall-clock time, locale, or network — no flakiness found.
|
||||
35
audit-results/UNVERIFIED_RISKS.md
Normal file
35
audit-results/UNVERIFIED_RISKS.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Unverified Risks
|
||||
|
||||
Risks that could not be fully confirmed within audit constraints (need a browser, a container run, a live deployment, or timing-dependent reproduction). All are code-verified as plausible; the missing piece is runtime confirmation.
|
||||
|
||||
| ID | Title | Why unverified | Suggested verification |
|
||||
|---|---|---|---|
|
||||
| FIN-02 | Hardcoded fallback `SESSION_SECRET` → session forgery | Code path fully traced (seal/verify semantics verified in iron-session), but no live production deployment exists here to demonstrate an actual forged-cookie login | Boot production build without `SESSION_SECRET`, forge a cookie with the fallback password (iron-webcrypto seal script), assert full access; then with a real secret assert rejection |
|
||||
| FIN-06 | No `.dockerignore` → win32 modules in Linux image | Mechanics verified (PE32+ DLL present in local node_modules; Dockerfile COPY order), exact failure mode (build error vs `ERR_DLOPEN_FAILED`) not observed | Run `docker build .` on this Windows host; inspect `better_sqlite3.node`/argon2 prebuilds in the image (must be ELF) |
|
||||
| FIN-13 | Progress PATCH-after-DELETE race | Timing-dependent; needs throttled network | DevTools throttling: submit final answer + Finish immediately; inspect `StudyProgress` after |
|
||||
| FIN-14 | Out-of-order progress PATCHes | Timing-dependent | DevTools throttling with rapid grading; reload and compare resume point/results |
|
||||
| FIN-22 | Entrypoint migrate crash loop / runner CLI install | Requires container build + run on `node:22-slim` (docker CLI unavailable in audit environment) | `docker build` + fresh-volume run (assert 200 + `_prisma_migrations`); corrupt-volume run (assert actionable failure, not silent loop) |
|
||||
| FIN-24 | Healthcheck/backup gaps | Behavior observable only in a real container run | Compose up with a corrupt volume; observe restart loop and absence of health status |
|
||||
| FIN-32 | Group-delete sortOrder duplicates | Deterministic from code; visual impact needs a browser | Delete a group in the app, inspect Uncategorized order + DB sortOrders |
|
||||
| FIN-33 | Keyboard cross-group moves impossible | Deterministic from code (empty `handleDragOver`), needs manual keyboard test | Tab to a drag handle, attempt keyboard cross-group move |
|
||||
| FIN-34 | Restart DELETE race | Timing-dependent | Throttled network: Restart, grade first card, inspect progress row |
|
||||
| FIN-36 | Previous-card animation race | Timing-dependent (350 ms window) | Grade then immediately click Previous; observe index jump |
|
||||
| FIN-37 | SRS page stale on focus | Deterministic from code; browser needed to observe | Two tabs: delete deck in one, focus the other |
|
||||
| FIN-39 | Proxy destroy cookie lost on redirect | Deterministic from code; browser needed to observe Set-Cookie | Stale-generation cookie → follow redirect → inspect response headers |
|
||||
| FIN-40 | Secure cookie flag off | Deterministic from code (flag logic); deployment-dependent | Inspect `Set-Cookie` in a production container |
|
||||
| FIN-46 | Cross-class fetch race | Timing-dependent | Throttled network + fast class switching |
|
||||
| FIN-48 | Empty slug / stale slug on rename | Deterministic from code (slugify verified); UI behavior needs browser | Create class named "!!!", try to navigate to it; rename a class, check old URL |
|
||||
| FIN-49 | Shared-viewer localStorage key collisions | Code-verified key construction; symptom needs browser | Open two share tokens for the same content, answer in one, reload the other |
|
||||
| FIN-51 | Collapsed-groups expand flash | Cosmetic; browser-only | Visit library with saved collapsed state |
|
||||
|
||||
## Items verified safe (hypotheses disproven — recorded for completeness)
|
||||
|
||||
- SQLite relative-path split-brain between Prisma CLI and app (both cwd-relative → same `dev.db`; Docker uses absolute path) — verified safe.
|
||||
- Foreign-key enforcement (better-sqlite3 compiled with `SQLITE_DEFAULT_FOREIGN_KEYS=1`) — cascades/SetNull fire; verified safe.
|
||||
- Transactionality of deck/quiz/attempt/reorder writes — nested creates + `$transaction`; verified safe.
|
||||
- `StudyProgress`/`ShareLink` NULL-uniqueness — service-level guards prevent duplicates via app paths; only direct API misuse (FIN-41/FIN-21) can create junk rows.
|
||||
- Client/server boundary violations — none found (no client import of `@/lib/db`/services/prisma).
|
||||
- XSS via Markdown — react-markdown without `rehype-raw`; no `dangerouslySetInnerHTML` on user data; verified safe.
|
||||
- Password-reset nonce/token crypto (192-bit token, SHA-256 digest, `timingSafeEqual`, expiry) — sound; abuse vectors are FIN-10.
|
||||
- SRS optimistic concurrency (`expectedStateVersion` → 409, rollback via refetch) — correct.
|
||||
- Migrations are purely additive; no NOT NULL-without-default, no DROP — safe on populated migration-tracked DBs (except FIN-01 drift).
|
||||
38
audit-results/VERIFICATION_LOG.md
Normal file
38
audit-results/VERIFICATION_LOG.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Verification Log
|
||||
|
||||
Chronological record of every command run, its outcome, and classification (baseline / environment / possible defect).
|
||||
|
||||
## Environment notes
|
||||
|
||||
- OS: Windows, shell: bash (git-bash)
|
||||
- `node_modules` was **absent** at audit start → `npm test` initially failed with `'vitest' is not recognized`. This is an environment condition, not a repo defect. `npm ci` completed successfully during the first audit session (518 packages present).
|
||||
- No existing application database (`dev.db*`) exists anywhere in the repo — no real user data to protect; the migration-chain test uses a brand-new temp DB under `audit-results/tmp`.
|
||||
- `src/generated/prisma/` (tracked) hashed BEFORE any prisma command: `audit-results/tmp/generated-before.sha256`.
|
||||
|
||||
## Entries
|
||||
|
||||
| # | Time | Command | Result | Classification |
|
||||
|---|---|---|---|---|
|
||||
| 1 | session start | `git status --short` | clean; only `?? .reasonix/` and `?? audit-results/` untracked | baseline |
|
||||
| 2 | session start | `npm test` (first run, prior session) | FAILED: `'vitest' is not recognized` (no node_modules) | environment |
|
||||
| 3 | session start | `npm ci` (background, prior session) | completed; 518 packages in node_modules | environment |
|
||||
| 4 | 22:36 | `npx prisma validate` | PASS — "The schema at prisma\schema.prisma is valid" (exit 0) | baseline |
|
||||
| 5 | 22:36 | `npm test` | PASS — 6 files, 30 tests, all green (arcade ×20, spacedRepetition ×10) | baseline |
|
||||
| 6 | 22:37 | `npm run lint` | 28 problems: 9 errors, 19 warnings (react-hooks/set-state-in-effect errors + unused-vars warnings). All pre-existing; matches AGENTS.md known baseline. | baseline |
|
||||
| 7 | 22:37 | `npm run build` (background) | PASS — route table emitted, no compile errors | baseline |
|
||||
| 8 | 22:38 | `DATABASE_URL=file:./audit-results/tmp/audit-migration.db npx prisma migrate deploy` | PASS — all 4 migrations applied to brand-new temp DB | baseline |
|
||||
| 9 | 22:38 | `sha256sum -c audit-results/tmp/generated-before.sha256` | PASS — every generated Prisma file byte-identical (all ": OK") | baseline |
|
||||
| 10 | 22:38 | `git status --short` | only untracked `.reasonix/`, `audit-results/`; no tracked modifications | baseline |
|
||||
| 11 | 22:38 | `git diff --check` | clean (no whitespace errors) | baseline |
|
||||
| 12 | 22:40 | `git log --all --oneline -- prisma/migrations/` + `git show 7af0935 --stat` | Confirms drift: commit 7af0935 changed `prisma/schema.prisma` (+31) and `dev.db` (139264→155648 bytes) with **no migration**; `dev.db` present in 8 commits of history | possible defect (FIN-01, FIN-42) |
|
||||
| 13 | 22:41 | `node audit-results/tmp/inspect-db.cjs audit-results/tmp/audit-migration.db` | Temp DB (built solely by `migrate deploy`) has **no** `MaterialGroup` table and **no** `groupId` column on Deck/QuizSet/ShareLink | possible defect (FIN-01) |
|
||||
| 14 | 22:42 | `node audit-results/tmp/reproduce-drift.cjs` | Prisma-shaped queries fail: `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` — fresh-deploy breakage proven | possible defect (FIN-01) |
|
||||
| 15 | 22:45 | `node --check audit-results/tmp/inspect-db.cjs && node --check audit-results/tmp/reproduce-drift.cjs` | diagnostic scripts syntax-valid | baseline |
|
||||
| 16 | 22:50 | `node audit-results/tmp/validate-findings.cjs` | `FINDINGS.json` VALID JSON — 53 findings: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation; no duplicate ids | baseline |
|
||||
| 17 | 23:00 | `git status --short` + `git diff --check` (final) | Only untracked `.reasonix/`, `audit-results/`; no tracked file modified; diff clean | baseline |
|
||||
|
||||
## Classification legend
|
||||
|
||||
- **baseline** — repository behaves as expected; recorded for reference.
|
||||
- **environment** — failure caused by local machine state, not repo code.
|
||||
- **possible defect** — outcome may indicate a repo defect; investigated further in findings.
|
||||
BIN
audit-results/tmp/audit-migration.db
Normal file
BIN
audit-results/tmp/audit-migration.db
Normal file
Binary file not shown.
26
audit-results/tmp/generated-before.sha256
Normal file
26
audit-results/tmp/generated-before.sha256
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
cb737289f5b5f6cdb8b90fc23d2c89ebd41d6602ce99e88bf6dfd751beab7103 *src/generated/prisma/browser.ts
|
||||
2ea331124cc56c9c9a360b4a85f42f029992811ece7efe9db0ec1300a43f59c3 *src/generated/prisma/client.ts
|
||||
f1f6280ff65c1e8aab5ce30307c8777ce1795f81bf1a513396263fc1c9a30e5c *src/generated/prisma/commonInputTypes.ts
|
||||
8bcc37ae19ee9c55424b735ce5f3a0972b7e741958ef86b350c9a54dc2238a0d *src/generated/prisma/enums.ts
|
||||
d52628a3dc8285d9014e2fda489badce6c88604810e2975310ab6c40ec3c708d *src/generated/prisma/internal/class.ts
|
||||
91794eb9b3c395e57b96d18f2bae895149f7b70ddf0fda8b817e7aa9da17a950 *src/generated/prisma/internal/prismaNamespace.ts
|
||||
6645109792ed08507d79da319aaecc2752bffa6e8bd5a3364dccc5ae93e2f23c *src/generated/prisma/internal/prismaNamespaceBrowser.ts
|
||||
b8ffef1ad4428179847aafe66abee3ad5eb74a84fff46265532551b10ab2537d *src/generated/prisma/models.ts
|
||||
daecbb1c94a96d19da4accfdf81fe8f11fa73c543958016bc7d6060d454f3943 *src/generated/prisma/models/AnswerOption.ts
|
||||
87e18727cec17a341d32709bc0c1602c66e6c6dcc57672b9c9fbe38a6ce12801 *src/generated/prisma/models/ArcadeAttempt.ts
|
||||
0c9de62bdbefafe8b5ff5300d1d764cc73235a077f8e3b3e03e6144287610c9d *src/generated/prisma/models/ArcadePack.ts
|
||||
67b8ea01f0a866d14db7fdff3178a080e2879990129c5e08690463fb9940f48d *src/generated/prisma/models/AuthSecurity.ts
|
||||
6885465a57019e8cba28755d9c6abaa087e913d666d02d8912abf0790c41a77b *src/generated/prisma/models/Class.ts
|
||||
ea873177511c6f7354dc01ec0ad228d9f777bca2fbfb15d281d3f2ab8f817ebf *src/generated/prisma/models/Deck.ts
|
||||
1082ff29bec6e4f920c93d778032ffffa324c312b05efd6c5fa7ada1be6674f7 *src/generated/prisma/models/Flashcard.ts
|
||||
de94c2da405c08a0b690aeb1b0cdc03582c94ccddaf913f4c6d1b18d8c413b61 *src/generated/prisma/models/MaterialGroup.ts
|
||||
941998d2c098e9fe5cd0a888bac470190108238c936c1d7cfce2742ed1e40d8a *src/generated/prisma/models/Question.ts
|
||||
3d54ed93e516f4d773c200b166a84ab2c9c8b7b0d7f7ddfd61afc3ca057ee755 *src/generated/prisma/models/QuizAttempt.ts
|
||||
454f19f97a82b8f37fc5c5179e87afe6ea185a79810043471d55c0a8ca26f14a *src/generated/prisma/models/QuizSet.ts
|
||||
3b884aaaad311e8f22c1390cf24ebe69b74b52a97c213781d2a4cbb56288b8a7 *src/generated/prisma/models/Setting.ts
|
||||
1ccf32b2f56b1d35c9b7152d2e1817b4c1b7853549a1ff187e9fb7c29fa49f2a *src/generated/prisma/models/ShareLink.ts
|
||||
cba950d431c702afe67f3ac92d8397275d3aeeebbc59d357fd20dec2123e0ee2 *src/generated/prisma/models/SpacedRepetitionCardState.ts
|
||||
2ef43d1c5d7e2eb0d85080fb29703ae99c1704ba8f5200703e2bb8e7a617bf04 *src/generated/prisma/models/SpacedRepetitionSet.ts
|
||||
0bf9eab9f937ef2d621ccf0cea8e209eae9372a9b333dfe6b18267e563a6b2b0 *src/generated/prisma/models/SpacedRepetitionSetDeck.ts
|
||||
916bda8f95647f0dc3560bc6848c4a97d4c1f37fd5ed54e6736d9d7d45e08adc *src/generated/prisma/models/StudyActivity.ts
|
||||
36724157e76b49df49e7827eacbcb0ab8336917f35913777701d8a6830770500 *src/generated/prisma/models/StudyProgress.ts
|
||||
52
audit-results/tmp/inspect-db.cjs
Normal file
52
audit-results/tmp/inspect-db.cjs
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
// Read-only diagnostic: introspect the temp DB created by `prisma migrate deploy`
|
||||
// to check whether the migrated schema matches schema.prisma (drift check).
|
||||
// Usage: node audit-results/tmp/inspect-db.cjs <path-to-sqlite-db>
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dbPath = process.argv[2];
|
||||
if (!dbPath) {
|
||||
console.error("usage: node inspect-db.cjs <db-path>");
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const db = new Database(path.resolve(dbPath), { readonly: true });
|
||||
|
||||
const tables = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
|
||||
console.log("=== TABLES ===");
|
||||
console.log(tables.join(", "));
|
||||
console.log("Has MaterialGroup table:", tables.includes("MaterialGroup"));
|
||||
|
||||
for (const table of ["Deck", "QuizSet", "ShareLink", "MaterialGroup"]) {
|
||||
if (tables.includes(table)) {
|
||||
const cols = db
|
||||
.prepare(`PRAGMA table_info('${table}')`)
|
||||
.all()
|
||||
.map((r) => r.name);
|
||||
console.log(`--- ${table} columns ---`);
|
||||
console.log(cols.join(", "));
|
||||
if (table === "Deck" || table === "QuizSet" || table === "ShareLink") {
|
||||
console.log(` -> has groupId:`, cols.includes("groupId"));
|
||||
}
|
||||
} else {
|
||||
console.log(`--- ${table}: MISSING TABLE ---`);
|
||||
}
|
||||
}
|
||||
|
||||
const indexes = db
|
||||
.prepare("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name")
|
||||
.all()
|
||||
.map((r) => `${r.tbl_name}.${r.name}`);
|
||||
console.log("=== INDEXES (count) ===", indexes.length);
|
||||
|
||||
// Confirm migrations table
|
||||
const applied = db.prepare("SELECT migration_name FROM _prisma_migrations ORDER BY started_at").all();
|
||||
console.log("=== APPLIED MIGRATIONS ===");
|
||||
applied.forEach((m) => console.log(" -", m.migration_name));
|
||||
|
||||
db.close();
|
||||
console.log("DONE");
|
||||
40
audit-results/tmp/reproduce-drift.cjs
Normal file
40
audit-results/tmp/reproduce-drift.cjs
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Read-only diagnostic: execute the exact SQL shape the generated Prisma client
|
||||
// emits for deck.findMany() / materialGroup.findMany() against a DB built purely
|
||||
// from `prisma migrate deploy` (predev + Docker entrypoint flow).
|
||||
// Proves DBAUD-01: migrated DB is incompatible with the shipped client.
|
||||
const path = require("node:path");
|
||||
const Database = require("better-sqlite3");
|
||||
|
||||
const dbPath = path.resolve("audit-results/tmp/audit-migration.db");
|
||||
const db = new Database(dbPath, { readonly: true });
|
||||
|
||||
// 1. Shape of prisma.deck.findMany({ include: { class: true } }) — Prisma selects
|
||||
// every scalar field, including groupId (schema.prisma:33-34, generated client).
|
||||
const deckSql =
|
||||
'SELECT "Deck"."id", "Deck"."classId", "Deck"."name", "Deck"."description", "Deck"."sortOrder", "Deck"."createdAt", "Deck"."groupId" FROM "Deck"';
|
||||
try {
|
||||
db.prepare(deckSql).all();
|
||||
console.log("deck.findMany SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("deck.findMany SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
// 2. Shape of prisma.materialGroup.findMany()
|
||||
const groupSql = 'SELECT "MaterialGroup"."id", "MaterialGroup"."classId", "MaterialGroup"."name", "MaterialGroup"."type", "MaterialGroup"."sortOrder", "MaterialGroup"."createdAt" FROM "MaterialGroup"';
|
||||
try {
|
||||
db.prepare(groupSql).all();
|
||||
console.log("materialGroup.findMany SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("materialGroup.findMany SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
// 3. Shape of prisma.shareLink.findFirst() (share page validation)
|
||||
const shareSql = 'SELECT "ShareLink"."id", "ShareLink"."targetType", "ShareLink"."deckId", "ShareLink"."quizSetId", "ShareLink"."groupId", "ShareLink"."createdAt" FROM "ShareLink" LIMIT 1';
|
||||
try {
|
||||
db.prepare(shareSql).all();
|
||||
console.log("shareLink.findFirst SQL: OK");
|
||||
} catch (e) {
|
||||
console.log("shareLink.findFirst SQL: FAILED ->", e.message);
|
||||
}
|
||||
|
||||
db.close();
|
||||
27
audit-results/tmp/validate-findings.cjs
Normal file
27
audit-results/tmp/validate-findings.cjs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
// Validates audit-results/FINDINGS.json parses and reports counts per severity.
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const file = path.resolve("audit-results/FINDINGS.json");
|
||||
const raw = fs.readFileSync(file, "utf8");
|
||||
const data = JSON.parse(raw); // throws if invalid
|
||||
|
||||
const findings = data.findings;
|
||||
const bySeverity = {};
|
||||
for (const f of findings) {
|
||||
bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1;
|
||||
}
|
||||
const byConfidence = {};
|
||||
for (const f of findings) {
|
||||
byConfidence[f.confidence] = (byConfidence[f.confidence] || 0) + 1;
|
||||
}
|
||||
|
||||
const ids = findings.map((f) => f.id);
|
||||
const dupes = ids.filter((id, i) => ids.indexOf(id) !== i);
|
||||
if (dupes.length) throw new Error("duplicate ids: " + dupes.join(","));
|
||||
|
||||
console.log("FINDINGS.json: VALID JSON");
|
||||
console.log("total findings:", findings.length);
|
||||
console.log("by severity:", JSON.stringify(bySeverity));
|
||||
console.log("by confidence:", JSON.stringify(byConfidence));
|
||||
console.log("ids:", ids.join(", "));
|
||||
149
benchmarks/remediationBenchmark.test.ts
Normal file
149
benchmarks/remediationBenchmark.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { performance } from "node:perf_hooks";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { afterAll, describe, expect, it } from "vitest";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getActivitySummary } from "@/services/activityService";
|
||||
import { listClasses } from "@/services/classService";
|
||||
import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService";
|
||||
|
||||
const ACTIVITY_ROWS = 100_000;
|
||||
const CARD_ROWS = 10_000;
|
||||
const SETS = 5;
|
||||
const REQUEST_BUDGET_MS = 250;
|
||||
const NOW = new Date("2026-08-07T18:00:00.000Z");
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
function databasePath() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url?.startsWith("file:./")) {
|
||||
throw new Error("Benchmark requires the disposable Vitest database");
|
||||
}
|
||||
return path.resolve(process.cwd(), url.slice("file:".length));
|
||||
}
|
||||
|
||||
function seedRealApplicationSchema() {
|
||||
const database = new Database(databasePath());
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
const insertActivity = database.prepare(
|
||||
'INSERT INTO "StudyActivity" ("id", "type", "occurredAt") VALUES (?, ?, ?)'
|
||||
);
|
||||
const insertDeck = database.prepare(
|
||||
'INSERT INTO "Deck" ("id", "classId", "name", "sortOrder") VALUES (?, ?, ?, ?)'
|
||||
);
|
||||
const insertSet = database.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "newCardsPerDay", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertMembership = database.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
|
||||
);
|
||||
const insertCard = database.prepare(
|
||||
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
);
|
||||
const insertState = database.prepare(`
|
||||
INSERT INTO "SpacedRepetitionCardState"
|
||||
("id", "setId", "flashcardId", "due", "stability", "difficulty", "elapsedDays", "scheduledDays", "learningSteps", "reps", "lapses", "state", "firstReviewedAt", "lastReview", "createdAt", "updatedAt")
|
||||
VALUES (?, ?, ?, ?, 1, 5, 1, ?, 0, 1, 0, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
database.transaction(() => {
|
||||
database.prepare(
|
||||
'INSERT INTO "Class" ("id", "slug", "name", "sortOrder") VALUES (?, ?, ?, 0)'
|
||||
).run("benchmark-class", "benchmark-class", "Benchmark Class");
|
||||
|
||||
for (let index = 0; index < ACTIVITY_ROWS; index += 1) {
|
||||
const ageDays = index % 730;
|
||||
const type = index % 3 === 0
|
||||
? "FLASHCARD"
|
||||
: index % 3 === 1
|
||||
? "QUIZ_QUESTION"
|
||||
: "ARCADE_GROUP";
|
||||
insertActivity.run(
|
||||
`activity-${index}`,
|
||||
type,
|
||||
NOW.getTime() - ageDays * 86_400_000
|
||||
);
|
||||
}
|
||||
|
||||
for (let setIndex = 0; setIndex < SETS; setIndex += 1) {
|
||||
const deckId = `benchmark-deck-${setIndex}`;
|
||||
const setId = `benchmark-set-${setIndex}`;
|
||||
insertDeck.run(deckId, "benchmark-class", `Deck ${setIndex}`, setIndex);
|
||||
insertSet.run(setId, "benchmark-class", `Set ${setIndex}`, 30, setIndex, NOW.getTime());
|
||||
insertMembership.run(setId, deckId, 0);
|
||||
for (let cardIndex = setIndex; cardIndex < CARD_ROWS; cardIndex += SETS) {
|
||||
const cardId = `benchmark-card-${cardIndex}`;
|
||||
const due = NOW.getTime() + ((cardIndex % 200) - 100) * 60_000;
|
||||
const firstReviewed = NOW.getTime() - (cardIndex % 30) * 86_400_000;
|
||||
const scheduledDays = cardIndex % 4 === 0 ? 1 : 0;
|
||||
const state = cardIndex % 4;
|
||||
insertCard.run(cardId, deckId, `Front ${cardIndex}`, `Back ${cardIndex}`, cardIndex);
|
||||
insertState.run(
|
||||
`benchmark-state-${cardIndex}`,
|
||||
setId,
|
||||
cardId,
|
||||
due,
|
||||
scheduledDays,
|
||||
state,
|
||||
firstReviewed,
|
||||
firstReviewed,
|
||||
firstReviewed,
|
||||
NOW.getTime()
|
||||
);
|
||||
}
|
||||
}
|
||||
})();
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function measure<T>(label: string, operation: () => Promise<T>) {
|
||||
const start = performance.now();
|
||||
const value = await operation();
|
||||
return {
|
||||
label,
|
||||
latencyMs: Number((performance.now() - start).toFixed(2)),
|
||||
value,
|
||||
};
|
||||
}
|
||||
|
||||
describe("remediation performance condition", () => {
|
||||
it("measures actual migrated services at the expected upper-use fixture", async () => {
|
||||
seedRealApplicationSchema();
|
||||
const activity = await measure("getActivitySummary", () => getActivitySummary(NOW));
|
||||
const availability = await measure("getStudyAvailabilityByClass", () =>
|
||||
getStudyAvailabilityByClass(NOW)
|
||||
);
|
||||
const classPolling = await measure("listClasses/navbar-poll", () => listClasses());
|
||||
const measurements = [activity, availability, classPolling].map((result) => ({
|
||||
label: result.label,
|
||||
latencyMs: result.latencyMs,
|
||||
}));
|
||||
|
||||
console.info(JSON.stringify({
|
||||
fixture: { activityRows: ACTIVITY_ROWS, cards: CARD_ROWS, sets: SETS },
|
||||
budgetMs: REQUEST_BUDGET_MS,
|
||||
prismaServiceOperations: { activity: 1, availability: 1, classPolling: 2 },
|
||||
measurements,
|
||||
activity: {
|
||||
displayedDays: activity.value.days.length,
|
||||
currentStreak: activity.value.currentStreak,
|
||||
},
|
||||
readyCards: availability.value["benchmark-class"]?.readyCards,
|
||||
}, null, 2));
|
||||
|
||||
expect(activity.value.days).toHaveLength(53 * 7);
|
||||
expect(activity.value.currentStreak).toBeGreaterThan(53 * 7);
|
||||
expect(availability.value["benchmark-class"]).toBeDefined();
|
||||
expect(classPolling.value).toHaveLength(1);
|
||||
for (const measurement of measurements) {
|
||||
expect(measurement.latencyMs, measurement.label).toBeLessThanOrEqual(
|
||||
REQUEST_BUDGET_MS
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,15 @@
|
|||
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
|
||||
environment:
|
||||
- NODE_ENV=development
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- ./data:/app/data
|
||||
|
|
@ -4,11 +4,13 @@ services:
|
|||
container_name: study-app
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "3000:3000"
|
||||
- "3000:3726"
|
||||
environment:
|
||||
- DATABASE_URL=file:/app/data/study.db
|
||||
- SESSION_SECRET=${SESSION_SECRET}
|
||||
- ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH}
|
||||
- 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}
|
||||
- NODE_ENV=production
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
|
|
|
|||
|
|
@ -1,4 +1,12 @@
|
|||
#!/bin/sh
|
||||
set -e
|
||||
npx prisma migrate deploy
|
||||
|
||||
DEFAULT_SECRET='dev-session-secret-change-in-production-must-be-32-chars'
|
||||
TRIMMED_SECRET=$(printf '%s' "${SESSION_SECRET:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//')
|
||||
if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SECRET" = "$DEFAULT_SECRET" ]; then
|
||||
echo >&2 "SESSION_SECRET must be a non-default value of at least 32 characters"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
./node_modules/.bin/prisma migrate deploy
|
||||
exec node server.js
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const eslintConfig = defineConfig([
|
|||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
"audit-results/tmp/**",
|
||||
]),
|
||||
]);
|
||||
|
||||
|
|
|
|||
1
out.css
1
out.css
|
|
@ -1 +0,0 @@
|
|||
:root { --color-primary: #fff; }
|
||||
625
package-lock.json
generated
625
package-lock.json
generated
File diff suppressed because it is too large
Load diff
11
package.json
11
package.json
|
|
@ -8,7 +8,11 @@
|
|||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"benchmark:remediation": "vitest run --config vitest.benchmark.config.ts",
|
||||
"db:preflight": "node scripts/migration-preflight.mjs",
|
||||
"db:backup": "node scripts/backup-database.mjs",
|
||||
"auth:reset": "node scripts/request-password-reset.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
|
|
@ -20,7 +24,8 @@
|
|||
"better-sqlite3": "^12.11.1",
|
||||
"iron-session": "^8.0.4",
|
||||
"jsonrepair": "^3.14.1",
|
||||
"next": "16.2.9",
|
||||
"next": "16.3.0",
|
||||
"prisma": "^7.8.0",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
|
|
@ -30,12 +35,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"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,68 @@
|
|||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "MaterialGroup" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"type" TEXT NOT NULL,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "MaterialGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
CREATE TABLE "new_Deck" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"groupId" TEXT,
|
||||
CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Deck_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Deck" ("classId", "createdAt", "description", "id", "name", "sortOrder")
|
||||
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "Deck";
|
||||
DROP TABLE "Deck";
|
||||
ALTER TABLE "new_Deck" RENAME TO "Deck";
|
||||
|
||||
CREATE TABLE "new_QuizSet" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"groupId" TEXT,
|
||||
CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "QuizSet_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_QuizSet" ("classId", "createdAt", "description", "id", "name", "sortOrder")
|
||||
SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "QuizSet";
|
||||
DROP TABLE "QuizSet";
|
||||
ALTER TABLE "new_QuizSet" RENAME TO "QuizSet";
|
||||
|
||||
CREATE TABLE "new_ShareLink" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"targetType" TEXT NOT NULL,
|
||||
"deckId" TEXT,
|
||||
"quizSetId" TEXT,
|
||||
"groupId" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ShareLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_ShareLink" ("createdAt", "deckId", "id", "quizSetId", "targetType")
|
||||
SELECT "createdAt", "deckId", "id", "quizSetId", "targetType" FROM "ShareLink";
|
||||
DROP TABLE "ShareLink";
|
||||
ALTER TABLE "new_ShareLink" RENAME TO "ShareLink";
|
||||
CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId");
|
||||
CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId");
|
||||
CREATE UNIQUE INDEX "ShareLink_groupId_key" ON "ShareLink"("groupId");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT;
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
-- AlterTable
|
||||
ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy';
|
||||
ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0;
|
||||
|
|
@ -99,6 +99,8 @@ 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)
|
||||
|
|
@ -109,13 +111,14 @@ model StudyProgress {
|
|||
}
|
||||
|
||||
model QuizAttempt {
|
||||
id String @id @default(uuid())
|
||||
quizSetId String
|
||||
score Float
|
||||
maxScore Int
|
||||
answersJson String
|
||||
isPartialRetake Boolean @default(false)
|
||||
completedAt DateTime @default(now())
|
||||
id String @id @default(uuid())
|
||||
quizSetId String
|
||||
score Float
|
||||
maxScore Int
|
||||
answersJson String
|
||||
reviewSnapshotJson String?
|
||||
isPartialRetake Boolean @default(false)
|
||||
completedAt DateTime @default(now())
|
||||
|
||||
quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
|
|
|||
42
scripts/backup-database.mjs
Normal file
42
scripts/backup-database.mjs
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
function argument(name) {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] : undefined;
|
||||
}
|
||||
|
||||
export async function backupDatabase(sourcePath, outputPath) {
|
||||
const source = path.resolve(sourcePath);
|
||||
const output = path.resolve(outputPath);
|
||||
if (!existsSync(source)) throw new Error(`Source database does not exist: ${source}`);
|
||||
if (existsSync(output)) throw new Error(`Backup destination already exists: ${output}`);
|
||||
if (source === output) throw new Error("Backup destination must differ from source");
|
||||
|
||||
const database = new Database(source, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
await database.backup(output);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const backup = new Database(output, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const integrity = backup.pragma("integrity_check", { simple: true });
|
||||
if (integrity !== "ok") throw new Error(`Backup integrity check failed: ${integrity}`);
|
||||
} finally {
|
||||
backup.close();
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const source = argument("--source") ?? databasePathFromUrl(process.env.DATABASE_URL);
|
||||
const output = argument("--output");
|
||||
if (!output) throw new Error("Usage: npm run db:backup -- --output <new-backup.db>");
|
||||
const completedPath = await backupDatabase(source, output);
|
||||
console.info(`Verified SQLite backup created: ${completedPath}`);
|
||||
}
|
||||
10
scripts/databasePath.mjs
Normal file
10
scripts/databasePath.mjs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import path from "node:path";
|
||||
|
||||
export function databasePathFromUrl(databaseUrl, cwd = process.cwd()) {
|
||||
if (!databaseUrl?.startsWith("file:")) {
|
||||
throw new Error("DATABASE_URL must be a file: SQLite URL");
|
||||
}
|
||||
const rawPath = databaseUrl.slice("file:".length).split("?")[0];
|
||||
if (!rawPath) throw new Error("DATABASE_URL does not contain a database path");
|
||||
return path.resolve(cwd, rawPath);
|
||||
}
|
||||
312
scripts/migration-preflight.mjs
Normal file
312
scripts/migration-preflight.mjs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
export const GROUP_MIGRATION = "20260807090000_add_material_groups";
|
||||
|
||||
function tableExists(database, table) {
|
||||
return Boolean(
|
||||
database
|
||||
.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(table)
|
||||
);
|
||||
}
|
||||
|
||||
function hasColumn(database, table, column) {
|
||||
if (!tableExists(database, table)) return false;
|
||||
return database.pragma(`table_info(${table})`).some((item) => item.name === column);
|
||||
}
|
||||
|
||||
function hasForeignKey(database, table, from, target, onDelete) {
|
||||
return database
|
||||
.pragma(`foreign_key_list(${table})`)
|
||||
.some(
|
||||
(item) =>
|
||||
item.from === from && item.table === target && item.on_delete === onDelete
|
||||
);
|
||||
}
|
||||
|
||||
function hasUniqueIndex(database, table, indexName, column) {
|
||||
const index = database
|
||||
.pragma(`index_list(${table})`)
|
||||
.find((item) => item.name === indexName && item.unique === 1);
|
||||
if (!index) return false;
|
||||
const columns = database.pragma(`index_info(${indexName})`);
|
||||
return columns.length === 1 && columns[0].name === column;
|
||||
}
|
||||
|
||||
function schemaSnapshot(database) {
|
||||
const tables = database
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' AND name <> '_prisma_migrations' ORDER BY name"
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
|
||||
return tables.map((table) => ({
|
||||
table,
|
||||
columns: database.pragma(`table_info(${JSON.stringify(table)})`).map((column) => ({
|
||||
name: column.name,
|
||||
type: column.type,
|
||||
notnull: column.notnull,
|
||||
defaultValue: column.dflt_value,
|
||||
primaryKey: column.pk,
|
||||
})),
|
||||
foreignKeys: database
|
||||
.pragma(`foreign_key_list(${JSON.stringify(table)})`)
|
||||
.map((foreignKey) => ({
|
||||
id: foreignKey.id,
|
||||
sequence: foreignKey.seq,
|
||||
target: foreignKey.table,
|
||||
from: foreignKey.from,
|
||||
to: foreignKey.to,
|
||||
onUpdate: foreignKey.on_update,
|
||||
onDelete: foreignKey.on_delete,
|
||||
match: foreignKey.match,
|
||||
}))
|
||||
.sort((left, right) => left.id - right.id || left.sequence - right.sequence),
|
||||
indexes: database
|
||||
.pragma(`index_list(${JSON.stringify(table)})`)
|
||||
.filter((index) => !index.name.startsWith("sqlite_autoindex_"))
|
||||
.map((index) => ({
|
||||
name: index.name,
|
||||
unique: index.unique,
|
||||
partial: index.partial,
|
||||
columns: database
|
||||
.pragma(`index_info(${JSON.stringify(index.name)})`)
|
||||
.map((column) => column.name),
|
||||
}))
|
||||
.sort((left, right) => left.name.localeCompare(right.name)),
|
||||
}));
|
||||
}
|
||||
|
||||
function expectedSchemaSnapshot(migrationNames) {
|
||||
const database = new Database(":memory:");
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
for (const migrationName of migrationNames) {
|
||||
database.exec(
|
||||
readFileSync(
|
||||
path.join(
|
||||
process.cwd(),
|
||||
"prisma",
|
||||
"migrations",
|
||||
migrationName,
|
||||
"migration.sql"
|
||||
),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
return schemaSnapshot(database);
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function inspectMigrationState(databasePath) {
|
||||
if (!existsSync(databasePath)) {
|
||||
throw new Error(`Database does not exist: ${databasePath}`);
|
||||
}
|
||||
const database = new Database(databasePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const migrationNames = tableExists(database, "_prisma_migrations")
|
||||
? database
|
||||
.prepare(
|
||||
'SELECT "migration_name" FROM "_prisma_migrations" WHERE "finished_at" IS NOT NULL AND "rolled_back_at" IS NULL'
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.migration_name)
|
||||
: [];
|
||||
const committedMigrations = readdirSync(
|
||||
path.join(process.cwd(), "prisma", "migrations"),
|
||||
{ withFileTypes: true }
|
||||
)
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
const priorMigrations = committedMigrations.filter(
|
||||
(name) => name < GROUP_MIGRATION
|
||||
);
|
||||
const laterMigrations = committedMigrations.filter(
|
||||
(name) => name > GROUP_MIGRATION
|
||||
);
|
||||
|
||||
const artifacts = {
|
||||
materialGroup: tableExists(database, "MaterialGroup"),
|
||||
deckColumn: hasColumn(database, "Deck", "groupId"),
|
||||
quizColumn: hasColumn(database, "QuizSet", "groupId"),
|
||||
shareColumn: hasColumn(database, "ShareLink", "groupId"),
|
||||
deckForeignKey: hasForeignKey(
|
||||
database,
|
||||
"Deck",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"SET NULL"
|
||||
),
|
||||
quizForeignKey: hasForeignKey(
|
||||
database,
|
||||
"QuizSet",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"SET NULL"
|
||||
),
|
||||
shareForeignKey: hasForeignKey(
|
||||
database,
|
||||
"ShareLink",
|
||||
"groupId",
|
||||
"MaterialGroup",
|
||||
"CASCADE"
|
||||
),
|
||||
shareUnique: hasUniqueIndex(
|
||||
database,
|
||||
"ShareLink",
|
||||
"ShareLink_groupId_key",
|
||||
"groupId"
|
||||
),
|
||||
};
|
||||
const values = Object.values(artifacts);
|
||||
const groupArtifactsPresent = values.every(Boolean);
|
||||
const groupArtifactsAbsent = values.every((value) => !value);
|
||||
const groupMigrationApplied = migrationNames.includes(GROUP_MIGRATION);
|
||||
const priorHistoryComplete = priorMigrations.every((name) =>
|
||||
migrationNames.includes(name)
|
||||
);
|
||||
const knownMigrationNames = new Set(committedMigrations);
|
||||
const historyHasUnknown = migrationNames.some(
|
||||
(name) => !knownMigrationNames.has(name)
|
||||
);
|
||||
const laterHistoryPresent = laterMigrations.some((name) =>
|
||||
migrationNames.includes(name)
|
||||
);
|
||||
const actualSchema = schemaSnapshot(database);
|
||||
const matches = (expected) =>
|
||||
JSON.stringify(actualSchema) === JSON.stringify(expected);
|
||||
const preGroupSchemaExact = matches(expectedSchemaSnapshot(priorMigrations));
|
||||
const adoptedGroupSchemaExact = matches(
|
||||
expectedSchemaSnapshot([...priorMigrations, GROUP_MIGRATION])
|
||||
);
|
||||
const appliedPrefix = committedMigrations.filter((name, index) =>
|
||||
committedMigrations.slice(0, index + 1).every((candidate) =>
|
||||
migrationNames.includes(candidate)
|
||||
)
|
||||
);
|
||||
const appliedHistoryExact =
|
||||
!historyHasUnknown &&
|
||||
migrationNames.length === appliedPrefix.length &&
|
||||
migrationNames.every((name) => appliedPrefix.includes(name));
|
||||
const trackedSchemaExact =
|
||||
appliedHistoryExact && matches(expectedSchemaSnapshot(appliedPrefix));
|
||||
|
||||
let classification = "CONFLICT";
|
||||
if (groupMigrationApplied && trackedSchemaExact) classification = "CURRENT";
|
||||
else if (
|
||||
!groupMigrationApplied &&
|
||||
!laterHistoryPresent &&
|
||||
priorHistoryComplete &&
|
||||
migrationNames.length === priorMigrations.length &&
|
||||
groupArtifactsAbsent &&
|
||||
preGroupSchemaExact
|
||||
) classification = "APPLY";
|
||||
else if (
|
||||
!groupMigrationApplied &&
|
||||
!laterHistoryPresent &&
|
||||
priorHistoryComplete &&
|
||||
migrationNames.length === priorMigrations.length &&
|
||||
groupArtifactsPresent &&
|
||||
adoptedGroupSchemaExact
|
||||
) classification = "ADOPT";
|
||||
|
||||
return {
|
||||
classification,
|
||||
artifacts,
|
||||
migrationNames,
|
||||
priorHistoryComplete,
|
||||
exactSchema: {
|
||||
preGroup: preGroupSchemaExact,
|
||||
adoptedGroup: adoptedGroupSchemaExact,
|
||||
tracked: trackedSchemaExact,
|
||||
},
|
||||
};
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
function verifyBackup(backupPath, sourcePath) {
|
||||
if (!backupPath || !existsSync(backupPath)) {
|
||||
throw new Error("--resolve requires an existing verified --backup file");
|
||||
}
|
||||
if (path.resolve(backupPath) === path.resolve(sourcePath)) {
|
||||
throw new Error("--backup must be a separate database file, not the source database");
|
||||
}
|
||||
const backup = new Database(backupPath, { readonly: true, fileMustExist: true });
|
||||
const source = new Database(sourcePath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
if (backup.pragma("integrity_check", { simple: true }) !== "ok") {
|
||||
throw new Error("Backup failed SQLite integrity_check");
|
||||
}
|
||||
if (JSON.stringify(schemaSnapshot(backup)) !== JSON.stringify(schemaSnapshot(source))) {
|
||||
throw new Error("Backup schema does not match the source database");
|
||||
}
|
||||
const sourceCounts = Object.fromEntries(
|
||||
schemaSnapshot(source).map(({ table }) => [
|
||||
table,
|
||||
source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
const backupCounts = Object.fromEntries(
|
||||
schemaSnapshot(backup).map(({ table }) => [
|
||||
table,
|
||||
backup.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
if (JSON.stringify(sourceCounts) !== JSON.stringify(backupCounts)) {
|
||||
throw new Error("Backup row counts do not match the source database");
|
||||
}
|
||||
} finally {
|
||||
backup.close();
|
||||
source.close();
|
||||
}
|
||||
}
|
||||
|
||||
function resolveMigration(databaseUrl) {
|
||||
const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js");
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[cli, "migrate", "resolve", "--applied", GROUP_MIGRATION],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: { ...process.env, DATABASE_URL: databaseUrl },
|
||||
encoding: "utf8",
|
||||
}
|
||||
);
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`Prisma migration resolve failed:\n${result.stdout}\n${result.stderr}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const databaseUrl = process.env.DATABASE_URL;
|
||||
const databasePath = databasePathFromUrl(databaseUrl);
|
||||
const state = inspectMigrationState(databasePath);
|
||||
console.info(JSON.stringify(state, null, 2));
|
||||
|
||||
if (process.argv.includes("--resolve")) {
|
||||
if (state.classification !== "ADOPT") {
|
||||
throw new Error(`Refusing adoption for ${state.classification} database state`);
|
||||
}
|
||||
const backupIndex = process.argv.indexOf("--backup");
|
||||
verifyBackup(
|
||||
backupIndex >= 0 ? process.argv[backupIndex + 1] : undefined,
|
||||
databasePath
|
||||
);
|
||||
resolveMigration(databaseUrl);
|
||||
console.info(`Marked ${GROUP_MIGRATION} applied after exact-schema preflight.`);
|
||||
} else if (state.classification === "CONFLICT") {
|
||||
process.exitCode = 2;
|
||||
}
|
||||
}
|
||||
56
scripts/request-password-reset.mjs
Normal file
56
scripts/request-password-reset.mjs
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
||||
import { existsSync } from "node:fs";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
import { databasePathFromUrl } from "./databasePath.mjs";
|
||||
|
||||
const PASSWORD_HASH_KEY = "admin_password_hash";
|
||||
const RESET_TOKEN_KEY = "admin_password_reset";
|
||||
const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000;
|
||||
|
||||
function activeRecord(value) {
|
||||
try {
|
||||
const record = JSON.parse(value);
|
||||
return new Date(record.expiresAt).getTime() > Date.now();
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function createResetToken(databasePath) {
|
||||
if (!existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`);
|
||||
const database = new Database(databasePath, { fileMustExist: true });
|
||||
try {
|
||||
if (!database.prepare('SELECT 1 FROM "Setting" WHERE "key" = ?').get(PASSWORD_HASH_KEY)) {
|
||||
throw new Error("Password recovery is unavailable before initial setup");
|
||||
}
|
||||
const existing = database
|
||||
.prepare('SELECT "value" FROM "Setting" WHERE "key" = ?')
|
||||
.get(RESET_TOKEN_KEY);
|
||||
if (existing && activeRecord(existing.value)) {
|
||||
throw new Error("An unexpired reset token already exists; it was not replaced");
|
||||
}
|
||||
|
||||
const token = randomBytes(24).toString("base64url");
|
||||
const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString();
|
||||
const value = JSON.stringify({
|
||||
digest: createHash("sha256").update(token, "utf8").digest("hex"),
|
||||
nonce: randomUUID(),
|
||||
expiresAt,
|
||||
});
|
||||
database
|
||||
.prepare(
|
||||
'INSERT INTO "Setting" ("key", "value") VALUES (?, ?) ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"'
|
||||
)
|
||||
.run(RESET_TOKEN_KEY, value);
|
||||
return { token, expiresAt };
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
|
||||
if (import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
const result = createResetToken(databasePathFromUrl(process.env.DATABASE_URL));
|
||||
console.info(`Password reset token: ${result.token}`);
|
||||
console.info(`Expires: ${result.expiresAt}`);
|
||||
}
|
||||
197
scripts/verify-backup-restore.mjs
Normal file
197
scripts/verify-backup-restore.mjs
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
copyFileSync,
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
rmSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import path from "node:path";
|
||||
import Database from "better-sqlite3";
|
||||
import { backupDatabase } from "./backup-database.mjs";
|
||||
|
||||
const root = path.join(process.cwd(), ".test-databases");
|
||||
const directory = path.join(root, `backup-restore-${randomUUID()}`);
|
||||
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
throw new Error("Unsafe disposable backup verification path");
|
||||
}
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const sourcePath = path.join(directory, "source.test.db");
|
||||
const backupPath = path.join(directory, "backup.test.db");
|
||||
const restoredPath = path.join(directory, "restored.test.db");
|
||||
let source;
|
||||
|
||||
function rowCounts(database) {
|
||||
const tables = database
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name"
|
||||
)
|
||||
.all()
|
||||
.map((row) => row.name);
|
||||
return Object.fromEntries(
|
||||
tables.map((table) => [
|
||||
table,
|
||||
database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count,
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
source = new Database(sourcePath);
|
||||
source.pragma("foreign_keys = ON");
|
||||
source.pragma("journal_mode = WAL");
|
||||
source.pragma("wal_autocheckpoint = 0");
|
||||
const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), {
|
||||
withFileTypes: true,
|
||||
})
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
for (const migration of migrations) {
|
||||
source.exec(
|
||||
readFileSync(
|
||||
path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"),
|
||||
"utf8"
|
||||
)
|
||||
);
|
||||
}
|
||||
source.transaction(() => {
|
||||
source
|
||||
.prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)')
|
||||
.run("backup-class", "Representative content", "representative-content", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-group", "backup-class", "Backup group", "DECK", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
"backup-deck",
|
||||
"backup-class",
|
||||
"backup-group",
|
||||
"Backup deck",
|
||||
"Restore drill content",
|
||||
0
|
||||
);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-card", "backup-deck", "Representative front", "Representative back", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
"backup-question",
|
||||
"backup-quiz",
|
||||
"MULTIPLE_CHOICE",
|
||||
"Representative prompt",
|
||||
"Representative rationale",
|
||||
"backup",
|
||||
0
|
||||
);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-option", "backup-question", "Correct option", 1, 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run("backup-srs", "backup-class", "Backup SRS", 0, Date.now());
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)'
|
||||
)
|
||||
.run("backup-srs", "backup-deck", 0);
|
||||
source
|
||||
.prepare(
|
||||
'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)'
|
||||
)
|
||||
.run("backup-share", "GROUP", "backup-group");
|
||||
})();
|
||||
|
||||
const walPath = `${sourcePath}-wal`;
|
||||
if (!existsSync(walPath) || statSync(walPath).size === 0) {
|
||||
throw new Error("Backup drill did not create an active WAL database");
|
||||
}
|
||||
const sourceWalBytes = statSync(walPath).size;
|
||||
const sourceCounts = rowCounts(source);
|
||||
await backupDatabase(sourcePath, backupPath);
|
||||
copyFileSync(backupPath, restoredPath);
|
||||
source.close();
|
||||
source = undefined;
|
||||
|
||||
const restored = new Database(restoredPath, { readonly: true, fileMustExist: true });
|
||||
try {
|
||||
const integrity = restored.pragma("integrity_check", { simple: true });
|
||||
const foreignKeyErrors = restored.pragma("foreign_key_check");
|
||||
const restoredCounts = rowCounts(restored);
|
||||
const representative = restored
|
||||
.prepare(`
|
||||
SELECT c.name AS className, g.name AS groupName, d.description,
|
||||
f.front, s.name AS setName, sl.targetType
|
||||
FROM "Class" c
|
||||
JOIN "MaterialGroup" g ON g.classId = c.id
|
||||
JOIN "Deck" d ON d.groupId = g.id
|
||||
JOIN "Flashcard" f ON f.deckId = d.id
|
||||
JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id
|
||||
JOIN "SpacedRepetitionSet" s ON s.id = sd.setId
|
||||
JOIN "ShareLink" sl ON sl.groupId = g.id
|
||||
WHERE d.id = ?
|
||||
`)
|
||||
.get("backup-deck");
|
||||
const quizRelationship = restored
|
||||
.prepare(`
|
||||
SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect
|
||||
FROM "QuizSet" q
|
||||
JOIN "Question" question ON question.quizSetId = q.id
|
||||
JOIN "AnswerOption" option ON option.questionId = question.id
|
||||
WHERE q.id = ?
|
||||
`)
|
||||
.get("backup-quiz");
|
||||
if (
|
||||
integrity !== "ok" ||
|
||||
foreignKeyErrors.length !== 0 ||
|
||||
JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) ||
|
||||
representative?.description !== "Restore drill content" ||
|
||||
representative?.setName !== "Backup SRS" ||
|
||||
representative?.targetType !== "GROUP" ||
|
||||
quizRelationship?.isCorrect !== 1
|
||||
) {
|
||||
throw new Error("Restored database did not preserve integrity, counts, content, and relationships");
|
||||
}
|
||||
console.info(
|
||||
JSON.stringify(
|
||||
{
|
||||
method: "better-sqlite3 online backup while the WAL source remained open",
|
||||
sourceWalBytes,
|
||||
integrity,
|
||||
foreignKeyErrors,
|
||||
counts: restoredCounts,
|
||||
representative,
|
||||
quizRelationship,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
restored.close();
|
||||
}
|
||||
} finally {
|
||||
source?.close();
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
83
scripts/verify-http-smoke.mjs
Normal file
83
scripts/verify-http-smoke.mjs
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawn } from "node:child_process";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const root = path.join(process.cwd(), ".test-databases");
|
||||
const directory = path.join(root, `http-smoke-${randomUUID()}`);
|
||||
if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) {
|
||||
throw new Error("Unsafe disposable HTTP smoke path");
|
||||
}
|
||||
mkdirSync(directory, { recursive: true });
|
||||
const databasePath = path.join(directory, "http-smoke.test.db");
|
||||
const database = new Database(databasePath);
|
||||
try {
|
||||
database.pragma("foreign_keys = ON");
|
||||
for (const migration of readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort()) {
|
||||
database.exec(readFileSync(path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8"));
|
||||
}
|
||||
} finally {
|
||||
database.close();
|
||||
}
|
||||
|
||||
const port = 3789;
|
||||
const server = spawn(
|
||||
process.execPath,
|
||||
[path.join(process.cwd(), "node_modules", "next", "dist", "bin", "next"), "start", "-p", String(port)],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
DATABASE_URL: `file:${databasePath.replaceAll("\\", "/")}`,
|
||||
SESSION_SECRET: "http-smoke-secret-0123456789abcdef0123456789",
|
||||
ALLOW_INITIAL_SETUP: "true",
|
||||
},
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
windowsHide: true,
|
||||
}
|
||||
);
|
||||
let output = "";
|
||||
server.stdout.on("data", (chunk) => { output += chunk.toString(); });
|
||||
server.stderr.on("data", (chunk) => { output += chunk.toString(); });
|
||||
|
||||
try {
|
||||
let health;
|
||||
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||
try {
|
||||
health = await fetch(`http://127.0.0.1:${port}/api/health`);
|
||||
if (health.ok) break;
|
||||
} catch {}
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
}
|
||||
if (!health?.ok) throw new Error(`Health check did not become ready.\n${output}`);
|
||||
const login = await fetch(`http://127.0.0.1:${port}/login`);
|
||||
const setup = await fetch(`http://127.0.0.1:${port}/api/auth/setup-status`);
|
||||
const setupBody = await setup.json();
|
||||
const protectedDot = await fetch(`http://127.0.0.1:${port}/api/decks/file.json`, { redirect: "manual" });
|
||||
if (!login.ok || !setup.ok || setupBody.setupRequired !== true || setupBody.setupAllowed !== true || protectedDot.status !== 307) {
|
||||
throw new Error("HTTP smoke responses did not match the production contract");
|
||||
}
|
||||
console.info(JSON.stringify({
|
||||
health: health.status,
|
||||
login: login.status,
|
||||
setup: setupBody,
|
||||
protectedDot: { status: protectedDot.status, location: protectedDot.headers.get("location") },
|
||||
}, null, 2));
|
||||
if (process.argv.includes("--stay")) {
|
||||
console.info(`Browser smoke server ready at http://127.0.0.1:${port}/login`);
|
||||
await new Promise(() => {});
|
||||
}
|
||||
} finally {
|
||||
server.kill();
|
||||
await new Promise((resolve) => {
|
||||
if (server.exitCode !== null) return resolve();
|
||||
server.once("exit", resolve);
|
||||
setTimeout(resolve, 2_000);
|
||||
});
|
||||
rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
|
@ -25,6 +25,8 @@ interface DeckData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
updatedAt: string;
|
||||
}>;
|
||||
}
|
||||
|
|
@ -42,19 +44,24 @@ export default function DeckStudyPage() {
|
|||
async function handleRestart() {
|
||||
if (!deck) 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(() => {})
|
||||
]);
|
||||
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 locally and force remount
|
||||
setDeck({ ...deck, progress: [] });
|
||||
|
|
@ -75,7 +82,8 @@ export default function DeckStudyPage() {
|
|||
}, [deckId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchDeck();
|
||||
const timer = window.setTimeout(() => void fetchDeck(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [fetchDeck]);
|
||||
|
||||
if (loading || !deck) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
|
@ -13,7 +13,6 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
|
|
@ -41,6 +40,8 @@ interface DeckItem {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -50,15 +51,25 @@ interface MaterialGroup {
|
|||
sortOrder: number;
|
||||
}
|
||||
|
||||
const cache: Record<string, { decks: DeckItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
function getProgressLabel(deck: DeckItem) {
|
||||
if (!deck.progress?.length) return null;
|
||||
const prog = deck.progress[0];
|
||||
const order = JSON.parse(prog.orderJson) as string[];
|
||||
const results = prog.cardResultsJson
|
||||
? (JSON.parse(prog.cardResultsJson) as Record<string, string>)
|
||||
: {};
|
||||
let order: string[];
|
||||
let results: Record<string, string>;
|
||||
try {
|
||||
const parsedOrder: unknown = JSON.parse(prog.orderJson);
|
||||
const parsedResults: unknown = prog.cardResultsJson
|
||||
? JSON.parse(prog.cardResultsJson)
|
||||
: {};
|
||||
if (!Array.isArray(parsedOrder)) throw new Error("Invalid progress order");
|
||||
order = parsedOrder.filter((id): id is string => typeof id === "string");
|
||||
results =
|
||||
typeof parsedResults === "object" && parsedResults !== null
|
||||
? (parsedResults as Record<string, string>)
|
||||
: {};
|
||||
} catch {
|
||||
return "Saved session needs repair";
|
||||
}
|
||||
const correctCount = Object.values(results).filter((r) => r === "correct").length;
|
||||
const total = order.length;
|
||||
const current = Math.min(prog.currentIndex + 1, total);
|
||||
|
|
@ -70,10 +81,21 @@ 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, classSlug }: SortableDeckCardProps) {
|
||||
function SortableDeckCard({
|
||||
deck,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groups,
|
||||
onMove,
|
||||
moveDisabled = false,
|
||||
classSlug,
|
||||
}: SortableDeckCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -117,10 +139,24 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
|
|||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this deck.")) return;
|
||||
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(() => {})
|
||||
]);
|
||||
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 deck could not be restarted. Your saved progress was kept.");
|
||||
return;
|
||||
}
|
||||
window.location.href = `/${classSlug}/flashcards/${deck.id}`;
|
||||
}}
|
||||
className="flex-1 text-center py-2 px-4 rounded-lg bg-bg-surface-alt border border-border text-text-heading text-sm font-medium hover:bg-border transition-all duration-200 cursor-pointer shadow-sm"
|
||||
|
|
@ -136,6 +172,20 @@ function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: SortableDeckCar
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="sr-only" htmlFor={`move-deck-${deck.id}`}>Move {deck.name} to group</label>
|
||||
<select
|
||||
id={`move-deck-${deck.id}`}
|
||||
value={deck.groupId ?? ""}
|
||||
onChange={(event) => onMove(deck.id, event.target.value || null)}
|
||||
disabled={moveDisabled}
|
||||
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
|
||||
aria-label={`Move ${deck.name} to group`}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onEdit(deck)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -172,10 +222,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
|||
export default function FlashcardsPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [decks, setDecks] = useState<DeckItem[]>(cache[classSlug]?.decks || []);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
||||
const [animate] = useState(!cache[classSlug]);
|
||||
const [decks, setDecks] = useState<DeckItem[]>([]);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const requestGeneration = useRef(0);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -185,13 +237,17 @@ export default function FlashcardsPage() {
|
|||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const saved = localStorage.getItem('flashcards_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
|
|
@ -202,43 +258,54 @@ export default function FlashcardsPage() {
|
|||
});
|
||||
}
|
||||
|
||||
const fetchAll = useCallback(async (cId: string) => {
|
||||
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||
try {
|
||||
const [deckRes, groupRes] = await Promise.all([
|
||||
fetch(`/api/decks/list?classId=${cId}`),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`),
|
||||
fetch(`/api/decks/list?classId=${cId}`, { signal }),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }),
|
||||
]);
|
||||
const ds = deckRes.ok ? await deckRes.json() : [];
|
||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
||||
if (!deckRes.ok || !groupRes.ok) throw new Error("Unable to load flashcard library");
|
||||
const ds = await deckRes.json();
|
||||
const gs = await groupRes.json();
|
||||
if (generation !== requestGeneration.current) return;
|
||||
setDecks(ds);
|
||||
setGroups(gs);
|
||||
cache[classSlug] = { decks: ds, groups: gs };
|
||||
} catch {
|
||||
setDecks([]);
|
||||
setGroups([]);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (generation === requestGeneration.current) setLoading(false);
|
||||
}
|
||||
}, [classSlug]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const generation = ++requestGeneration.current;
|
||||
async function init() {
|
||||
try {
|
||||
const classRes = await fetch("/api/classes");
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||
if (!classRes.ok) throw new Error("Unable to load class");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (generation !== requestGeneration.current) return;
|
||||
setClassId(cls.id);
|
||||
fetchAll(cls.id);
|
||||
} catch {
|
||||
await fetchAll(cls.id, generation, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [classSlug, fetchAll]);
|
||||
const timer = window.setTimeout(() => void init(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [classSlug, fetchAll, reloadKey]);
|
||||
|
||||
// Group Management
|
||||
async function handleCreateGroup() {
|
||||
|
|
@ -253,45 +320,68 @@ export default function FlashcardsPage() {
|
|||
setGroups((prev) => [g, ...prev]);
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
setActionMessage(`Created group ${g.name}.`);
|
||||
} else {
|
||||
setActionMessage("The group could not be created. Please retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup(id: string) {
|
||||
if (!editGroupName.trim()) return;
|
||||
await fetch(`/api/material-groups/${id}`, {
|
||||
const response = await fetch(`/api/material-groups/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be renamed. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||
setEditingGroupId(null);
|
||||
setActionMessage("Group renamed.");
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id: string, name: string) {
|
||||
if (!confirm(`Delete group "${name}"? Decks inside will be moved to Uncategorized.`)) return;
|
||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
setDecks((prev) => prev.map((d) => (d.groupId === id ? { ...d, groupId: null } : d)));
|
||||
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetchAll(classId);
|
||||
setActionMessage(`Deleted group ${name}; its decks are now Uncategorized.`);
|
||||
}
|
||||
|
||||
// Deck Management
|
||||
async function handleDeleteDeck(deck: DeckItem) {
|
||||
if (!confirm(`Delete "${deck.name}" and all its cards?`)) return;
|
||||
await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
||||
const response = await fetch(`/api/decks/${deck.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The deck could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setDecks((prev) => prev.filter((d) => d.id !== deck.id));
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
setActionMessage(`Deleted deck ${deck.name}.`);
|
||||
}
|
||||
|
||||
async function handleRenameDeck(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
await fetch(`/api/decks/${id}`, {
|
||||
const response = await fetch(`/api/decks/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The deck could not be updated. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setDecks((prev) =>
|
||||
prev.map((d) => (d.id === id ? { ...d, name: editName.trim(), description: editDescription.trim() || null } : d))
|
||||
);
|
||||
setEditingId(null);
|
||||
setActionMessage("Deck updated.");
|
||||
}
|
||||
|
||||
// Drag and drop setup
|
||||
|
|
@ -301,15 +391,13 @@ export default function FlashcardsPage() {
|
|||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
if (isReordering) return;
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
// optional layout updates here
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -334,40 +422,76 @@ export default function FlashcardsPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (targetGroupId !== undefined) {
|
||||
setDecks((items) => {
|
||||
const oldIndex = items.findIndex((d) => d.id === activeId);
|
||||
const overIndex = items.findIndex((d) => d.id === overId);
|
||||
let newItems = [...items];
|
||||
|
||||
newItems[oldIndex].groupId = targetGroupId;
|
||||
const oldIndex = decks.findIndex((deck) => deck.id === activeId);
|
||||
const overIndex = decks.findIndex((deck) => deck.id === overId);
|
||||
let nextItems = decks.map((deck) =>
|
||||
deck.id === activeId ? { ...deck, groupId: targetGroupId } : { ...deck }
|
||||
);
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const [moved] = nextItems.splice(oldIndex, 1);
|
||||
nextItems.push(moved);
|
||||
}
|
||||
await persistDeckReorder(
|
||||
decks,
|
||||
nextItems,
|
||||
new Set([activeDeck.groupId, targetGroupId])
|
||||
);
|
||||
}
|
||||
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
||||
newItems.push(oldItem);
|
||||
}
|
||||
async function persistDeckReorder(
|
||||
previousItems: DeckItem[],
|
||||
proposedItems: DeckItem[],
|
||||
affectedGroups: Set<string | null>
|
||||
) {
|
||||
const counters = new Map<string | null, number>();
|
||||
const nextItems = proposedItems.map((item) => {
|
||||
if (!affectedGroups.has(item.groupId)) return item;
|
||||
const sortOrder = counters.get(item.groupId) ?? 0;
|
||||
counters.set(item.groupId, sortOrder + 1);
|
||||
return { ...item, sortOrder };
|
||||
});
|
||||
const updates: ReorderItem[] = nextItems
|
||||
.filter((item) => affectedGroups.has(item.groupId))
|
||||
.map((item) => ({ id: item.id, groupId: item.groupId }));
|
||||
|
||||
const affectedGroups = new Set([activeDeck.groupId, targetGroupId]);
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(d => d.groupId === gId);
|
||||
gItems.forEach((item, index) => {
|
||||
item.sortOrder = index;
|
||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
||||
});
|
||||
});
|
||||
|
||||
fetch("/api/decks/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
|
||||
return newItems;
|
||||
setDecks(nextItems);
|
||||
setIsReordering(true);
|
||||
try {
|
||||
const response = await fetch("/api/decks/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Reorder rejected");
|
||||
return true;
|
||||
} catch {
|
||||
setDecks(previousItems);
|
||||
window.alert("The deck move could not be saved. The library was refreshed.");
|
||||
await fetchAll(classId);
|
||||
return false;
|
||||
} finally {
|
||||
setIsReordering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveDeck(deckId: string, targetGroupId: string | null) {
|
||||
if (isReordering) return;
|
||||
const activeDeck = decks.find((deck) => deck.id === deckId);
|
||||
if (!activeDeck || activeDeck.groupId === targetGroupId) return;
|
||||
const nextItems = decks
|
||||
.filter((deck) => deck.id !== deckId)
|
||||
.map((deck) => ({ ...deck }));
|
||||
nextItems.push({ ...activeDeck, groupId: targetGroupId });
|
||||
const saved = await persistDeckReorder(
|
||||
decks,
|
||||
nextItems,
|
||||
new Set([activeDeck.groupId, targetGroupId])
|
||||
);
|
||||
if (saved) {
|
||||
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
|
||||
setActionMessage(`Moved ${activeDeck.name} to ${destination}.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -381,6 +505,11 @@ export default function FlashcardsPage() {
|
|||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
{actionMessage && (
|
||||
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Collection</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Flashcard decks</h2></div>
|
||||
|
|
@ -443,15 +572,23 @@ export default function FlashcardsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && decks.length === 0 && groups.length === 0 && (
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
|
||||
<p className="font-bold">Flashcard library could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && decks.length === 0 && groups.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No decks yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first deck or create a group</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
{!loading && !loadError && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedDecks.map(group => (
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
|
|
@ -485,7 +622,7 @@ export default function FlashcardsPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop decks here</div>
|
||||
) : (
|
||||
group.decks.map(deck => (
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -510,7 +647,7 @@ export default function FlashcardsPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized decks</div>
|
||||
) : (
|
||||
uncategorizedDecks.map(deck => (
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} />
|
||||
<SortableDeckCard key={deck.id} deck={deck} onEdit={(d: DeckItem) => { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -522,7 +659,7 @@ export default function FlashcardsPage() {
|
|||
<DragOverlay>
|
||||
{activeDeck ? (
|
||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
||||
<SortableDeckCard deck={activeDeck} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ interface QuizAttempt {
|
|||
score: number;
|
||||
maxScore: number;
|
||||
answersJson: string;
|
||||
reviewSnapshotJson?: string | null;
|
||||
isPartialRetake: boolean;
|
||||
completedAt: string;
|
||||
}
|
||||
|
|
@ -42,6 +43,8 @@ interface QuizData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -60,13 +63,23 @@ export default function QuizStudyPage() {
|
|||
|
||||
async function handleRestart() {
|
||||
if (!quiz) 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(() => {});
|
||||
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 locally and force remount
|
||||
setQuiz({ ...quiz, progress: [] });
|
||||
|
|
@ -104,7 +117,8 @@ export default function QuizStudyPage() {
|
|||
}, [quizId, classSlug, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuizAndAttempts();
|
||||
const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [fetchQuizAndAttempts]);
|
||||
|
||||
const [showTopics, setShowTopics] = useState(false);
|
||||
|
|
@ -221,6 +235,7 @@ export default function QuizStudyPage() {
|
|||
key={restartKey}
|
||||
quiz={quiz}
|
||||
retakeIds={retakeIds}
|
||||
sessionKey={restartKey}
|
||||
onFinished={() => {
|
||||
router.push(`/${classSlug}/quizzes`);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { ImportModal } from "@/components/import/ImportModal";
|
||||
|
|
@ -13,7 +13,6 @@ import {
|
|||
useSensor,
|
||||
useSensors,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
DragOverlay,
|
||||
useDroppable,
|
||||
|
|
@ -41,6 +40,8 @@ interface QuizItem {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
answersJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
|
|
@ -50,16 +51,25 @@ interface MaterialGroup {
|
|||
sortOrder: number;
|
||||
}
|
||||
|
||||
const cache: Record<string, { quizzes: QuizItem[]; groups: MaterialGroup[] }> = {};
|
||||
|
||||
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, classSlug }: SortableQuizCardProps) {
|
||||
function SortableQuizCard({
|
||||
quiz,
|
||||
onEdit,
|
||||
onDelete,
|
||||
groups,
|
||||
onMove,
|
||||
moveDisabled = false,
|
||||
classSlug,
|
||||
}: SortableQuizCardProps) {
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id });
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
|
|
@ -103,11 +113,22 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
|
|||
<button
|
||||
onClick={async () => {
|
||||
if (!confirm("Start over from the beginning? This will clear your current progress for this quiz.")) return;
|
||||
await fetch("/api/progress", {
|
||||
const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL");
|
||||
if (!progress) return;
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }),
|
||||
}).catch(() => {});
|
||||
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;
|
||||
}
|
||||
window.location.href = `/${classSlug}/quizzes/${quiz.id}`;
|
||||
}}
|
||||
className="flex-1 cursor-pointer rounded-lg border border-border bg-bg-surface-alt px-4 py-2 text-center text-sm font-medium text-text-heading shadow-sm transition-all duration-200 hover:bg-border"
|
||||
|
|
@ -123,6 +144,20 @@ function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: SortableQuizCar
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<label className="sr-only" htmlFor={`move-quiz-${quiz.id}`}>Move {quiz.name} to group</label>
|
||||
<select
|
||||
id={`move-quiz-${quiz.id}`}
|
||||
value={quiz.groupId ?? ""}
|
||||
onChange={(event) => onMove(quiz.id, event.target.value || null)}
|
||||
disabled={moveDisabled}
|
||||
className="min-h-9 max-w-36 rounded-lg border border-border bg-bg-surface-alt px-2 text-xs text-text-heading disabled:opacity-50"
|
||||
aria-label={`Move ${quiz.name} to group`}
|
||||
>
|
||||
<option value="">Uncategorized</option>
|
||||
{groups.map((group) => (
|
||||
<option key={group.id} value={group.id}>{group.name}</option>
|
||||
))}
|
||||
</select>
|
||||
<button onClick={() => onEdit(quiz)} className="p-2 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface-alt transition-all duration-200 cursor-pointer" title="Edit">
|
||||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" />
|
||||
|
|
@ -159,10 +194,12 @@ function DroppableContainer({ id, children }: { id: string; children: React.Reac
|
|||
export default function QuizzesPage() {
|
||||
const params = useParams();
|
||||
const classSlug = params.classSlug as string;
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>(cache[classSlug]?.quizzes || []);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>(cache[classSlug]?.groups || []);
|
||||
const [loading, setLoading] = useState(!cache[classSlug]);
|
||||
const [animate] = useState(!cache[classSlug]);
|
||||
const [quizzes, setQuizzes] = useState<QuizItem[]>([]);
|
||||
const [groups, setGroups] = useState<MaterialGroup[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [reloadKey, setReloadKey] = useState(0);
|
||||
const requestGeneration = useRef(0);
|
||||
const [showImport, setShowImport] = useState(false);
|
||||
const [classId, setClassId] = useState<string>("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
|
|
@ -172,13 +209,17 @@ export default function QuizzesPage() {
|
|||
const [newGroupName, setNewGroupName] = useState("");
|
||||
const [editingGroupId, setEditingGroupId] = useState<string | null>(null);
|
||||
const [editGroupName, setEditGroupName] = useState("");
|
||||
const [actionMessage, setActionMessage] = useState<string | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {}
|
||||
}
|
||||
const timer = window.setTimeout(() => {
|
||||
const saved = localStorage.getItem('quizzes_collapsed_groups');
|
||||
if (saved) {
|
||||
try { setCollapsedGroups(JSON.parse(saved)); } catch {}
|
||||
}
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
function toggleGroup(id: string) {
|
||||
|
|
@ -189,43 +230,54 @@ export default function QuizzesPage() {
|
|||
});
|
||||
}
|
||||
|
||||
const fetchAll = useCallback(async (cId: string) => {
|
||||
const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => {
|
||||
try {
|
||||
const [quizRes, groupRes] = await Promise.all([
|
||||
fetch(`/api/quizzes/list?classId=${cId}`),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`),
|
||||
fetch(`/api/quizzes/list?classId=${cId}`, { signal }),
|
||||
fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }),
|
||||
]);
|
||||
const qs = quizRes.ok ? await quizRes.json() : [];
|
||||
const gs = groupRes.ok ? await groupRes.json() : [];
|
||||
if (!quizRes.ok || !groupRes.ok) throw new Error("Unable to load quiz library");
|
||||
const qs = await quizRes.json();
|
||||
const gs = await groupRes.json();
|
||||
if (generation !== requestGeneration.current) return;
|
||||
setQuizzes(qs);
|
||||
setGroups(gs);
|
||||
cache[classSlug] = { quizzes: qs, groups: gs };
|
||||
} catch {
|
||||
setQuizzes([]);
|
||||
setGroups([]);
|
||||
setLoadError(null);
|
||||
} catch (error) {
|
||||
if (signal?.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (generation === requestGeneration.current) setLoading(false);
|
||||
}
|
||||
}, [classSlug]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const generation = ++requestGeneration.current;
|
||||
async function init() {
|
||||
try {
|
||||
const classRes = await fetch("/api/classes");
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
const classRes = await fetch("/api/classes", { signal: controller.signal });
|
||||
if (!classRes.ok) throw new Error("Unable to load class");
|
||||
const classes = await classRes.json();
|
||||
const cls = classes.find((c: { slug: string }) => c.slug === classSlug);
|
||||
if (!cls) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!cls) throw new Error("Class not found");
|
||||
if (generation !== requestGeneration.current) return;
|
||||
setClassId(cls.id);
|
||||
fetchAll(cls.id);
|
||||
} catch {
|
||||
await fetchAll(cls.id, generation, controller.signal);
|
||||
} catch (error) {
|
||||
if (controller.signal.aborted || generation !== requestGeneration.current) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load quiz library");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
init();
|
||||
}, [classSlug, fetchAll]);
|
||||
const timer = window.setTimeout(() => void init(), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [classSlug, fetchAll, reloadKey]);
|
||||
|
||||
// Group Management
|
||||
async function handleCreateGroup() {
|
||||
|
|
@ -240,45 +292,67 @@ export default function QuizzesPage() {
|
|||
setGroups((prev) => [g, ...prev]);
|
||||
setIsCreatingGroup(false);
|
||||
setNewGroupName("");
|
||||
setActionMessage(`Created group ${g.name}.`);
|
||||
} else {
|
||||
setActionMessage("The group could not be created. Please retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRenameGroup(id: string) {
|
||||
if (!editGroupName.trim()) return;
|
||||
await fetch(`/api/material-groups/${id}`, {
|
||||
const response = await fetch(`/api/material-groups/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editGroupName.trim() }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be renamed. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setGroups((prev) => prev.map((g) => (g.id === id ? { ...g, name: editGroupName.trim() } : g)));
|
||||
setEditingGroupId(null);
|
||||
setActionMessage("Group renamed.");
|
||||
}
|
||||
|
||||
async function handleDeleteGroup(id: string, name: string) {
|
||||
if (!confirm(`Delete group "${name}"? Quizzes inside will be moved to Uncategorized.`)) return;
|
||||
await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
setGroups((prev) => prev.filter((g) => g.id !== id));
|
||||
setQuizzes((prev) => prev.map((q) => (q.groupId === id ? { ...q, groupId: null } : q)));
|
||||
const response = await fetch(`/api/material-groups/${id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The group could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
await fetchAll(classId);
|
||||
setActionMessage(`Deleted group ${name}; its quizzes are now Uncategorized.`);
|
||||
}
|
||||
|
||||
// Quiz Management
|
||||
async function handleDeleteQuiz(quiz: QuizItem) {
|
||||
if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return;
|
||||
await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
||||
const response = await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" });
|
||||
if (!response.ok) {
|
||||
setActionMessage("The quiz could not be deleted. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id));
|
||||
setActionMessage(`Deleted quiz ${quiz.name}.`);
|
||||
}
|
||||
|
||||
async function handleRenameQuiz(id: string) {
|
||||
if (!editName.trim()) return;
|
||||
await fetch(`/api/quizzes/${id}`, {
|
||||
const response = await fetch(`/api/quizzes/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: editName.trim(), description: editDescription.trim() || null }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
setActionMessage("The quiz could not be updated. No changes were applied.");
|
||||
return;
|
||||
}
|
||||
setQuizzes((prev) =>
|
||||
prev.map((q) => (q.id === id ? { ...q, name: editName.trim(), description: editDescription.trim() || null } : q))
|
||||
);
|
||||
setEditingId(null);
|
||||
setActionMessage("Quiz updated.");
|
||||
}
|
||||
|
||||
// Drag and drop setup
|
||||
|
|
@ -288,26 +362,13 @@ export default function QuizzesPage() {
|
|||
);
|
||||
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [isReordering, setIsReordering] = useState(false);
|
||||
|
||||
function handleDragStart(event: DragStartEvent) {
|
||||
if (isReordering) return;
|
||||
setActiveId(event.active.id as string);
|
||||
}
|
||||
|
||||
function handleDragOver(event: DragOverEvent) {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id as string;
|
||||
const overId = over.id as string;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
// Is it dropping over a container directly?
|
||||
const isOverContainer = over.data.current?.sortable?.containerId || overId;
|
||||
// ... we handle layout updates in dragEnd to keep it simple, or here for smooth lists.
|
||||
// For simplicity, we just handle the final move in dragEnd.
|
||||
}
|
||||
|
||||
async function handleDragEnd(event: DragEndEvent) {
|
||||
setActiveId(null);
|
||||
const { active, over } = event;
|
||||
|
|
@ -333,43 +394,76 @@ export default function QuizzesPage() {
|
|||
}
|
||||
}
|
||||
|
||||
if (targetGroupId !== undefined) {
|
||||
setQuizzes((items) => {
|
||||
const oldIndex = items.findIndex((q) => q.id === activeId);
|
||||
const overIndex = items.findIndex((q) => q.id === overId);
|
||||
let newItems = [...items];
|
||||
|
||||
newItems[oldIndex].groupId = targetGroupId;
|
||||
const oldIndex = quizzes.findIndex((quiz) => quiz.id === activeId);
|
||||
const overIndex = quizzes.findIndex((quiz) => quiz.id === overId);
|
||||
let nextItems = quizzes.map((quiz) =>
|
||||
quiz.id === activeId ? { ...quiz, groupId: targetGroupId } : { ...quiz }
|
||||
);
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
nextItems = arrayMove(nextItems, oldIndex, overIndex);
|
||||
} else {
|
||||
const [moved] = nextItems.splice(oldIndex, 1);
|
||||
nextItems.push(moved);
|
||||
}
|
||||
await persistQuizReorder(
|
||||
quizzes,
|
||||
nextItems,
|
||||
new Set([activeQuiz.groupId, targetGroupId])
|
||||
);
|
||||
}
|
||||
|
||||
if (overIndex >= 0 && overIndex !== oldIndex) {
|
||||
newItems = arrayMove(newItems, oldIndex, overIndex);
|
||||
} else {
|
||||
// just moved to end of a group
|
||||
const oldItem = newItems.splice(oldIndex, 1)[0];
|
||||
newItems.push(oldItem);
|
||||
}
|
||||
async function persistQuizReorder(
|
||||
previousItems: QuizItem[],
|
||||
proposedItems: QuizItem[],
|
||||
affectedGroups: Set<string | null>
|
||||
) {
|
||||
const counters = new Map<string | null, number>();
|
||||
const nextItems = proposedItems.map((item) => {
|
||||
if (!affectedGroups.has(item.groupId)) return item;
|
||||
const sortOrder = counters.get(item.groupId) ?? 0;
|
||||
counters.set(item.groupId, sortOrder + 1);
|
||||
return { ...item, sortOrder };
|
||||
});
|
||||
const updates: ReorderItem[] = nextItems
|
||||
.filter((item) => affectedGroups.has(item.groupId))
|
||||
.map((item) => ({ id: item.id, groupId: item.groupId }));
|
||||
|
||||
// Re-calculate sortOrder for the affected groups to persist
|
||||
const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]);
|
||||
const updates: ReorderItem[] = [];
|
||||
|
||||
affectedGroups.forEach(gId => {
|
||||
const gItems = newItems.filter(q => q.groupId === gId);
|
||||
gItems.forEach((item, index) => {
|
||||
item.sortOrder = index;
|
||||
updates.push({ id: item.id, sortOrder: index, groupId: item.groupId });
|
||||
});
|
||||
});
|
||||
|
||||
// Fire API
|
||||
fetch("/api/quizzes/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
|
||||
return newItems;
|
||||
setQuizzes(nextItems);
|
||||
setIsReordering(true);
|
||||
try {
|
||||
const response = await fetch("/api/quizzes/reorder", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ items: updates }),
|
||||
});
|
||||
if (!response.ok) throw new Error("Reorder rejected");
|
||||
return true;
|
||||
} catch {
|
||||
setQuizzes(previousItems);
|
||||
window.alert("The quiz move could not be saved. The library was refreshed.");
|
||||
await fetchAll(classId);
|
||||
return false;
|
||||
} finally {
|
||||
setIsReordering(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMoveQuiz(quizId: string, targetGroupId: string | null) {
|
||||
if (isReordering) return;
|
||||
const activeQuiz = quizzes.find((quiz) => quiz.id === quizId);
|
||||
if (!activeQuiz || activeQuiz.groupId === targetGroupId) return;
|
||||
const nextItems = quizzes
|
||||
.filter((quiz) => quiz.id !== quizId)
|
||||
.map((quiz) => ({ ...quiz }));
|
||||
nextItems.push({ ...activeQuiz, groupId: targetGroupId });
|
||||
const saved = await persistQuizReorder(
|
||||
quizzes,
|
||||
nextItems,
|
||||
new Set([activeQuiz.groupId, targetGroupId])
|
||||
);
|
||||
if (saved) {
|
||||
const destination = groups.find((group) => group.id === targetGroupId)?.name ?? "Uncategorized";
|
||||
setActionMessage(`Moved ${activeQuiz.name} to ${destination}.`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -384,6 +478,11 @@ export default function QuizzesPage() {
|
|||
|
||||
return (
|
||||
<div className="pb-20">
|
||||
{actionMessage && (
|
||||
<p className="mb-4 rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status" aria-live="polite">
|
||||
{actionMessage}
|
||||
</p>
|
||||
)}
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div><p className="text-xs font-bold uppercase tracking-[0.16em] text-primary">Practice</p><h2 className="editorial-title mt-1 text-3xl text-text-heading">Practice quizzes</h2></div>
|
||||
|
|
@ -447,15 +546,23 @@ export default function QuizzesPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && quizzes.length === 0 && groups.length === 0 && (
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="mb-6 rounded-2xl border border-error/30 bg-error-bg p-5 text-error">
|
||||
<p className="font-bold">Quiz library could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => setReloadKey((value) => value + 1)} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && quizzes.length === 0 && groups.length === 0 && (
|
||||
<div className="text-center py-16">
|
||||
<h3 className="text-lg font-semibold text-text-heading mb-1">No quizzes yet</h3>
|
||||
<p className="text-text-secondary mb-4">Import your first quiz or create a group</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
{!loading && !loadError && (
|
||||
<DndContext sensors={sensors} collisionDetection={pointerWithin} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
|
||||
<div className="space-y-8">
|
||||
{groupedQuizzes.map(group => (
|
||||
<div key={group.id} className="rounded-2xl border border-border-light bg-bg-surface/70 p-4 shadow-[var(--shadow-card)] sm:p-5">
|
||||
|
|
@ -476,7 +583,7 @@ export default function QuizzesPage() {
|
|||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} />
|
||||
<ShareMenu targetType="GROUP" contentId={group.id} classSlug={params.classSlug as string} containsQuizAnswers />
|
||||
<button onClick={() => { setEditingGroupId(group.id); setEditGroupName(group.name); }} className="p-1.5 rounded-lg text-text-muted hover:text-text-heading hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z" /></svg></button>
|
||||
<button onClick={() => handleDeleteGroup(group.id, group.name)} className="p-1.5 rounded-lg text-text-muted hover:text-error hover:bg-bg-surface transition-colors cursor-pointer"><svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /></svg></button>
|
||||
</div>
|
||||
|
|
@ -489,7 +596,7 @@ export default function QuizzesPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">Drop quizzes here</div>
|
||||
) : (
|
||||
group.quizzes.map(quiz => (
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -514,7 +621,7 @@ export default function QuizzesPage() {
|
|||
<div className="col-span-1 md:col-span-2 text-center py-4 text-text-muted text-sm border-2 border-dashed border-border-light rounded-lg">No uncategorized quizzes</div>
|
||||
) : (
|
||||
uncategorizedQuizzes.map(quiz => (
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} />
|
||||
<SortableQuizCard key={quiz.id} quiz={quiz} onEdit={(q: QuizItem) => { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} />
|
||||
))
|
||||
)}
|
||||
</DroppableContainer>
|
||||
|
|
@ -526,7 +633,7 @@ export default function QuizzesPage() {
|
|||
<DragOverlay>
|
||||
{activeQuiz ? (
|
||||
<div className="opacity-80 scale-105 shadow-xl rotate-2">
|
||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} classSlug={classSlug} />
|
||||
<SortableQuizCard quiz={activeQuiz} onEdit={()=>{}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} />
|
||||
</div>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7
|
|||
export default function HomePage() {
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newName, setNewName] = useState("");
|
||||
const [creating, setCreating] = useState(false);
|
||||
|
|
@ -27,13 +28,25 @@ export default function HomePage() {
|
|||
const [editName, setEditName] = useState("");
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
|
||||
useEffect(() => { fetchClasses(); }, []);
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
void fetchClasses(controller.signal);
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
async function fetchClasses() {
|
||||
async function fetchClasses(signal?: AbortSignal) {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const res = await fetch("/api/classes");
|
||||
const res = await fetch("/api/classes", { signal });
|
||||
if (!res.ok) throw new Error("The server could not load your classes.");
|
||||
setClasses(await res.json());
|
||||
} finally { setLoading(false); }
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Unable to load classes.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreate(e: React.FormEvent) {
|
||||
|
|
@ -42,14 +55,21 @@ 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) { setNewName(""); setShowCreate(false); fetchClasses(); }
|
||||
if (!res.ok) throw new Error("The class could not be created.");
|
||||
setNewName(""); setShowCreate(false); void fetchClasses();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "The class could not be created.");
|
||||
} finally { setCreating(false); }
|
||||
}
|
||||
|
||||
async function handleDelete(id: string, name: string) {
|
||||
if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return;
|
||||
await fetch(`/api/classes/${id}`, { method: "DELETE" });
|
||||
fetchClasses();
|
||||
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();
|
||||
}
|
||||
|
||||
async function handleSaveEdit(id: string) {
|
||||
|
|
@ -57,7 +77,10 @@ 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) { setEditingId(null); fetchClasses(); }
|
||||
if (!res.ok) throw new Error("The class could not be renamed.");
|
||||
setEditingId(null); void fetchClasses();
|
||||
} catch (error) {
|
||||
setLoadError(error instanceof Error ? error.message : "The class could not be renamed.");
|
||||
} finally { setSavingEdit(false); }
|
||||
}
|
||||
|
||||
|
|
@ -93,7 +116,15 @@ export default function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && classes.length === 0 && (
|
||||
{!loading && loadError && (
|
||||
<div role="alert" className="rounded-2xl border border-error/30 bg-error-bg p-6 text-error">
|
||||
<p className="font-bold">Your study spaces could not be loaded.</p>
|
||||
<p className="mt-1 text-sm">{loadError}</p>
|
||||
<button onClick={() => void fetchClasses()} className="mt-4 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !loadError && classes.length === 0 && (
|
||||
<div className="paper-grid rounded-[2rem] border border-dashed border-border bg-bg-surface px-6 py-16 text-center">
|
||||
<div className="mx-auto mb-5 grid h-14 w-14 place-items-center rounded-2xl bg-bg-callout text-2xl text-primary">+</div>
|
||||
<h2 className="editorial-title text-2xl text-text-heading">Your desk is ready</h2>
|
||||
|
|
@ -102,7 +133,7 @@ export default function HomePage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{!loading && classes.length > 0 && (
|
||||
{!loading && !loadError && classes.length > 0 && (
|
||||
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{classes.map((cls, index) => (
|
||||
<article key={cls.id} className="group relative overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)] transition-all hover:-translate-y-1 hover:shadow-[var(--shadow-card-hover)]">
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { checkRateLimit } from "@/lib/rateLimiter";
|
|||
import { login } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkRateLimit(`login:${ip}`).allowed) {
|
||||
if (!checkRateLimit("login:global").allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many requests. Try again shortly." },
|
||||
{ status: 429 }
|
||||
|
|
|
|||
|
|
@ -1,8 +1,20 @@
|
|||
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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,17 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
import { checkRateLimit } from "@/lib/rateLimiter";
|
||||
import { requestPasswordReset } from "@/services/authService";
|
||||
|
||||
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,
|
||||
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,
|
||||
maxRequests: 1,
|
||||
});
|
||||
if (!rateLimit.allowed) {
|
||||
|
|
@ -15,11 +21,18 @@ export async function POST(request: NextRequest) {
|
|||
);
|
||||
}
|
||||
|
||||
if (!(await requestPasswordReset())) {
|
||||
const result = await requestPasswordReset();
|
||||
if (result.status === "missing-password") {
|
||||
return NextResponse.json(
|
||||
{ error: "Password recovery is unavailable before initial setup." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
if (result.status === "active-token") {
|
||||
return NextResponse.json(
|
||||
{ error: "An unexpired reset token already exists." },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json({ success: true, token: result.token });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,7 @@ import { resetTokenSchema } from "@/lib/validation/authSchemas";
|
|||
import { verifyPasswordResetToken } from "@/services/authService";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const ip = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
|
||||
if (!checkRateLimit(`password-reset-verify:${ip}`).allowed) {
|
||||
if (!checkRateLimit("password-reset-verify:global").allowed) {
|
||||
return NextResponse.json(
|
||||
{ error: "Too many attempts. Try again shortly." },
|
||||
{ status: 429 }
|
||||
|
|
|
|||
|
|
@ -1,13 +1,9 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { getSetupStatus } from "@/services/authService";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const setting = await prisma.setting.findUnique({
|
||||
where: { key: "admin_password_hash" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ setupRequired: !setting });
|
||||
return NextResponse.json(await getSetupStatus());
|
||||
} catch (error) {
|
||||
console.error("Failed to check setup status:", error);
|
||||
return NextResponse.json(
|
||||
|
|
|
|||
|
|
@ -1,21 +1,24 @@
|
|||
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 body = await request.json().catch(() => null);
|
||||
|
||||
const parsed = cardUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid card update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
try {
|
||||
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 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -24,11 +27,14 @@ 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 {
|
||||
return NextResponse.json({ error: "Card not found" }, { status: 404 });
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || (!body.name && body.name !== "")) {
|
||||
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
|
||||
const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "A valid class name is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await classService.updateClass(id, {
|
||||
name: body.name?.trim(),
|
||||
});
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -27,11 +27,14 @@ 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 {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,23 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as classService from "@/services/classService";
|
||||
import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export async function GET() {
|
||||
const classes = await classService.listClasses();
|
||||
return NextResponse.json(classes);
|
||||
return NextResponse.json(await classService.listClasses());
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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 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 newClass = await classService.createClass(body.name.trim());
|
||||
return NextResponse.json(newClass, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,26 @@
|
|||
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 body = await request.json().catch(() => null);
|
||||
|
||||
if (
|
||||
!body?.front ||
|
||||
!body?.back ||
|
||||
typeof body.front !== "string" ||
|
||||
typeof body.back !== "string"
|
||||
) {
|
||||
const parsed = cardContentSchema.safeParse(await request.json().catch(() => null));
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "front and back are required" },
|
||||
{ error: "Card front and back are required and must be within the content limit", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const card = await cardService.createCard(deckId, {
|
||||
front: body.front.trim(),
|
||||
back: body.back.trim(),
|
||||
});
|
||||
|
||||
return NextResponse.json(card, { status: 201 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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,
|
||||
|
|
@ -20,16 +21,21 @@ export async function PATCH(
|
|||
ctx: RouteContext<"/api/decks/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid deck update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await deckService.updateDeck(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
const updated = await deckService.updateDeck(id, parsed.data);
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +48,11 @@ export async function DELETE(
|
|||
try {
|
||||
await deckService.deleteDeck(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Deck not found" }, { status: 404 });
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,29 @@
|
|||
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 {
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await reorderContent("DECK", parsed.data);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,40 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as deckService from "@/services/deckService";
|
||||
import { flashcardImportSchema } from "@/lib/validation/importSchemas";
|
||||
import { deckImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
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 { 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);
|
||||
const parsed = deckImportRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{ error: "Invalid deck import", details: parsed.error.issues },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const deck = await deckService.createDeckFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined,
|
||||
groupId || null
|
||||
);
|
||||
|
||||
return NextResponse.json(deck, { status: 201 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
12
src/app/api/health/route.ts
Normal file
12
src/app/api/health/route.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { checkApplicationHealth } from "@/services/healthService";
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
await checkApplicationHealth();
|
||||
return NextResponse.json({ status: "ok" });
|
||||
} catch (error) {
|
||||
console.error("Health check failed: database schema is unavailable", error);
|
||||
return NextResponse.json({ status: "unhealthy" }, { status: 503 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +1,44 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas";
|
||||
import {
|
||||
deleteMaterialGroup,
|
||||
renameMaterialGroup,
|
||||
} from "@/services/materialGroupService";
|
||||
|
||||
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 {
|
||||
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);
|
||||
return NextResponse.json(await renameMaterialGroup(id, parsed.data.name));
|
||||
} catch (error) {
|
||||
return NextResponse.json({ error: "Failed to update material group" }, { status: 500 });
|
||||
if (
|
||||
typeof error === "object" &&
|
||||
error !== null &&
|
||||
"code" in error &&
|
||||
error.code === "P2025"
|
||||
) {
|
||||
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||
}
|
||||
console.error("Failed to rename material group", error);
|
||||
return NextResponse.json({ error: "Failed to rename group" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
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 });
|
||||
const { id } = await params;
|
||||
if (!(await deleteMaterialGroup(id))) {
|
||||
return NextResponse.json({ error: "Group not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,57 +1,38 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/db";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import {
|
||||
materialGroupCreateSchema,
|
||||
materialGroupQuerySchema,
|
||||
} from "@/lib/validation/materialGroupSchemas";
|
||||
import {
|
||||
createMaterialGroup,
|
||||
listMaterialGroups,
|
||||
} from "@/services/materialGroupService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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 });
|
||||
const parsed = materialGroupQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid group query" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(
|
||||
await listMaterialGroups(parsed.data.classId, parsed.data.type)
|
||||
);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
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 parsed = materialGroupCreateSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid group" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
const group = await createMaterialGroup(parsed.data);
|
||||
if (!group) {
|
||||
return NextResponse.json({ error: "Class not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json(group, { status: 201 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,64 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as progressService from "@/services/progressService";
|
||||
import {
|
||||
progressDeleteSchema,
|
||||
progressPatchSchema,
|
||||
progressQuerySchema,
|
||||
} from "@/lib/validation/progressSchemas";
|
||||
import {
|
||||
ProgressConflictError,
|
||||
ProgressNotFoundError,
|
||||
ProgressValidationError,
|
||||
clearProgress,
|
||||
getProgress,
|
||||
saveProgress,
|
||||
} from "@/services/progressService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
|
||||
const progress = await progressService.getProgress(
|
||||
contentType,
|
||||
contentId,
|
||||
mode
|
||||
const parsed = progressQuerySchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
|
||||
return NextResponse.json(progress);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid progress query" }, { status: 400 });
|
||||
}
|
||||
return NextResponse.json(await getProgress(parsed.data));
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
const parsed = progressPatchSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid progress" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.contentType || !body?.contentId || !body?.mode) {
|
||||
const parsed = progressDeleteSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "contentType, contentId, and mode are required" },
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
await progressService.clearProgress(
|
||||
body.contentType,
|
||||
body.contentId,
|
||||
body.mode
|
||||
);
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
return NextResponse.json({ cleared: await clearProgress(parsed.data) });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +1,40 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService";
|
||||
import { scoreQuiz } from "@/lib/scoring";
|
||||
import {
|
||||
QuizAttemptValidationError,
|
||||
QuizNotFoundError,
|
||||
listQuizAttempts,
|
||||
submitQuizAttempt,
|
||||
} from "@/services/quizService";
|
||||
import { quizAttemptSchema } from "@/lib/validation/attemptSchemas";
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
ctx: RouteContext<"/api/quizzes/[id]/attempt">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body || !body.answersJson) {
|
||||
const parsed = quizAttemptSchema.safeParse(
|
||||
await request.json().catch(() => null)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "answersJson is required" },
|
||||
{ error: parsed.error.issues[0]?.message ?? "Invalid attempt" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const quizSet = await getQuizSetWithQuestions(id);
|
||||
if (!quizSet) {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Parse answers
|
||||
let answers: Record<string, string[]>;
|
||||
try {
|
||||
answers = JSON.parse(body.answersJson);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 });
|
||||
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 });
|
||||
}
|
||||
|
||||
// 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(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
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,
|
||||
|
|
@ -20,16 +21,21 @@ export async function PATCH(
|
|||
ctx: RouteContext<"/api/quizzes/[id]">
|
||||
) {
|
||||
const { id } = await ctx.params;
|
||||
const body = await request.json().catch(() => null);
|
||||
const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null));
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid quiz update", details: parsed.error.issues }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await quizService.updateQuizSet(id, {
|
||||
name: body?.name?.trim(),
|
||||
description: body?.description,
|
||||
});
|
||||
const updated = await quizService.updateQuizSet(id, parsed.data);
|
||||
return NextResponse.json(updated);
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -42,7 +48,11 @@ export async function DELETE(
|
|||
try {
|
||||
await quizService.deleteQuizSet(id);
|
||||
return NextResponse.json({ success: true });
|
||||
} catch {
|
||||
return NextResponse.json({ error: "Quiz not found" }, { status: 404 });
|
||||
} 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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,32 +1,29 @@
|
|||
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 {
|
||||
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,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
await reorderContent("QUIZ", parsed.data);
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,43 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import * as quizService from "@/services/quizService";
|
||||
import { quizImportSchema } from "@/lib/validation/importSchemas";
|
||||
import { quizImportRequestSchema } from "@/lib/validation/importSchemas";
|
||||
import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body) {
|
||||
return NextResponse.json({ error: "Invalid request body" }, { status: 400 });
|
||||
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 { 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);
|
||||
const parsed = quizImportRequestSchema.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ error: "Validation failed", details: parsed.error.issues },
|
||||
{
|
||||
error: "Invalid quiz import. SATA questions need at least two correct options.",
|
||||
details: parsed.error.issues,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const quizSet = await quizService.createQuizSetFromImport(
|
||||
classId,
|
||||
parsed.data,
|
||||
name?.trim() || undefined,
|
||||
groupId || null
|
||||
);
|
||||
|
||||
return NextResponse.json(quizSet, { status: 201 });
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService";
|
||||
import { shareTargetSchema } from "@/lib/validation/shareSchemas";
|
||||
import {
|
||||
ShareTargetNotFoundError,
|
||||
getShareLinkForContent,
|
||||
isContentSharedViaGroup,
|
||||
toggleShareLink,
|
||||
} from "@/services/shareService";
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
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 parsed = shareTargetSchema.safeParse(
|
||||
Object.fromEntries(request.nextUrl.searchParams)
|
||||
);
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json({ error: "Invalid share target" }, { 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) {
|
||||
|
|
@ -25,20 +26,27 @@ export async function GET(request: NextRequest) {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ token: link?.id || null, isGroupShared: false });
|
||||
return NextResponse.json({ token: link?.id ?? null, isGroupShared: false });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const body = await request.json().catch(() => null);
|
||||
|
||||
if (!body?.targetType || !body?.contentId) {
|
||||
return NextResponse.json(
|
||||
{ error: "targetType and contentId are required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
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
|
||||
);
|
||||
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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,14 +5,15 @@ import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
|||
|
||||
type LoginStage = "login" | "token" | "password" | "success";
|
||||
|
||||
async function postJson(path: string, body?: object) {
|
||||
async function postJson<T extends object = Record<string, never>>(path: string, body?: object): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body ?? {}),
|
||||
});
|
||||
const data: { error?: string } = await response.json();
|
||||
const data = await response.json() as T & { error?: string };
|
||||
if (!response.ok) throw new Error(data.error || "Request failed");
|
||||
return data;
|
||||
}
|
||||
|
||||
export default function LoginPage() {
|
||||
|
|
@ -23,12 +24,26 @@ export default function LoginPage() {
|
|||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
||||
const [setupAllowed, setSetupAllowed] = useState(false);
|
||||
const [statusError, setStatusError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/auth/setup-status")
|
||||
.then((response) => response.json())
|
||||
.then((data) => setSetupRequired(data.setupRequired))
|
||||
.catch(() => setSetupRequired(false));
|
||||
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();
|
||||
}, []);
|
||||
|
||||
async function run(action: () => Promise<void>) {
|
||||
|
|
@ -53,8 +68,8 @@ export default function LoginPage() {
|
|||
|
||||
function beginReset() {
|
||||
void run(async () => {
|
||||
await postJson("/api/auth/password-reset/request");
|
||||
setToken("");
|
||||
const response = await postJson<{ token: string }>("/api/auth/password-reset/request");
|
||||
setToken(response.token);
|
||||
setStage("token");
|
||||
});
|
||||
}
|
||||
|
|
@ -139,6 +154,12 @@ export default function LoginPage() {
|
|||
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
||||
{stage === "login" && (
|
||||
<form onSubmit={handleLogin} className="space-y-4">
|
||||
{setupRequired && !setupAllowed && (
|
||||
<div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">
|
||||
Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server.
|
||||
</div>
|
||||
)}
|
||||
{statusError && <ErrorMessage message={statusError} />}
|
||||
<PasswordField
|
||||
id="password"
|
||||
label="Password"
|
||||
|
|
@ -148,7 +169,7 @@ export default function LoginPage() {
|
|||
autoFocus
|
||||
/>
|
||||
<ErrorMessage message={error} />
|
||||
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
||||
<PrimaryButton loading={loading} disabled={password.length < 8 || (setupRequired && !setupAllowed)}>
|
||||
{setupRequired ? "Save Password & Login" : "Sign In"}
|
||||
</PrimaryButton>
|
||||
{!setupRequired && (
|
||||
|
|
|
|||
|
|
@ -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 }: { data: SharedGroupData }) {
|
||||
export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
|
|
@ -16,29 +16,34 @@ export function SharedGroupViewer({ data }: { data: SharedGroupData }) {
|
|||
const [progressMap, setProgressMap] = useState<Record<string, { currentIndex: number, total: number }>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||
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 timer = window.setTimeout(() => {
|
||||
const newProgress: Record<string, { currentIndex: number, total: number }> = {};
|
||||
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]);
|
||||
|
||||
const handleRestart = (e: React.MouseEvent, itemId: string) => {
|
||||
e.preventDefault();
|
||||
const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`;
|
||||
const key = data.type === "DECK"
|
||||
? `flashcard_progress_${token}_${itemId}`
|
||||
: `quiz_progress_${token}_${itemId}`;
|
||||
localStorage.removeItem(key);
|
||||
setProgressMap(prev => {
|
||||
const next = { ...prev };
|
||||
|
|
|
|||
|
|
@ -12,24 +12,29 @@ interface SharedViewerProps {
|
|||
type: "flashcards" | "quizzes";
|
||||
data: SharedStudyItem;
|
||||
groupMode?: boolean;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export function SharedViewer({ type, data, groupMode = false }: SharedViewerProps) {
|
||||
export function SharedViewer({ type, data, groupMode = false, token }: SharedViewerProps) {
|
||||
const pathname = usePathname();
|
||||
const [restartKey, setRestartKey] = useState(0);
|
||||
const [view, setView] = useState<"study" | "list">("study");
|
||||
const [hasSavedSession, setHasSavedSession] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// 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]);
|
||||
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]);
|
||||
|
||||
function handleRestart() {
|
||||
const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`;
|
||||
const key = type === "flashcards"
|
||||
? `flashcard_progress_${token}_${data.id}`
|
||||
: `quiz_progress_${token}_${data.id}`;
|
||||
localStorage.removeItem(key);
|
||||
setHasSavedSession(false);
|
||||
setRestartKey(k => k + 1);
|
||||
|
|
@ -141,6 +146,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
|||
cards={data.cards ?? []}
|
||||
deckId={data.id}
|
||||
isShared={true}
|
||||
storageNamespace={token}
|
||||
/>
|
||||
) : (
|
||||
<CardList cards={data.cards ?? []} />
|
||||
|
|
@ -156,6 +162,7 @@ export function SharedViewer({ type, data, groupMode = false }: SharedViewerProp
|
|||
}}
|
||||
retakeIds={null}
|
||||
isShared={true}
|
||||
storageNamespace={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export async function generateMetadata(
|
|||
}
|
||||
|
||||
const link = await getShareLinkMeta(token);
|
||||
return buildShareMetadata(link, { classSlug, itemId });
|
||||
return buildShareMetadata(link, { classSlug, itemId, pathType: type });
|
||||
}
|
||||
|
||||
export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) {
|
||||
|
|
@ -71,18 +71,29 @@ 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 = link.group.decks.find(d => d.id === itemId) ?? null;
|
||||
targetData = validDecks.find(d => d.id === itemId) ?? null;
|
||||
targetType = "flashcards";
|
||||
} else {
|
||||
targetData = link.group.quizSets.find(q => q.id === itemId) ?? null;
|
||||
targetData = validQuizSets.find(q => q.id === itemId) ?? null;
|
||||
targetType = "quizzes";
|
||||
}
|
||||
if (!targetData) notFound();
|
||||
} else {
|
||||
targetData = link.group;
|
||||
targetData = {
|
||||
...link.group,
|
||||
decks: validDecks,
|
||||
quizSets: validQuizSets,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
targetData = type === "flashcards" ? link.deck : link.quizSet;
|
||||
|
|
@ -126,12 +137,13 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
|
|||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="mx-auto max-w-5xl px-4 py-6 sm:px-6 sm:py-8 lg:px-8">
|
||||
{type === "groups" && !itemId ? (
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} />
|
||||
<SharedGroupViewer data={targetData as SharedGroupData} token={token} />
|
||||
) : (
|
||||
<SharedViewer
|
||||
type={targetType}
|
||||
data={targetData as SharedStudyItem}
|
||||
groupMode={type === "groups"}
|
||||
token={token}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ 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);
|
||||
|
|
@ -50,6 +51,7 @@ 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();
|
||||
}
|
||||
|
||||
|
|
@ -65,6 +67,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
setNewFront("");
|
||||
setNewBack("");
|
||||
setShowAdd(false);
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCardsChanged();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
|
|
@ -89,7 +92,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
|
|||
</div>
|
||||
|
||||
{/* Card list */}
|
||||
{cards.map((card, index) => (
|
||||
{cards.map((card) => (
|
||||
<div
|
||||
key={card.id}
|
||||
className="overflow-hidden rounded-2xl border border-border-light bg-bg-surface shadow-[var(--shadow-card)]"
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from "react";
|
|||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { recordStudyActivity } from "@/lib/activityClient";
|
||||
import { normalizeProgress } from "@/lib/progressNormalization";
|
||||
import {
|
||||
STREAK_FLAME_THRESHOLD,
|
||||
StreakBurst,
|
||||
|
|
@ -25,47 +26,78 @@ interface ProgressData {
|
|||
currentIndex: number;
|
||||
orderJson: string;
|
||||
cardResultsJson: string | null;
|
||||
sessionId: string;
|
||||
revision: number;
|
||||
}
|
||||
|
||||
interface FlashcardViewerProps {
|
||||
cards: Card[];
|
||||
deckId: string;
|
||||
isShared?: boolean;
|
||||
storageNamespace?: string;
|
||||
initialProgress?: ProgressData | null;
|
||||
}
|
||||
|
||||
type CardResult = "correct" | "missed";
|
||||
|
||||
export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) {
|
||||
function initialFlashcardState(cards: Card[], progress?: ProgressData | null) {
|
||||
if (!progress) {
|
||||
return {
|
||||
order: cards.map((card) => card.id),
|
||||
currentIndex: 0,
|
||||
data: {} as Record<string, CardResult>,
|
||||
completed: cards.length === 0,
|
||||
wasRecovered: false,
|
||||
};
|
||||
}
|
||||
return normalizeProgress({
|
||||
orderJson: progress.orderJson,
|
||||
currentIndex: progress.currentIndex,
|
||||
dataJson: progress.cardResultsJson,
|
||||
liveIds: cards.map((card) => card.id),
|
||||
isValidValue: (value): value is CardResult =>
|
||||
value === "correct" || value === "missed",
|
||||
});
|
||||
}
|
||||
|
||||
export function FlashcardViewer({
|
||||
cards,
|
||||
deckId,
|
||||
isShared = false,
|
||||
storageNamespace,
|
||||
initialProgress,
|
||||
}: FlashcardViewerProps) {
|
||||
const sharedStorageKey = `flashcard_progress_${storageNamespace ?? "shared"}_${deckId}`;
|
||||
const [initialState] = useState(() => initialFlashcardState(cards, initialProgress));
|
||||
const [isLoaded, setIsLoaded] = useState(!isShared);
|
||||
|
||||
const [order, setOrder] = useState<string[]>(
|
||||
initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id)
|
||||
);
|
||||
const [currentIndex, setCurrentIndex] = useState(
|
||||
initialProgress ? initialProgress.currentIndex : 0
|
||||
);
|
||||
const [order, setOrder] = useState<string[]>(initialState.order);
|
||||
const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex);
|
||||
const [isFlipped, setIsFlipped] = useState(false);
|
||||
const [hasFlippedOnce, setHasFlippedOnce] = useState(false);
|
||||
const [results, setResults] = useState<Record<string, CardResult>>(
|
||||
initialProgress && initialProgress.cardResultsJson
|
||||
? JSON.parse(initialProgress.cardResultsJson)
|
||||
: {}
|
||||
);
|
||||
const [results, setResults] = useState<Record<string, CardResult>>(initialState.data);
|
||||
const [isShuffled, setIsShuffled] = useState<boolean>(
|
||||
initialProgress ? initialProgress.mode === "SHUFFLED" : false
|
||||
);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [restoreWarning, setRestoreWarning] = useState(
|
||||
initialState.wasRecovered
|
||||
? "Some saved progress was invalid or referenced deleted cards, so it was safely repaired."
|
||||
: null
|
||||
);
|
||||
const [isTransitioning, setIsTransitioning] = useState(false);
|
||||
|
||||
// If progress is provided and index is already at or past the end, it means completed
|
||||
const [completed, setCompleted] = useState(
|
||||
initialProgress
|
||||
? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length
|
||||
: false
|
||||
);
|
||||
const [completed, setCompleted] = useState(initialState.completed);
|
||||
const [swipeClass, setSwipeClass] = useState("");
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const gradingRef = useRef(false);
|
||||
const transitionTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const sessionIdRef = useRef(
|
||||
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
|
||||
);
|
||||
const revisionRef = useRef(initialProgress?.revision ?? 0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
|
||||
// Touch/drag state
|
||||
const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false });
|
||||
|
|
@ -73,7 +105,6 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null;
|
||||
const 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
|
||||
|
|
@ -127,29 +158,104 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
|
||||
// Load from localStorage if shared
|
||||
useEffect(() => {
|
||||
if (isShared) {
|
||||
if (!isShared) return;
|
||||
const timer = window.setTimeout(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(`flashcard_progress_${deckId}`);
|
||||
const saved = localStorage.getItem(sharedStorageKey);
|
||||
if (saved) {
|
||||
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);
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
const record =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
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."
|
||||
);
|
||||
}
|
||||
setToastMessage("Session restored");
|
||||
}
|
||||
} catch (e) {
|
||||
// Fallback to defaults
|
||||
} catch {
|
||||
setRestoreWarning(
|
||||
"Saved progress could not be read. A fresh session was started safely."
|
||||
);
|
||||
}
|
||||
setIsLoaded(true);
|
||||
}
|
||||
}, [isShared, deckId, cards]);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isShared, sharedStorageKey, cards]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveProgress = useCallback(
|
||||
(
|
||||
orderArr: string[],
|
||||
index: number,
|
||||
cardResults: Record<string, CardResult>,
|
||||
mode: "SEQUENTIAL" | "SHUFFLED"
|
||||
) => {
|
||||
if (isShared) {
|
||||
localStorage.setItem(
|
||||
sharedStorageKey,
|
||||
JSON.stringify({
|
||||
mode,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
results: cardResults,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const revision = ++revisionRef.current;
|
||||
saveQueueRef.current = saveQueueRef.current
|
||||
.then(async () => {
|
||||
const response = await fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deckId,
|
||||
mode,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
cardResults,
|
||||
sessionId: sessionIdRef.current,
|
||||
revision,
|
||||
}),
|
||||
});
|
||||
if (response.status === 409) {
|
||||
setToastMessage("Progress changed in another session; reload to continue safely.");
|
||||
return;
|
||||
}
|
||||
if (!response.ok) throw new Error("Progress save failed");
|
||||
})
|
||||
.catch(() => {
|
||||
setToastMessage("Progress could not be saved. Your current view is unchanged.");
|
||||
});
|
||||
},
|
||||
[deckId, isShared, sharedStorageKey]
|
||||
);
|
||||
|
||||
function toggleShuffle() {
|
||||
if (isTransitioning) return;
|
||||
const newShuffled = !isShuffled;
|
||||
setIsShuffled(newShuffled);
|
||||
|
||||
|
|
@ -175,6 +281,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
(grade: CardResult) => {
|
||||
if (!currentCard || !hasFlippedOnce || gradingRef.current) return;
|
||||
gradingRef.current = true;
|
||||
setIsTransitioning(true);
|
||||
|
||||
if (!isShared) {
|
||||
recordStudyActivity("FLASHCARD").catch(() => {});
|
||||
|
|
@ -187,15 +294,17 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
// Animate
|
||||
setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left");
|
||||
|
||||
setTimeout(() => {
|
||||
transitionTimerRef.current = setTimeout(() => {
|
||||
gradingRef.current = false;
|
||||
setIsTransitioning(false);
|
||||
setSwipeClass("");
|
||||
setIsFlipped(false);
|
||||
setHasFlippedOnce(false);
|
||||
|
||||
if (currentIndex + 1 >= order.length) {
|
||||
setCompleted(true);
|
||||
saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
setCurrentIndex(order.length);
|
||||
saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
} else {
|
||||
const nextIndex = currentIndex + 1;
|
||||
setCurrentIndex(nextIndex);
|
||||
|
|
@ -203,13 +312,13 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
}
|
||||
}, 350);
|
||||
},
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared]
|
||||
[currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress]
|
||||
);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
function handleKeyDown(e: KeyboardEvent) {
|
||||
if (completed) return;
|
||||
if (completed || isTransitioning) return;
|
||||
|
||||
if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
|
|
@ -227,7 +336,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [completed, isFlipped, gradeCard]);
|
||||
}, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]);
|
||||
|
||||
// Touch handlers for swipe
|
||||
function handleTouchStart(e: React.TouchEvent) {
|
||||
|
|
@ -262,39 +371,9 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
}
|
||||
}
|
||||
|
||||
// Save progress (debounced / fire-and-forget)
|
||||
function saveProgress(
|
||||
orderArr: string[],
|
||||
index: number,
|
||||
cardResults: Record<string, CardResult>,
|
||||
m: "SEQUENTIAL" | "SHUFFLED"
|
||||
) {
|
||||
if (isShared) {
|
||||
localStorage.setItem(`flashcard_progress_${deckId}`, JSON.stringify({
|
||||
mode: m,
|
||||
currentIndex: index,
|
||||
order: orderArr,
|
||||
results: cardResults,
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
fetch("/api/progress", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
contentType: "DECK",
|
||||
contentId: deckId,
|
||||
mode: m,
|
||||
currentIndex: index,
|
||||
orderJson: JSON.stringify(orderArr),
|
||||
cardResultsJson: JSON.stringify(cardResults),
|
||||
}),
|
||||
}).catch(() => {}); // fire-and-forget
|
||||
}
|
||||
|
||||
// Restart handlers
|
||||
function restartFullSet() {
|
||||
if (isTransitioning) return;
|
||||
gradingRef.current = false;
|
||||
let newOrder: string[];
|
||||
if (isShuffled) {
|
||||
|
|
@ -312,6 +391,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
}
|
||||
|
||||
function redoMissed() {
|
||||
if (isTransitioning) return;
|
||||
const missedIds = Object.entries(results)
|
||||
.filter(([, r]) => r === "missed")
|
||||
.map(([id]) => id);
|
||||
|
|
@ -337,6 +417,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
if (completed) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-16">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-md rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
<div className="w-full max-w-md rounded-3xl border border-border-light bg-bg-surface p-8 text-center shadow-[var(--shadow-card)]">
|
||||
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-success-bg mb-4">
|
||||
<svg className="w-7 h-7 text-success" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
|
@ -393,6 +478,11 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
// Study view
|
||||
return (
|
||||
<div className="flex flex-col items-center">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
{/* Running tally */}
|
||||
<div className="mb-5 w-full max-w-4xl rounded-xl border border-border-light bg-bg-surface/70 px-4 py-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 text-sm">
|
||||
|
|
@ -421,6 +511,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
ref={cardRef}
|
||||
className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`}
|
||||
onClick={() => {
|
||||
if (isTransitioning) return;
|
||||
setToastMessage(null);
|
||||
setIsFlipped(!isFlipped);
|
||||
if (!isFlipped) setHasFlippedOnce(true);
|
||||
|
|
@ -506,7 +597,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
<div className="flex items-center gap-2 md:gap-4 animate-fade-in pointer-events-auto">
|
||||
<button
|
||||
onClick={() => gradeCard("missed")}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg sm:flex-none sm:px-7"
|
||||
disabled={isTransitioning}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-error/30 bg-bg-surface px-4 font-bold text-error transition-colors hover:bg-error-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
|
|
@ -515,7 +607,8 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
</button>
|
||||
<button
|
||||
onClick={() => gradeCard("correct")}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg sm:flex-none sm:px-7"
|
||||
disabled={isTransitioning}
|
||||
className="flex min-h-12 flex-1 items-center justify-center gap-2 rounded-xl border-2 border-success/30 bg-bg-surface px-4 font-bold text-success transition-colors hover:bg-success-bg disabled:cursor-not-allowed disabled:opacity-50 sm:flex-none sm:px-7"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
|
|
@ -531,6 +624,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
{currentIndex > 0 ? (
|
||||
<button
|
||||
onClick={() => {
|
||||
if (isTransitioning) return;
|
||||
const prevIndex = currentIndex - 1;
|
||||
setCurrentIndex(prevIndex);
|
||||
setIsFlipped(false);
|
||||
|
|
@ -542,6 +636,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
setResults(newResults);
|
||||
saveProgress(order, prevIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL");
|
||||
}}
|
||||
disabled={isTransitioning}
|
||||
className="p-2 rounded-full text-text-muted hover:bg-bg-surface-alt hover:text-text-heading transition-all duration-200 cursor-pointer bg-bg-base/50"
|
||||
title="Previous Card"
|
||||
>
|
||||
|
|
@ -553,6 +648,7 @@ export function FlashcardViewer({ cards, deckId, isShared = false, initialProgre
|
|||
|
||||
<button
|
||||
onClick={toggleShuffle}
|
||||
disabled={isTransitioning}
|
||||
className={`p-2 rounded-full transition-all duration-200 cursor-pointer bg-bg-base/50 ${
|
||||
isShuffled
|
||||
? "text-primary hover:bg-primary/10"
|
||||
|
|
|
|||
|
|
@ -43,9 +43,11 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
return;
|
||||
}
|
||||
|
||||
const validCards = cards.filter((c) => c.front.trim() && c.back.trim());
|
||||
if (validCards.length === 0) {
|
||||
setError("Please add at least one complete card.");
|
||||
const incompleteRows = cards.flatMap((card, index) =>
|
||||
card.front.trim() && card.back.trim() ? [] : [index + 1]
|
||||
);
|
||||
if (incompleteRows.length > 0) {
|
||||
setError(`Complete or remove card row${incompleteRows.length === 1 ? "" : "s"}: ${incompleteRows.join(", ")}.`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +63,7 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
type: "flashcards",
|
||||
deckName: deckName,
|
||||
description: description || undefined,
|
||||
cards: validCards.map((c) => ({
|
||||
cards: cards.map((c) => ({
|
||||
front: c.front,
|
||||
back: c.back,
|
||||
})),
|
||||
|
|
@ -70,9 +72,11 @@ export function CreateTab({ classId, onCreated }: CreateTabProps) {
|
|||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error("Failed to create deck");
|
||||
const response = await res.json().catch(() => null) as { error?: string } | null;
|
||||
throw new Error(response?.error ?? "Failed to create deck");
|
||||
}
|
||||
|
||||
window.dispatchEvent(new Event("study-decks-changed"));
|
||||
onCreated();
|
||||
} catch (err: unknown) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create deck");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
export function GenerateTab({ importType }: { importType: "flashcards" | "quizzes" | "connections" | "crossword" }) {
|
||||
const [instructions, setInstructions] = useState("");
|
||||
|
|
@ -9,19 +9,39 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
const [saveResult, setSaveResult] = useState<"success" | "error" | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [packCount, setPackCount] = useState(1);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
|
||||
const batchOverride = importType === "connections" && packCount > 1
|
||||
? `TEMPORARY BATCH OVERRIDE:\nGenerate exactly ${packCount} distinct Connections packs. Return one raw JSON array containing exactly ${packCount} objects that each follow the schema below. Make the packs meaningfully different from one another. This override takes precedence over any later instruction to return one object.\n\n`
|
||||
: "";
|
||||
const displayedInstructions = `${batchOverride}${instructions}`;
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/settings/llm-instructions?type=${importType}`)
|
||||
.then((res) => res.json())
|
||||
.then((data) => setInstructions(data.value))
|
||||
.finally(() => setLoading(false));
|
||||
const loadInstructions = useCallback(async (signal?: AbortSignal) => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
try {
|
||||
const response = await fetch(`/api/settings/llm-instructions?type=${importType}`, { signal });
|
||||
if (!response.ok) throw new Error("Instructions could not be loaded.");
|
||||
const data = await response.json() as { value?: unknown };
|
||||
if (typeof data.value !== "string") throw new Error("The server returned invalid instructions.");
|
||||
setInstructions(data.value);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) return;
|
||||
setLoadError(error instanceof Error ? error.message : "Instructions could not be loaded.");
|
||||
} finally {
|
||||
if (!signal?.aborted) setLoading(false);
|
||||
}
|
||||
}, [importType]);
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => void loadInstructions(controller.signal), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [loadInstructions]);
|
||||
|
||||
async function handleSave() {
|
||||
setSaving(true);
|
||||
setSaveResult(null);
|
||||
|
|
@ -50,25 +70,44 @@ export function GenerateTab({ importType }: { importType: "flashcards" | "quizze
|
|||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ value: "__RESET__" }),
|
||||
});
|
||||
if (!res.ok) throw new Error("Failed to reset instructions");
|
||||
const data = await res.json();
|
||||
if (typeof data.value !== "string") throw new Error("Invalid reset response");
|
||||
setInstructions(data.value);
|
||||
setSaveResult("success");
|
||||
} catch {
|
||||
setSaveResult("error");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCopy() {
|
||||
await navigator.clipboard.writeText(displayedInstructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
try {
|
||||
await navigator.clipboard.writeText(displayedInstructions);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
setLoadError("Clipboard access failed. Select and copy the instructions manually.");
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="animate-subtle-pulse text-text-muted">Loading instructions...</div>;
|
||||
}
|
||||
|
||||
if (loadError && !instructions) {
|
||||
return (
|
||||
<div role="alert" className="rounded-xl border border-error/30 bg-error-bg p-4 text-error">
|
||||
<p>{loadError}</p>
|
||||
<button onClick={() => void loadInstructions()} className="mt-3 min-h-10 rounded-lg border border-error/40 px-4 text-sm font-bold">Retry</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{loadError && <p role="alert" className="rounded-lg bg-error-bg p-3 text-sm text-error">{loadError}</p>}
|
||||
<p className="text-sm text-text-secondary">
|
||||
Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -134,6 +134,9 @@ 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");
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ 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;
|
||||
|
|
@ -20,17 +21,28 @@ interface ReviewItem {
|
|||
}
|
||||
|
||||
export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsProps) {
|
||||
const snapshot = parseQuizReviewSnapshot(attempt.reviewSnapshotJson);
|
||||
let answers: Record<string, string[]> = {};
|
||||
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 = quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||
const questionsToReview = snapshot?.questions ??
|
||||
quiz.questions.filter((q) => answeredIds.includes(q.id));
|
||||
const snapshotPoints = new Map(
|
||||
snapshot?.questions.map((question) => [question.id, question.points]) ?? []
|
||||
);
|
||||
|
||||
questionsToReview.forEach((q) => {
|
||||
const formattedQ = {
|
||||
|
|
@ -38,7 +50,8 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
type: q.type as "MULTIPLE_CHOICE" | "SATA",
|
||||
options: q.options,
|
||||
};
|
||||
const score = scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
const score = snapshotPoints.get(q.id) ??
|
||||
scoreQuestion(formattedQ, answers[q.id] || []);
|
||||
|
||||
// If not full credit (1.0), add to review and missed arrays
|
||||
if (score < 1) {
|
||||
|
|
@ -50,6 +63,9 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
});
|
||||
}
|
||||
});
|
||||
const liveQuestionIds = new Set(quiz.questions.map((question) => question.id));
|
||||
const retakeableMissedIds = missedIds.filter((id) => liveQuestionIds.has(id));
|
||||
const reviewQuiz = { ...quiz, questions: questionsToReview };
|
||||
|
||||
const percentage = attempt.maxScore > 0 ? (attempt.score / attempt.maxScore) * 100 : 0;
|
||||
|
||||
|
|
@ -88,12 +104,12 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
</button>
|
||||
)}
|
||||
|
||||
{missedIds.length > 0 && (
|
||||
{retakeableMissedIds.length > 0 && (
|
||||
<button
|
||||
onClick={() => onRetake(missedIds)}
|
||||
onClick={() => onRetake(retakeableMissedIds)}
|
||||
className="px-6 py-2.5 rounded-lg bg-primary text-white font-medium hover:bg-primary-hover transition-all duration-200 cursor-pointer"
|
||||
>
|
||||
Retake Missed ({missedIds.length})
|
||||
Retake Missed ({retakeableMissedIds.length})
|
||||
</button>
|
||||
)}
|
||||
|
||||
|
|
@ -112,7 +128,7 @@ export function QuizResults({ quiz, attempt, onRetake, onClose }: QuizResultsPro
|
|||
{/* Category Breakdown */}
|
||||
<div className="bg-bg-surface rounded-2xl border border-border-light shadow-[var(--shadow-card)] p-6 md:p-8">
|
||||
<h3 className="text-lg font-bold text-text-heading mb-6">Category Breakdown</h3>
|
||||
<CategoryBreakdown quiz={quiz} answeredIds={answeredIds} answers={answers} />
|
||||
<CategoryBreakdown quiz={reviewQuiz} answeredIds={answeredIds} answers={answers} />
|
||||
</div>
|
||||
|
||||
{/* Review List */}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ 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;
|
||||
|
|
@ -33,16 +34,39 @@ 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, isShared = false, onFinished }: QuizViewerProps) {
|
||||
export function QuizViewer({
|
||||
quiz,
|
||||
retakeIds,
|
||||
sessionKey = "default",
|
||||
storageNamespace,
|
||||
isShared = false,
|
||||
onFinished,
|
||||
}: QuizViewerProps) {
|
||||
const submittingQuestionRef = useRef<string | null>(null);
|
||||
const finishingRef = useRef(false);
|
||||
const [initialQuiz] = useState(() => quiz);
|
||||
const [initialRetakeIds] = useState(() => retakeIds);
|
||||
const [initialProgress] = useState(() =>
|
||||
quiz.progress?.find((progress) => progress.mode === "SEQUENTIAL")
|
||||
);
|
||||
const progressSessionIdRef = useRef(
|
||||
initialProgress?.sessionId ?? globalThis.crypto.randomUUID()
|
||||
);
|
||||
const progressRevisionRef = useRef(initialProgress?.revision ?? 0);
|
||||
const saveQueueRef = useRef(Promise.resolve());
|
||||
const [order, setOrder] = useState<string[]>([]);
|
||||
const [attemptScope, setAttemptScope] = useState<string[]>([]);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [answers, setAnswers] = useState<Record<string, string[]>>({});
|
||||
const [submittedAnswers, setSubmittedAnswers] = useState<string[]>([]);
|
||||
|
|
@ -51,6 +75,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [resultsData, setResultsData] = useState<QuizAttempt | null>(null);
|
||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
||||
const [isFinishing, setIsFinishing] = useState(false);
|
||||
const [finishError, setFinishError] = useState<string | null>(null);
|
||||
const [restoreWarning, setRestoreWarning] = useState<string | null>(null);
|
||||
const sessionIdentity = `${quiz.id}:${sessionKey}`;
|
||||
const sharedStorageKey = `quiz_progress_${storageNamespace ?? "shared"}_${quiz.id}`;
|
||||
|
||||
// Auto-hide toast
|
||||
useEffect(() => {
|
||||
|
|
@ -62,16 +91,18 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
|
||||
// Initialize session
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
// Generate shuffled options once per session mount
|
||||
const newShuffledOpts: Record<string, Option[]> = {};
|
||||
for (const q of quiz.questions) {
|
||||
for (const q of initialQuiz.questions) {
|
||||
newShuffledOpts[q.id] = [...q.options].sort(() => Math.random() - 0.5);
|
||||
}
|
||||
setShuffledOptions(newShuffledOpts);
|
||||
|
||||
// If retaking specific questions
|
||||
if (retakeIds && retakeIds.length > 0) {
|
||||
setOrder(retakeIds);
|
||||
if (initialRetakeIds && initialRetakeIds.length > 0) {
|
||||
setOrder(initialRetakeIds);
|
||||
setAttemptScope(initialRetakeIds);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
|
|
@ -82,62 +113,120 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
// Shared links: load from localStorage
|
||||
if (isShared) {
|
||||
try {
|
||||
const saved = localStorage.getItem(`quiz_progress_${quiz.id}`);
|
||||
const saved = localStorage.getItem(sharedStorageKey);
|
||||
if (saved) {
|
||||
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 || {}));
|
||||
const parsed: unknown = JSON.parse(saved);
|
||||
const record =
|
||||
typeof parsed === "object" && parsed !== null
|
||||
? (parsed as Record<string, unknown>)
|
||||
: {};
|
||||
const liveIds = initialQuiz.questions.map((question) => question.id);
|
||||
const optionsByQuestion = new Map(
|
||||
initialQuiz.questions.map((question) => [
|
||||
question.id,
|
||||
new Set(question.options.map((option) => option.id)),
|
||||
])
|
||||
);
|
||||
const restored = normalizeProgress({
|
||||
orderJson: record.order,
|
||||
currentIndex: record.currentIndex,
|
||||
dataJson: record.answers,
|
||||
liveIds,
|
||||
isValidValue: (value, questionId): value is string[] =>
|
||||
Array.isArray(value) &&
|
||||
new Set(value).size === value.length &&
|
||||
value.every(
|
||||
(optionId) =>
|
||||
typeof optionId === "string" &&
|
||||
optionsByQuestion.get(questionId)?.has(optionId) === true
|
||||
),
|
||||
});
|
||||
const restoredSet = new Set(restored.order);
|
||||
const restoredOrder = [
|
||||
...restored.order,
|
||||
...liveIds.filter((id) => !restoredSet.has(id)),
|
||||
];
|
||||
setOrder(restoredOrder);
|
||||
setAttemptScope(restoredOrder);
|
||||
setCurrentIndex(
|
||||
restoredOrder.length === 0
|
||||
? 0
|
||||
: Math.min(restored.currentIndex, restoredOrder.length - 1)
|
||||
);
|
||||
setAnswers(restored.data);
|
||||
setSubmittedAnswers(Object.keys(restored.data));
|
||||
if (restored.wasRecovered || restoredOrder.length !== restored.order.length) {
|
||||
setRestoreWarning(
|
||||
"Some saved quiz progress was invalid or referenced changed questions, so it was safely repaired."
|
||||
);
|
||||
}
|
||||
setToastMessage("Session restored");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Fallback to defaults
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, normal sequential logic checking for progress
|
||||
const prog = quiz.progress?.find((p) => p.mode === "SEQUENTIAL");
|
||||
const prog = initialProgress;
|
||||
if (prog) {
|
||||
let savedOrder: string[] = [];
|
||||
try {
|
||||
savedOrder = JSON.parse(prog.orderJson);
|
||||
} catch {
|
||||
savedOrder = quiz.questions.map((q) => q.id);
|
||||
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 savedAnswers: Record<string, string[]> = {};
|
||||
if (prog.answersJson) {
|
||||
try {
|
||||
savedAnswers = JSON.parse(prog.answersJson);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
setOrder(savedOrder);
|
||||
setCurrentIndex(Math.min(prog.currentIndex, savedOrder.length - 1));
|
||||
setAnswers(savedAnswers);
|
||||
setSubmittedAnswers(Object.keys(savedAnswers));
|
||||
} else {
|
||||
const freshOrder = quiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
const freshOrder = initialQuiz.questions.map((q) => q.id).sort(() => Math.random() - 0.5);
|
||||
setOrder(freshOrder);
|
||||
setAttemptScope(freshOrder);
|
||||
setCurrentIndex(0);
|
||||
setAnswers({});
|
||||
setSubmittedAnswers([]);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [quiz, retakeIds, isShared]);
|
||||
}, 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [sessionIdentity, isShared, sharedStorageKey, initialQuiz, initialRetakeIds, initialProgress]);
|
||||
|
||||
// Persist progress as you go
|
||||
function saveProgress(
|
||||
idx: number,
|
||||
ans: Record<string, string[]>
|
||||
) {
|
||||
if (retakeIds) return; // Don't persist retake partial state
|
||||
if (attemptScope.length !== quiz.questions.length) return;
|
||||
|
||||
if (isShared) {
|
||||
localStorage.setItem(`quiz_progress_${quiz.id}`, JSON.stringify({
|
||||
localStorage.setItem(sharedStorageKey, JSON.stringify({
|
||||
currentIndex: idx,
|
||||
order: order,
|
||||
answers: ans,
|
||||
|
|
@ -145,18 +234,32 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
return;
|
||||
}
|
||||
|
||||
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(() => {});
|
||||
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.");
|
||||
});
|
||||
}
|
||||
|
||||
if (loading) return <div>Loading quiz...</div>;
|
||||
|
|
@ -217,18 +320,21 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
}
|
||||
|
||||
async function handleFinish() {
|
||||
if (finishingRef.current) return;
|
||||
finishingRef.current = true;
|
||||
setIsFinishing(true);
|
||||
setFinishError(null);
|
||||
if (isShared) {
|
||||
localStorage.removeItem(`quiz_progress_${quiz.id}`);
|
||||
localStorage.removeItem(sharedStorageKey);
|
||||
}
|
||||
|
||||
// If shared, grade it locally and show results, don't ping backend
|
||||
if (isShared) {
|
||||
// Simulate an attempt object
|
||||
let runningScore = 0;
|
||||
let maxScore = quiz.questions.length; // Actually, depends on retakeIds, but shared doesn't support retakeIds typically
|
||||
if (retakeIds) maxScore = retakeIds.length;
|
||||
const maxScore = attemptScope.length;
|
||||
|
||||
const scoredItems = (retakeIds || quiz.questions.map(q => q.id)).map(qId => {
|
||||
const scoredItems = attemptScope.map(qId => {
|
||||
const q = quiz.questions.find((x) => x.id === qId);
|
||||
if (!q) return 0;
|
||||
const formattedQ = {
|
||||
|
|
@ -245,45 +351,40 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
score: runningScore,
|
||||
maxScore: maxScore,
|
||||
answersJson: JSON.stringify(answers),
|
||||
isPartialRetake: false,
|
||||
isPartialRetake: attemptScope.length !== quiz.questions.length,
|
||||
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({
|
||||
answersJson: JSON.stringify(answers),
|
||||
isPartialRetake,
|
||||
questionIds: attemptScope,
|
||||
answers,
|
||||
}),
|
||||
});
|
||||
|
||||
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);
|
||||
if (!res.ok) {
|
||||
const payload = (await res.json().catch(() => null)) as
|
||||
| { error?: string }
|
||||
| null;
|
||||
throw new Error(payload?.error ?? "Could not save this attempt");
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setResultsData(await res.json());
|
||||
} catch (error) {
|
||||
setFinishError(
|
||||
error instanceof Error ? error.message : "Could not save this attempt"
|
||||
);
|
||||
} finally {
|
||||
finishingRef.current = false;
|
||||
setIsFinishing(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -295,11 +396,13 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
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<string, Option[]> = {};
|
||||
|
|
@ -319,6 +422,11 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
|
||||
return (
|
||||
<div className="flex flex-col items-center relative">
|
||||
{restoreWarning && (
|
||||
<div className="mb-4 w-full max-w-4xl rounded-xl bg-badge-bg px-4 py-3 text-sm text-text-secondary" role="status">
|
||||
{restoreWarning}
|
||||
</div>
|
||||
)}
|
||||
{/* Toast Notification */}
|
||||
{toastMessage && (
|
||||
<div className="absolute top-4 left-1/2 -translate-x-1/2 z-30 animate-fade-in pointer-events-none">
|
||||
|
|
@ -504,12 +612,20 @@ export function QuizViewer({ quiz, retakeIds, isShared = false, onFinished }: Qu
|
|||
Next Question
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm"
|
||||
>
|
||||
Finish Quiz
|
||||
</button>
|
||||
<div className="w-full">
|
||||
{finishError && (
|
||||
<div className="mb-3 rounded-xl bg-error-bg px-4 py-3 text-sm text-error" role="alert">
|
||||
{finishError} Your answers are still here; try again.
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFinish}
|
||||
disabled={isFinishing}
|
||||
className="w-full py-3.5 rounded-xl bg-success hover:bg-success/90 text-white font-bold text-lg tracking-wide transition-all duration-200 cursor-pointer shadow-sm disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isFinishing ? "Saving attempt..." : "Finish Quiz"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,16 @@ export function SpacedRepetitionSets() {
|
|||
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => void load(), 0);
|
||||
return () => window.clearTimeout(timer);
|
||||
function refreshWhenVisible() {
|
||||
if (document.visibilityState === "visible") void load();
|
||||
}
|
||||
window.addEventListener("focus", refreshWhenVisible);
|
||||
window.addEventListener("study-decks-changed", refreshWhenVisible);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
window.removeEventListener("focus", refreshWhenVisible);
|
||||
window.removeEventListener("study-decks-changed", refreshWhenVisible);
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
async function createSet() {
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ 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<string | null>(null);
|
||||
|
||||
const refreshDueCards = useCallback(async () => {
|
||||
if (!classSlug) return;
|
||||
|
|
@ -53,9 +55,18 @@ export function Navbar() {
|
|||
}, [refreshDueCards]);
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
router.refresh();
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
const nav = (
|
||||
|
|
@ -110,7 +121,8 @@ export function Navbar() {
|
|||
<span className="text-xs font-semibold text-text-muted">Appearance</span>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
<button onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error">
|
||||
{logoutError && <p role="alert" className="mt-2 rounded-lg bg-error-bg p-2 text-xs text-error">{logoutError}</p>}
|
||||
<button aria-label={loggingOut ? "Signing out" : logoutError ? "Retry sign out" : "Sign out"} disabled={loggingOut} onClick={handleLogout} className="mt-1 flex min-h-11 w-full items-center gap-3 rounded-xl px-3 text-left text-sm font-semibold text-text-secondary transition-colors hover:bg-error-bg hover:text-error disabled:opacity-50">
|
||||
<span aria-hidden>↗</span> Sign out
|
||||
</button>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
|
||||
interface ShareMenuProps {
|
||||
targetType: "DECK" | "QUIZ" | "GROUP";
|
||||
contentId: string;
|
||||
classSlug: string;
|
||||
compact?: boolean;
|
||||
containsQuizAnswers?: boolean;
|
||||
}
|
||||
|
||||
export function ShareMenu({ targetType, contentId, classSlug, compact = false }: ShareMenuProps) {
|
||||
export function ShareMenu({
|
||||
targetType,
|
||||
contentId,
|
||||
classSlug,
|
||||
compact = false,
|
||||
containsQuizAnswers = targetType === "QUIZ",
|
||||
}: ShareMenuProps) {
|
||||
const [token, setToken] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [isGroupShared, setIsGroupShared] = useState(false);
|
||||
const [groupName, setGroupName] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -35,28 +43,55 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
|||
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;
|
||||
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]);
|
||||
const controller = new AbortController();
|
||||
const timer = window.setTimeout(() => void loadShareInfo(controller.signal), 0);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
}, [open, loadShareInfo]);
|
||||
|
||||
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);
|
||||
}
|
||||
|
|
@ -73,9 +108,13 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
|||
: "quizzes";
|
||||
const itemQuery = isGroupShared ? `?itemId=${encodeURIComponent(contentId)}` : "";
|
||||
const url = `${window.location.origin}/shared/${classSlug}/${typeStr}/${token}${itemQuery}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
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.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -85,6 +124,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
|||
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"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
|
||||
|
|
@ -99,6 +139,11 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
|||
|
||||
{loading ? (
|
||||
<div className="h-8 bg-bg-surface-alt rounded w-full animate-subtle-pulse" />
|
||||
) : error ? (
|
||||
<div role="alert" className="rounded-lg bg-error-bg p-3 text-sm text-error">
|
||||
<p>{error}</p>
|
||||
<button onClick={() => void loadShareInfo()} className="mt-2 min-h-9 rounded-lg border border-error/40 px-3 font-bold">Retry</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
@ -106,6 +151,7 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
|||
<button
|
||||
onClick={handleToggle}
|
||||
disabled={isGroupShared}
|
||||
aria-label={token || isGroupShared ? "Disable public sharing" : "Enable public sharing"}
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
token || isGroupShared ? 'bg-primary' : 'bg-border'
|
||||
} ${isGroupShared ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`}
|
||||
|
|
@ -127,8 +173,13 @@ export function ShareMenu({ targetType, contentId, classSlug, compact = false }:
|
|||
{(token || isGroupShared) && (
|
||||
<div className="space-y-2 pt-2 border-t border-border-light">
|
||||
<p className="text-xs text-text-muted">
|
||||
Anyone with the link can view and study this {targetType === "GROUP" ? "group" : (targetType === "DECK" ? "deck" : "quiz")}. No progress is saved.
|
||||
Anyone with the link can view and study this {targetType === "GROUP" ? "group" : (targetType === "DECK" ? "deck" : "quiz")}. Progress is stored only in that recipient's browser.
|
||||
</p>
|
||||
{containsQuizAnswers && (
|
||||
<p className="rounded-lg bg-badge-bg px-3 py-2 text-xs text-text-secondary">
|
||||
Shared quizzes send correct answers and rationales to the recipient's browser for local grading, so the answer key is inspectable.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="w-full flex items-center justify-center gap-2 py-2 px-3 rounded-lg border border-primary text-primary hover:bg-primary/5 transition-colors text-sm font-medium cursor-pointer"
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -80,12 +80,12 @@ export type PrismaVersion = {
|
|||
}
|
||||
|
||||
/**
|
||||
* Prisma Client JS version: 7.8.0
|
||||
* Query Engine version: 3c6e192761c0362d496ed980de936e2f3cebcd3a
|
||||
* Prisma Client JS version: 7.9.1
|
||||
* Query Engine version: e922089b7d7502aff4249d5da3420f6fa55fc6ad
|
||||
*/
|
||||
export const prismaVersion: PrismaVersion = {
|
||||
client: "7.8.0",
|
||||
engine: "3c6e192761c0362d496ed980de936e2f3cebcd3a"
|
||||
client: "7.9.1",
|
||||
engine: "e922089b7d7502aff4249d5da3420f6fa55fc6ad"
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -155,6 +155,19 @@ export type Subset<T, U> = {
|
|||
[key in keyof T]: key extends keyof U ? T[key] : never;
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolved type of the argument passed to the `PrismaClient` constructor.
|
||||
*
|
||||
* When called without a narrower options type (the common case), this resolves
|
||||
* to `PrismaClientOptions` directly, which produces a clear TypeScript error
|
||||
* message (`not assignable to parameter of type 'PrismaClientOptions'`) when
|
||||
* the argument is missing or incomplete. When the user supplies a narrower
|
||||
* options type (e.g. via a literal), it falls back to `Subset` to keep
|
||||
* filtering out unknown properties.
|
||||
*/
|
||||
export type PrismaClientConstructorArgs<Options extends PrismaClientOptions> =
|
||||
[PrismaClientOptions] extends [Options] ? PrismaClientOptions : Subset<Options, PrismaClientOptions>;
|
||||
|
||||
/**
|
||||
* SelectSubset
|
||||
* @desc From `T` pick properties that exist in `U`. Simple version of Intersection.
|
||||
|
|
@ -187,7 +200,7 @@ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never };
|
|||
export type XOR<T, U> =
|
||||
T extends object ?
|
||||
U extends object ?
|
||||
(Without<T, U> & U) | (Without<U, T> & T)
|
||||
((Without<T, U> & U) | (Without<U, T> & T)) & object
|
||||
: U : T
|
||||
|
||||
|
||||
|
|
@ -1871,6 +1884,8 @@ export const StudyProgressScalarFieldEnum = {
|
|||
orderJson: 'orderJson',
|
||||
answersJson: 'answersJson',
|
||||
cardResultsJson: 'cardResultsJson',
|
||||
sessionId: 'sessionId',
|
||||
revision: 'revision',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
|
|
@ -1883,6 +1898,7 @@ export const QuizAttemptScalarFieldEnum = {
|
|||
score: 'score',
|
||||
maxScore: 'maxScore',
|
||||
answersJson: 'answersJson',
|
||||
reviewSnapshotJson: 'reviewSnapshotJson',
|
||||
isPartialRetake: 'isPartialRetake',
|
||||
completedAt: 'completedAt'
|
||||
} as const
|
||||
|
|
@ -2090,19 +2106,10 @@ export type BatchPayload = {
|
|||
export const defineExtension = runtime.Extensions.defineExtension as unknown as runtime.Types.Extensions.ExtendsHook<"define", TypeMapCb, runtime.Types.Extensions.DefaultArgs>
|
||||
export type DefaultPrismaClient = PrismaClient
|
||||
export type ErrorFormat = 'pretty' | 'colorless' | 'minimal'
|
||||
export type PrismaClientOptions = ({
|
||||
/**
|
||||
* Instance of a Driver Adapter, e.g., like one provided by `@prisma/adapter-pg`.
|
||||
*/
|
||||
adapter: runtime.SqlDriverAdapterFactory
|
||||
accelerateUrl?: never
|
||||
} | {
|
||||
/**
|
||||
* Prisma Accelerate URL allowing the client to connect through Accelerate instead of a direct database.
|
||||
*/
|
||||
accelerateUrl: string
|
||||
adapter?: never
|
||||
}) & {
|
||||
/**
|
||||
* Options common to all variants of `PrismaClientOptions`, regardless of whether you connect to your database through a driver adapter or through Prisma Accelerate.
|
||||
*/
|
||||
export interface PrismaClientBaseOptions {
|
||||
/**
|
||||
* @default "colorless"
|
||||
*/
|
||||
|
|
@ -2189,6 +2196,56 @@ export type PrismaClientOptions = ({
|
|||
*/
|
||||
queryPlanCacheMaxSize?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* `PrismaClient` options for connecting to your database through Prisma Accelerate instead of a driver adapter.
|
||||
*
|
||||
* Learn more: https://pris.ly/d/accelerate
|
||||
*/
|
||||
export interface PrismaClientOptionsWithAccelerateUrl extends PrismaClientBaseOptions {
|
||||
/**
|
||||
* The Prisma Accelerate connection URL. Use this option to connect to your database through Prisma Accelerate instead of using a driver adapter to connect directly.
|
||||
*
|
||||
* Learn more: https://pris.ly/d/accelerate
|
||||
*/
|
||||
accelerateUrl: string
|
||||
adapter?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* `PrismaClient` options for connecting to your database through a driver adapter. This is the common case in Prisma 7.
|
||||
*
|
||||
* Learn more: https://pris.ly/d/driver-adapters
|
||||
*/
|
||||
export interface PrismaClientOptionsWithAdapter extends PrismaClientBaseOptions {
|
||||
/**
|
||||
* A driver adapter that PrismaClient uses to connect to your database, such as the ones provided by `@prisma/adapter-pg`, `@prisma/adapter-libsql`, `@prisma/adapter-planetscale`, etc.
|
||||
*
|
||||
* A driver adapter is **required** unless you connect to your database through Prisma Accelerate (in which case use `accelerateUrl` instead).
|
||||
*
|
||||
* Learn more: https://pris.ly/d/driver-adapters
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { PrismaPg } from '@prisma/adapter-pg'
|
||||
* import { PrismaClient } from './generated/prisma/client'
|
||||
*
|
||||
* const adapter = new PrismaPg({ connectionString: process.env.DATABASE_URL })
|
||||
* const prisma = new PrismaClient({ adapter })
|
||||
* ```
|
||||
*/
|
||||
adapter: runtime.SqlDriverAdapterFactory
|
||||
accelerateUrl?: never
|
||||
}
|
||||
|
||||
/**
|
||||
* Options passed to the `PrismaClient` constructor.
|
||||
*
|
||||
* A driver adapter (or, alternatively, a Prisma Accelerate URL) is **required**. See {@link PrismaClientOptionsWithAdapter} and {@link PrismaClientOptionsWithAccelerateUrl} for the two variants. All other properties live in {@link PrismaClientBaseOptions} and are optional.
|
||||
*
|
||||
* Learn more about driver adapters: https://pris.ly/d/driver-adapters
|
||||
*/
|
||||
export type PrismaClientOptions = PrismaClientOptionsWithAccelerateUrl | PrismaClientOptionsWithAdapter
|
||||
export type GlobalOmitConfig = {
|
||||
class?: Prisma.ClassOmit
|
||||
deck?: Prisma.DeckOmit
|
||||
|
|
|
|||
|
|
@ -166,6 +166,8 @@ export const StudyProgressScalarFieldEnum = {
|
|||
orderJson: 'orderJson',
|
||||
answersJson: 'answersJson',
|
||||
cardResultsJson: 'cardResultsJson',
|
||||
sessionId: 'sessionId',
|
||||
revision: 'revision',
|
||||
updatedAt: 'updatedAt'
|
||||
} as const
|
||||
|
||||
|
|
@ -178,6 +180,7 @@ export const QuizAttemptScalarFieldEnum = {
|
|||
score: 'score',
|
||||
maxScore: 'maxScore',
|
||||
answersJson: 'answersJson',
|
||||
reviewSnapshotJson: 'reviewSnapshotJson',
|
||||
isPartialRetake: 'isPartialRetake',
|
||||
completedAt: 'completedAt'
|
||||
} as const
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export type QuizAttemptMinAggregateOutputType = {
|
|||
score: number | null
|
||||
maxScore: number | null
|
||||
answersJson: string | null
|
||||
reviewSnapshotJson: string | null
|
||||
isPartialRetake: boolean | null
|
||||
completedAt: Date | null
|
||||
}
|
||||
|
|
@ -52,6 +53,7 @@ export type QuizAttemptMaxAggregateOutputType = {
|
|||
score: number | null
|
||||
maxScore: number | null
|
||||
answersJson: string | null
|
||||
reviewSnapshotJson: string | null
|
||||
isPartialRetake: boolean | null
|
||||
completedAt: Date | null
|
||||
}
|
||||
|
|
@ -62,6 +64,7 @@ export type QuizAttemptCountAggregateOutputType = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: number
|
||||
reviewSnapshotJson: number
|
||||
isPartialRetake: number
|
||||
completedAt: number
|
||||
_all: number
|
||||
|
|
@ -84,6 +87,7 @@ export type QuizAttemptMinAggregateInputType = {
|
|||
score?: true
|
||||
maxScore?: true
|
||||
answersJson?: true
|
||||
reviewSnapshotJson?: true
|
||||
isPartialRetake?: true
|
||||
completedAt?: true
|
||||
}
|
||||
|
|
@ -94,6 +98,7 @@ export type QuizAttemptMaxAggregateInputType = {
|
|||
score?: true
|
||||
maxScore?: true
|
||||
answersJson?: true
|
||||
reviewSnapshotJson?: true
|
||||
isPartialRetake?: true
|
||||
completedAt?: true
|
||||
}
|
||||
|
|
@ -104,6 +109,7 @@ export type QuizAttemptCountAggregateInputType = {
|
|||
score?: true
|
||||
maxScore?: true
|
||||
answersJson?: true
|
||||
reviewSnapshotJson?: true
|
||||
isPartialRetake?: true
|
||||
completedAt?: true
|
||||
_all?: true
|
||||
|
|
@ -201,6 +207,7 @@ export type QuizAttemptGroupByOutputType = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson: string | null
|
||||
isPartialRetake: boolean
|
||||
completedAt: Date
|
||||
_count: QuizAttemptCountAggregateOutputType | null
|
||||
|
|
@ -234,6 +241,7 @@ export type QuizAttemptWhereInput = {
|
|||
score?: Prisma.FloatFilter<"QuizAttempt"> | number
|
||||
maxScore?: Prisma.IntFilter<"QuizAttempt"> | number
|
||||
answersJson?: Prisma.StringFilter<"QuizAttempt"> | string
|
||||
reviewSnapshotJson?: Prisma.StringNullableFilter<"QuizAttempt"> | string | null
|
||||
isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean
|
||||
completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string
|
||||
quizSet?: Prisma.XOR<Prisma.QuizSetScalarRelationFilter, Prisma.QuizSetWhereInput>
|
||||
|
|
@ -245,6 +253,7 @@ export type QuizAttemptOrderByWithRelationInput = {
|
|||
score?: Prisma.SortOrder
|
||||
maxScore?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
reviewSnapshotJson?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
isPartialRetake?: Prisma.SortOrder
|
||||
completedAt?: Prisma.SortOrder
|
||||
quizSet?: Prisma.QuizSetOrderByWithRelationInput
|
||||
|
|
@ -259,6 +268,7 @@ export type QuizAttemptWhereUniqueInput = Prisma.AtLeast<{
|
|||
score?: Prisma.FloatFilter<"QuizAttempt"> | number
|
||||
maxScore?: Prisma.IntFilter<"QuizAttempt"> | number
|
||||
answersJson?: Prisma.StringFilter<"QuizAttempt"> | string
|
||||
reviewSnapshotJson?: Prisma.StringNullableFilter<"QuizAttempt"> | string | null
|
||||
isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean
|
||||
completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string
|
||||
quizSet?: Prisma.XOR<Prisma.QuizSetScalarRelationFilter, Prisma.QuizSetWhereInput>
|
||||
|
|
@ -270,6 +280,7 @@ export type QuizAttemptOrderByWithAggregationInput = {
|
|||
score?: Prisma.SortOrder
|
||||
maxScore?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
reviewSnapshotJson?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
isPartialRetake?: Prisma.SortOrder
|
||||
completedAt?: Prisma.SortOrder
|
||||
_count?: Prisma.QuizAttemptCountOrderByAggregateInput
|
||||
|
|
@ -288,6 +299,7 @@ export type QuizAttemptScalarWhereWithAggregatesInput = {
|
|||
score?: Prisma.FloatWithAggregatesFilter<"QuizAttempt"> | number
|
||||
maxScore?: Prisma.IntWithAggregatesFilter<"QuizAttempt"> | number
|
||||
answersJson?: Prisma.StringWithAggregatesFilter<"QuizAttempt"> | string
|
||||
reviewSnapshotJson?: Prisma.StringNullableWithAggregatesFilter<"QuizAttempt"> | string | null
|
||||
isPartialRetake?: Prisma.BoolWithAggregatesFilter<"QuizAttempt"> | boolean
|
||||
completedAt?: Prisma.DateTimeWithAggregatesFilter<"QuizAttempt"> | Date | string
|
||||
}
|
||||
|
|
@ -297,6 +309,7 @@ export type QuizAttemptCreateInput = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson?: string | null
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: Date | string
|
||||
quizSet: Prisma.QuizSetCreateNestedOneWithoutAttemptsInput
|
||||
|
|
@ -308,6 +321,7 @@ export type QuizAttemptUncheckedCreateInput = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson?: string | null
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: Date | string
|
||||
}
|
||||
|
|
@ -317,6 +331,7 @@ export type QuizAttemptUpdateInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
quizSet?: Prisma.QuizSetUpdateOneRequiredWithoutAttemptsNestedInput
|
||||
|
|
@ -328,6 +343,7 @@ export type QuizAttemptUncheckedUpdateInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
|
@ -338,6 +354,7 @@ export type QuizAttemptCreateManyInput = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson?: string | null
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: Date | string
|
||||
}
|
||||
|
|
@ -347,6 +364,7 @@ export type QuizAttemptUpdateManyMutationInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
|
@ -357,6 +375,7 @@ export type QuizAttemptUncheckedUpdateManyInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
|
@ -377,6 +396,7 @@ export type QuizAttemptCountOrderByAggregateInput = {
|
|||
score?: Prisma.SortOrder
|
||||
maxScore?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
reviewSnapshotJson?: Prisma.SortOrder
|
||||
isPartialRetake?: Prisma.SortOrder
|
||||
completedAt?: Prisma.SortOrder
|
||||
}
|
||||
|
|
@ -392,6 +412,7 @@ export type QuizAttemptMaxOrderByAggregateInput = {
|
|||
score?: Prisma.SortOrder
|
||||
maxScore?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
reviewSnapshotJson?: Prisma.SortOrder
|
||||
isPartialRetake?: Prisma.SortOrder
|
||||
completedAt?: Prisma.SortOrder
|
||||
}
|
||||
|
|
@ -402,6 +423,7 @@ export type QuizAttemptMinOrderByAggregateInput = {
|
|||
score?: Prisma.SortOrder
|
||||
maxScore?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
reviewSnapshotJson?: Prisma.SortOrder
|
||||
isPartialRetake?: Prisma.SortOrder
|
||||
completedAt?: Prisma.SortOrder
|
||||
}
|
||||
|
|
@ -466,6 +488,7 @@ export type QuizAttemptCreateWithoutQuizSetInput = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson?: string | null
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: Date | string
|
||||
}
|
||||
|
|
@ -475,6 +498,7 @@ export type QuizAttemptUncheckedCreateWithoutQuizSetInput = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson?: string | null
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: Date | string
|
||||
}
|
||||
|
|
@ -513,6 +537,7 @@ export type QuizAttemptScalarWhereInput = {
|
|||
score?: Prisma.FloatFilter<"QuizAttempt"> | number
|
||||
maxScore?: Prisma.IntFilter<"QuizAttempt"> | number
|
||||
answersJson?: Prisma.StringFilter<"QuizAttempt"> | string
|
||||
reviewSnapshotJson?: Prisma.StringNullableFilter<"QuizAttempt"> | string | null
|
||||
isPartialRetake?: Prisma.BoolFilter<"QuizAttempt"> | boolean
|
||||
completedAt?: Prisma.DateTimeFilter<"QuizAttempt"> | Date | string
|
||||
}
|
||||
|
|
@ -522,6 +547,7 @@ export type QuizAttemptCreateManyQuizSetInput = {
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson?: string | null
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: Date | string
|
||||
}
|
||||
|
|
@ -531,6 +557,7 @@ export type QuizAttemptUpdateWithoutQuizSetInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
|
@ -540,6 +567,7 @@ export type QuizAttemptUncheckedUpdateWithoutQuizSetInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
|
@ -549,6 +577,7 @@ export type QuizAttemptUncheckedUpdateManyWithoutQuizSetInput = {
|
|||
score?: Prisma.FloatFieldUpdateOperationsInput | number
|
||||
maxScore?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
answersJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
reviewSnapshotJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
isPartialRetake?: Prisma.BoolFieldUpdateOperationsInput | boolean
|
||||
completedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
|
@ -561,6 +590,7 @@ export type QuizAttemptSelect<ExtArgs extends runtime.Types.Extensions.InternalA
|
|||
score?: boolean
|
||||
maxScore?: boolean
|
||||
answersJson?: boolean
|
||||
reviewSnapshotJson?: boolean
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: boolean
|
||||
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
|
||||
|
|
@ -572,6 +602,7 @@ export type QuizAttemptSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||
score?: boolean
|
||||
maxScore?: boolean
|
||||
answersJson?: boolean
|
||||
reviewSnapshotJson?: boolean
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: boolean
|
||||
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
|
||||
|
|
@ -583,6 +614,7 @@ export type QuizAttemptSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
|
|||
score?: boolean
|
||||
maxScore?: boolean
|
||||
answersJson?: boolean
|
||||
reviewSnapshotJson?: boolean
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: boolean
|
||||
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
|
||||
|
|
@ -594,11 +626,12 @@ export type QuizAttemptSelectScalar = {
|
|||
score?: boolean
|
||||
maxScore?: boolean
|
||||
answersJson?: boolean
|
||||
reviewSnapshotJson?: boolean
|
||||
isPartialRetake?: boolean
|
||||
completedAt?: boolean
|
||||
}
|
||||
|
||||
export type QuizAttemptOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "quizSetId" | "score" | "maxScore" | "answersJson" | "isPartialRetake" | "completedAt", ExtArgs["result"]["quizAttempt"]>
|
||||
export type QuizAttemptOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "quizSetId" | "score" | "maxScore" | "answersJson" | "reviewSnapshotJson" | "isPartialRetake" | "completedAt", ExtArgs["result"]["quizAttempt"]>
|
||||
export type QuizAttemptInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
quizSet?: boolean | Prisma.QuizSetDefaultArgs<ExtArgs>
|
||||
}
|
||||
|
|
@ -620,6 +653,7 @@ export type $QuizAttemptPayload<ExtArgs extends runtime.Types.Extensions.Interna
|
|||
score: number
|
||||
maxScore: number
|
||||
answersJson: string
|
||||
reviewSnapshotJson: string | null
|
||||
isPartialRetake: boolean
|
||||
completedAt: Date
|
||||
}, ExtArgs["result"]["quizAttempt"]>
|
||||
|
|
@ -1051,6 +1085,7 @@ export interface QuizAttemptFieldRefs {
|
|||
readonly score: Prisma.FieldRef<"QuizAttempt", 'Float'>
|
||||
readonly maxScore: Prisma.FieldRef<"QuizAttempt", 'Int'>
|
||||
readonly answersJson: Prisma.FieldRef<"QuizAttempt", 'String'>
|
||||
readonly reviewSnapshotJson: Prisma.FieldRef<"QuizAttempt", 'String'>
|
||||
readonly isPartialRetake: Prisma.FieldRef<"QuizAttempt", 'Boolean'>
|
||||
readonly completedAt: Prisma.FieldRef<"QuizAttempt", 'DateTime'>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,10 +28,12 @@ export type AggregateStudyProgress = {
|
|||
|
||||
export type StudyProgressAvgAggregateOutputType = {
|
||||
currentIndex: number | null
|
||||
revision: number | null
|
||||
}
|
||||
|
||||
export type StudyProgressSumAggregateOutputType = {
|
||||
currentIndex: number | null
|
||||
revision: number | null
|
||||
}
|
||||
|
||||
export type StudyProgressMinAggregateOutputType = {
|
||||
|
|
@ -44,6 +46,8 @@ export type StudyProgressMinAggregateOutputType = {
|
|||
orderJson: string | null
|
||||
answersJson: string | null
|
||||
cardResultsJson: string | null
|
||||
sessionId: string | null
|
||||
revision: number | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +61,8 @@ export type StudyProgressMaxAggregateOutputType = {
|
|||
orderJson: string | null
|
||||
answersJson: string | null
|
||||
cardResultsJson: string | null
|
||||
sessionId: string | null
|
||||
revision: number | null
|
||||
updatedAt: Date | null
|
||||
}
|
||||
|
||||
|
|
@ -70,6 +76,8 @@ export type StudyProgressCountAggregateOutputType = {
|
|||
orderJson: number
|
||||
answersJson: number
|
||||
cardResultsJson: number
|
||||
sessionId: number
|
||||
revision: number
|
||||
updatedAt: number
|
||||
_all: number
|
||||
}
|
||||
|
|
@ -77,10 +85,12 @@ export type StudyProgressCountAggregateOutputType = {
|
|||
|
||||
export type StudyProgressAvgAggregateInputType = {
|
||||
currentIndex?: true
|
||||
revision?: true
|
||||
}
|
||||
|
||||
export type StudyProgressSumAggregateInputType = {
|
||||
currentIndex?: true
|
||||
revision?: true
|
||||
}
|
||||
|
||||
export type StudyProgressMinAggregateInputType = {
|
||||
|
|
@ -93,6 +103,8 @@ export type StudyProgressMinAggregateInputType = {
|
|||
orderJson?: true
|
||||
answersJson?: true
|
||||
cardResultsJson?: true
|
||||
sessionId?: true
|
||||
revision?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +118,8 @@ export type StudyProgressMaxAggregateInputType = {
|
|||
orderJson?: true
|
||||
answersJson?: true
|
||||
cardResultsJson?: true
|
||||
sessionId?: true
|
||||
revision?: true
|
||||
updatedAt?: true
|
||||
}
|
||||
|
||||
|
|
@ -119,6 +133,8 @@ export type StudyProgressCountAggregateInputType = {
|
|||
orderJson?: true
|
||||
answersJson?: true
|
||||
cardResultsJson?: true
|
||||
sessionId?: true
|
||||
revision?: true
|
||||
updatedAt?: true
|
||||
_all?: true
|
||||
}
|
||||
|
|
@ -219,6 +235,8 @@ export type StudyProgressGroupByOutputType = {
|
|||
orderJson: string
|
||||
answersJson: string | null
|
||||
cardResultsJson: string | null
|
||||
sessionId: string
|
||||
revision: number
|
||||
updatedAt: Date
|
||||
_count: StudyProgressCountAggregateOutputType | null
|
||||
_avg: StudyProgressAvgAggregateOutputType | null
|
||||
|
|
@ -255,6 +273,8 @@ export type StudyProgressWhereInput = {
|
|||
orderJson?: Prisma.StringFilter<"StudyProgress"> | string
|
||||
answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
|
||||
cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
|
||||
sessionId?: Prisma.StringFilter<"StudyProgress"> | string
|
||||
revision?: Prisma.IntFilter<"StudyProgress"> | number
|
||||
updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string
|
||||
deck?: Prisma.XOR<Prisma.DeckNullableScalarRelationFilter, Prisma.DeckWhereInput> | null
|
||||
quizSet?: Prisma.XOR<Prisma.QuizSetNullableScalarRelationFilter, Prisma.QuizSetWhereInput> | null
|
||||
|
|
@ -270,6 +290,8 @@ export type StudyProgressOrderByWithRelationInput = {
|
|||
orderJson?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cardResultsJson?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
sessionId?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
deck?: Prisma.DeckOrderByWithRelationInput
|
||||
quizSet?: Prisma.QuizSetOrderByWithRelationInput
|
||||
|
|
@ -290,6 +312,8 @@ export type StudyProgressWhereUniqueInput = Prisma.AtLeast<{
|
|||
orderJson?: Prisma.StringFilter<"StudyProgress"> | string
|
||||
answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
|
||||
cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
|
||||
sessionId?: Prisma.StringFilter<"StudyProgress"> | string
|
||||
revision?: Prisma.IntFilter<"StudyProgress"> | number
|
||||
updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string
|
||||
deck?: Prisma.XOR<Prisma.DeckNullableScalarRelationFilter, Prisma.DeckWhereInput> | null
|
||||
quizSet?: Prisma.XOR<Prisma.QuizSetNullableScalarRelationFilter, Prisma.QuizSetWhereInput> | null
|
||||
|
|
@ -305,6 +329,8 @@ export type StudyProgressOrderByWithAggregationInput = {
|
|||
orderJson?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
cardResultsJson?: Prisma.SortOrderInput | Prisma.SortOrder
|
||||
sessionId?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
_count?: Prisma.StudyProgressCountOrderByAggregateInput
|
||||
_avg?: Prisma.StudyProgressAvgOrderByAggregateInput
|
||||
|
|
@ -326,6 +352,8 @@ export type StudyProgressScalarWhereWithAggregatesInput = {
|
|||
orderJson?: Prisma.StringWithAggregatesFilter<"StudyProgress"> | string
|
||||
answersJson?: Prisma.StringNullableWithAggregatesFilter<"StudyProgress"> | string | null
|
||||
cardResultsJson?: Prisma.StringNullableWithAggregatesFilter<"StudyProgress"> | string | null
|
||||
sessionId?: Prisma.StringWithAggregatesFilter<"StudyProgress"> | string
|
||||
revision?: Prisma.IntWithAggregatesFilter<"StudyProgress"> | number
|
||||
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"StudyProgress"> | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -337,6 +365,8 @@ export type StudyProgressCreateInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
deck?: Prisma.DeckCreateNestedOneWithoutProgressInput
|
||||
quizSet?: Prisma.QuizSetCreateNestedOneWithoutProgressInput
|
||||
|
|
@ -352,6 +382,8 @@ export type StudyProgressUncheckedCreateInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
|
|
@ -363,6 +395,8 @@ export type StudyProgressUpdateInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
deck?: Prisma.DeckUpdateOneWithoutProgressNestedInput
|
||||
quizSet?: Prisma.QuizSetUpdateOneWithoutProgressNestedInput
|
||||
|
|
@ -378,6 +412,8 @@ export type StudyProgressUncheckedUpdateInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -391,6 +427,8 @@ export type StudyProgressCreateManyInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
|
|
@ -402,6 +440,8 @@ export type StudyProgressUpdateManyMutationInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -415,6 +455,8 @@ export type StudyProgressUncheckedUpdateManyInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -448,11 +490,14 @@ export type StudyProgressCountOrderByAggregateInput = {
|
|||
orderJson?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
cardResultsJson?: Prisma.SortOrder
|
||||
sessionId?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type StudyProgressAvgOrderByAggregateInput = {
|
||||
currentIndex?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type StudyProgressMaxOrderByAggregateInput = {
|
||||
|
|
@ -465,6 +510,8 @@ export type StudyProgressMaxOrderByAggregateInput = {
|
|||
orderJson?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
cardResultsJson?: Prisma.SortOrder
|
||||
sessionId?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
|
|
@ -478,11 +525,14 @@ export type StudyProgressMinOrderByAggregateInput = {
|
|||
orderJson?: Prisma.SortOrder
|
||||
answersJson?: Prisma.SortOrder
|
||||
cardResultsJson?: Prisma.SortOrder
|
||||
sessionId?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
updatedAt?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type StudyProgressSumOrderByAggregateInput = {
|
||||
currentIndex?: Prisma.SortOrder
|
||||
revision?: Prisma.SortOrder
|
||||
}
|
||||
|
||||
export type StudyProgressCreateNestedManyWithoutDeckInput = {
|
||||
|
|
@ -577,6 +627,8 @@ export type StudyProgressCreateWithoutDeckInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
quizSet?: Prisma.QuizSetCreateNestedOneWithoutProgressInput
|
||||
}
|
||||
|
|
@ -590,6 +642,8 @@ export type StudyProgressUncheckedCreateWithoutDeckInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
|
|
@ -631,6 +685,8 @@ export type StudyProgressScalarWhereInput = {
|
|||
orderJson?: Prisma.StringFilter<"StudyProgress"> | string
|
||||
answersJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
|
||||
cardResultsJson?: Prisma.StringNullableFilter<"StudyProgress"> | string | null
|
||||
sessionId?: Prisma.StringFilter<"StudyProgress"> | string
|
||||
revision?: Prisma.IntFilter<"StudyProgress"> | number
|
||||
updatedAt?: Prisma.DateTimeFilter<"StudyProgress"> | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -642,6 +698,8 @@ export type StudyProgressCreateWithoutQuizSetInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
deck?: Prisma.DeckCreateNestedOneWithoutProgressInput
|
||||
}
|
||||
|
|
@ -655,6 +713,8 @@ export type StudyProgressUncheckedCreateWithoutQuizSetInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
|
|
@ -692,6 +752,8 @@ export type StudyProgressCreateManyDeckInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
|
|
@ -703,6 +765,8 @@ export type StudyProgressUpdateWithoutDeckInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
quizSet?: Prisma.QuizSetUpdateOneWithoutProgressNestedInput
|
||||
}
|
||||
|
|
@ -716,6 +780,8 @@ export type StudyProgressUncheckedUpdateWithoutDeckInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -728,6 +794,8 @@ export type StudyProgressUncheckedUpdateManyWithoutDeckInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -740,6 +808,8 @@ export type StudyProgressCreateManyQuizSetInput = {
|
|||
orderJson: string
|
||||
answersJson?: string | null
|
||||
cardResultsJson?: string | null
|
||||
sessionId?: string
|
||||
revision?: number
|
||||
updatedAt?: Date | string
|
||||
}
|
||||
|
||||
|
|
@ -751,6 +821,8 @@ export type StudyProgressUpdateWithoutQuizSetInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
deck?: Prisma.DeckUpdateOneWithoutProgressNestedInput
|
||||
}
|
||||
|
|
@ -764,6 +836,8 @@ export type StudyProgressUncheckedUpdateWithoutQuizSetInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -776,6 +850,8 @@ export type StudyProgressUncheckedUpdateManyWithoutQuizSetInput = {
|
|||
orderJson?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
answersJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
cardResultsJson?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
|
||||
sessionId?: Prisma.StringFieldUpdateOperationsInput | string
|
||||
revision?: Prisma.IntFieldUpdateOperationsInput | number
|
||||
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
|
||||
}
|
||||
|
||||
|
|
@ -791,6 +867,8 @@ export type StudyProgressSelect<ExtArgs extends runtime.Types.Extensions.Interna
|
|||
orderJson?: boolean
|
||||
answersJson?: boolean
|
||||
cardResultsJson?: boolean
|
||||
sessionId?: boolean
|
||||
revision?: boolean
|
||||
updatedAt?: boolean
|
||||
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
|
||||
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
|
||||
|
|
@ -806,6 +884,8 @@ export type StudyProgressSelectCreateManyAndReturn<ExtArgs extends runtime.Types
|
|||
orderJson?: boolean
|
||||
answersJson?: boolean
|
||||
cardResultsJson?: boolean
|
||||
sessionId?: boolean
|
||||
revision?: boolean
|
||||
updatedAt?: boolean
|
||||
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
|
||||
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
|
||||
|
|
@ -821,6 +901,8 @@ export type StudyProgressSelectUpdateManyAndReturn<ExtArgs extends runtime.Types
|
|||
orderJson?: boolean
|
||||
answersJson?: boolean
|
||||
cardResultsJson?: boolean
|
||||
sessionId?: boolean
|
||||
revision?: boolean
|
||||
updatedAt?: boolean
|
||||
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
|
||||
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
|
||||
|
|
@ -836,10 +918,12 @@ export type StudyProgressSelectScalar = {
|
|||
orderJson?: boolean
|
||||
answersJson?: boolean
|
||||
cardResultsJson?: boolean
|
||||
sessionId?: boolean
|
||||
revision?: boolean
|
||||
updatedAt?: boolean
|
||||
}
|
||||
|
||||
export type StudyProgressOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "contentType" | "deckId" | "quizSetId" | "mode" | "currentIndex" | "orderJson" | "answersJson" | "cardResultsJson" | "updatedAt", ExtArgs["result"]["studyProgress"]>
|
||||
export type StudyProgressOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "contentType" | "deckId" | "quizSetId" | "mode" | "currentIndex" | "orderJson" | "answersJson" | "cardResultsJson" | "sessionId" | "revision" | "updatedAt", ExtArgs["result"]["studyProgress"]>
|
||||
export type StudyProgressInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
|
||||
deck?: boolean | Prisma.StudyProgress$deckArgs<ExtArgs>
|
||||
quizSet?: boolean | Prisma.StudyProgress$quizSetArgs<ExtArgs>
|
||||
|
|
@ -869,6 +953,8 @@ export type $StudyProgressPayload<ExtArgs extends runtime.Types.Extensions.Inter
|
|||
orderJson: string
|
||||
answersJson: string | null
|
||||
cardResultsJson: string | null
|
||||
sessionId: string
|
||||
revision: number
|
||||
updatedAt: Date
|
||||
}, ExtArgs["result"]["studyProgress"]>
|
||||
composites: {}
|
||||
|
|
@ -1304,6 +1390,8 @@ export interface StudyProgressFieldRefs {
|
|||
readonly orderJson: Prisma.FieldRef<"StudyProgress", 'String'>
|
||||
readonly answersJson: Prisma.FieldRef<"StudyProgress", 'String'>
|
||||
readonly cardResultsJson: Prisma.FieldRef<"StudyProgress", 'String'>
|
||||
readonly sessionId: Prisma.FieldRef<"StudyProgress", 'String'>
|
||||
readonly revision: Prisma.FieldRef<"StudyProgress", 'Int'>
|
||||
readonly updatedAt: Prisma.FieldRef<"StudyProgress", 'DateTime'>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { getIronSession, type SessionOptions } from "iron-session";
|
||||
import { getIronSession } from "iron-session";
|
||||
import { cookies } from "next/headers";
|
||||
import {
|
||||
getPasswordResetSessionOptions,
|
||||
getSessionOptions,
|
||||
} from "@/lib/sessionConfig";
|
||||
|
||||
export interface SessionData {
|
||||
isAuthenticated: boolean;
|
||||
|
|
@ -11,31 +15,9 @@ interface PasswordResetSessionData {
|
|||
expiresAt?: string;
|
||||
}
|
||||
|
||||
const sessionOptions: SessionOptions = {
|
||||
password: process.env.SESSION_SECRET || "dev-session-secret-change-in-production-must-be-32-chars",
|
||||
cookieName: "study-app-session",
|
||||
cookieOptions: {
|
||||
secure: process.env.NODE_ENV === "production" && process.env.SECURE_COOKIES === "true",
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
},
|
||||
};
|
||||
|
||||
const passwordResetSessionOptions: SessionOptions = {
|
||||
password: sessionOptions.password,
|
||||
cookieName: "study-app-password-reset",
|
||||
cookieOptions: {
|
||||
secure: sessionOptions.cookieOptions?.secure,
|
||||
httpOnly: true,
|
||||
sameSite: "lax" as const,
|
||||
maxAge: 15 * 60,
|
||||
path: "/",
|
||||
},
|
||||
};
|
||||
|
||||
export async function getSession() {
|
||||
const cookieStore = await cookies();
|
||||
return getIronSession<SessionData>(cookieStore, sessionOptions);
|
||||
return getIronSession<SessionData>(cookieStore, getSessionOptions());
|
||||
}
|
||||
|
||||
export async function createSession(sessionGeneration: number) {
|
||||
|
|
@ -59,7 +41,7 @@ export async function getPasswordResetSession() {
|
|||
const cookieStore = await cookies();
|
||||
return getIronSession<PasswordResetSessionData>(
|
||||
cookieStore,
|
||||
passwordResetSessionOptions
|
||||
getPasswordResetSessionOptions()
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
20
src/lib/limitedJson.test.ts
Normal file
20
src/lib/limitedJson.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { readLimitedJson, RequestTooLargeError } from "./limitedJson";
|
||||
|
||||
describe("readLimitedJson", () => {
|
||||
it("rejects declared and measured oversized request bodies", async () => {
|
||||
const declared = new NextRequest("http://localhost/api/decks", {
|
||||
method: "POST",
|
||||
headers: { "content-length": "100" },
|
||||
body: "{}",
|
||||
});
|
||||
await expect(readLimitedJson(declared, 10)).rejects.toBeInstanceOf(RequestTooLargeError);
|
||||
|
||||
const measured = new NextRequest("http://localhost/api/decks", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ value: "oversized" }),
|
||||
});
|
||||
await expect(readLimitedJson(measured, 10)).rejects.toBeInstanceOf(RequestTooLargeError);
|
||||
});
|
||||
});
|
||||
24
src/lib/limitedJson.ts
Normal file
24
src/lib/limitedJson.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import type { NextRequest } from "next/server";
|
||||
import { CONTENT_LIMITS } from "@/lib/validation/contentSchemas";
|
||||
|
||||
export class RequestTooLargeError extends Error {}
|
||||
|
||||
export async function readLimitedJson(
|
||||
request: NextRequest,
|
||||
maximumBytes = CONTENT_LIMITS.requestBytes
|
||||
): Promise<unknown> {
|
||||
const declaredLength = Number(request.headers.get("content-length"));
|
||||
if (Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
|
||||
throw new RequestTooLargeError(`Request body must not exceed ${maximumBytes} bytes`);
|
||||
}
|
||||
|
||||
const body = await request.text();
|
||||
if (new TextEncoder().encode(body).byteLength > maximumBytes) {
|
||||
throw new RequestTooLargeError(`Request body must not exceed ${maximumBytes} bytes`);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(body) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
70
src/lib/progressNormalization.test.ts
Normal file
70
src/lib/progressNormalization.test.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeProgress } from "./progressNormalization";
|
||||
|
||||
const isGrade = (value: unknown): value is "correct" | "missed" =>
|
||||
value === "correct" || value === "missed";
|
||||
|
||||
function normalize(order: string[], currentIndex: number, liveIds: string[]) {
|
||||
return normalizeProgress({
|
||||
orderJson: JSON.stringify(order),
|
||||
currentIndex,
|
||||
dataJson: JSON.stringify({ a: "correct", deleted: "missed" }),
|
||||
liveIds,
|
||||
isValidValue: isGrade,
|
||||
});
|
||||
}
|
||||
|
||||
describe("normalizeProgress", () => {
|
||||
it("preserves the logical current card when a prior card was deleted", () => {
|
||||
expect(normalize(["a", "b", "c"], 1, ["b", "c"]).currentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("uses the next survivor when the current card was deleted", () => {
|
||||
expect(normalize(["a", "b", "c"], 1, ["a", "c"]).currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("uses the previous survivor when no later card remains", () => {
|
||||
expect(normalize(["a", "b", "c"], 2, ["a", "b"]).currentIndex).toBe(1);
|
||||
});
|
||||
|
||||
it("preserves completed state after stale IDs are removed", () => {
|
||||
const result = normalize(["a", "b", "c"], 3, ["a", "c"]);
|
||||
expect(result.currentIndex).toBe(2);
|
||||
expect(result.completed).toBe(true);
|
||||
});
|
||||
|
||||
it("recovers malformed JSON and filters stale result keys", () => {
|
||||
const result = normalizeProgress({
|
||||
orderJson: "not-json",
|
||||
currentIndex: -1,
|
||||
dataJson: '{"a":"correct","b":"invalid"}',
|
||||
liveIds: ["a", "b"],
|
||||
isValidValue: isGrade,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
order: ["a", "b"],
|
||||
currentIndex: 0,
|
||||
data: { a: "correct" },
|
||||
completed: false,
|
||||
wasRecovered: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a clear completed state when all saved cards were deleted", () => {
|
||||
const result = normalize(["deleted"], 0, []);
|
||||
expect(result).toMatchObject({ order: [], currentIndex: 0, completed: true });
|
||||
});
|
||||
|
||||
it("lets content-specific validation reject a value owned by another item", () => {
|
||||
const result = normalizeProgress({
|
||||
orderJson: '["q1","q2"]',
|
||||
currentIndex: 0,
|
||||
dataJson: '{"q1":["q2-option"]}',
|
||||
liveIds: ["q1", "q2"],
|
||||
isValidValue: (value, id): value is string[] =>
|
||||
Array.isArray(value) && value.every((optionId) => optionId === `${id}-option`),
|
||||
});
|
||||
expect(result.data).toEqual({});
|
||||
expect(result.wasRecovered).toBe(true);
|
||||
});
|
||||
});
|
||||
108
src/lib/progressNormalization.ts
Normal file
108
src/lib/progressNormalization.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
interface NormalizeProgressInput<T> {
|
||||
orderJson: unknown;
|
||||
currentIndex: unknown;
|
||||
dataJson?: unknown;
|
||||
liveIds: string[];
|
||||
isValidValue: (value: unknown, id: string) => value is T;
|
||||
}
|
||||
|
||||
export interface NormalizedProgress<T> {
|
||||
order: string[];
|
||||
currentIndex: number;
|
||||
data: Record<string, T>;
|
||||
completed: boolean;
|
||||
wasRecovered: boolean;
|
||||
}
|
||||
|
||||
function parseJson(value: unknown): { value: unknown; invalid: boolean } {
|
||||
if (typeof value !== "string") return { value, invalid: false };
|
||||
try {
|
||||
return { value: JSON.parse(value), invalid: false };
|
||||
} catch {
|
||||
return { value: undefined, invalid: true };
|
||||
}
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function normalizeProgress<T>({
|
||||
orderJson,
|
||||
currentIndex,
|
||||
dataJson,
|
||||
liveIds,
|
||||
isValidValue,
|
||||
}: NormalizeProgressInput<T>): NormalizedProgress<T> {
|
||||
const uniqueLiveIds = [...new Set(liveIds)];
|
||||
const liveIdSet = new Set(uniqueLiveIds);
|
||||
const parsedOrder = parseJson(orderJson);
|
||||
const hasValidSavedOrder =
|
||||
Array.isArray(parsedOrder.value) &&
|
||||
parsedOrder.value.every((id) => typeof id === "string");
|
||||
const savedOrder = hasValidSavedOrder
|
||||
? (parsedOrder.value as string[])
|
||||
: uniqueLiveIds;
|
||||
|
||||
let wasRecovered = parsedOrder.invalid || !hasValidSavedOrder;
|
||||
const seen = new Set<string>();
|
||||
const order = savedOrder.filter((id) => {
|
||||
const keep = liveIdSet.has(id) && !seen.has(id);
|
||||
if (!keep) wasRecovered = true;
|
||||
seen.add(id);
|
||||
return keep;
|
||||
});
|
||||
|
||||
const savedIndex =
|
||||
typeof currentIndex === "number" &&
|
||||
Number.isSafeInteger(currentIndex) &&
|
||||
currentIndex >= 0
|
||||
? currentIndex
|
||||
: 0;
|
||||
if (savedIndex !== currentIndex) wasRecovered = true;
|
||||
|
||||
const savedCompleted =
|
||||
hasValidSavedOrder && savedOrder.length > 0 && savedIndex >= savedOrder.length;
|
||||
let normalizedIndex = 0;
|
||||
if (order.length === 0 || savedCompleted) {
|
||||
normalizedIndex = order.length;
|
||||
} else {
|
||||
const currentId = savedOrder[savedIndex];
|
||||
if (currentId && order.includes(currentId)) {
|
||||
normalizedIndex = order.indexOf(currentId);
|
||||
} else {
|
||||
const nextId = savedOrder
|
||||
.slice(savedIndex + 1)
|
||||
.find((id) => order.includes(id));
|
||||
const previousId = savedOrder
|
||||
.slice(0, savedIndex)
|
||||
.reverse()
|
||||
.find((id) => order.includes(id));
|
||||
const replacementId = nextId ?? previousId;
|
||||
normalizedIndex = replacementId ? order.indexOf(replacementId) : 0;
|
||||
if (currentId !== order[normalizedIndex]) wasRecovered = true;
|
||||
}
|
||||
}
|
||||
|
||||
const parsedData = parseJson(dataJson ?? {});
|
||||
const rawData = isRecord(parsedData.value) ? parsedData.value : {};
|
||||
if (parsedData.invalid || !isRecord(parsedData.value)) wasRecovered = true;
|
||||
const data: Record<string, T> = {};
|
||||
for (const [id, value] of Object.entries(rawData)) {
|
||||
if (liveIdSet.has(id) && isValidValue(value, id)) {
|
||||
data[id] = value;
|
||||
} else {
|
||||
wasRecovered = true;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
order,
|
||||
currentIndex: normalizedIndex,
|
||||
data,
|
||||
completed: order.length === 0 || normalizedIndex === order.length,
|
||||
wasRecovered,
|
||||
};
|
||||
}
|
||||
25
src/lib/publicPaths.test.ts
Normal file
25
src/lib/publicPaths.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { isPublicPath } from "./publicPaths";
|
||||
|
||||
describe("isPublicPath", () => {
|
||||
it.each([
|
||||
"/login",
|
||||
"/api/auth/login",
|
||||
"/api/health",
|
||||
"/shared/class/quizzes/token",
|
||||
"/_next/static/chunk.js",
|
||||
"/favicon.ico",
|
||||
])("allows intentional public path %s", (pathname) => {
|
||||
expect(isPublicPath(pathname)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/private/file.txt",
|
||||
"/api/decks/file.json",
|
||||
"/api/authentication/fake",
|
||||
"/shared-secret",
|
||||
"/private/%2e%2e/data",
|
||||
])("does not bypass authentication for %s", (pathname) => {
|
||||
expect(isPublicPath(pathname)).toBe(false);
|
||||
});
|
||||
});
|
||||
17
src/lib/publicPaths.ts
Normal file
17
src/lib/publicPaths.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
const EXACT_PUBLIC_PATHS = new Set([
|
||||
"/login",
|
||||
"/api/health",
|
||||
"/favicon.ico",
|
||||
"/robots.txt",
|
||||
"/sitemap.xml",
|
||||
"/icon.svg",
|
||||
]);
|
||||
|
||||
export function isPublicPath(pathname: string) {
|
||||
return (
|
||||
EXACT_PUBLIC_PATHS.has(pathname) ||
|
||||
pathname.startsWith("/_next/") ||
|
||||
pathname.startsWith("/shared/") ||
|
||||
pathname.startsWith("/api/auth/")
|
||||
);
|
||||
}
|
||||
33
src/lib/quizSnapshots.ts
Normal file
33
src/lib/quizSnapshots.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { QuizQuestion } from "@/types/study";
|
||||
|
||||
export interface SnapshotQuestion extends QuizQuestion {
|
||||
selections: string[];
|
||||
points: number;
|
||||
}
|
||||
|
||||
export interface QuizReviewSnapshot {
|
||||
version: 1;
|
||||
questions: SnapshotQuestion[];
|
||||
}
|
||||
|
||||
export function parseQuizReviewSnapshot(
|
||||
value: string | null | undefined
|
||||
): QuizReviewSnapshot | null {
|
||||
if (!value) return null;
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
if (
|
||||
typeof parsed !== "object" ||
|
||||
parsed === null ||
|
||||
!("version" in parsed) ||
|
||||
parsed.version !== 1 ||
|
||||
!("questions" in parsed) ||
|
||||
!Array.isArray(parsed.questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return parsed as QuizReviewSnapshot;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
30
src/lib/rateLimiter.test.ts
Normal file
30
src/lib/rateLimiter.test.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { checkRateLimit, clearRateLimits } from "./rateLimiter";
|
||||
|
||||
describe("checkRateLimit", () => {
|
||||
beforeEach(() => {
|
||||
clearRateLimits();
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-08-07T12:00:00Z"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("blocks after the configured global request limit", () => {
|
||||
expect(checkRateLimit("reset", { maxRequests: 1 }).allowed).toBe(true);
|
||||
expect(checkRateLimit("reset", { maxRequests: 1 })).toMatchObject({
|
||||
allowed: false,
|
||||
retryAfterMs: 60_000,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a request after the window expires", () => {
|
||||
checkRateLimit("reset", { windowMs: 1_000, maxRequests: 1 });
|
||||
vi.advanceTimersByTime(1_000);
|
||||
expect(
|
||||
checkRateLimit("reset", { windowMs: 1_000, maxRequests: 1 }).allowed
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -12,6 +12,10 @@ const store = new Map<string, RateLimitEntry>();
|
|||
const WINDOW_MS = 60 * 1000; // 1 minute
|
||||
const MAX_REQUESTS = 10;
|
||||
|
||||
export function clearRateLimits() {
|
||||
store.clear();
|
||||
}
|
||||
|
||||
export function checkRateLimit(
|
||||
key: string,
|
||||
options: { windowMs?: number; maxRequests?: number } = {}
|
||||
|
|
|
|||
48
src/lib/scoring.test.ts
Normal file
48
src/lib/scoring.test.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { scoreQuestion, scoreQuiz } from "./scoring";
|
||||
|
||||
describe("quiz scoring", () => {
|
||||
it("returns zero rather than NaN for invalid zero-correct SATA data", () => {
|
||||
expect(
|
||||
scoreQuestion(
|
||||
{
|
||||
id: "q1",
|
||||
type: "SATA",
|
||||
options: [{ id: "o1", isCorrect: false }],
|
||||
},
|
||||
["o1"]
|
||||
)
|
||||
).toBe(0);
|
||||
});
|
||||
|
||||
it("does not award duplicate SATA selections more than once", () => {
|
||||
expect(
|
||||
scoreQuestion(
|
||||
{
|
||||
id: "q1",
|
||||
type: "SATA",
|
||||
options: [
|
||||
{ id: "o1", isCorrect: true },
|
||||
{ id: "o2", isCorrect: true },
|
||||
],
|
||||
},
|
||||
["o1", "o1"]
|
||||
)
|
||||
).toBe(0.5);
|
||||
});
|
||||
|
||||
it("keeps totals finite when legacy questions are malformed", () => {
|
||||
const result = scoreQuiz(
|
||||
[
|
||||
{
|
||||
id: "q1",
|
||||
type: "SATA",
|
||||
options: [{ id: "o1", isCorrect: false }],
|
||||
},
|
||||
],
|
||||
{ q1: [] }
|
||||
);
|
||||
expect(Number.isFinite(result.total)).toBe(true);
|
||||
expect(result).toMatchObject({ total: 0, maxScore: 1 });
|
||||
});
|
||||
});
|
||||
|
|
@ -9,19 +9,22 @@ export function scoreQuestion(
|
|||
question: QuestionLite,
|
||||
selectedIds: string[]
|
||||
): number {
|
||||
const uniqueSelectedIds = [...new Set(selectedIds)];
|
||||
const correctIds = question.options
|
||||
.filter((o) => o.isCorrect)
|
||||
.map((o) => o.id);
|
||||
|
||||
if (question.type === "MULTIPLE_CHOICE") {
|
||||
return correctIds.includes(selectedIds[0]) ? 1 : 0;
|
||||
return correctIds.includes(uniqueSelectedIds[0]) ? 1 : 0;
|
||||
}
|
||||
|
||||
if (correctIds.length === 0) return 0;
|
||||
|
||||
// SATA: partial credit
|
||||
const correctSelected = selectedIds.filter((id) =>
|
||||
const correctSelected = uniqueSelectedIds.filter((id) =>
|
||||
correctIds.includes(id)
|
||||
).length;
|
||||
const incorrectSelected = selectedIds.filter(
|
||||
const incorrectSelected = uniqueSelectedIds.filter(
|
||||
(id) => !correctIds.includes(id)
|
||||
).length;
|
||||
return Math.max(0, correctSelected - incorrectSelected) / correctIds.length;
|
||||
|
|
|
|||
43
src/lib/sessionConfig.test.ts
Normal file
43
src/lib/sessionConfig.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { sealData, unsealData } from "iron-session";
|
||||
import {
|
||||
DEVELOPMENT_SESSION_SECRET,
|
||||
getSessionOptions,
|
||||
} from "./sessionConfig";
|
||||
|
||||
describe("session configuration", () => {
|
||||
it.each([undefined, "", "short", DEVELOPMENT_SESSION_SECRET])(
|
||||
"rejects invalid production secret %s",
|
||||
(secret) => {
|
||||
expect(() =>
|
||||
getSessionOptions({ NODE_ENV: "production", SESSION_SECRET: secret })
|
||||
).toThrow(/SESSION_SECRET/);
|
||||
}
|
||||
);
|
||||
|
||||
it("allows the labelled fallback only outside production", () => {
|
||||
expect(getSessionOptions({ NODE_ENV: "development" }).password).toBe(
|
||||
DEVELOPMENT_SESSION_SECRET
|
||||
);
|
||||
});
|
||||
|
||||
it("honors secure cookies only when explicitly enabled in production", () => {
|
||||
const options = getSessionOptions({
|
||||
NODE_ENV: "production",
|
||||
SESSION_SECRET: "a-secure-production-secret-that-is-long-enough",
|
||||
SECURE_COOKIES: "true",
|
||||
});
|
||||
expect(options.cookieOptions?.secure).toBe(true);
|
||||
});
|
||||
|
||||
it("does not authenticate data sealed with the development fallback under a real secret", async () => {
|
||||
const sealed = await sealData(
|
||||
{ isAuthenticated: true },
|
||||
{ password: DEVELOPMENT_SESSION_SECRET, ttl: 60 }
|
||||
);
|
||||
await expect(unsealData(sealed, {
|
||||
password: "a-secure-production-secret-that-is-long-enough",
|
||||
ttl: 60,
|
||||
})).resolves.toEqual({});
|
||||
});
|
||||
});
|
||||
58
src/lib/sessionConfig.ts
Normal file
58
src/lib/sessionConfig.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { SessionOptions } from "iron-session";
|
||||
|
||||
export const DEVELOPMENT_SESSION_SECRET =
|
||||
"dev-session-secret-change-in-production-must-be-32-chars";
|
||||
|
||||
function sessionPassword(env: NodeJS.ProcessEnv) {
|
||||
const configured = env.SESSION_SECRET?.trim();
|
||||
if (env.NODE_ENV === "production") {
|
||||
if (
|
||||
!configured ||
|
||||
configured.length < 32 ||
|
||||
configured === DEVELOPMENT_SESSION_SECRET
|
||||
) {
|
||||
throw new Error(
|
||||
"SESSION_SECRET must be a non-default value of at least 32 characters in production"
|
||||
);
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
if (!configured) return DEVELOPMENT_SESSION_SECRET;
|
||||
if (configured.length < 32) {
|
||||
throw new Error("SESSION_SECRET must be at least 32 characters");
|
||||
}
|
||||
return configured;
|
||||
}
|
||||
|
||||
export function getSessionOptions(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): SessionOptions {
|
||||
return {
|
||||
password: sessionPassword(env),
|
||||
cookieName: "study-app-session",
|
||||
cookieOptions: {
|
||||
secure: env.NODE_ENV === "production" && env.SECURE_COOKIES === "true",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getPasswordResetSessionOptions(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): SessionOptions {
|
||||
const sessionOptions = getSessionOptions(env);
|
||||
return {
|
||||
password: sessionOptions.password,
|
||||
cookieName: "study-app-password-reset",
|
||||
cookieOptions: {
|
||||
secure: sessionOptions.cookieOptions?.secure,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
maxAge: 15 * 60,
|
||||
path: "/",
|
||||
},
|
||||
};
|
||||
}
|
||||
34
src/lib/shareMetadata.test.ts
Normal file
34
src/lib/shareMetadata.test.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildShareMetadata, type ShareMetaLink } from "./shareMetadata";
|
||||
|
||||
describe("buildShareMetadata", () => {
|
||||
const deckLink: ShareMetaLink = {
|
||||
targetType: "DECK",
|
||||
deck: {
|
||||
name: "Deck",
|
||||
description: null,
|
||||
class: { slug: "class", name: "Class" },
|
||||
_count: { cards: 3 },
|
||||
},
|
||||
quizSet: null,
|
||||
group: null,
|
||||
};
|
||||
|
||||
it("does not describe a token under a mismatched shared route type", () => {
|
||||
expect(
|
||||
buildShareMetadata(deckLink, {
|
||||
classSlug: "class",
|
||||
pathType: "quizzes",
|
||||
}).title
|
||||
).toBe("Study Desk");
|
||||
});
|
||||
|
||||
it("describes the target only when token, class, and route type agree", () => {
|
||||
expect(
|
||||
buildShareMetadata(deckLink, {
|
||||
classSlug: "class",
|
||||
pathType: "flashcards",
|
||||
}).title
|
||||
).toContain("Deck");
|
||||
});
|
||||
});
|
||||
|
|
@ -22,6 +22,7 @@ interface MetaItem {
|
|||
}
|
||||
|
||||
export interface ShareMetaLink {
|
||||
targetType: string;
|
||||
deck: {
|
||||
name: string;
|
||||
description: string | null;
|
||||
|
|
@ -38,8 +39,8 @@ export interface ShareMetaLink {
|
|||
name: string;
|
||||
type: string;
|
||||
class: MetaClass;
|
||||
decks: { id: string; name: string; description: string | null; _count: { cards: number } }[];
|
||||
quizSets: { id: string; name: string; description: string | null; _count: { questions: number } }[];
|
||||
decks: { id: string; name: string; description: string | null; class: MetaClass; _count: { cards: number } }[];
|
||||
quizSets: { id: string; name: string; description: string | null; class: MetaClass; _count: { questions: number } }[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
|
|
@ -95,16 +96,30 @@ function itemMetadata(
|
|||
*/
|
||||
export function buildShareMetadata(
|
||||
link: ShareMetaLink | null,
|
||||
options: { classSlug?: string; itemId?: string } = {},
|
||||
options: {
|
||||
classSlug?: string;
|
||||
itemId?: string;
|
||||
pathType?: "flashcards" | "quizzes" | "groups";
|
||||
} = {},
|
||||
): Metadata {
|
||||
const { classSlug, itemId } = options;
|
||||
const { classSlug, itemId, pathType } = options;
|
||||
|
||||
if (!link) return toMetadata(SITE_NAME, SITE_DESCRIPTION);
|
||||
|
||||
// Never describe content whose URL does not agree with the stored class, so
|
||||
// previews stay silent for the same requests the page itself rejects.
|
||||
const targets = [link.deck, link.quizSet, link.group].filter(Boolean);
|
||||
const target = link.deck ?? link.quizSet ?? link.group;
|
||||
if (!target || (classSlug && target.class.slug !== classSlug)) {
|
||||
const targetAgrees =
|
||||
(link.targetType === "DECK" && Boolean(link.deck) && pathType !== "quizzes" && pathType !== "groups") ||
|
||||
(link.targetType === "QUIZ" && Boolean(link.quizSet) && pathType !== "flashcards" && pathType !== "groups") ||
|
||||
(link.targetType === "GROUP" && Boolean(link.group) && pathType !== "flashcards" && pathType !== "quizzes");
|
||||
if (
|
||||
!target ||
|
||||
targets.length !== 1 ||
|
||||
!targetAgrees ||
|
||||
(classSlug && target.class.slug !== classSlug)
|
||||
) {
|
||||
return toMetadata(SITE_NAME, SITE_DESCRIPTION);
|
||||
}
|
||||
|
||||
|
|
@ -129,8 +144,12 @@ export function buildShareMetadata(
|
|||
const isDeckGroup = group.type === "DECK";
|
||||
|
||||
if (itemId) {
|
||||
const deck = isDeckGroup ? group.decks.find((d) => d.id === itemId) : undefined;
|
||||
const quizSet = isDeckGroup ? undefined : group.quizSets.find((q) => q.id === itemId);
|
||||
const deck = isDeckGroup
|
||||
? group.decks.find((d) => d.id === itemId && d.class.slug === group.class.slug)
|
||||
: undefined;
|
||||
const quizSet = isDeckGroup
|
||||
? undefined
|
||||
: group.quizSets.find((q) => q.id === itemId && q.class.slug === group.class.slug);
|
||||
|
||||
if (deck) {
|
||||
return itemMetadata(
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue