diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index 088cd7e..0000000 --- a/.dockerignore +++ /dev/null @@ -1,20 +0,0 @@ -node_modules -.next -.git -.forgejo -.github -.env* -!.env.example -dev.db* -study.db* -data -coverage -.test-databases -.npm-cache -.prisma-cache -audit-results -*.log -*.tsbuildinfo -out.css -temp.css -test.css diff --git a/.forgejo/workflows/build.yml b/.forgejo/workflows/build.yml index 105c385..4ed4ca5 100644 --- a/.forgejo/workflows/build.yml +++ b/.forgejo/workflows/build.yml @@ -1,4 +1,4 @@ -name: Verify and publish container +name: Automated Container Build on: push: @@ -11,60 +11,15 @@ jobs: steps: - name: Checkout Code uses: actions/checkout@v4 - with: - # The changed-file lint step compares the pushed commit with its parent. - fetch-depth: 2 - name: Log into Local Registry run: | echo "${{ secrets.FORGEJO_PAT }}" | docker login git.elijahkuntz.com -u "${{ gitea.actor }}" --password-stdin - - name: Install locked dependencies - run: npm ci - - - name: Run tests - run: npm test - - - name: Validate Prisma schema and migration drift + - name: Build and Push Image run: | - npx prisma validate - DATABASE_URL=file:./ci-migration.test.db npx prisma migrate deploy - npx prisma migrate diff --from-migrations prisma/migrations --to-schema prisma/schema.prisma --exit-code - - - name: Build application - run: npm run build - - - name: Lint changed source files - run: | - if BASE=$(git rev-parse HEAD^ 2>/dev/null); then - git diff --name-only --diff-filter=ACMR -z "$BASE" HEAD -- '*.ts' '*.tsx' '*.js' '*.mjs' | - xargs -0 -r npx eslint - else - git ls-files -z -- '*.ts' '*.tsx' '*.js' '*.mjs' | - xargs -0 -r npx eslint - 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) - SMOKE_CONTAINER="study-smoke-$(openssl rand -hex 8)" - cleanup_smoke() { - docker rm -f "$SMOKE_CONTAINER" > /dev/null 2>&1 || true - } - trap cleanup_smoke EXIT - docker build -t "$IMAGE_SHA" . - docker run -d --name "$SMOKE_CONTAINER" -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}}' "$SMOKE_CONTAINER")" = "healthy" ]; then break; fi - sleep 2 - done - test "$(docker inspect --format='{{.State.Health.Status}}' "$SMOKE_CONTAINER")" = "healthy" - docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/login').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))" - docker exec "$SMOKE_CONTAINER" node -e "fetch('http://127.0.0.1:3726/api/auth/setup-status').then(async r => { const body = await r.json(); if (!r.ok || body.setupRequired !== true) process.exit(1) }).catch(() => process.exit(1))" - cleanup_smoke - trap - EXIT - docker tag "$IMAGE_SHA" "$IMAGE_PATH:latest" - docker push "$IMAGE_SHA" - docker push "$IMAGE_PATH:latest" + # Force the entire image path string to lowercase dynamically + IMAGE_PATH=$(echo "git.elijahkuntz.com/${{ gitea.actor }}/${{ github.event.repository.name }}:latest" | tr '[:upper:]' '[:lower:]') + + docker build -t "$IMAGE_PATH" . + docker push "$IMAGE_PATH" \ No newline at end of file diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index f1f1a79..0000000 --- a/.gitattributes +++ /dev/null @@ -1,2 +0,0 @@ -# Prisma 7 generated doc comments contain trailing spaces; do not hand-edit generated output. -src/generated/prisma/** -whitespace diff --git a/.gitignore b/.gitignore index 4da595d..5ef6a52 100644 --- a/.gitignore +++ b/.gitignore @@ -12,18 +12,6 @@ # testing /coverage -/.test-databases/ - -# local SQLite study data -/dev.db -/dev.db-journal -/dev.db-shm -/dev.db-wal -/data/ -/study.db -/study.db-journal -/study.db-shm -/study.db-wal # next.js /.next/ diff --git a/AGENTS.md b/AGENTS.md index e33c9fe..e398d64 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,242 +1,340 @@ # Study Desk - AI Contributor Guide -This file is the operating guide for AI agents and human contributors working in this repository. +This file is the project-specific operating guide for AI agents and human contributors working in this repository. -## Project summary +## Project overview -Study Desk is a self-hosted, single-user study application built with Next.js, React, TypeScript, Prisma, and SQLite. +Study Desk is a self-hosted, single-user study application. It organizes study material by class and supports: -Core capabilities: +- Flashcard decks with Markdown content, self-grading, shuffle persistence, resume state, and card management. +- Practice quizzes with multiple-choice and SATA questions, scoring, rationales, category breakdowns, retakes, and attempt history. +- Importing validated JSON files and generating importable JSON from user-provided study material. +- Material groups for organizing decks and quizzes. +- Password-protected private pages with public, read-only share links for decks, quizzes, and groups. +- Light and dark themes with responsive layouts for desktop and mobile use. -- Flashcard decks with Markdown content, self-grading, shuffle persistence, resume state, and card management -- Practice quizzes with multiple-choice and SATA questions, partial-credit scoring, rationales, category breakdowns, retakes, and attempt history -- JSON import, repair, validation, and generation workflows -- Material groups for organizing decks and quizzes -- Password-protected private pages -- Public, read-only share links for decks, quizzes, and groups -- Responsive light and dark themes +The application is intentionally a small single-user container rather than a multi-tenant SaaS product. There is no `User` model. Authentication state is held in an encrypted `iron-session` cookie, and content ownership is implicit in the one local installation. -This is intentionally a single-user container, not a multi-tenant SaaS application. There is no `User` model. Content ownership is implicit in the local installation. +When documentation conflicts with implementation, prefer the current source code and `prisma/schema.prisma` over the older planning document or the stock README. -When documentation conflicts with implementation, prefer: +## Mandatory project rules -1. Current source code -2. `prisma/schema.prisma` -3. Applied migrations -4. This file -5. Older planning documents and the stock README +### Next.js version and documentation -## Mandatory working rules +This repository uses Next.js `16.2.9`, React `19.2.4`, and the App Router. This is not a generic Next.js project. Before changing Next.js routing, layouts, dynamic parameters, server/client boundaries, middleware, or framework APIs: -- Inspect `git status --short` before editing. -- Preserve unrelated user changes. -- Keep changes scoped to the requested behavior. -- Do not use destructive commands such as `git reset --hard` or `git checkout --` unless explicitly requested. -- Prefer narrow, patch-based edits over rewriting entire files. +1. Read the relevant guide under `node_modules/next/dist/docs/`. +2. Follow the version installed in this repository, not a remembered API from an older Next.js version. +3. Pay attention to deprecation notices in build output. The current project still uses `src/middleware.ts`; Next.js currently warns that the `middleware` convention is moving toward `proxy`. Do not perform that migration as unrelated cleanup. + +Relevant local references include: + +- `node_modules/next/dist/docs/01-app/01-getting-started/05-server-and-client-components.md` +- `node_modules/next/dist/docs/01-app/01-getting-started/04-linking-and-navigating.md` +- `node_modules/next/dist/docs/01-app/03-api-reference/01-directives/use-client.md` +- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/route.md` +- `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/dynamic-routes.md` + +### General contribution behavior + +- Ask a clarifying question before proceeding when missing information would materially change the implementation or create a risky assumption. Otherwise make a narrow, documented assumption and continue. +- Preserve existing user changes. Inspect `git status` before editing and do not use destructive commands such as `git reset --hard` or `git checkout --` unless explicitly requested. +- Use `apply_patch` for source and documentation edits. +- Keep changes scoped to the requested behavior. Do not rewrite unrelated components, migrate frameworks, or clean up the whole repository during a feature fix. - Do not commit secrets, `.env` files, database contents, generated credentials, or private study material. +- Prefer TypeScript types over `any`, even though some existing files have `any` and the current lint baseline is not clean. - Do not manually edit generated Prisma files under `src/generated/prisma/`. -- Do not modify an already-applied migration. Change `prisma/schema.prisma`, then create a new migration. -- Prefer TypeScript types over `any`. -- Add dependencies only when the existing stack cannot reasonably solve the problem. -- When dependencies change, update `package-lock.json` and verify the build. -- Separate UI, route handling, services, and low-level utilities by responsibility. -- Ask a clarifying question only when missing information would materially change the implementation or create a risky assumption. +- Do not manually edit an already-applied migration. Change `prisma/schema.prisma`, then create a new migration. +- Add dependencies only when the existing stack cannot reasonably solve the problem. If dependencies change, update `package-lock.json` and verify the build. +- Keep route handlers, services, and UI components separated by responsibility as described below. -## Stack and source of truth +## Stack and repository configuration -Confirm exact versions in `package.json`. The project currently uses: +| Area | Current implementation | +| --- | --- | +| Language | TypeScript, strict mode enabled | +| UI | React 19 with Next.js 16 App Router | +| Styling | Tailwind CSS v4 through `src/app/globals.css` and `@tailwindcss/postcss` | +| Fonts | `Manrope` for interface text and `Newsreader` for editorial headings via `next/font/google` | +| Persistence | SQLite with Prisma 7 and `@prisma/adapter-better-sqlite3` | +| Authentication | `iron-session` cookie, Argon2 password hashing, in-memory login rate limiter | +| Validation | Zod schemas in `src/lib/validation/` | +| Markdown | `react-markdown` with `remark-gfm` | +| Drag and drop | `@dnd-kit/core`, `@dnd-kit/sortable`, `@dnd-kit/utilities` | +| JSON repair | `jsonrepair` through `src/lib/jsonRepair.ts` | +| Runtime | Node 22-slim in the Docker image; local Node/npm should be compatible with the lockfile | +| Deployment | Next standalone output in a single Docker container | -- Next.js 16 App Router -- React 19 -- TypeScript in strict mode -- Tailwind CSS v4 -- Prisma 7 with SQLite and `@prisma/adapter-better-sqlite3` -- `iron-session` for encrypted session cookies -- Argon2 password hashing -- Zod validation -- `react-markdown` with `remark-gfm` -- `@dnd-kit` for sortable UI -- Node 22 in Docker +Important configuration files: -Important files: +- `package.json` - scripts and dependency versions. +- `next.config.ts` - standalone output and `better-sqlite3` as a server external package. +- `tsconfig.json` - strict TypeScript, bundler module resolution, and `@/*` mapped to `src/*`. +- `eslint.config.mjs` - Next core-web-vitals and Next TypeScript rules. +- `postcss.config.mjs` - Tailwind v4 PostCSS integration. +- `prisma.config.ts` - Prisma schema and SQLite URL resolution. +- `Dockerfile`, `docker-compose.yml`, `docker-compose.override.yml`, `docker-entrypoint.sh` - container build and runtime. -- `package.json` - scripts and dependency versions -- `prisma/schema.prisma` - database source of truth -- `src/lib/db.ts` - shared Prisma client -- `src/lib/auth.ts` - session helpers -- `src/services/` - business logic and database access -- `src/app/api/` - route handlers -- `src/app/globals.css` - design tokens and global styling -- `src/config/studyModes.ts` - shared study-mode navigation -- `SKILL.md` - project workflow for adding API routes -- `study-app-implementation-plan.md` - historical architectural context only -- `README.md` - currently not authoritative +## Commands and local setup -For framework-sensitive changes involving routing, layouts, dynamic params, middleware, server/client boundaries, or framework APIs, follow nearby repository patterns and consult version-matched Next.js documentation. Do not perform unrelated framework migrations. +### Install and run locally -## Local development - -For a clean checkout: +From the repository root: ```bash -npm ci +npm install npx prisma generate npm run dev ``` -On Windows PowerShell, use `npm.cmd` and `npx.cmd` if script execution policy blocks the default shims. +Then open `http://localhost:3000`. -Useful scripts: +On Windows PowerShell, this environment may block the `npm.ps1` and `npx.ps1` shims because of execution policy. Use `npm.cmd` and `npx.cmd` when necessary: -```bash -npm run dev -npm run build -npm run start -npm run lint +```powershell +npm.cmd install +npx.cmd prisma generate +npm.cmd run dev ``` -There is currently no configured test runner. Use focused manual verification plus `npm run build`. Run lint as well, but distinguish pre-existing failures from failures introduced by the current change. +The default local database is `dev.db` when `DATABASE_URL` is `file:./dev.db`. There is no seed script; classes and study material are created through the UI or API. -## Environment and authentication +### Environment variables -Common environment variables: +Copy `.env.example` to `.env` for local work. The current variables are: -- `DATABASE_URL` - SQLite URL -- `SESSION_SECRET` - `iron-session` secret, at least 32 characters outside throwaway local development -- `SECURE_COOKIES` - marks the session cookie secure when set to `true` -- `ADMIN_PASSWORD_HASH` - present in `.env.example`, but currently not used by the login flow +| Variable | Purpose | +| --- | --- | +| `DATABASE_URL` | Prisma SQLite URL. Local default: `file:./dev.db`. | +| `SESSION_SECRET` | Secret used to encrypt the `iron-session` cookie. Use a random value of at least 32 characters outside local throwaway development. | +| `ADMIN_PASSWORD_HASH` | Present in `.env.example`, but the current login implementation does not read it. See authentication below. | +| `SECURE_COOKIES` | If set to `true` in production, the session cookie is marked `secure`. | -Current password behavior: +The `.env` file is ignored by Git. Never paste real secrets into source files, documentation, logs, or commits. -1. On first login, the submitted password is Argon2-hashed. -2. The hash is stored in the `Setting` row `admin_password_hash`. -3. Later logins verify against that stored hash. +### Password initialization behavior -Do not change password provisioning casually. Any change must update login, setup-status behavior, deployment documentation, and security handling together. +The current implementation initializes the password on the first login when the `Setting` row `admin_password_hash` does not exist: -Authentication state is stored in the `study-app-session` cookie. +1. The first password submitted at `/login` is Argon2-hashed and stored in the `Setting` table. +2. Later logins verify against that stored hash. +3. `ADMIN_PASSWORD_HASH` is currently not wired into this flow despite appearing in `.env.example` and the planning document. -Public paths include: +Do not change this behavior casually. If environment-driven password provisioning is added, update the login route, setup-status route, deployment documentation, and security handling together. -- `/login` -- `/api/auth/*` -- `/shared/*` -- Static assets and Next internals +### Available scripts -Everything else requires authentication unless explicitly designed and reviewed as public. +```bash +npm run dev # Next development server +npm run build # Production build; also runs TypeScript checking +npm run start # Start the built app locally +npm run lint # ESLint +``` -Do not log passwords, secrets, full share tokens, or private study content. +There is currently no test runner configured in `package.json`. Use the production build plus focused manual/browser verification for UI changes. -## Database and migration safety +### Database workflow -The local `dev.db` may contain real study data. - -- Do not delete, reset, replace, or recreate it during normal work. -- Back up the database before destructive migration work. -- Use the shared Prisma client from `@/lib/db`. -- Do not create additional `PrismaClient` instances in request handlers or components. -- Use transactions when partial writes would leave inconsistent state. -- Return 404 responses for missing records instead of exposing raw Prisma errors. - -After changing the Prisma schema: +After changing `prisma/schema.prisma`: ```bash npx prisma generate npx prisma migrate dev --name describe_the_change ``` -Use `npx prisma migrate deploy` for deployed environments. +Use `npx prisma migrate deploy` for an existing deployment. The Docker entrypoint runs `prisma migrate deploy` before starting the server. -## Architecture conventions +Be careful with `dev.db`: it may contain the developer's actual study data. Do not delete or reset it as part of normal feature work. Inspect migration status and make a backup before any destructive database operation. + +## Docker details + +The production image is a three-stage Node 22-slim build: + +1. Install dependencies. +2. Generate Prisma and run `next build`. +3. Run the Next standalone server with SQLite data mounted at `/app/data`. + +The runtime image sets `DATABASE_URL=file:/app/data/study.db` and `PORT=3726`. `docker-entrypoint.sh` applies migrations and then starts `server.js`. + +Known compose detail: `docker-compose.yml` currently publishes `3000:3000`, while the production Dockerfile sets the runtime port to `3726`. Verify or correct that mapping before relying on the production compose file. `docker-compose.override.yml` is a development override that runs `npm run dev` and uses port 3000. + +## Current project structure + +The important source tree is: + +```text +prisma/ + schema.prisma # SQLite/Prisma source of truth + migrations/ # Applied database migrations + +src/ + app/ + layout.tsx # Root metadata, fonts, theme bootstrap + globals.css # Tailwind v4, design tokens, global styles + icon.svg + login/page.tsx # Public login/setup screen + (protected)/ + layout.tsx # Session check and authenticated navbar shell + page.tsx # Authenticated class library + [classSlug]/ + layout.tsx # Class lookup, header, and study-mode tabs + page.tsx # Redirects to flashcards + flashcards/page.tsx # Grouped deck library and drag/drop ordering + flashcards/[deckId]/page.tsx + # Deck study/manage screen + quizzes/page.tsx # Grouped quiz library and drag/drop ordering + quizzes/[quizId]/page.tsx + # Quiz taking/history screen + shared/[classSlug]/[type]/[token]/ + page.tsx # Public read-only share route + SharedViewer.tsx # Public deck/quiz viewer + SharedGroupViewer.tsx # Public group selector/viewer + api/ + .../route.ts # Route handlers; see API map below + + components/ + flashcards/ # Flashcard viewer, manager, and list + quizzes/ # Quiz viewer, results, history, breakdown + import/ # Import modal and Create/Import/Generate tabs + ui/ # Navbar, class header/tabs, theme, share menu + + services/ + classService.ts + deckService.ts + cardService.ts + quizService.ts + progressService.ts + shareService.ts + settingsService.ts # Persistence for LLM instruction templates + + lib/ + db.ts # Prisma singleton with better-sqlite3 adapter + auth.ts # iron-session helpers + rateLimiter.ts # In-memory login sliding-window limiter + scoring.ts # Quiz scoring + shuffle.ts # Stable order/session helpers + jsonRepair.ts # JSON parse/repair pipeline + validation/importSchemas.ts + + config/studyModes.ts # Central list of flashcards/quizzes modes + middleware.ts # Auth gate and public-path exceptions + generated/prisma/ # Generated Prisma client; do not hand edit + +public/ # Static assets +``` + +`(protected)` is a route group and is not part of the URL. The `classSlug`, `deckId`, `quizId`, and share `token` segments are dynamic route parameters. + +## Page routes and API surface + +### Page routes + +| Route | Behavior | +| --- | --- | +| `/login` | Public password setup/login page. | +| `/` | Authenticated class library. | +| `/` | Redirects to `//flashcards`. | +| `//flashcards` | Grouped flashcard deck library. | +| `//flashcards/` | Flashcard study or card-management view. | +| `//quizzes` | Grouped quiz library. | +| `//quizzes/` | Quiz-taking or attempt-history view. | +| `/shared//flashcards/` | Public shared deck. | +| `/shared//quizzes/` | Public shared quiz. | +| `/shared//groups/` | Public shared group; `?itemId=...` can select an item. | + +### API routes + +All API routes are behind `src/middleware.ts` unless they are under `/api/auth`. The API is currently consumed by client components with `fetch`. + +| Endpoint | Methods and purpose | +| --- | --- | +| `/api/auth/login` | `POST` login, first-time password setup, rate limiting, progressive lockout. | +| `/api/auth/logout` | `POST` destroy the session. | +| `/api/auth/setup-status` | `GET` whether the password setting exists. | +| `/api/classes` | `GET` list classes; `POST` create class. | +| `/api/classes/[id]` | `PATCH` rename; `DELETE` class and cascaded content. | +| `/api/decks` | `POST` validate and import a deck. | +| `/api/decks/list` | `GET` decks for `classId`. | +| `/api/decks/[id]` | `GET` deck with cards; `PATCH` rename/description; `DELETE`. | +| `/api/decks/[id]/cards` | `POST` append a card. | +| `/api/cards/[id]` | `PATCH` edit card; `DELETE` card. | +| `/api/decks/reorder` | `PATCH` persist deck order/group placement. | +| `/api/quizzes` | `POST` validate and import a quiz. | +| `/api/quizzes/list` | `GET` quizzes for `classId`. | +| `/api/quizzes/[id]` | `GET` quiz with questions/options; `PATCH` rename/description; `DELETE`. | +| `/api/quizzes/[id]/attempt` | `GET` attempt history; `POST` score and save an attempt. | +| `/api/quizzes/reorder` | `PATCH` persist quiz order/group placement. | +| `/api/material-groups` | `GET` groups by `classId` and optional `type`; `POST` create group. | +| `/api/material-groups/[id]` | `PATCH` rename/group metadata; `DELETE` group. | +| `/api/progress` | `GET`, `PATCH`, and `DELETE` resume progress for deck/quiz and mode. | +| `/api/settings/llm-instructions` | `GET`/`PATCH` saved generation instructions by `type`; `__RESET__` restores defaults. | +| `/api/share` | `GET` share state; `POST` toggle a deck, quiz, or group link. | + +## Architecture and coding conventions ### Server and client components -- Use Server Components by default. -- Add `"use client"` only when state, effects, event handlers, browser APIs, drag and drop, or client-side fetching are required. -- Keep database and authentication modules out of client import graphs. -- Do not access `window`, `localStorage`, `navigator`, or clipboard APIs from Server Components. -- Prefer `Link` for ordinary internal navigation. -- Use `useRouter` when navigation follows an action or must be imperative. -- Follow nearby Next.js 16 patterns for dynamic params and route context types. +- Use Server Components by default for layouts and pages that only fetch or compose data. +- Add `"use client"` only where state, event handlers, effects, browser APIs, drag/drop, or client-side fetches are needed. +- Once a file is a Client Component, its directly imported module graph is included in the client bundle. Keep server-only database/auth code out of client imports. +- Do not access `window`, `localStorage`, `navigator`, or the clipboard API from a Server Component. +- Dynamic server route params use the current Next.js 16 conventions in this repository. Follow nearby examples such as `src/app/(protected)/[classSlug]/layout.tsx` and API `RouteContext` types instead of inventing older parameter types. +- Prefer `Link` for normal internal navigation. Use `useRouter` only when navigation follows an action or requires imperative behavior. -### API routes and services +### API handlers and services -Preferred route-handler flow: +The preferred API route shape is: -1. Parse and validate external input. -2. Delegate business logic to focused service or utility functions. -3. Return a useful status and JSON response. +1. Parse and validate request input. +2. Call one function in the matching service or low-level utility. +3. Return a `NextResponse` with a useful status and JSON body. -Business logic and Prisma calls normally belong in `src/services/` or a focused `src/lib/` module. Reuse existing services before creating new abstractions. +Business logic and Prisma calls belong in `src/services/` or a focused `src/lib/` utility. Reuse an existing service before creating a new one. `SKILL.md` contains the project-specific workflow for adding an API route. -Some older routes still contain direct Prisma calls or loose input handling. Improve the requested path without turning a small task into a broad refactor. +A few older routes still contain direct Prisma calls or loose input handling. Follow the preferred pattern for new work without broad refactoring unless the task specifically requests it. -### Validation +### Prisma and database access -- Reuse existing Zod schemas where available. -- Never rely only on client-side validation. -- Validate IDs, required strings, enums, arrays, and import payloads at the API boundary. -- Keep stored JSON parseable. -- Use `jsonRepair.ts` only for user- or LLM-generated import text, not to hide malformed database state. +- Import the shared client from `@/lib/db`; do not instantiate a new `PrismaClient` in a request or component. +- Prisma uses the generated client at `src/generated/prisma`, configured by `prisma/schema.prisma`. +- Keep database names and relation behavior consistent with the schema: `Deck` and `QuizSet` belong to a `Class`; cards/questions/options cascade from their parent; group deletion sets item `groupId` to null; share links and progress cascade with their content. +- Use transactions for multi-row reorder/update operations where partial writes would be harmful. +- Handle missing records as 404s at the route boundary rather than leaking Prisma exceptions. -## UI and accessibility +### Validation and input handling -Use the existing design system in `src/app/globals.css`. +- Reuse `flashcardImportSchema` and `quizImportSchema` from `src/lib/validation/importSchemas.ts` for import payloads on both client preview and server commit. +- Do not trust client-side validation alone. +- Validate IDs, enum-like values (`DECK`/`QUIZ`, `SEQUENTIAL`/`SHUFFLED`, question types), required strings, and array shapes at the API boundary. +- Keep stored JSON fields parseable and handle malformed or stale JSON defensively. `jsonRepair.ts` is for repairing user/LLM-generated import text, not for hiding malformed database state. -Common tokens include: +### UI and design system -- `bg-bg-base` -- `bg-bg-surface` -- `bg-bg-surface-alt` -- `text-text-heading` -- `text-text-secondary` -- `text-text-muted` -- `primary` -- `primary-hover` -- `border` -- `border-light` -- `success` -- `error` +- Use the CSS variables and Tailwind aliases defined in `src/app/globals.css`: `bg-bg-base`, `bg-bg-surface`, `bg-bg-surface-alt`, `text-text-heading`, `text-text-secondary`, `text-text-muted`, `primary`, `primary-hover`, `border`, `border-light`, `success`, and `error`. +- Preserve both light and dark theme behavior. Avoid introducing one-off colors when an existing token is appropriate. +- Use `editorial-title` for major serif headings, `font-sans`/Manrope for interface text, and the existing card/modal shadows and rounded-corner language. +- Maintain responsive behavior at mobile widths. Study pages, library cards, modals, and action rows must remain usable without horizontal overflow. +- Use visible focus states, descriptive `title`/`aria-label` text for icon-only buttons, semantic buttons/links, and keyboard-accessible controls. +- Keep Markdown rendering consistent with the existing `ReactMarkdown` + `remarkGfm` usage and `.markdown-content` styles. +- Use the existing `@dnd-kit` patterns when changing sortable cards or groups. Do not attach drag listeners to nested action buttons. -Preserve: - -- Light and dark themes -- Responsive mobile layouts -- Existing card, modal, shadow, and rounded-corner language -- `editorial-title` for major serif headings -- Manrope for interface text -- Existing Markdown rendering through `ReactMarkdown`, `remarkGfm`, and `.markdown-content` -- Existing `@dnd-kit` patterns for sortable content - -Accessibility requirements: - -- Use semantic buttons and links. -- Preserve visible keyboard focus states. -- Give icon-only controls an accessible name with visible text or `aria-label`. -- Keep interactive controls keyboard accessible. -- Avoid horizontal overflow on study pages, library cards, modals, and action rows. -- Do not attach drag listeners to nested action buttons. - -## Domain invariants +## Domain behavior to preserve ### Flashcards -- A deck contains ordered `Flashcard` rows with `front` and `back`. -- Study progress stores order and results as JSON. -- Study mode may be sequential or shuffled. -- Saved progress may contain stale card IDs after deletion. Resume helpers must filter and clamp stale IDs safely. -- Preserve both study and manage views. -- Preserve existing sharing entry points when adding new ones. +- A deck contains ordered `Flashcard` rows with `front` and `back` strings. +- Study progress is stored in `StudyProgress` as JSON order/results and can be sequential or shuffled. +- Deleting a card can leave a stale ID in saved `orderJson`; the shuffle/session helpers filter and clamp stale IDs when resuming. +- The deck page supports study and manage views. Keep the existing viewer-header share action when adding other share entry points. ### Quizzes -- A quiz contains ordered questions with ordered answer options. +- A quiz contains ordered questions, each with ordered answer options. - `MULTIPLE_CHOICE` questions must have exactly one correct option. -- `SATA` questions may have multiple correct options. -- SATA uses partial-credit scoring in `src/lib/scoring.ts`. -- Partial retakes score only questions present in the submitted answer set. -- Attempts persist score, maximum score, submitted answers, retake state, and completion time. -- In-progress answers and position persist through `/api/progress`. +- `SATA` questions may have multiple correct options and use partial credit in `src/lib/scoring.ts`. +- Attempts are persisted as `QuizAttempt` rows with score, maximum score, answer JSON, retake flag, and completion time. +- Partial retakes score only the questions included in the submitted answer set. +- The quiz viewer persists in-progress answers and position through `/api/progress`. ### Imports and generation @@ -270,81 +368,112 @@ Quiz import shape: } ``` -The app does not call an LLM directly. The Generate UI prepares instructions and accepts generated JSON from the user. - -Preserve Markdown inside JSON string values and render it through existing Markdown components. - -Keep the current separation between `ImportModal`, `CreateTab`, `ImportTab`, and `GenerateTab`. +- Import UI is split into `ImportModal`, `CreateTab`, `ImportTab`, and `GenerateTab`. Keep that separation rather than creating one large modal component. +- Generation instructions are stored in the `Setting` table and are editable/resettable through `/api/settings/llm-instructions`. The app does not call an LLM itself; the Generate tab prepares instructions and accepts generated JSON from the user. +- Generated text may contain Markdown inside JSON string values. Preserve it and render it safely through the existing Markdown components. ### Sharing -- `ShareLink` may target a `DECK`, `QUIZ`, or `GROUP`. -- Reuse `src/components/ui/ShareMenu.tsx`. -- Public routes are `/shared///`. -- Public viewers are read-only. -- Public viewers must not write authenticated progress. -- Validate that token, type, and class slug agree before returning shared content. -- A deck or quiz may appear publicly shared through its group. Preserve inherited group-share behavior and `?itemId=...` handling. +- `ShareLink` rows use UUID IDs as public tokens and can target a `DECK`, `QUIZ`, or `GROUP`. +- `src/components/ui/ShareMenu.tsx` is the shared UI for loading share state, toggling a link, and copying the public URL. Reuse it rather than duplicating share URL or group-inheritance logic. +- Public URLs are `/shared///`, where item types are `flashcards`, `quizzes`, and `groups`. +- A deck or quiz can appear publicly shared through its group. The API returns the group token and the UI includes `?itemId=...` when copying a link to an item in a shared group. +- Shared viewers are read-only and should not write authenticated progress. Preserve the distinction between protected study viewers and public shared viewers. + +## Data model summary + +The source of truth is `prisma/schema.prisma`: + +- `Class` - named study space with a unique slug and sort order. +- `Deck` - flashcard collection belonging to a class, optionally assigned to a `MaterialGroup`. +- `Flashcard` - ordered front/back card belonging to a deck. +- `QuizSet` - quiz collection belonging to a class, optionally assigned to a `MaterialGroup`. +- `Question` - ordered quiz prompt, type, rationale, and category. +- `AnswerOption` - ordered answer option with `isCorrect`. +- `StudyProgress` - resumable deck/quiz state. Unique by parent and mode. +- `QuizAttempt` - completed quiz score and submitted answer JSON. +- `MaterialGroup` - typed (`DECK` or `QUIZ`) class grouping for library organization. +- `ShareLink` - optional one-to-one link for a deck, quiz, or group. +- `Setting` - key/value storage for the admin password hash and editable LLM instructions. +- `AuthSecurity` - singleton lockout/rate-limit state persisted in SQLite. + +Most parent relations use cascading deletion. Treat deletion of a class, deck, quiz, question, or group as a data-affecting operation and preserve confirmation behavior in the UI. + +## Authentication and security boundaries + +`src/middleware.ts` allows these paths without a session: + +- `/login` +- `/api/auth/*` +- `/shared/*` +- Static assets and Next internals + +Everything else requires the `study-app-session` cookie. Individual API routes generally rely on middleware for the authenticated boundary, so a new public endpoint must be an explicit, reviewed decision. + +The login route has two protections: + +- An in-memory per-IP sliding-window limiter: 10 attempts per minute. +- Progressive lockout stored in `AuthSecurity`: one minute after 5 failures, five minutes after 10, thirty minutes after 15, and twenty-four hours after 20. + +Do not log passwords, session secrets, full share tokens, or private study content. Treat public share routes as intentionally public and validate that the token, type, and class slug agree before returning content. ## Verification checklist -Before editing: +### Before editing - Read `git status --short`. -- Inspect the target component, route, service, and schema paths relevant to the requested behavior. -- Check whether the change affects authentication, public sharing, migrations, generated Prisma output, or real study data. +- Inspect the target page/component, its API route, and its service before changing behavior. +- Read the relevant Next.js local guide if framework behavior is involved. +- Check whether the change affects the Prisma schema, migrations, auth boundary, public sharing, or generated client. -During implementation: +### During implementation -- Keep route, service, utility, and component responsibilities clear. +- Keep the route/service/component boundaries intact. - Add validation for new external input. -- Reuse existing design tokens, components, and services. -- Consider loading, error, empty, mobile, keyboard, light-theme, and dark-theme states. -- Avoid unrelated cleanup. +- Reuse existing design tokens, shared components, and services. +- Consider loading, error, empty, mobile, keyboard, and both theme states. +- Avoid synchronous state updates directly inside effects when a state initializer, derived value, event handler, or async callback is more appropriate. The current ESLint configuration reports these patterns. -After editing: +### After editing + +Run the narrowest relevant checks, then the full build when possible: ```bash npm run lint npm run build ``` -If lint already fails, report baseline failures separately. +If lint is already failing, report baseline failures separately from failures introduced by the change. Do not silence rules or add broad ESLint disables just to make a task appear clean. -For UI work, manually verify: +For UI changes, manually verify at least the affected authenticated route and its corresponding public/shared route if applicable. A useful smoke-test path is: -1. The affected authenticated route -2. The corresponding public/shared route when applicable -3. Loading, error, and empty states -4. Study resume and restart behavior when relevant -5. Drag and drop when relevant -6. Light and dark themes -7. A narrow mobile viewport -8. No horizontal overflow -9. Keyboard access for changed controls +1. Log in or complete first-time setup. +2. Create or open a class. +3. Import or edit representative flashcards and quizzes. +4. Exercise the changed action, including loading/error/empty states. +5. Check study resume/restart, drag/drop if relevant, and public sharing if relevant. +6. Check light/dark themes and a narrow mobile viewport. ## Known repository conditions -- `README.md` is still mostly stock create-next-app documentation. -- `study-app-implementation-plan.md` contains useful historical context but may be stale. -- `task.md` records completed implementation phases. -- `SKILL.md` describes the expected workflow for new API routes. -- The current lint baseline includes existing errors such as `any` usage and React effect-rule violations. -- A successful `next build` is the primary repository-wide compile and type check. -- The current middleware convention produces a known deprecation warning. -- `docker-compose.yml` may publish port `3000`, while the production Docker image uses port `3726`. Verify the mapping before relying on production Compose. -- Do not silently fix known baseline issues as unrelated cleanup. +- `README.md` is still mostly the default create-next-app README and is not a reliable description of the current product. +- `study-app-implementation-plan.md` is a valuable architectural reference, but some details are historical and the current source code wins. +- `task.md` records completed implementation phases and is useful for feature context. +- `SKILL.md` is the project-specific workflow for adding API routes; follow it for new route work. +- The current full lint command has pre-existing errors in several files, including existing `any` usage and React effect-rule violations. Do not attribute those to an unrelated small change without checking the changed lines. +- A successful `next build` is the primary repository-wide compile/type verification currently available. +- Next build output currently warns about the deprecated `middleware` file convention. Treat that as known baseline unless the task is specifically about the migration. ## Extending the application -For a genuinely new study mode: +For a genuinely new study mode, follow the existing extensibility pattern: -1. Add or update the Prisma model and migration when persistence is required. +1. Add the domain model and migration in Prisma if persistence is needed. 2. Add a focused service in `src/services/`. 3. Add API routes under `src/app/api/`. -4. Add page routes and client components. -5. Add the mode to `src/config/studyModes.ts` when it belongs in shared navigation. -6. Extend progress, import, scoring, or sharing only when required. -7. Verify authentication, public/private boundaries, mobile layout, and both themes. +4. Add the page route and client components. +5. Add the mode to `src/config/studyModes.ts` if it belongs in shared class navigation. +6. Update public sharing, progress, import, or scoring only if the new mode needs those capabilities. +7. Verify auth, mobile layout, theme support, and any public/private boundary. -Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a large page component. \ No newline at end of file +Do not add a new mode by placing unrelated logic into `deckService.ts`, `quizService.ts`, or a giant page component. The existing separation is intentional and is the main maintainability boundary in this project. diff --git a/Dockerfile b/Dockerfile index 90c938b..1c5addf 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,19 +1,11 @@ -# ---- base ---- -FROM node:22-slim AS base -RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* - # ---- deps ---- -FROM base AS deps +FROM node:22-slim AS deps WORKDIR /app COPY package.json package-lock.json ./ -RUN npm ci - -# ---- production dependencies ---- -FROM deps AS runtime-deps -RUN npm prune --omit=dev +RUN npm install # ---- builder ---- -FROM base AS builder +FROM node:22-slim AS builder WORKDIR /app COPY --from=deps /app/node_modules ./node_modules COPY . . @@ -21,13 +13,13 @@ RUN npx prisma generate RUN npm run build # ---- runner ---- -FROM base AS runner +FROM node:22-slim AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3726 -ENV HOSTNAME=0.0.0.0 ENV DATABASE_URL="file:/app/data/study.db" +RUN apt-get update -y && apt-get install -y openssl && rm -rf /var/lib/apt/lists/* RUN useradd --system --create-home appuser && mkdir -p /app/data && chown -R appuser:appuser /app COPY --from=builder /app/.next/standalone ./ @@ -35,13 +27,10 @@ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/public ./public COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma.config.ts ./ -COPY --from=builder /app/scripts ./scripts -COPY --from=runtime-deps /app/node_modules ./node_modules +RUN npm install prisma@^7.8.0 COPY docker-entrypoint.sh ./ RUN chmod +x docker-entrypoint.sh # Running as root to avoid permission denied on Unraid host-mounted volumes EXPOSE 3726 VOLUME ["/app/data"] -HEALTHCHECK --interval=10s --timeout=5s --start-period=20s --retries=6 \ - CMD ["node", "-e", "fetch('http://127.0.0.1:3726/api/health').then(r=>{if(!r.ok)process.exit(1)}).catch(()=>process.exit(1))"] ENTRYPOINT ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index 459a4a2..e215bc4 100644 --- a/README.md +++ b/README.md @@ -1,83 +1,4 @@ -Study Desk is a self-hosted, single-user study application. - -## Local development - -```powershell -npm.cmd ci -npx.cmd prisma generate -npm.cmd run dev -``` - -The automated test harness always creates a uniquely named database under -`.test-databases/`, applies committed migrations, and removes the database after -the run. It refuses `dev.db`, `/app/data/study.db`, existing databases, and paths -that are not explicitly test-named. - -## Production container - -Set a unique session secret of at least 32 characters. Production startup fails -before migrations or the HTTP server when the secret is missing, short, or the -published development fallback. - -```powershell -$env:SESSION_SECRET = '' -docker compose -f docker-compose.yml up --build -``` - -The host listens on `http://localhost:3000`; the container listens on port 3726. -Development Compose is intentionally explicit and is never auto-merged: - -```powershell -docker compose -f docker-compose.dev.yml up --build -``` - -For first-time production setup, either provide an Argon2 encoded -`ADMIN_PASSWORD_HASH`, or temporarily set `ALLOW_INITIAL_SETUP=true` for the -one-time setup request. Remove the flag after setup. Production HTTP password -reset initiation is unavailable; run this on the server instead: - -```powershell -npm.cmd run auth:reset -``` - -If TLS terminates at a trusted reverse proxy, forward requests only from that -proxy and set `SECURE_COOKIES=true`. Leave it false for intentional plain HTTP; -Secure cookies cannot be used over plain HTTP. - -## Database backup, migration adoption, and restore - -Never run migration tests against the only copy of a study database. Create a -lock-safe SQLite backup through the backup API and verify it before deployment: - -```powershell -$env:DATABASE_URL = 'file:./data/study.db' -npm.cmd run db:backup -- --output ./backups/study-before-upgrade.db -npm.cmd run db:preflight -npx.cmd prisma migrate deploy -``` - -The material-group preflight reports one of four states: - -- `APPLY`: migration history is complete and the group schema is absent; run - `prisma migrate deploy` normally. -- `ADOPT`: prior migrations are tracked and the database exactly matches the - intended schema of one or more unrecorded committed migrations. This includes - recovery from P3018 when an already schema-pushed table or column exists. - After reviewing the verified backup, explicitly run - `npm run db:preflight -- --resolve --backup `. -- `CURRENT`: migration and schema already agree. -- `CONFLICT`: partial or unknown state. Stop and recover manually; do not use - `db push`, reset, edit applied migrations, or mark anything applied. - -The production entrypoint runs this preflight before `prisma migrate deploy`. -It refuses `ADOPT` and `CONFLICT` states without changing migration history; -adoption always remains an explicit backup-gated operator action. - -Restore drill: stop Study Desk, keep the damaged database as evidence, restore -the verified backup to a new path, run SQLite `integrity_check` plus -`npm run db:preflight`, start the same prior application image against the -restored path, and compare class/deck/quiz counts plus representative content. -Only swap the production path after those checks pass. +This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). ## Getting Started diff --git a/audit-results/CONFIRMED_FINDINGS.md b/audit-results/CONFIRMED_FINDINGS.md deleted file mode 100644 index 4e08f96..0000000 --- a/audit-results/CONFIRMED_FINDINGS.md +++ /dev/null @@ -1,67 +0,0 @@ -# Confirmed Findings - -Severity: Critical / High / Medium / Low / Observation. Confidence: Confirmed / High-confidence inference / Unverified risk. -Full structured detail for every finding (files, functions, scenario, expected/actual, root cause, impact, evidence, fix direction, complexity, regression test, runtime-confirmation flag) is in `FINDINGS.json` (validated). Source IDs from specialist auditors are in parentheses. - -| ID | Severity | Confidence | Title | Area | -|---|---|---|---|---| -| FIN-01 (DBAUD-01, GRP-01) | **Critical** | Confirmed | Schema/migration drift: `MaterialGroup` table + `groupId` columns never migrated — fresh deployments' databases are incompatible with the app (verified: `no such column: Deck.groupId`, `no such table: MaterialGroup`) | DB/migrations | -| FIN-02 (AUTH-01, OPS-03) | **Critical** | High-confidence inference | Hardcoded fallback `SESSION_SECRET` (`"dev-session-secret-change-in-production-must-be-32-chars"`) in `src/lib/auth.ts:15` + `src/proxy.ts:8-9`; empty env var in shipped compose → forgeable session cookie → full auth bypass | Auth/deploy | -| FIN-03 (QUIZ-01, ADV-01) | **High** | Confirmed | In-viewer "Retake Missed" never sets `retakeIds` prop → persisted as a full attempt with a wrong, lower score; pollutes SEQUENTIAL progress | Quiz | -| FIN-04 (CARD-01, IMPT-01, QUIZ-06) | **High** | Confirmed | Stale card ids in saved progress never filtered on resume (`filterAndClampOrder` dead code) → deleting a card mid-session bricks the flashcard study session | Flashcards | -| FIN-05 (OPS-01) | **High** | Confirmed | Port mismatch: container listens on 3726, compose publishes 3000:3000 → production deployment unreachable | Deploy | -| FIN-06 (OPS-06) | **High** | High-confidence inference | No `.dockerignore`: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image | Deploy | -| FIN-07 (OPS-08) | **High** | Confirmed | `docker-compose.override.yml` auto-merges on plain `docker compose up` → production command silently runs dev mode | Deploy | -| FIN-08 (AUTH-04, ADV-09) | Medium | High-confidence inference | First-login provisioning takeover; `ADMIN_PASSWORD_HASH` env is dead config | Auth | -| FIN-09 (AUTH-02) | Medium | High-confidence inference | Proxy auth bypass for any path containing `.` (latent today; would expose any future non-UUID-id route) | Auth | -| FIN-10 (AUTH-03) | Medium | High-confidence inference | Password-reset abuse: token overwrite DoS, spoofable `x-forwarded-for` rate limits, unthrottled `complete` | Auth | -| FIN-11 (QUIZ-02) | Medium | High-confidence inference | Attempt route stores unvalidated `answersJson`; duplicate option ids inflate SATA credit; malformed shapes → 500 | Quiz | -| FIN-12 (QUIZ-03, FE-04) | Medium | Confirmed | Quiz Finish double-submit creates duplicate attempts; failure path silent | Quiz/FE | -| FIN-13 (QUIZ-04) | Medium | High-confidence inference | Progress PATCH can land after Finish's DELETE → stale "Continue" row for a completed quiz | Quiz | -| FIN-14 (CARD-02, FE-05, QUIZ-09) | Medium | High-confidence inference | Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins | Flashcards/Quiz | -| FIN-15 (CARD-03, ADV-07) | Medium | Confirmed | Completed flashcard session never persisted as complete → resume re-shows the last graded card | Flashcards | -| FIN-16 (CARD-04) | Medium | Confirmed | SRS review endpoint enforces membership only — no due-date or `newCardsPerDay` enforcement server-side | SRS | -| FIN-17 (CARD-05, ADV-02) | Medium | Confirmed | Unbounded DB scans: whole `StudyActivity` table per fetch; all SRS card states per set; Navbar polls every 60 s | Perf/DB | -| FIN-18 (FE-01, GRP-03, FE-02, GRP-05) | Medium | Confirmed | Library optimistic mutations are fire-and-forget (no `res.ok`, no rollback); cross-group reorder omits origin group from payload | FE/Groups | -| FIN-19 (GRP-02, CARD-07, AUTH-06) | Medium | Confirmed | Reorder/create endpoints trust client `groupId`/`sortOrder` — no class/type/existence validation; cross-class group membership can leak content into another class's share link | Groups | -| FIN-20 (FE-03) | Medium | Confirmed | Shared quiz session resets and options reshuffle on any parent re-render (unstable inline `quiz` prop) | FE | -| FIN-21 (IMPT-02, FE-08, QUIZ-05, ADV-10) | Medium | Confirmed | `/api/progress` PATCH completely unvalidated; unguarded client `JSON.parse` of persisted progress crashes pages | Quiz/FE | -| FIN-22 (OPS-02) | Medium | High-confidence inference | Entrypoint migrate-deploy chain fragile: crash loop on conflicting DB state; runner prisma CLI install unverified/unpinned | Deploy | -| FIN-23 (OPS-05) | Medium | Confirmed | Non-reproducible installs: `npm install` (not `ci`); unpinned `prisma@^7.8.0` re-install re-resolves the whole tree in the runner | Deploy | -| FIN-24 (OPS-07) | Medium | Confirmed | No healthcheck, no backup, no documented recovery | Deploy | -| FIN-25 (OPS-10, TEST-10) | Medium | Confirmed | CI builds/pushes without tests, lint, prisma validate, or container smoke test — deployment-breaking defects ship green | CI | -| FIN-26 (QUIZ-07) | Low | Confirmed | SATA scoring divides by `correctIds.length` with no zero-guard → NaN | Quiz | -| FIN-27 (QUIZ-08) | Low | Confirmed | Historical review/category breakdown recompute scores from current content, not stored attempt | Quiz | -| FIN-28 (IMPT-03) | Low | Confirmed | Card add/edit endpoints bypass Zod: empty strings storable; CreateTab silently drops incomplete cards | Imports | -| FIN-29 (IMPT-04) | Low | Confirmed | No payload size/string-length/array-length caps on import schemas/routes; whitespace-only names accepted | Imports | -| FIN-30 (IMPT-05) | Low | Confirmed | SATA constraint mismatch: instructions say ≥2 correct, schema enforces ≥1 | Imports | -| FIN-31 (IMPT-06) | Low | Confirmed | Unvalidated `name`/`groupId` override fields on import POST routes → 500; PATCH routes mask failures as 404 | Imports | -| FIN-32 (GRP-04) | Low | Confirmed | Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint/tie-break | Groups | -| FIN-33 (GRP-06) | Low | Confirmed | Keyboard users cannot move items between groups (empty `handleDragOver`) | Groups/a11y | -| FIN-34 (CARD-08) | Low | High-confidence inference | Restart deletes progress fire-and-forget; slow DELETE can remove the fresh session's progress row | Flashcards | -| FIN-35 (CARD-09) | Low | Confirmed | Concurrent first review of same new card → P2002 → generic 500 instead of 409 | SRS | -| FIN-36 (CARD-10) | Low | High-confidence inference | "Previous card" during the 350 ms grade animation races the pending timeout | Flashcards | -| FIN-37 (CARD-11) | Low | Confirmed | SRS set page never refreshes on focus → stale membership after deck deletion in another tab | SRS/FE | -| FIN-38 (CARD-06) | Low | Confirmed | Day boundary hardcoded to Arizona (UTC-7): "today"/new-card limit/streak roll over at 07:00 UTC | SRS | -| FIN-39 (AUTH-05) | Low | Confirmed | Proxy `destroy()` cookie-clear lost on redirect; layout check ignores `sessionGeneration` | Auth | -| FIN-40 (AUTH-08) | Low | Confirmed | Session cookie Secure flag off in shipped compose (plain HTTP, no TLS story) | Auth/deploy | -| FIN-41 (DBAUD-02, AUTH-07) | Low | Confirmed | `/api/share` accepts arbitrary `targetType` → repeatable junk all-NULL `ShareLink` rows | Auth/DB | -| FIN-42 (DBAUD-03) | Low | High-confidence inference | `dev.db` committed in git history (8 commits), remains in blobs on the LAN remote | Repo hygiene | -| FIN-43 (OPS-09) | Low | Confirmed | `.gitignore` gaps (`data/`, `study.db*`); scratch files `out.css`/`temp.css`/`test.css` tracked | Repo hygiene | -| FIN-44 (FE-07, ADV-04) | Low | Confirmed | Dashboard `fetchClasses` no `.catch`/`res.ok` → unhandled rejection, misleading empty state (or render crash) | FE | -| FIN-45 (FE-09) | Low | Confirmed | Effect fetches without `.catch` in GenerateTab and ShareMenu | FE | -| FIN-46 (FE-10) | Low | High-confidence inference | Cross-class navigation fetch race renders wrong-class data; module cache never invalidated | FE | -| FIN-47 (ADV-03) | Low | Confirmed | Logout has no error handling — failed logout strands the user | FE | -| FIN-48 (ADV-05) | Low | Confirmed | `slugify` can yield an empty slug (unreachable class); renames never update the URL slug | Classes | -| FIN-49 (ADV-08) | Low | High-confidence inference | Shared-viewer localStorage keys item-scoped, not token-scoped → session bleed across tokens | Sharing/FE | -| FIN-50 (GRP-07) | Observation | Confirmed | Group `sortOrder` uses inverted desc convention with unvalidated PATCH — latent trap | Groups | -| FIN-51 (FE-11) | Observation | Confirmed | Collapsed-groups state read in effect → one-frame expand flash | FE | -| FIN-52 (AUTH-10) | Observation | Confirmed | Shared quiz links ship the full answer key to anonymous viewers — by design, worth a warning | Sharing | -| FIN-53 (TEST-01) | Low | Confirmed | False-confidence test: "distinct valid state for every rating" never asserts distinctness | Tests | - -## Notes on consolidation - -- Duplicate findings across specialist reports were merged under one root cause (see `sourceIds`). -- FE-06 ("all /api/* routes unauthenticated") was **refuted** by the adversarial review — `src/proxy.ts` IS the compiled middleware and enforces session checks; the residual truth is FIN-09 (dot-bypass). See REJECTED_FINDINGS.md. -- OPS-02's fresh-volume crash-loop framing was weakened by the adversarial review (on a fresh volume `migrate deploy` succeeds; the app then fails per FIN-01) — reframed as FIN-22. -- FIN-18 severity downgraded from the frontend auditor's High to Medium after adversarial review (no visible break for a single user; silent divergence self-heals on reload). diff --git a/audit-results/COVERAGE_MAP.md b/audit-results/COVERAGE_MAP.md deleted file mode 100644 index 467bbc0..0000000 --- a/audit-results/COVERAGE_MAP.md +++ /dev/null @@ -1,38 +0,0 @@ -# Coverage Map - -Mapping of repository areas → audit status → specialist report reference. Statuses: done / done+verified (parent-verified with runtime evidence) / partial. - -| Area | Files | Auditor | Status | Report ref | -|---|---|---|---|---| -| Prisma schema/migrations/transactions/cascades | prisma/schema.prisma, prisma/migrations/*, prisma.config.ts, src/lib/db.ts, services | DB specialist (task-1) | done+verified (drift reproduced) | sa_20260806_054030_000000000_5ed877563a0d | -| Quiz scoring/attempts/history/retakes/progress | src/lib/scoring.ts, quizService, progressService, quizzes/*, api/quizzes/**, api/progress | Quiz specialist (task-2) | done+verified (server scoring path re-checked) | sa_20260806_054030_000000000_e75a8ce575f9 | -| Flashcards/SRS/progress/deletion/ordering | flashcards/*, spaced-repetition/*, cardService, deckService, spacedRepetitionService, api/cards|decks|spaced-repetition-sets|progress | Flashcard specialist (task-3) | done | sa_20260806_054030_000000000_a222804c133c | -| Auth/protected routes/API authz/sharing | src/proxy.ts, lib/auth.ts, authService, shareService, shareMetadata, api/auth/**, api/share, shared/*, all api routes | Auth specialist (task-4) | done+verified (proxy + fallback secret read directly) | sa_20260806_054030_000000000_aab0c59dbad3 | -| Imports/exports/generation/Zod/malformed input | lib/validation/*, jsonRepair, components/import/*, api/decks|quizzes|material-groups|settings | Import specialist (task-5) | done | sa_20260806_054030_000000000_5e72c2713989 | -| Material groups/library/drag-drop/deletion/orphans | api/material-groups/**, reorder routes, library pages, classService | Groups specialist (task-6) | done | sa_20260806_054030_000000000_9ae1f3dfa282 | -| Frontend state/refresh/races/localStorage/boundaries | all components + pages + shared viewers | Frontend specialist (task-7) | done | sa_20260806_054030_000000000_0e17fd155702 | -| Test quality/coverage gaps | *.test.ts, vitest.config.ts, package.json, CI workflow | Test specialist (task-8) | done | sa_20260806_054030_000000000_cd3652deb3f7 | -| Docker/startup/env/scripts/production/recovery | Dockerfile, docker-entrypoint.sh, compose files, package.json, prisma.config.ts, next.config.ts, .gitignore, CI | Ops specialist (task-9) | done | sa_20260806_054030_000000000_f49cdaacaabf | -| Adversarial review (challenge Critical/High + missed issues) | (all of the above + lightly-covered files) | Adversarial specialist (task-10) | done — 9/10 challenged findings confirmed/weakened, FE-06 refuted, 10 new issues added | sa_20260806_055608_000000000_c550c742d5dc | - -## Parent-level verification performed (see VERIFICATION_LOG.md and audit-results/tmp/) - -- `npx prisma validate` — PASS -- `npm test` — PASS (30 tests: 20 arcade + 10 spacedRepetition) -- `npm run lint` — 28 baseline problems (9 errors / 19 warnings), all pre-existing -- `npm run build` — PASS (route table incl. "ƒ Proxy (Middleware)") -- `prisma migrate deploy` on fresh temp DB — PASS (creates DB **missing** MaterialGroup/groupId → FIN-01 drift confirmed) -- Temp DB introspection (`audit-results/tmp/inspect-db.cjs`) — `MaterialGroup` table and `groupId` columns absent -- Prisma-shaped SQL against temp DB (`audit-results/tmp/reproduce-drift.cjs`) — `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` -- `FINDINGS.json` validity — PASS (53 findings; validator `audit-results/tmp/validate-findings.cjs`) - -## Coverage gaps (areas NOT fully audited) - -1. **Arcade feature internals** — intentionally out of scope (may be removed). Only checked for shared-DB/build/deploy impact (Arcade models/indexes verified in migrations). -2. **Real browser interaction** — no browser available in the audit environment: drag-drop, resume flows, share pages, keyboard a11y, theme behavior were code-verified only; findings needing runtime confirmation are flagged in UNVERIFIED_RISKS.md. -3. **Container build/run** — docker CLI unavailable: OPS-02/FIN-22, FIN-06, FIN-24 need a container run to observe exact failure modes. -4. **git history deep-dive** — limited to drift commit (7af0935), dev.db commits, and migration commits; other commits not diffed line-by-line. -5. **Committed dev.db blob contents** — could not be fully enumerated (no sqlite3 CLI; blob grep only; no admin_password hash found in checked blobs). -6. **Live dev.db** — none exists in the workspace; runtime behavior on a populated database (perf findings FIN-17) not measured. -7. **Network/remote behavior** — the Forgejo remote and CI execution were not reachable; CI findings are static analysis of `.forgejo/workflows/build.yml`. -8. **External services** — none (app calls no LLM/third-party APIs). diff --git a/audit-results/EXECUTIVE_SUMMARY.md b/audit-results/EXECUTIVE_SUMMARY.md deleted file mode 100644 index a45f275..0000000 --- a/audit-results/EXECUTIVE_SUMMARY.md +++ /dev/null @@ -1,69 +0,0 @@ -# Study Desk — Overnight Read-Only Audit: Executive Summary - -Status: **COMPLETE** (2026-08-06) - -## Scope and method - -Comprehensive read-only audit of the Study repository (Next.js 16 / React 19 / Prisma 7 / SQLite, self-hosted single-user study app). Ten specialist subagents audited: (1) Prisma schema/migrations/transactions/cascades/drift, (2) quiz scoring/attempts/retakes/progress, (3) flashcards/SRS/progress/ordering, (4) auth/protected routes/API authorization/sharing, (5) imports/exports/generation/Zod validation, (6) material groups/library/drag-drop/deletion, (7) frontend state/races/localStorage/boundaries, (8) test quality, (9) Docker/env/production/recovery, (10) adversarial review challenging every Critical/High finding and hunting missed issues. The parent agent independently re-verified all Critical/High claims (code tracing, git history, temp-DB migration chain, DB introspection, Prisma-shaped SQL probes). The Arcade feature set was excluded per scope unless it affects the main app, shared dependencies, database integrity, build, or deployment. - -## Constraints honored - -- **No source code, tests, migrations, config, package files, lockfiles, docs, or databases modified.** Final `git status --short`: only untracked `.reasonix/` and `audit-results/`; `git diff --check` clean; `src/generated/prisma/` byte-identical before/after all prisma commands (sha256). -- All reports and diagnostic artifacts live under `audit-results/` (reports + `tmp/`: `audit-migration.db`, `inspect-db.cjs`, `reproduce-drift.cjs`, `validate-findings.cjs`, `generated-before.sha256`). -- Existing databases: none found in the workspace; the migration-chain test used a brand-new temp DB. - -## Baseline verification - -| Check | Result | -|---|---| -| `npm test` | ✅ PASS — 30 tests / 6 files (20 arcade out of scope, 10 spacedRepetition) | -| `npm run lint` | ⚠️ 28 problems (9 errors, 19 warnings) — all pre-existing baseline | -| `npm run build` | ✅ PASS — full route table incl. "ƒ Proxy (Middleware)" | -| `npx prisma validate` | ✅ PASS | -| Migration chain on fresh temp DB | ✅ PASS — 4/4 migrations apply, but the resulting DB is **missing** `MaterialGroup` + `groupId` columns (drift, see below) | - -## Findings (53 total: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation) - -Full detail in `FINDINGS.json` (validated) and `CONFIRMED_FINDINGS.md`. - -### Critical - -1. **FIN-01 — Schema/migration drift (Confirmed, runtime-verified).** `prisma/schema.prisma` defines `MaterialGroup` and `Deck/QuizSet/ShareLink.groupId` (added by commit 7af0935, which also rewrote a committed `dev.db` via `prisma db push`) but **no migration creates them**. Every DB built by `prisma migrate deploy` — the `predev` hook and the Docker entrypoint — lacks these objects; the generated client then fails every Deck/QuizSet/ShareLink/MaterialGroup query (`no such column: Deck.groupId`, `no such table: MaterialGroup` — reproduced against a temp DB). **Any fresh install/deploy is non-functional.** The app only works on the developer's db-push-synced local database. -2. **FIN-02 — Hardcoded session-secret fallback (High-confidence inference).** `src/lib/auth.ts:15` and `src/proxy.ts:8-9` fall back to the public constant `"dev-session-secret-change-in-production-must-be-32-chars"` whenever `SESSION_SECRET` is unset — and the shipped `docker-compose.yml` passes `${SESSION_SECRET}`, which is empty by default. Anyone with the source can forge an iron-session cookie (`{isAuthenticated:true, sessionGeneration:1}`) and fully bypass authentication on such deployments. - -### High - -3. **FIN-03 — "Retake Missed" persists as a full attempt with a wrong lower score** (in-viewer retake never sets the `retakeIds` prop; server scores all unanswered questions as 0) and pollutes SEQUENTIAL progress. -4. **FIN-04 — Deleting a card mid-session bricks the flashcard study session** (`filterAndClampOrder` is dead code; resume never filters stale ids; no skip UI). -5. **FIN-05 — Production compose is unreachable**: container listens on 3726 (`ENV PORT=3726`), compose publishes 3000:3000. -6. **FIN-06 — No `.dockerignore`**: Windows-host builds inject win32 native modules (better-sqlite3, argon2) into the Linux image. -7. **FIN-07 — `docker-compose.override.yml` auto-merges**: plain `docker compose up` silently runs dev mode in production. - -### Medium (selected) - -First-login provisioning takeover + dead `ADMIN_PASSWORD_HASH` (FIN-08); proxy auth bypass for dot-containing paths (FIN-09); password-reset abuse (FIN-10); unvalidated attempt `answersJson` (FIN-11); finish double-submit duplicates attempts (FIN-12); progress PATCH/DELETE race (FIN-13); unsequenced progress autosaves (FIN-14); completed session resumes on last card (FIN-15); SRS review endpoint lacks due/new-card-limit enforcement (FIN-16); unbounded DB scans (FIN-17); library optimistic mutations without rollback (FIN-18); unvalidated `groupId`/`sortOrder` incl. cross-class group membership (FIN-19); shared-quiz reshuffle on re-render (FIN-20); unvalidated `/api/progress` + unguarded `JSON.parse` crashes (FIN-21); entrypoint migrate fragility (FIN-22); non-reproducible installs (FIN-23); no healthcheck/backup (FIN-24); CI ships without tests/lint/prisma/smoke gates (FIN-25). - -### Low / Observation - -25 Low (e.g. SATA div-by-zero NaN, historical score recompute, card-edit endpoints without Zod, no import size caps, sortOrder duplicates after group deletion, keyboard cross-group moves impossible, restart DELETE race, P2002 → 500, git history containing `dev.db`, `.gitignore` gaps, dashboard fetch crash, empty slug from `slugify`, shared-viewer localStorage key collisions) and 3 Observations (group sortOrder desc convention, collapse flash, shared quizzes ship the answer key by design). - -## Verification highlights - -- FIN-01 disproved attempts: checked whether the client avoids selecting `groupId` (it doesn't — generated `Deck.ts` includes it in every payload), whether pages swallow errors (they don't), and whether any migration creates the objects (grep = 0 hits). Confirmed via live probes instead: see `audit-results/tmp/reproduce-drift.cjs` output in VERIFICATION_LOG.md. -- Adversarial review **refuted** the frontend auditor's "no auth on API routes" claim (proxy is compiled middleware — verified in `.next` artifacts), **weakened** the entrypoint crash-loop framing (fresh-volume `migrate deploy` succeeds), and downgraded the optimistic-reorder severity. It independently confirmed all other Critical/High findings and added 10 missed issues, merged into the final list. - -## Repository areas NOT fully audited - -1. Arcade feature internals (out of scope; shared-DB/build/deploy impact only — verified in migrations). -2. Real browser interaction (no browser available): drag-drop, resume flows, share pages, keyboard a11y, themes — code-verified; flagged for runtime confirmation in UNVERIFIED_RISKS.md. -3. Container build/run (docker CLI unavailable): FIN-06, FIN-22, FIN-24 need a container run. -4. Deep git-history diff of every commit; full content audit of the committed `dev.db` blobs (no sqlite3 CLI; no credentials found in the checked blobs). -5. Runtime performance on a populated database (FIN-17 not measured). -6. CI/remote execution (Forgejo pipeline analyzed statically). - -## Recommended priority (when fixes are authorized) - -1. **FIN-01** — generate and commit the missing migration (`prisma migrate dev --name add_material_groups`); add a CI drift gate. Everything else depends on a working fresh deployment. -2. **FIN-02 / FIN-05 / FIN-06 / FIN-07** — harden the deployment path: require `SESSION_SECRET`, fix the port mapping, add `.dockerignore`, rename the dev override, and smoke-test the image in CI. -3. **FIN-03 / FIN-04** — the two most user-visible application bugs (wrong retake scores; stuck flashcard sessions). -4. Then the Medium cluster (validation, races, SRS enforcement, progress handling) and finally Low items + TEST_GAPS.md. diff --git a/audit-results/FINDINGS.json b/audit-results/FINDINGS.json deleted file mode 100644 index 437d5eb..0000000 --- a/audit-results/FINDINGS.json +++ /dev/null @@ -1,1024 +0,0 @@ -{ - "audit": "Study Desk comprehensive read-only audit", - "generatedAt": "2026-08-06T00:00:00Z", - "method": "10 specialist subagent audits (DB, quiz, flashcards/SRS, auth/sharing, imports, groups, frontend, tests, ops, adversarial) + parent verification (baseline commands, migration chain on temp DB, drift reproduction, code-path re-tracing). Read-only; no tracked files modified.", - "outOfScope": "Arcade feature internals (may be removed) except where they affect the main app, shared dependencies, database integrity, build, or deployment.", - "findings": [ - { - "id": "FIN-01", - "sourceIds": ["DBAUD-01", "GRP-01"], - "title": "Schema/migration drift: MaterialGroup table and Deck/QuizSet/ShareLink.groupId columns were never migrated — every fresh deployment's database is incompatible with the app", - "severity": "Critical", - "confidence": "Confirmed", - "files": ["prisma/schema.prisma:33-34,61-62,128,133,196-208", "prisma/migrations/20260628011409_init/migration.sql:10-19,32-40,92-101", "src/generated/prisma/models/Deck.ts:44,54,64,201", "src/app/api/material-groups/route.ts:24,43,49", "package.json:6", "docker-entrypoint.sh:3"], - "functions": ["createDeckFromImport", "createQuizSetFromImport", "toggleShareLink", "isContentSharedViaGroup", "MaterialGroup CRUD routes", "predev", "docker-entrypoint.sh"], - "scenario": "Fresh clone → npm run dev (predev runs prisma migrate deploy) or docker-compose with empty volume → open any class page. GET /api/decks/list fails with 'no such column: Deck.groupId'; GET /api/material-groups fails with 'no such table: MaterialGroup'. Verified: temp DB built from the 4 migrations has no MaterialGroup table and no groupId columns; Prisma-shaped SELECTs fail with the exact errors above.", - "expected": "migrate deploy produces a DB matching schema.prisma (groups feature present).", - "actual": "The migration chain (the only DB-init path in dev and Docker) produces a DB missing the entire groups feature; even Deck/QuizSet/ShareLink reads fail because the generated client selects groupId.", - "rootCause": "Commit 7af0935 changed schema.prisma (+31 lines) and updated the committed dev.db binary (a prisma db push artifact) but created no migration. The developer's db-push-synced local DB masks the drift; AGENTS.md rule 'change schema, then create a new migration' was violated.", - "impact": "On any fresh install/deploy (new machine, new container volume, CI), the core app is non-functional: library pages fail, group/import/reorder/share operations error. No data loss on migration-tracked DBs; a db-push DB without _prisma_migrations would make migrate deploy fail with 'table already exists'.", - "evidence": "git show 7af0935 --stat (dev.db 139264→155648 bytes, schema +31, no migration); git log --all -- prisma/migrations (only 4 migration commits); audit-results/tmp/inspect-db.cjs + reproduce-drift.cjs runs (no such column/table errors); grep MaterialGroup|groupId across migrations = 0 hits.", - "testCoverage": "None. Only arcade + spacedRepetition tests exist; no migration/drift/integration test.", - "fixDirection": "Create a new migration (prisma migrate dev --name add_material_groups) adding MaterialGroup table, groupId columns with FKs (SET NULL for Deck/QuizSet, CASCADE for ShareLink), and the ShareLink_groupId_key unique index; verify with prisma migrate diff --from-migrations --to-schema-datamodel (empty diff); add CI drift gate. Do NOT edit applied migrations.", - "fixComplexity": "M", - "regressionTest": "CI: migrate deploy on fresh temp DB → prisma migrate diff empty; smoke query prisma.deck.findFirst() + prisma.materialGroup.count().", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-02", - "sourceIds": ["AUTH-01", "OPS-03"], - "title": "Hardcoded fallback SESSION_SECRET enables full session-cookie forgery and authentication bypass when the env var is unset (the shipped docker-compose default)", - "severity": "Critical", - "confidence": "High-confidence inference", - "files": ["src/lib/auth.ts:14-22", "src/proxy.ts:6-11,31-43", "docker-compose.yml:10", "src/services/authService.ts:61-67,128"], - "functions": ["getSession", "createSession", "isAuthenticated", "proxy()", "login", "completePasswordReset"], - "scenario": "docker compose up without SESSION_SECRET in .env (no .env.example exists, README silent) → compose interpolates an empty string → both lib/auth.ts and proxy.ts fall back to the public constant 'dev-session-secret-change-in-production-must-be-32-chars'. Anyone with the source seals {isAuthenticated:true, sessionGeneration:1} with that password (iron-session seal) and sets cookie study-app-session=; the proxy decrypts it successfully and grants full access to every protected route and API.", - "expected": "Missing production secret should fail hard at startup.", - "actual": "Silent fallback to a publicly-known secret; sessionGeneration defaults to '1' (only password reset bumps it), so a forged generation:1 cookie is valid in the default state.", - "rootCause": "process.env.SESSION_SECRET || in two places with no startup validation; compose passes possibly-empty variable; no documented secret provisioning.", - "impact": "Complete loss of confidentiality, integrity, availability of the entire single-user dataset (decks, cards, quizzes incl. rationales, attempts, progress, settings) on any deployment that omits the variable — read, modify, or wipe everything.", - "evidence": "auth.ts:15 and proxy.ts:8-9 use the identical fallback; compose:10 passes ${SESSION_SECRET}; grep shows no other consumption or validation; iron-session seals with the password (verified in node_modules/iron-session).", - "testCoverage": "None (no auth tests).", - "fixDirection": "Refuse to boot in production when SESSION_SECRET is unset or equals the known fallback (throw at module load); compose: SESSION_SECRET: ${SESSION_SECRET:?must be set}; ship .env.example; document openssl rand -hex 32.", - "fixComplexity": "S", - "regressionTest": "Unit: sessionOptions/proxy reject missing/known secret under NODE_ENV=production; integration: cookie sealed with fallback rejected when a real secret is set.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-03", - "sourceIds": ["QUIZ-01", "ADV-01"], - "title": "In-viewer 'Retake Missed' is persisted as a full attempt with a wrong, lower score and pollutes SEQUENTIAL progress", - "severity": "High", - "confidence": "Confirmed", - "files": ["src/components/quizzes/QuizViewer.tsx:137,219-288,295-310", "src/app/api/quizzes/[id]/attempt/route.ts:33-43", "src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx:213-223"], - "functions": ["QuizViewer.handleFinish", "QuizViewer.onRetake", "saveProgress", "POST /api/quizzes/[id]/attempt"], - "scenario": "Finish a 10-question quiz missing 4; click 'Retake Missed (4)'; answer the 4 questions; Finish. The retake resets local state but never sets the retakeIds prop; handleFinish computes isPartialRetake=!!retakeIds=false; the server scores ALL questions (6 unanswered → 0) and persists e.g. 3/10 instead of 3/4. saveProgress also runs during the retake (guard reads the prop), writing retake-only answers into SEQUENTIAL progress.", - "expected": "Partial retake scored only over submitted questions (domain invariant), maxScore = submitted count, no progress pollution.", - "actual": "Full-attempt scoring with wrong low score; abandoned retakes leave progress pointing at the retake subset.", - "rootCause": "Retake mode is tracked via a prop (set only by the page-level AttemptHistory flow), but the results-screen retake keeps state local and never lifts it; all three retake-dependent behaviors (saveProgress guard, isPartialRetake, progress DELETE) read the prop.", - "impact": "Wrong persisted scores and history entries for the most common retake path; percentage drops and compounds on further retakes; resume can show only the retake subset.", - "evidence": "QuizViewer.tsx:295-310 has no retakeIds setter (verified by reading); attempt/route.ts:40-43 filters only when isPartialRetake===true (verified); server cannot detect partial answers itself (adversarial review confirmed).", - "testCoverage": "None.", - "fixDirection": "Track retake mode in component state initialized from the prop (useState(!!retakeIds)), set it in onRetake; use it in saveProgress/isPartialRetake/DELETE; defense-in-depth: server validates isPartialRetake consistency with answer-set size.", - "fixComplexity": "M", - "regressionTest": "Component/integration test: full quiz → onRetake(missedIds) → answer → finish → assert POST body isPartialRetake===true and maxScore equals submitted question count.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-04", - "sourceIds": ["CARD-01", "IMPT-01", "QUIZ-06"], - "title": "Stale card ids in saved progress are never filtered on resume (filterAndClampOrder is dead code) — deleting a card mid-session bricks the flashcard study session", - "severity": "High", - "confidence": "Confirmed", - "files": ["src/lib/shuffle.ts:27-35", "src/components/flashcards/FlashcardViewer.tsx:42-44,73,174-207", "src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:157-172", "src/services/cardService.ts:33-38"], - "functions": ["filterAndClampOrder", "generateOrder", "FlashcardViewer (resume init)", "deleteCard", "GET /api/decks/[id]"], - "scenario": "Study a deck to card N; open Manage; delete the current or any upcoming card; return to Study/reload. order still contains the deleted id; currentCard is undefined; the card area renders nothing, gradeCard early-returns, no skip UI exists → permanently stuck until Restart (which wipes progress). Quiz resume has the same latent gap (currently unreachable since question editing doesn't exist).", - "expected": "Resume filters deleted ids and clamps the index — the cardService.ts:34-35 comment claims this.", - "actual": "filterAndClampOrder has zero call sites (grep-verified); resume copies orderJson verbatim.", - "rootCause": "The cleanup helper was implemented but never wired into the viewer's resume path; viewer initializes order once from props and never reconciles with the live cards list.", - "impact": "Session becomes unusable after any card deletion affecting the current/upcoming position; user must restart, losing in-progress results. No data corruption.", - "evidence": "grep filterAndClampOrder → definition + comment only; FlashcardViewer.tsx:42-44 parses raw orderJson (verified); gradeCard guard at :176.", - "testCoverage": "None.", - "fixDirection": "Call filterAndClampOrder on resume with (orderJson, existing card ids, currentIndex); also filter redoMissed/restartFullSet and results; add skip affordance as defense.", - "fixComplexity": "S", - "regressionTest": "Unit test filterAndClampOrder; component test: resume with stale id at ≤ currentIndex → filtered order, clamped index, card renders.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-05", - "sourceIds": ["OPS-01"], - "title": "Port mismatch: container listens on 3726 but docker-compose publishes 3000:3000 — production deployment is unreachable", - "severity": "High", - "confidence": "Confirmed", - "files": ["Dockerfile:19,34", "docker-compose.yml:6-7", ".next/standalone/server.js:8"], - "functions": ["Next standalone startServer (PORT env)", "compose port publishing"], - "scenario": "docker compose -f docker-compose.yml up on a server → entrypoint exec node server.js → listens on 0.0.0.0:3726 (ENV PORT=3726) → compose publishes host 3000 → container 3000 where nothing listens → curl http://server:3000 → connection refused. Dev path works only because the override runs npm run dev (PORT unset → 3000).", - "expected": "Published port reaches the app.", - "actual": "No listener on container port 3000; AGENTS.md already flags the mismatch ('verify the mapping') but the repo ships broken.", - "rootCause": "Dockerfile PORT=3726 chosen for the image; compose port mapping never updated; EXPOSE is metadata only.", - "impact": "Production deployment totally unreachable via the documented path — complete service outage.", - "evidence": "server.js:8 (parseInt(process.env.PORT,10)||3000); Dockerfile:19; compose:6-7 (verified by reading).", - "testCoverage": "None (CI builds but never runs the image).", - "fixDirection": "Map '3726:3726' in compose (or drop ENV PORT); add healthcheck on the real port; document in README.", - "fixComplexity": "S", - "regressionTest": "CI smoke: run image with compose mapping, curl the published port, expect HTTP 200.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-06", - "sourceIds": ["OPS-06"], - "title": "No .dockerignore: building on a Windows host injects win32 native modules (better-sqlite3, argon2) into the Linux image", - "severity": "High", - "confidence": "High-confidence inference", - "files": ["Dockerfile:10-11,25", "(missing .dockerignore)", "next.config.ts:3-6"], - "functions": ["docker build context", "COPY . ."], - "scenario": "On this Windows machine: docker build . → context includes local node_modules (win32 better_sqlite3.node PE32+ DLL verified) and .next → builder's COPY . . overwrites the Linux deps from the deps stage → next build traces win32 natives into .next/standalone → the Linux runner image contains Windows binaries → ERR_DLOPEN_FAILED at runtime or build failure. CI (Linux) masks it entirely.", - "expected": "Linux-built natives in the Linux image.", - "actual": "Build-host platform leaks into the image when building from Windows.", - "rootCause": "No .dockerignore; COPY . . after COPY --from=deps node_modules.", - "impact": "Locally built production image is broken or fails to build; context bloat (node_modules, .next, .git).", - "evidence": "Dockerfile COPY order (verified); local node_modules contains win32 binaries (adversarial review verified PE32+ via file); no .dockerignore (glob).", - "testCoverage": "None.", - "fixDirection": "Add .dockerignore (node_modules, .next, .git, .env*, data, audit-results, *.db*).", - "fixComplexity": "S", - "regressionTest": "CI assertion: container's better_sqlite3.node is ELF, not PE32+.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-07", - "sourceIds": ["OPS-08"], - "title": "docker-compose.override.yml auto-merges on plain 'docker compose up' — the production command silently runs dev mode", - "severity": "High", - "confidence": "Confirmed", - "files": ["docker-compose.override.yml:3-12", "docker-compose.yml:2-14"], - "functions": ["Compose override auto-merge", "npm run dev"], - "scenario": "Operator on the server runs the conventional 'docker compose up -d' with the repo present → Compose v2 auto-merges docker-compose.override.yml → build target deps, command npm run dev, NODE_ENV=development, bind mount → production runs dev server with hot reload and dev semantics. Running with -f docker-compose.yml instead hits FIN-05 (port mismatch). Every compose invocation is misconfigured.", - "expected": "One documented production command.", - "actual": "Two broken paths: dev-mode-in-production (silent) or unreachable (port mismatch).", - "rootCause": "Dev override committed under the conventional auto-merge filename with no -f documentation.", - "impact": "Unpredictable runtime behavior in production; dev-mode performance/logging; production bugs masked.", - "evidence": "Override file contents (verified); standard Compose v2 auto-merge semantics; no README instructions (stock create-next-app).", - "testCoverage": "None.", - "fixDirection": "Rename to docker-compose.dev.yml and document COMPOSE_FILE/-f usage; document the exact production command.", - "fixComplexity": "S", - "regressionTest": "docker compose config for the documented production invocation must show production settings.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-08", - "sourceIds": ["AUTH-04", "ADV-09"], - "title": "First-login provisioning takeover: whoever logs in first becomes admin; ADMIN_PASSWORD_HASH env is dead config", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/services/authService.ts:91-108", "src/app/api/auth/setup-status/route.ts:6-17", "docker-compose.yml:11", "src/lib/validation/authSchemas.ts"], - "functions": ["login", "GET /api/auth/setup-status"], - "scenario": "App reachable before the owner's first login: attacker polls the public /api/auth/setup-status until setupRequired:true, then POSTs any 8+ char password → becomes admin; owner's first login later fails. ADMIN_PASSWORD_HASH passed in compose is never read by any code (grep-verified).", - "expected": "Password seeded from env or setup bound to the deployment; env var consumed.", - "actual": "Provisioning is 'whoever logs in first'; the intended seeding env var is ignored.", - "impact": "Full account takeover if the instance is exposed pre-setup; misleading deployment config.", - "evidence": "authService.ts:97-101 first-login provisioning; setup-status public; grep ADMIN_PASSWORD_HASH → compose only.", - "testCoverage": "None.", - "fixDirection": "Consume ADMIN_PASSWORD_HASH at startup (seed Setting + mark setupRequired false) or require a one-time setup token; document firewall-until-setup.", - "fixComplexity": "S-M", - "regressionTest": "Boot with ADMIN_PASSWORD_HASH set → setup-status false, only that password works.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-09", - "sourceIds": ["AUTH-02"], - "title": "Proxy auth bypass for any path containing a dot — latent authorization gap in every API route", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/proxy.ts:22-28,48-49"], - "functions": ["proxy()"], - "scenario": "GET /api/decks/.json → proxy sees '.' → passes through without session check → the [id] segment routes to the handler. Today it 404s (UUID lookups miss); any future route with non-UUID keys (numeric ids, slugs) becomes unauthenticated instantly.", - "expected": "Every protected path requires a valid session.", - "actual": "pathname.includes('.') bypasses the check entirely (asset convenience rule written too broadly).", - "rootCause": "Static-asset rule not scoped to asset paths or an allowlist.", - "impact": "Latent authorization bypass primitive; inconsistent 404-vs-redirect UX masking the gap.", - "evidence": "proxy.ts:25 (verified by reading); compiled middleware contains the same logic (.next artifacts, adversarial review).", - "testCoverage": "None.", - "fixDirection": "Replace includes('.') with an explicit allowlist (favicon, known assets) or exclude /api/* from the bypass.", - "fixComplexity": "S", - "regressionTest": "Unauthenticated requests with dot-suffixed ids (.json, .rsc, %2E) → redirect/401, not handler execution.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-10", - "sourceIds": ["AUTH-03"], - "title": "Password-reset endpoints abuseable: token overwrite DoS, spoofable x-forwarded-for rate limits, unthrottled complete", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/app/api/auth/password-reset/request/route.ts:5-24", "src/app/api/auth/password-reset/verify/route.ts:6-25", "src/app/api/auth/password-reset/complete/route.ts:5-24", "src/services/authService.ts:132-154", "src/lib/rateLimiter.ts:10-36"], - "functions": ["requestPasswordReset", "verifyPasswordResetToken", "completePasswordReset", "checkRateLimit"], - "scenario": "Owner requests reset → token T valid 15 min. Attacker POSTs request repeatedly with rotating x-forwarded-for headers → each call upserts the token record, killing T; log flooded with token lines; verify is rate-limited per spoofable IP; complete has no rate limit at all.", - "expected": "Token issuance throttled globally, outstanding token preserved or invalidated with delay.", - "actual": "Per-IP in-memory limits keyed on a client-controlled header; token overwrite per request; complete unlimited.", - "impact": "Account-recovery DoS (matters once FIN-02 is fixed); log flooding; CPU/DB load.", - "evidence": "authService.ts:146-150 upsert; routes' header trust (verified).", - "testCoverage": "None.", - "fixDirection": "DB-backed global rate limits; refuse minting while one is outstanding; rate-limit complete; document trusted-proxy requirement.", - "fixComplexity": "M", - "regressionTest": "Spoofed-IP parallel requests → only N tokens; outstanding token survives; complete 429 past threshold.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-11", - "sourceIds": ["QUIZ-02"], - "title": "Attempt route stores unvalidated answersJson; duplicate option ids inflate SATA credit; malformed shapes cause 500s", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/app/api/quizzes/[id]/attempt/route.ts:24-60", "src/lib/scoring.ts:21-27"], - "functions": ["POST /api/quizzes/[id]/attempt", "scoreQuestion"], - "scenario": "Crafted request: SATA with correct [A,B], submit answersJson {\"qid\":[\"A\",\"A\"]} → correctSelected=2 → full credit for one distinct option selected twice; answersJson 'null' → Object.keys(null) throws → 500; raw body persisted verbatim.", - "expected": "Server validates ids against the quiz set, dedupes, rejects malformed shapes with 4xx.", - "actual": "Only JSON parseability checked; duplicates double-count; garbage persisted forever.", - "impact": "Score integrity only exploitable via crafted HTTP (single-user app → low practical impact); corrupt attempt data persists.", - "evidence": "attempt/route.ts:27-30,58; scoring.ts counts occurrences (verified).", - "testCoverage": "None.", - "fixDirection": "Zod schema for answersJson (Record, ids verified, dedupe, reject unknown); persist validated object.", - "fixComplexity": "M", - "regressionTest": "scoreQuestion with duplicate selections; API tests with unknown ids, 'null', arrays → 400/422.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-12", - "sourceIds": ["QUIZ-03", "FE-04"], - "title": "Quiz Finish double-submit creates duplicate attempts; failure path gives no user feedback", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/components/quizzes/QuizViewer.tsx:219-288,506-513"], - "functions": ["QuizViewer.handleFinish"], - "scenario": "Double-click 'Finish Quiz' on the last question → two identical QuizAttempt rows; attempt history inflated. On POST failure only console.error runs; the button stays active.", - "expected": "One attempt per completion with in-flight guard.", - "actual": "No guard (per-question submit has submittingQuestionRef; finish has none); error path silent.", - "impact": "Duplicate history entries, confusing retake stats.", - "evidence": "QuizViewer.tsx:257-287 no guard ref; button not disabled (verified).", - "testCoverage": "None.", - "fixDirection": "finishingRef/isFinishing state; disable button in flight; visible error state on failure.", - "fixComplexity": "S", - "regressionTest": "Double-click Finish with delayed mocked POST → exactly one POST.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-13", - "sourceIds": ["QUIZ-04"], - "title": "Progress PATCH can land after Finish's DELETE — stale 'Continue' row reappears for a completed quiz", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/components/quizzes/QuizViewer.tsx:148-159,271-281", "src/app/api/progress/route.ts:49-66"], - "functions": ["QuizViewer.saveProgress", "QuizViewer.handleFinish", "DELETE /api/progress"], - "scenario": "On the final question a non-awaited PATCH fires; user immediately clicks Finish; POST attempt succeeds; DELETE progress runs. If the PATCH lands after the DELETE (or DELETE fails), StudyProgress is recreated → quiz list shows 'Continue' for a completed quiz → resume → re-finish → duplicate attempt.", - "expected": "Attempt persistence and progress cleanup coordinated.", - "actual": "Three uncoordinated HTTP calls; no transaction, no sequencing.", - "impact": "Intermittent stale progress, duplicate attempts.", - "evidence": "QuizViewer.tsx:159 .catch(() => {}) swallows PATCH failure; DELETE is a separate request (verified).", - "testCoverage": "None.", - "fixDirection": "POST /attempt deletes the matching SEQUENTIAL progress row in the same Prisma transaction server-side.", - "fixComplexity": "M", - "regressionTest": "API test: POST attempt then assert progress row gone (transactional).", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-14", - "sourceIds": ["CARD-02", "FE-05", "QUIZ-09"], - "title": "Fire-and-forget, unsequenced progress autosaves: out-of-order PATCHes regress resume state; multi-tab last-write-wins loses answers", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["src/components/quizzes/QuizViewer.tsx:133-160", "src/components/flashcards/FlashcardViewer.tsx:265-294", "src/services/progressService.ts:36-64", "src/app/api/progress/route.ts:26-47"], - "functions": ["saveProgress", "upsertProgress", "PATCH /api/progress"], - "scenario": "On a slow connection, grade cards or submit answers rapidly: each action fires a non-awaited full-state PATCH; if PATCH(n) resolves after PATCH(n+1) the server stores the older index/results → resume point and tallies regress; closing the tab mid-flight loses the last grade. Two tabs editing the same quiz → later write clobbers the other tab's answers.", - "expected": "Latest state wins; writes ordered.", - "actual": "Last arrival wins; no sequence/version; no beforeunload flush.", - "impact": "Silent resume-point regressions, lost answers, possibly wrong final scores.", - "evidence": "FlashcardViewer.tsx:282-293 fetch().catch(() => {}); progressService unconditional update (verified).", - "testCoverage": "None.", - "fixDirection": "Serialize/queue saves (latest snapshot), add monotonic sequence or updatedAt compare-and-set server-side, flush on pagehide.", - "fixComplexity": "M", - "regressionTest": "Out-of-order PATCH simulation → only latest sequence persisted.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-15", - "sourceIds": ["CARD-03", "ADV-07"], - "title": "Completed flashcard session is never persisted as complete — resume re-shows the last (already graded) card instead of the summary", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/components/flashcards/FlashcardViewer.tsx:61-65,196-203"], - "functions": ["FlashcardViewer completed init", "gradeCard completion path"], - "scenario": "Finish the last card → saveProgress stores currentIndex = order.length - 1 → reload: completed = currentIndex >= order.length is false → the last card is re-presented; re-grading it overwrites the recorded result.", - "expected": "Reload of a completed session shows the 'Set complete' summary.", - "actual": "Last card re-presented.", - "impact": "Confusing resume; minor result-map overwrite on re-grade.", - "evidence": "Lines 61-65 vs 196-203 (verified).", - "testCoverage": "None.", - "fixDirection": "Persist an explicit completed flag or save currentIndex = order.length on completion.", - "fixComplexity": "S", - "regressionTest": "Viewer init with currentIndex === orderJson.length shows summary.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-16", - "sourceIds": ["CARD-04"], - "title": "SRS review endpoint enforces membership only — no due-date or newCardsPerDay enforcement server-side", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/services/spacedRepetitionService.ts:406-447,388-404", "src/app/api/spaced-repetition-sets/[id]/reviews/route.ts:6-22"], - "functions": ["reviewCard", "POST /api/spaced-repetition-sets/[id]/reviews"], - "scenario": "Direct POST can (a) introduce a brand-new card after the daily limit is exhausted (firstReviewedAt counts toward introducedToday), and (b) review a not-yet-due card early (FSRS reschedules, compressing the interval). The UI enforces limits only through queue construction.", - "expected": "Server enforces the same queue rules the UI shows.", - "actual": "reviewCard checks only set membership and expectedStateVersion.", - "impact": "'New cards per day' guarantee is not data-level; future-dated cards can be advanced early (single-user: crafted requests/other tabs).", - "evidence": "reviewCard 414-420 membership-only (verified).", - "testCoverage": "None.", - "fixDirection": "Reject if no state and newCardsAvailable<=0, or state exists and not due (outside learn-ahead).", - "fixComplexity": "M", - "regressionTest": "Service test: new card with limit 0 → 400/409; not-due review → 400/409.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-17", - "sourceIds": ["CARD-05", "ADV-02"], - "title": "Unbounded DB scans: every activity fetch reads the entire StudyActivity table; class polling loads all SRS card states every 60 seconds", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/services/activityService.ts:65-68", "src/services/spacedRepetitionService.ts:54-142,153-220", "src/components/ui/Navbar.tsx:42-53", "src/services/classService.ts:11-22"], - "functions": ["getActivitySummary", "getSetSummary", "getStudyAvailabilityByClass", "listClasses", "Navbar.refreshDueCards"], - "scenario": "Every grade/review/quiz submit inserts a StudyActivity row; GET /api/activity reads the whole table; every page load and every 60 s the Navbar polls /api/classes which loads all sets + all card states for all classes. Query cost grows linearly forever.", - "expected": "Bounded, date-filtered queries.", - "actual": "Client-side aggregation over unfiltered findMany; N+1 per set.", - "impact": "Progressive page-load/nav lag in a long-lived self-hosted DB.", - "evidence": "activityService.ts:65-68 no where clause; Navbar interval 60s (verified).", - "testCoverage": "None.", - "fixDirection": "Date-filter StudyActivity, SQL-side aggregation, limit state scans to needed fields, cache availability.", - "fixComplexity": "M", - "regressionTest": "Activity summary with >371-day-old rows returns same result; query-shape assertion.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-18", - "sourceIds": ["FE-01", "GRP-03", "FE-02", "GRP-05"], - "title": "Library optimistic mutations are fire-and-forget: reorder/delete/rename never check res.ok, never roll back; cross-group reorder omits the origin group from the payload", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:244-295,313-372", "src/app/(protected)/[classSlug]/quizzes/page.tsx:231-282,311-374", "src/components/flashcards/CardManager.tsx:34-72"], - "functions": ["handleDragEnd", "handleDeleteGroup", "handleDeleteDeck", "handleRenameDeck", "saveEdit", "deleteCard"], - "scenario": "Drag a deck while offline → list keeps the new order; reload → order reverts silently (no error). Cross-group drag: newItems[oldIndex].groupId = targetGroupId mutates the object shared with activeDeck before affectedGroups is computed → origin group excluded from the PATCH payload → sortOrder gaps. Rename/delete fetches ignore res.ok; CardManager clears the editor even on failure.", - "expected": "Rollback to server truth + error message on failure.", - "actual": "Fire-and-forget fetches; in-place mutation; silent divergence until reload.", - "impact": "Lost operations without feedback; sortOrder gaps; phantom state. (Adversarial review: no visible break for single user → downgraded from High; the mutation-before-read payload bug is real.)", - "evidence": "flashcards/page.tsx:363-367 no await/catch; :343 mutation before :352 read (verified); SpacedRepetitionSets.tsx:277 shows the correct rollback pattern.", - "testCoverage": "None.", - "fixDirection": "Compute payload before mutating; await PATCH; on failure refetch or revert; check res.ok everywhere; disable drags while saving.", - "fixComplexity": "M", - "regressionTest": "Mock failing reorder → state refetched/reverted; unit test affectedGroups includes origin group.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-19", - "sourceIds": ["GRP-02", "CARD-07", "AUTH-06"], - "title": "Reorder/create endpoints trust client groupId and sortOrder — no class/type/existence/complete-set validation; cross-class group membership can expose content in another class's share link", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/lib/validation/reorderSchemas.ts:3-11", "src/app/api/decks/reorder/route.ts:7-26", "src/app/api/quizzes/reorder/route.ts:7-26", "src/app/api/decks/route.ts:12,31", "src/services/deckService.ts:39-48", "src/services/quizService.ts:44-53", "src/app/shared/[classSlug]/[type]/[token]/page.tsx:64-83"], - "functions": ["PATCH /api/decks/reorder", "PATCH /api/quizzes/reorder", "createDeckFromImport", "createQuizSetFromImport", "SharedPage"], - "scenario": "(a) Crafted reorder assigns a deck from class A to a QUIZ-type group (or a class-B group) — FK passes, deck disappears from the library. (b) Partial reorder payloads leave duplicate sortOrders. (c) A deck assigned to another class's group appears in that group's public share link; the shared page validates only the group's class.", - "expected": "Server validates membership/type/class and renumbers authoritatively.", - "actual": "Blind write-through of client-computed values; shape-only Zod schema.", - "impact": "Content becomes invisible; ambiguous ordering; cross-class content can leak into a group share link (privacy nuance for a single user).", - "evidence": "reorderSchemas shape-only; routes' unconditional $transaction (verified).", - "testCoverage": "None.", - "fixDirection": "Server-side validation in reorder routes (same class, existing group of matching type, renumber 0..n-1, reject duplicates); validate groupId on create; per-item class check in SharedPage.", - "fixComplexity": "M", - "regressionTest": "Cross-class groupId rejected; QUIZ group for deck rejected; partial set renumbered; shared group page with cross-class item → notFound.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-20", - "sourceIds": ["FE-03"], - "title": "Shared quiz session resets and options reshuffle on any parent re-render (unstable inline quiz prop object)", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:149-159", "src/components/quizzes/QuizViewer.tsx:64-130"], - "functions": ["SharedViewer render", "QuizViewer init effect"], - "scenario": "Open a shared quiz link; any SharedViewer re-render (auto after setHasSavedSession, topics toggle, view switch) creates a new quiz object → QuizViewer's init effect re-runs → shuffledOptions regenerate (options move mid-question) and, without a saved localStorage session, a fresh random question order is generated (the current question changes).", - "expected": "Stable question/option order for the whole session.", - "actual": "Options (and possibly the current question) reshuffle after unrelated UI toggles.", - "impact": "Confusing UX; mis-reading moved options; answers tracked by id so scores stay correct.", - "evidence": "SharedViewer.tsx:151-156 inline object literal (verified); QuizViewer deps [quiz, retakeIds, isShared].", - "testCoverage": "None.", - "fixDirection": "Memoize the quiz object (useMemo on data.id) or make init mount-only/state-lazy.", - "fixComplexity": "S", - "regressionTest": "Render shared QuizViewer, answer, re-render parent → option order array unchanged.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-21", - "sourceIds": ["IMPT-02", "FE-08", "QUIZ-05", "ADV-10"], - "title": "/api/progress PATCH is completely unvalidated and client JSON.parse of persisted progress is unguarded — malformed rows crash pages", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["src/app/api/progress/route.ts:26-47", "src/services/progressService.ts:18-65", "src/app/(protected)/[classSlug]/flashcards/page.tsx:55-67", "src/components/flashcards/FlashcardViewer.tsx:42-44,50-54,61-65"], - "functions": ["PATCH /api/progress", "upsertProgress", "getProgressLabel", "FlashcardViewer init"], - "scenario": "PATCH with contentType:'QUIZZ', mode:'weird', orderJson:'not json', currentIndex:-5 — all accepted and persisted (no Zod anywhere in the route). Malformed orderJson then crashes the flashcards list page at render (getProgressLabel JSON.parse) and the viewer at mount (unguarded useState initializers); bogus contentId → FK violation → uncaught 500. QuizViewer guards the same parse (QuizViewer.tsx:104-116); the flashcard paths do not.", - "expected": "400 on invalid payloads; safe fallback on read.", - "actual": "Garbage persisted; render-time SyntaxError white-screens pages.", - "impact": "Page crash on the flashcards tab; latent trap for any future writer bug.", - "evidence": "progress/route.ts imports no validator (verified); flashcards/page.tsx:58-61 bare JSON.parse; FlashcardViewer.tsx:43,52,63 bare parses.", - "testCoverage": "None.", - "fixDirection": "Zod schema for progress PATCH (enums, JSON-string validity, index >= 0); wrap client parses in try/catch with fallback (mirror QuizViewer).", - "fixComplexity": "S", - "regressionTest": "API: malformed orderJson → 400; render: corrupt progress row → page renders with fallback.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-22", - "sourceIds": ["OPS-02"], - "title": "Entrypoint migrate-deploy chain is fragile: failure = crash loop with no recovery path; runner prisma CLI install is unverified and unpinned", - "severity": "Medium", - "confidence": "High-confidence inference", - "files": ["docker-entrypoint.sh:2-4", "Dockerfile:16-36", "prisma.config.ts:7"], - "functions": ["docker-entrypoint.sh", "RUN npm install prisma@^7.8.0"], - "scenario": "set -e + npx prisma migrate deploy + exec node server.js. On a FRESH volume migrate succeeds (no drift detection — the DB is then incompatible per FIN-01, so the app 500s but the container 'runs'). On conflicting DB state (db-push DB, manual tampering, locked file) migrate fails → container exits → restart: unless-stopped → infinite crash loop, no documented recovery. The runner's unpinned npm install prisma@^7.8.0 (no lockfile, re-resolves the whole tree, runtime network dependency for npx) is plausible per Prisma 7 docs but never exercised by CI.", - "expected": "Migration failures are observable and recoverable; image contents reproducible.", - "actual": "Silent crash loop / broken-but-running container; no smoke test anywhere.", - "impact": "Prolonged outage without diagnostics on secondary triggers; nondeterministic images.", - "evidence": "entrypoint set -e (verified); adversarial review weakened the fresh-volume crash-loop framing (migrate succeeds there).", - "testCoverage": "None (CI never runs the image).", - "fixDirection": "Retry-with-backoff or one-shot init job; clear failure logs + documented recovery; install prisma pinned from the lockfile (or copy from builder); CI container smoke test.", - "fixComplexity": "M", - "regressionTest": "Container smoke: fresh volume → 200 and _prisma_migrations populated; corrupt volume → actionable log, no silent loop.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-23", - "sourceIds": ["OPS-05"], - "title": "Non-reproducible installs: npm install (not ci) in deps; unpinned prisma re-install re-resolving the whole tree in the runner", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["Dockerfile:5,30", "package.json:38"], - "functions": ["docker build stages"], - "scenario": "deps stage npm install can silently mutate the lockfile; the runner's npm install prisma@^7.8.0 against the standalone package.json re-resolves all caret ranges, downloads native modules, and floats image content with the registry.", - "expected": "Images reproducible from package-lock.json.", - "actual": "Runner image content not a function of the repo lockfile.", - "impact": "Nondeterministic images; invisible dependency drift (incl. prisma patch releases).", - "evidence": "Dockerfile:5,30 (verified); standalone package.json is a full copy.", - "testCoverage": "None.", - "fixDirection": "npm ci in deps; pin prisma exact version or copy CLI from builder stage.", - "fixComplexity": "M", - "regressionTest": "Two builds from same commit → prisma --version identical.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-24", - "sourceIds": ["OPS-07"], - "title": "No healthcheck, no backup, no documented recovery — outages are invisible to the orchestrator and the DB is a single point of failure", - "severity": "Medium", - "confidence": "Confirmed", - "files": ["docker-compose.yml:5", "docker-entrypoint.sh:3", "Dockerfile:35"], - "functions": ["compose orchestration", "entrypoint"], - "scenario": "A hung-but-running app shows 'running' in docker ps while /login 500s; a corrupt study.db means years of study data with no backup and no documented recovery procedure; no WAL mode (default rollback journal).", - "expected": "Observable health + backup + recovery path.", - "actual": "None of the three.", - "impact": "Prolonged undetected outages; permanent data loss on disk failure.", - "evidence": "compose has no healthcheck; entrypoint no retry (verified).", - "testCoverage": "None.", - "fixDirection": "Add healthcheck (wget -qO- http://127.0.0.1:3726/login); document volume backup; consider WAL + backup script.", - "fixComplexity": "S-M", - "regressionTest": "Compose healthcheck flips unhealthy when entrypoint fails.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-25", - "sourceIds": ["OPS-10", "TEST-10"], - "title": "CI builds and pushes the image without running tests, lint, prisma validate, or a container smoke test — every deployment-breaking defect ships with a green pipeline", - "severity": "Medium", - "confidence": "Confirmed", - "files": [".forgejo/workflows/build.yml:12-25", "package.json:6-11", "vitest.config.ts"], - "functions": ["CI workflow"], - "scenario": "Any of FIN-01/02/05/06/07 land on main → CI is green (docker build && push only) → broken image tagged latest. No PR pipeline, no test step, no coverage gate.", - "expected": "Tests, lint, prisma validate, and a container smoke gate the image.", - "actual": "None run; pipeline reports success while running zero tests.", - "impact": "Broken production artifacts shipped; regressions invisible until deployment.", - "evidence": "build.yml steps (verified); package.json test script never referenced by any workflow.", - "testCoverage": "The gap itself.", - "fixDirection": "Add npm ci && npm test && lint && prisma validate jobs plus a container smoke (curl /login on fresh volume); tag by commit SHA.", - "fixComplexity": "M", - "regressionTest": "CI fails when the container doesn't return 200 on a fresh volume (catches FIN-01/FIN-05).", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-26", - "sourceIds": ["QUIZ-07"], - "title": "SATA scoring divides by correctIds.length with no zero-guard → NaN scores", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/scoring.ts:27", "src/lib/validation/importSchemas.ts:16-18"], - "functions": ["scoreQuestion (SATA branch)"], - "scenario": "A SATA question with zero correct options → 0/0 → NaN; scoreQuiz sums NaN; persisted score NaN. Unreachable through the current import path (Zod enforces >=1 correct) but unguarded at score time — any future content-edit feature or DB drift corrupts scores silently.", - "expected": "Defensive 0 for degenerate questions.", - "actual": "NaN propagates.", - "impact": "Latent corruption risk; none today.", - "evidence": "scoring.ts:27 no guard (verified).", - "testCoverage": "None.", - "fixDirection": "if (correctIds.length === 0) return 0;", - "fixComplexity": "S", - "regressionTest": "scoreQuestion with 0-correct SATA → 0; scoreQuiz finite.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-27", - "sourceIds": ["QUIZ-08"], - "title": "Historical review and category breakdown recompute scores from current content, not the stored attempt", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/quizzes/QuizResults.tsx:32-52", "src/components/quizzes/CategoryBreakdown.tsx:17-35"], - "functions": ["QuizResults", "CategoryBreakdown"], - "scenario": "If option correctness/type/set ever differs from attempt time (content editing is added later), per-question review scores and category bars diverge from the persisted total; 'Retake Missed' ids derive from current content. Today content is immutable so the recompute matches.", - "expected": "Render the attempt as scored.", - "actual": "Only total + answersJson stored; everything per-question recomputed.", - "impact": "Latent historical-corruption display bug; benign today.", - "evidence": "QuizResults.tsx:35-52 recompute vs :54 persisted banner (verified).", - "testCoverage": "None.", - "fixDirection": "Persist per-question points snapshot at submit time; render stored points with fallback for legacy rows.", - "fixComplexity": "M", - "regressionTest": "Score quiz, flip an option's isCorrect, re-render → per-question scores still match stored attempt.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-28", - "sourceIds": ["IMPT-03"], - "title": "Card add/edit endpoints bypass Zod: empty/whitespace strings storable; CreateTab silently drops incomplete cards", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/api/cards/[id]/route.ts:11-16", "src/app/api/decks/[id]/cards/route.ts:11-25", "src/components/flashcards/CardManager.tsx:34-48", "src/components/import/CreateTab.tsx:46-50"], - "functions": ["PATCH /api/cards/[id]", "POST /api/decks/[id]/cards", "saveEdit", "handleCreate"], - "scenario": "Edit a card, clear both textareas, Save → 200 with blank front persisted; PATCH with {} → silent no-op 200; CreateTab with 3 cards where one is blank → 2-card deck created with no warning.", - "expected": "400 on empty content; no silent drops.", - "actual": "Empty strings persisted; silent card dropping.", - "impact": "Blank/garbage cards in decks; misleading save success.", - "evidence": "cards/[id]/route.ts:11-16 (verified); CreateTab.tsx:46 filter.", - "testCoverage": "None.", - "fixDirection": "Shared cardContentSchema with safeParse in both endpoints; surface incomplete-card errors in CreateTab.", - "fixComplexity": "S", - "regressionTest": "API: PATCH card with '' → 400; CreateTab 3-cards-1-blank → error, nothing created.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-29", - "sourceIds": ["IMPT-04"], - "title": "No payload size/string-length/array-length caps on import schemas and routes; whitespace-only names accepted", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/validation/importSchemas.ts:3-45", "src/app/api/decks/route.ts:5-25", "src/app/api/quizzes/route.ts:5-25", "next.config.ts:3-6"], - "functions": ["flashcardImportSchema", "quizImportSchema", "POST /api/decks|quizzes"], - "scenario": "A 20 MB JSON with 100k cards → minutes-long UI freeze (per-keystroke parse+repair+validate pipeline in ImportTab), huge transaction; ' ' as deckName passes (no trim); very long strings bloat rows. SRS schemas cap at 120/5000 chars — import schemas don't.", - "expected": "Bounded inputs consistent with SRS schemas.", - "actual": "Unbounded everything; no body-size guard.", - "impact": "Self-DoS, DB bloat, visually-empty names.", - "evidence": "importSchemas.ts has no .max()/.trim() (verified); spacedRepetitionSchemas.ts:4-5 has caps.", - "testCoverage": "None.", - "fixDirection": "Add trim/min/max to strings, .max() on arrays, description caps, content-length guard, debounce ImportTab.", - "fixComplexity": "S-M", - "regressionTest": "Schema tests: whitespace names rejected, oversized arrays/strings rejected.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-30", - "sourceIds": ["IMPT-05"], - "title": "SATA constraint mismatch: LLM instructions require >=2 correct options, schema enforces only >=1", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/validation/importSchemas.ts:16-24", "src/services/settingsService.ts:38-39"], - "functions": ["quizImportSchema", "getDefaultInstructions"], - "scenario": "LLM returns a SATA question with exactly one correct:true → import succeeds despite instructions saying 'two or more options with correct: true'. Scoring still works (single correct), so no corruption — doc/schema drift.", - "expected": "Rejected per documented rule.", - "actual": "Accepted.", - "impact": "Semantic drift; 1-correct SATA graded with partial-credit formula.", - "evidence": "importSchemas refine >=1 vs settingsService.ts:38-39 (verified).", - "testCoverage": "None.", - "fixDirection": "Per-type refine: sata → correctCount >= 2 (or align instructions).", - "fixComplexity": "S", - "regressionTest": "Schema: SATA with 1 correct fails; 2+ passes; MC 0/2 fails.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-31", - "sourceIds": ["IMPT-06"], - "title": "Unvalidated name/groupId override fields on import POST routes throw 500s; PATCH routes mask all failures as 404", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/api/decks/route.ts:12,27-32", "src/app/api/quizzes/route.ts:12,27-32", "src/app/api/decks/[id]/route.ts:22-33", "src/app/api/quizzes/[id]/route.ts:22-33"], - "functions": ["POST /api/decks|quizzes", "PATCH /api/decks/[id]|quizzes/[id]"], - "scenario": "POST with name:123 → TypeError: name.trim is not a function → unhandled 500; PATCH with wrong-typed body → 404 'Deck not found' for an existing deck (misleading diagnostics).", - "expected": "400 with a validation message.", - "actual": "500 / misleading 404.", - "impact": "Error-handling quality only (UI never sends wrong types).", - "evidence": "decks/route.ts:12,27-32 (verified).", - "testCoverage": "None.", - "fixDirection": "Validate the whole request envelope with Zod; narrow PATCH catch to P2025 for 404.", - "fixComplexity": "S", - "regressionTest": "POST numeric name → 400; PATCH wrong-typed body → 400 not 404.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-32", - "sourceIds": ["GRP-04"], - "title": "Group deletion leaves duplicate sortOrder values in Uncategorized; no unique constraint and no tie-break → ambiguous, unstable order", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:270-275", "src/app/(protected)/[classSlug]/quizzes/page.tsx:257-262", "prisma/schema.prisma:24-39,52-67", "src/services/deckService.ts:5-7"], - "functions": ["handleDeleteGroup", "DELETE /api/material-groups/[id]", "listDecksByClass", "listQuizSetsByClass"], - "scenario": "Group A has decks sortOrder 0,1,2; Uncategorized has 0,1. Delete group A → SetNull moves rows without renumbering → duplicates; rendering ties resolve to SQLite scan order which can change after VACUUM. Dragging self-heals; imports into Uncategorized use max+1 leaving old collisions.", - "expected": "Renumbered sequential sortOrders after delete.", - "actual": "Stale group-relative values kept.", - "impact": "Cosmetic unstable ordering after the common 'delete group' operation.", - "evidence": "DELETE route plain delete; orderBy single-key (verified).", - "testCoverage": "None.", - "fixDirection": "Renumber in the delete route transaction; add deterministic orderBy tie-break (createdAt).", - "fixComplexity": "S", - "regressionTest": "Integration: delete group → all Uncategorized sortOrders unique and sequential.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-33", - "sourceIds": ["GRP-06"], - "title": "Keyboard users cannot move items between groups; no drag announcements", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:298-311", "src/app/(protected)/[classSlug]/quizzes/page.tsx:285-309"], - "functions": ["handleDragOver", "sensors"], - "scenario": "KeyboardSensor allows within-group reorder, but cross-container keyboard moves require onDragOver to move the active item between containers; handleDragOver is a no-op → cross-group moves are pointer-only. Screen readers get no live announcements.", - "expected": "Full DnD parity for keyboard.", - "actual": "Within-group only.", - "impact": "Accessibility gap; no data corruption.", - "evidence": "Empty handleDragOver bodies (verified).", - "testCoverage": "None.", - "fixDirection": "Implement handleDragOver for keyboard container switches or add explicit 'Move to group' actions.", - "fixComplexity": "M", - "regressionTest": "Keyboard test moving an item across two groups.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-34", - "sourceIds": ["CARD-08"], - "title": "Restart deletes progress fire-and-forget; a slow DELETE can remove the fresh session's newly created progress row", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx:42-62", "src/app/(protected)/[classSlug]/flashcards/page.tsx:117-125"], - "functions": ["handleRestart", "DELETE /api/progress"], - "scenario": "Click Restart → two unawaited DELETEs fire → viewer remounts → first grade PATCHes a new progress row → if a DELETE lands after the PATCH, the new row is deleted → next load shows no progress (session silently restarted).", - "expected": "Restart clears old progress only.", - "actual": "Delete-after-create race can remove the new row.", - "impact": "Occasional unexpected session reset (small window).", - "evidence": "[deckId]/page.tsx:46-61 .catch(() => {}) (verified).", - "testCoverage": "None.", - "fixDirection": "Await DELETEs before remount or use per-deck session ids so late DELETEs can't touch newer rows.", - "fixComplexity": "S", - "regressionTest": "Route-order simulation.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-35", - "sourceIds": ["CARD-09"], - "title": "Concurrent first review of the same new card hits the unique constraint → unhandled generic 500 instead of 409", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/services/spacedRepetitionService.ts:426-442", "src/lib/spacedRepetitionApi.ts:4-9"], - "functions": ["reviewCard", "spacedRepetitionErrorResponse"], - "scenario": "Two rapid reviews of a never-reviewed card (double-tap or two tabs): both see current=null, both create; loser hits P2002 → generic 500 'Spaced repetition request failed' (the update path maps this to 409 via optimistic concurrency; the create path doesn't).", - "expected": "409 'already reviewed'.", - "actual": "500 with no guidance.", - "impact": "Confusing error in an edge double-submit; review still recorded by the winner.", - "evidence": "reviewCard create path lacks the updateMany guard (verified).", - "testCoverage": "None.", - "fixDirection": "Catch P2002 → 409, or upsert-guard with retry.", - "fixComplexity": "S", - "regressionTest": "Two concurrent reviewCard calls → one 409.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-36", - "sourceIds": ["CARD-10"], - "title": "'Previous card' during the 350 ms grade animation races the pending timeout — index jumps forward, result map inconsistent", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/components/flashcards/FlashcardViewer.tsx:190-204,531-544"], - "functions": ["gradeCard", "previous-card handler"], - "scenario": "Grade a card, click Previous within 350 ms: click sets currentIndex-1; the pending timeout then fires setCurrentIndex(currentIndex+1) from a stale closure → jumps forward past the card the user navigated to; saveProgress writes results inconsistent with the visible card.", - "expected": "Navigation wins.", - "actual": "Timeout overwrites navigation.", - "impact": "Transient wrong position/result map; self-corrects on next action.", - "evidence": "Timeout uses closure currentIndex (verified).", - "testCoverage": "None.", - "fixDirection": "Disable prev during swipe or use functional setState/refs in the timeout.", - "fixComplexity": "S", - "regressionTest": "Component test simulating grade→previous→timeout sequence.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-37", - "sourceIds": ["CARD-11"], - "title": "SRS set page never refreshes on window focus — stale membership after deck deletion in another tab", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/spaced-repetition/SpacedRepetitionSets.tsx:167-192"], - "functions": ["SpacedRepetitionSets.load"], - "scenario": "Keep the SRS page open; delete a member deck in another tab → the UI still lists the deck (DB is cascade-clean); removing/dragging it yields 404 errors until manual reload. Navbar refreshes on focus; the sets page doesn't.", - "expected": "UI reflects DB state.", - "actual": "Mount-only load.", - "impact": "Stale list; harmless 404 messages.", - "evidence": "useEffect mount-only (verified); contrast Navbar.tsx:45.", - "testCoverage": "None.", - "fixDirection": "Add focus listener / deck-changed event to re-run load.", - "fixComplexity": "S", - "regressionTest": "Component-level focus-refresh test.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-38", - "sourceIds": ["CARD-06"], - "title": "Day boundary hardcoded to Arizona (UTC-7): 'today', new-card limit, and streak roll over at 07:00 UTC for non-Arizona users", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/spacedRepetition.ts:157-161", "src/services/activityService.ts:28-30", "src/lib/spacedRepetition.test.ts:56-61"], - "functions": ["getArizonaDayBounds", "getEffectiveDue", "toArizonaDateKey"], - "scenario": "For a UTC+8 user the day flips at 15:00 local; due 'today' and the daily new-card allowance become available mid-afternoon and reset mid-afternoon. Internally consistent (tests assert the design); the label 'Arizona time' in ActivityBanner confirms intent — but it's never a setting.", - "expected": "Configurable timezone.", - "actual": "Fixed UTC-7 offset.", - "impact": "Confusing day boundaries for non-Arizona users.", - "evidence": "spacedRepetition.ts:157-161 (verified); tests codify it.", - "testCoverage": "Yes — unit tests assert the current behavior.", - "fixDirection": "Make the timezone a Setting (default America/Phoenix) threaded through the pure functions.", - "fixComplexity": "M", - "regressionTest": "Parameterize with another offset and assert boundaries.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-39", - "sourceIds": ["AUTH-05"], - "title": "Proxy destroy() cookie-clear header is lost on redirect; page-level check ignores sessionGeneration", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/proxy.ts:30-43", "src/app/(protected)/layout.tsx:10-13", "src/lib/auth.ts:53-56"], - "functions": ["proxy()", "ProtectedLayout", "isAuthenticated"], - "scenario": "Stale/tampered cookie → proxy redirects to /login, but session.destroy() attaches Set-Cookie to the discarded NextResponse.next() response, not the redirect → the browser keeps the bad cookie → every navigation redirects again until re-login. Layout backstop checks only isAuthenticated, not generation.", - "expected": "Invalid cookie cleared on redirect.", - "actual": "Clear header dropped; defense-in-depth inconsistency.", - "impact": "Minor UX (redirect loop until re-login).", - "evidence": "proxy.ts:30-42 (verified); iron-session destroy writes to the passed response.", - "testCoverage": "None.", - "fixDirection": "Build the redirect first, then getIronSession(request, redirectResponse) before returning; make isAuthenticated generation-aware.", - "fixComplexity": "S", - "regressionTest": "Integration: stale-generation cookie → redirect response carries Set-Cookie clearing study-app-session.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-40", - "sourceIds": ["AUTH-08"], - "title": "Session cookie Secure flag off in the shipped compose (plain-HTTP deployment, no TLS story)", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/auth.ts:17-21", "docker-compose.yml:8-12"], - "functions": ["sessionOptions"], - "scenario": "Default docker compose up → secure=false → session cookie and login POST travel in cleartext on the LAN; any host can sniff and replay. SECURE_COOKIES=true on plain HTTP would break login entirely — the flag logic is correct; the deployment lacks TLS.", - "expected": "HTTPS with secure cookies.", - "actual": "Plain HTTP, no HSTS.", - "impact": "Credential/session sniffing on shared networks; critical if port-forwarded.", - "evidence": "auth.ts:18 (verified); compose sets no SECURE_COOKIES.", - "testCoverage": "None.", - "fixDirection": "Document/require a TLS proxy (Caddy/Traefik) + SECURE_COOKIES=true.", - "fixComplexity": "S", - "regressionTest": "Unit test flag computation across env combos.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-41", - "sourceIds": ["DBAUD-02", "AUTH-07"], - "title": "/api/share accepts arbitrary targetType — repeatable junk all-NULL ShareLink rows; no CHECK constraints or enum validation anywhere", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/api/share/route.ts:32-44", "src/services/shareService.ts:105-123", "prisma/schema.prisma:92-109,123-134"], - "functions": ["toggleShareLink", "POST /api/share"], - "scenario": "POST /api/share {targetType:'FOO', contentId:'x'} → toggleShareLink matches no branch → creates ShareLink with all three FKs null; SQLite unique indexes treat NULLs as distinct → unlimited junk rows; no cleanup path. StudyProgress has the same unvalidated contentType (FIN-21).", - "expected": "Only DECK/QUIZ/GROUP with exactly one target.", - "actual": "Any string accepted; junk rows persist.", - "impact": "Data hygiene (owner-controlled); no privilege boundary crossed.", - "evidence": "toggleShareLink fall-through create (verified).", - "testCoverage": "None.", - "fixDirection": "Zod enum validation at the route; optional CHECK constraints via raw migration.", - "fixComplexity": "S", - "regressionTest": "POST invalid targetType → 400, zero rows; GROUP twice → toggles to one row.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-42", - "sourceIds": ["DBAUD-03"], - "title": "SQLite dev.db was committed in git history (8 commits) and remains in blobs on the LAN remote", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["git history (dev.db blobs in b7ce314..7af0935)", ".git/config:8-10", ".gitignore:17"], - "functions": ["git history"], - "scenario": "Anyone with repo/server access can git show 7af0935:dev.db — a real SQLite DB containing StudyProgress rows (verified blob markers; no password hash found in the checked blobs, but content not fully enumerable read-only).", - "expected": "DB files never in VCS.", - "actual": "Present in history incl. the db-push drift variant.", - "impact": "Personal study content exposure to anyone with repo/server access; conditional (no credentials found).", - "evidence": "git log --all -- dev.db → 8 commits (verified).", - "testCoverage": "n/a.", - "fixDirection": "If the remote is ever shared: git filter-repo --path dev.db --invert-paths + force-push.", - "fixComplexity": "S-M", - "regressionTest": "git log --all -- dev.db empty after rewrite.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-43", - "sourceIds": ["OPS-09"], - "title": ".gitignore gaps: production data/ directory and study.db* not ignored; scratch files (out.css, temp.css, test.css) tracked", - "severity": "Low", - "confidence": "Confirmed", - "files": [".gitignore:16-20", "docker-compose.yml:13-14", "git ls-files"], - "functions": ["git", "compose volume"], - "scenario": "Running production compose from the repo creates ./data/study.db — not ignored → a careless 'git add .' commits the real production database. temp.css/test.css/out.css are tracked junk.", - "expected": "Database artifacts never committable.", - "actual": "Only /dev.db* guarded.", - "impact": "Risk of leaking the entire study database to the Forgejo remote.", - "evidence": ".gitignore contents vs compose volume path (verified).", - "testCoverage": "n/a.", - "fixDirection": "Add data/, /study.db*, audit-results/, .reasonix/; untrack the junk files.", - "fixComplexity": "S", - "regressionTest": "git check-ignore data/study.db returns the path.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-44", - "sourceIds": ["FE-07", "ADV-04"], - "title": "Dashboard fetchClasses has no .catch and no res.ok check — unhandled rejection and misleading empty state (or render crash on error JSON)", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/app/(protected)/page.tsx:30-37"], - "functions": ["fetchClasses"], - "scenario": "Network/server error on the dashboard → unhandled promise rejection → 'Your desk is ready' empty state despite existing classes; a 500 JSON error body stored into classes makes classes.map crash the render.", - "expected": "Error banner + retry.", - "actual": "Silent empty state / crash.", - "impact": "Misleading UI after transient failures.", - "evidence": "page.tsx:33-36 try/finally without catch (verified).", - "testCoverage": "None.", - "fixDirection": "Add catch → error state with retry; check res.ok before parsing.", - "fixComplexity": "S", - "regressionTest": "Reject /api/classes → error UI, not empty state.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-45", - "sourceIds": ["FE-09"], - "title": "Effect fetches without .catch in GenerateTab and ShareMenu — unhandled rejections, degraded UI states", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/import/GenerateTab.tsx:18-23", "src/components/ui/ShareMenu.tsx:38-48"], - "functions": ["GenerateTab effect", "ShareMenu effect"], - "scenario": "Settings/share fetch fails → unhandled rejection; GenerateTab textarea stays empty with no error (setInstructions(undefined) makes a controlled textarea temporarily uncontrolled); ShareMenu shows 'Enable link sharing' even when a share token exists server-side.", - "expected": "Error state.", - "actual": "Silent degraded UI.", - "impact": "Minor UX confusion; console noise.", - "evidence": "Missing .catch in both (verified).", - "testCoverage": "None.", - "fixDirection": "Add .catch → error message/retry; validate data.value before setState.", - "fixComplexity": "S", - "regressionTest": "Reject fetches → no unhandled rejection, error message shown.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-46", - "sourceIds": ["FE-10"], - "title": "Cross-class navigation fetch race: slow response from class A can render class A's data under class B's header", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:205-241", "src/app/(protected)/[classSlug]/quizzes/page.tsx:192-228"], - "functions": ["fetchAll", "init effect"], - "scenario": "Navigate quickly between two classes: class A's in-flight fetch resolves after class B's → setDecks(A-data) renders wrong-class content until the next action. Module-level cache never invalidated (brief stale flash on revisits).", - "expected": "Only the current class's data applies.", - "actual": "Last-resolved-wins with no guard.", - "impact": "Wrong-class content displayed transiently.", - "evidence": "No abort/stale-response guard (verified).", - "testCoverage": "None.", - "fixDirection": "AbortController or sequence check per classSlug; invalidate/drop the module cache.", - "fixComplexity": "S-M", - "regressionTest": "Out-of-order resolution of two fetchAll calls → only latest class's data set.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-47", - "sourceIds": ["ADV-03"], - "title": "Logout has no error handling — failed logout strands the user with an unhandled rejection", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/components/ui/Navbar.tsx:55-59"], - "functions": ["handleLogout"], - "scenario": "Network failure during POST /api/auth/logout → router.push('/login') never runs; unhandled rejection; the user appears logged out in UI but the session cookie persists.", - "expected": "Graceful failure handling.", - "actual": "None.", - "impact": "Stranded session on transient errors.", - "evidence": "Navbar.tsx:55-58 (verified).", - "testCoverage": "None.", - "fixDirection": "try/catch + user feedback; force navigation regardless.", - "fixComplexity": "S", - "regressionTest": "Mock failed logout → navigation still occurs or error shown.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-48", - "sourceIds": ["ADV-05"], - "title": "slugify can yield an empty slug (unreachable class) and renames never update the URL slug", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/services/classService.ts:4-10,47-64,66-71"], - "functions": ["slugify", "createClass", "updateClass"], - "scenario": "Create a class named '!!!' → slug '' → class created but unreachable via /[classSlug] (empty segment). Rename a class → slug stays the old value; bookmarked/old URLs keep pointing at the old slug while the header shows the new name.", - "expected": "Always-nonempty unique slugs; rename updates the slug (or redirects).", - "actual": "Empty slug possible; rename never touches slug.", - "impact": "Unreachable content; stale URLs after rename.", - "evidence": "classService.ts:4-9 slugify drops all non-alphanumerics (verified); updateClass updates name only.", - "testCoverage": "None.", - "fixDirection": "Fallback slug (e.g. 'class' + counter) when slugify returns ''; decide rename-vs-slug policy (update slug or 301 old → new).", - "fixComplexity": "S", - "regressionTest": "createClass('!!!') → non-empty unique slug; rename → old slug resolves or redirects.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-49", - "sourceIds": ["ADV-08"], - "title": "Shared-viewer localStorage session keys are item-scoped, not token-scoped — progress collides across share tokens and with the owner's own sessions", - "severity": "Low", - "confidence": "High-confidence inference", - "files": ["src/app/shared/[classSlug]/[type]/[token]/SharedViewer.tsx:26-33", "src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx:24-41", "src/components/flashcards/FlashcardViewer.tsx:129-150"], - "functions": ["localStorage session save/load"], - "scenario": "Two share links for the same deck/quiz (or the owner studying locally) share one localStorage key (flashcard_progress_ / quiz_progress_): progress from one token's session restores into another's; restored orders can reference cards not in this payload → stuck blank viewer (recoverable via restart).", - "expected": "Session scoped to the share token.", - "actual": "Scoped to content id only.", - "impact": "Cross-token session bleed; stuck viewers in edge cases.", - "evidence": "Key construction in SharedViewer/SharedGroupViewer (verified).", - "testCoverage": "None.", - "fixDirection": "Include the token (or a per-viewer random id) in the localStorage key.", - "fixComplexity": "S", - "regressionTest": "Two tokens for the same content → independent sessions.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-50", - "sourceIds": ["GRP-07"], - "title": "Group sortOrder uses an inverted desc convention (newest-first) with an unvalidated PATCH — latent trap for future group reordering", - "severity": "Observation", - "confidence": "Confirmed", - "files": ["src/app/api/material-groups/route.ts:26,43-47", "src/app/api/material-groups/[id]/route.ts:11-21"], - "functions": ["GET/POST /api/material-groups", "PATCH /api/material-groups/[id]"], - "scenario": "Groups order by sortOrder desc (newest first) while every other sortOrder in the codebase is asc; PATCH accepts arbitrary/duplicate sortOrder values with no validation. Internally consistent today (no group drag UI); the convention is fragile and the PATCH is a footgun.", - "expected": "Consistent, validated semantics.", - "actual": "Inverted convention, unbounded PATCH values.", - "impact": "Latent ordering trap; self-harm only via crafted requests.", - "evidence": "material-groups/route.ts:26 (verified).", - "testCoverage": "None.", - "fixDirection": "Remove sortOrder from the group PATCH contract or validate/normalize it; document the convention.", - "fixComplexity": "S", - "regressionTest": "PATCH with sortOrder 'abc' → 400.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-51", - "sourceIds": ["FE-11"], - "title": "Collapsed-groups state read in an effect — brief expand flash on every library visit", - "severity": "Observation", - "confidence": "Confirmed", - "files": ["src/app/(protected)/[classSlug]/flashcards/page.tsx:190-195", "src/app/(protected)/[classSlug]/quizzes/page.tsx:177-182"], - "functions": ["collapsed-groups localStorage init"], - "scenario": "Collapsed groups render expanded for one frame after navigation, then collapse (effect runs post-paint). Cosmetic; source of a known lint warning.", - "expected": "Collapsed from first paint.", - "actual": "One-frame flash.", - "impact": "Cosmetic layout jump.", - "evidence": "useState {} + effect sync (verified).", - "testCoverage": "None.", - "fixDirection": "Lazy useState initializer or useSyncExternalStore.", - "fixComplexity": "S", - "regressionTest": "Render with saved collapsed state → collapsed on first paint.", - "needsRuntimeConfirmation": true - }, - { - "id": "FIN-52", - "sourceIds": ["AUTH-10"], - "title": "Shared quiz links ship the full answer key (isCorrect, rationale) to anonymous viewers — by design, but worth an explicit warning", - "severity": "Observation", - "confidence": "Confirmed", - "files": ["src/services/shareService.ts:13-23", "src/components/quizzes/QuizViewer.tsx:377-487", "src/components/ui/ShareMenu.tsx:130"], - "functions": ["getShareLink", "QuizViewer shared grading"], - "scenario": "Anyone with a shared quiz token can read correct answers and rationales from the payload — inherent to the client-side self-grading design and documented ('Anyone with the link can view and study'). The token IS the answer key.", - "expected": "Documented sharing contract.", - "actual": "Exactly that.", - "impact": "None beyond the documented contract; consider a confirmation in the Share menu.", - "evidence": "shareService include without select-sanitization (verified).", - "testCoverage": "None.", - "fixDirection": "Optional: warning text in ShareMenu; a future 'practice-only' share mode without isCorrect.", - "fixComplexity": "S", - "regressionTest": "Assert shared payload omits attempts/progress/answersJson.", - "needsRuntimeConfirmation": false - }, - { - "id": "FIN-53", - "sourceIds": ["TEST-01"], - "title": "False-confidence test: 'distinct valid state for every rating' never asserts distinctness — scheduleRating rating-dependence is untested", - "severity": "Low", - "confidence": "Confirmed", - "files": ["src/lib/spacedRepetition.test.ts:15-23"], - "functions": ["scheduleRating (test)"], - "scenario": "The loop over AGAIN/HARD/GOOD/EASY asserts only reps===1, lastReview===now, due>now per rating — no cross-rating comparison. If ratingMap broke (all ratings → Good), the test still passes. previewRatings (a different function) is tested separately; scheduleRating's rating dependence is never asserted for learning/review states.", - "expected": "Ratings produce distinct schedules.", - "actual": "Distinctness never asserted.", - "impact": "False confidence in the SRS scheduling core.", - "evidence": "spacedRepetition.test.ts:15-23 (verified).", - "testCoverage": "The finding.", - "fixDirection": "Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; add a graduated-state lapse case.", - "fixComplexity": "S", - "regressionTest": "The improved test itself.", - "needsRuntimeConfirmation": false - } - ], - "notes": "Severity: Critical = destructive data loss / credential exposure / unauthorized access / unrecoverable migration failure / app-wide failure; High = major feature failure / persistent state corruption / deployment failure; Medium = meaningful incorrect behavior; Low = limited concrete defect; Observation = useful concern without confirmed defect. Confidence: Confirmed / High-confidence inference / Unverified risk. FIN-01..FIN-53 are the consolidated, deduplicated findings; source IDs from specialist auditors are preserved in sourceIds. FE-06 ('no auth on API routes') was refuted by the adversarial review and is recorded in REJECTED_FINDINGS.md. Test-gap findings beyond FIN-53 (scoring/auth/SRS-service/attempt/jsonRepair/shuffle/rateLimiter missing tests, no integration harness) are detailed in TEST_GAPS.md." -} diff --git a/audit-results/REJECTED_FINDINGS.md b/audit-results/REJECTED_FINDINGS.md deleted file mode 100644 index 32d0415..0000000 --- a/audit-results/REJECTED_FINDINGS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Rejected Findings - -Findings that were investigated and disproven, or removed as duplicate/stylistic/insignificant, with the reason. - -| ID | Original claim | Rejection reason | -|---|---|---| -| FE-06 | "All `/api/*` routes are unauthenticated (no middleware, no per-route checks) — curl without a session cookie returns data" | **Refuted by adversarial review + parent verification.** `src/proxy.ts` IS the compiled Next.js 16 middleware (verified in `.next/server/functions-config-manifest.json`, `middleware.js`, and the compiled chunk; build output shows "ƒ Proxy (Middleware)"). It decrypts the iron-session cookie and enforces `isAuthenticated` + `sessionGeneration` on every path except `/login`, `/api/auth`, `/shared`, `/_next*`, `/favicon*`, and paths containing `.`. The auditor searched for `middleware.ts` (the Next 15 name) and missed `proxy.ts`. The residual truth is the dot-bypass (FIN-09). The claim that "no frontend code handles 401s because there is no auth" is also wrong — there is no 401 because the proxy redirects to /login. | -| QUIZ-06 (standalone) | "Quiz resume with stale question ids crashes the viewer" | Merged into FIN-04. The quiz-side path is currently unreachable (no question editing/deletion exists in the app; quiz deletion cascades progress), so it is a latent variant of the same root cause (dead `filterAndClampOrder`), not a separate live defect. | -| OPS-02 (original framing) | "Fresh-volume deployment crash-loops because `migrate deploy` fails" | **Weakened by adversarial review.** On a fresh volume `prisma migrate deploy` *succeeds* (verified against a temp DB); the fresh-volume failure is FIN-01 (schema drift → app 500s while the container runs). The crash loop requires a secondary trigger (db-push DB, tampering, lock). Reframed as FIN-22 with the crash-loop path demoted. | -| FE-01 (original severity) | Optimistic drag-drop reorder without rollback rated High | **Severity downgraded to Medium by adversarial review.** Mechanics confirmed (fire-and-forget fetch, in-place mutation, origin group omitted from cross-group payload) but impact analysis: sortOrder gaps only, silent divergence self-heals on reload, no visible break for a single user. Merged into FIN-18. | -| AUTH-06 (standalone) | "Cross-class group membership via reorder API exposes another class's content" | Merged into FIN-19 (same root cause: `groupId`/`sortOrder` never validated server-side). The sharing-exposure angle is recorded as impact of FIN-19. | -| DBAUD-04 | "Relative SQLite path resolution may cause dev dual-DB split-brain" | **Disproven.** CLI (`prisma.config.ts`) and app adapter both resolve `file:./dev.db` cwd-relative; `predev` and the app run from the project root → same file. Docker uses absolute `file:/app/data/study.db`. Recorded as verified-safe in UNVERIFIED_RISKS.md. | -| DBAUD-05/06/07, QUIZ-08-verdicts, GRP-01-verdicts (various) | "Cascades broken / transactions partial / StudyProgress duplicates via NULL tricks / id regeneration on card edit / XSS via markdown / client-server boundary violations" | **Disproven** by the respective auditors with evidence: FKs enforced by compile default; nested creates implicit-transactional; service-level guards block duplicates; PATCH edits in place (ids preserved); no `rehype-raw`; no server-module imports in client components. Recorded as verified-safe. | -| FE-11 (as bug) | "Collapsed-groups expand flash" | Retained only as Observation (FIN-51) — cosmetic, no data impact. | -| Various | Style preferences, harmless duplication, generic best practices, speculative micro-optimizations, Arcade-only issues | Excluded per audit scope rules. | diff --git a/audit-results/REMEDIATION_PLAN.md b/audit-results/REMEDIATION_PLAN.md deleted file mode 100644 index 6719e4a..0000000 --- a/audit-results/REMEDIATION_PLAN.md +++ /dev/null @@ -1,340 +0,0 @@ -# Study Desk audit evaluation and remediation plan - -Date: 2026-08-06 -Scope: independent evaluation of `CONFIRMED_FINDINGS.md`, `FINDINGS.json`, the supporting audit reports, and the current repository source. -Change policy for this evaluation: this plan is the only new file; no application source, tests, migrations, configuration, generated files, database, or existing audit artifact was changed. - -## Executive verdict - -The audit is credible and the central findings hold up against the current source. The most urgent problems are not cosmetic: - -1. The committed migration chain does not create `MaterialGroup` or the three `groupId` columns, while the generated Prisma client selects those fields. A database created only with `prisma migrate deploy` is incompatible with the app. -2. Production authentication can use a public fallback session secret, making a deployment with an empty `SESSION_SECRET` forgeable. -3. The in-viewer quiz retake flow loses the retake scope and records the retake as a full, incorrectly scored attempt. -4. Flashcard resume trusts stale IDs and unguarded JSON, so deletion or malformed progress can leave the study viewer blank or crash it. -5. The production container path is presently unreliable: the port mapping is wrong without the development override, the conventional override silently changes normal Compose behavior, the build context can include host `node_modules`, and the runner installs a ranged Prisma CLI outside the lockfile. - -I agree with most of the remaining findings as defects or resilience gaps, but not always with their severity or proposed remedy. In particular: - -- FIN-04 needs more than wiring in the existing `filterAndClampOrder`: that helper does not preserve the logical current card when a stale ID occurs before the saved index, and it turns a completed index back into the last card. -- FIN-05 should normally use `3000:3726`, preserving the documented host port while mapping it to the actual container port. -- FIN-22 should not be fixed with an automatic migration retry loop. Schema conflicts should fail fast with actionable diagnostics and a documented recovery path. -- FIN-27 is more important than Low because changing quiz content can rewrite the meaning of persisted history. -- FIN-34 is only partially present: the dedicated deck page already awaits both DELETE requests, but the library start/restart path fires them without waiting. -- FIN-38 is not currently a defect for this Arizona-based installation. It becomes a portability requirement only if the app is meant to support a configurable local day boundary. -- FIN-42 does not justify a destructive history rewrite without evidence that the old database contained sensitive data and without coordinating every clone/remote. -- FIN-50 is an intentional convention. Newest named groups must remain first and Uncategorized must remain last; validation should make that convention harder to corrupt. -- FIN-52 is an architectural consequence of local grading in an anonymous read-only viewer. It merits a sharing warning, not removal of the answer key unless the product adopts server-side anonymous grading. - -## Finding-by-finding disposition - -Priority definitions: P0 = release/deployment blocker; P1 = next remediation batch; P2 = important hardening; P3 = worthwhile backlog; Conditional = verify a stated condition before changing behavior; No separate fix = intentional behavior or covered by another item. - -| ID | Verdict | Worth fixing? | Priority and disposition | -|---|---|---:|---| -| FIN-01 | Agree | Yes | **P0.** Add the missing migration plus a safe adoption runbook for already-`db push`-synchronized databases. | -| FIN-02 | Agree, conditional on an unset/blank production secret | Yes | **P0.** Production must fail before serving requests; development may use an explicitly development-only value. | -| FIN-03 | Agree | Yes | **P1.** Make attempted-question scope explicit and derive partial-retake status on the server. | -| FIN-04 | Agree; proposed helper is insufficient as written | Yes | **P1.** Normalize order, index, and results while preserving the logical current card and completed state. | -| FIN-05 | Agree | Yes | **P0.** Map host 3000 to container 3726 and smoke-test it. | -| FIN-06 | Agree with the mechanics; exact runtime failure is unconfirmed | Yes | **P0.** Add `.dockerignore` and prove native modules load in the Linux image. | -| FIN-07 | Agree | Yes | **P0.** Rename the development override and document explicit dev/prod commands. | -| FIN-08 | Partly agree: first-login setup is intentional, accidental exposure and dead config are real | Yes | **P1.** Honor a pre-provisioned admin hash or require an explicit one-time setup mode. | -| FIN-09 | Agree; latent with current IDs | Yes | **P1.** Replace the broad dot bypass with exact public/static path rules. | -| FIN-10 | Agree | Yes | **P1.** Prevent token overwrite, remove spoofable per-IP trust, and make reset initiation a local/admin operation. | -| FIN-11 | Agree | Yes | **P1.** Validate the whole attempt envelope, question scope, option ownership, array uniqueness, and types. | -| FIN-12 | Agree | Yes | **P1.** Add an in-flight guard, disable Finish, and surface failure/retry state. | -| FIN-13 | Agree as a credible timing race | Yes | **P1.** Persist the attempt and clear full-attempt progress in one server transaction. | -| FIN-14 | Agree as a credible timing race | Yes | **P2.** Add session identity and monotonic revisions, not only client debouncing. | -| FIN-15 | Agree | Yes | **P1.** Persist `currentIndex === order.length` and restore the summary state. | -| FIN-16 | Agree; impact is lower in a protected single-user app | Yes | **P2.** Accept only the queue-eligible card, including the intended learn-ahead rule. | -| FIN-17 | Agree that the queries are unbounded; user impact is unmeasured | Conditional | **P3.** Benchmark realistic data first, then optimize queries that exceed the budget. | -| FIN-18 | Agree | Yes | **P2.** Stop mutating shared objects, capture the origin group before mutation, await writes, and rollback/refetch on error. | -| FIN-19 | Agree | Yes | **P1.** Enforce class/type/group membership and server-owned ordering; retain a share-page defense. | -| FIN-20 | Agree | Yes | **P1.** Give the viewer a stable session input so unrelated parent renders cannot reset it. | -| FIN-21 | Agree | Yes | **P1.** Validate progress writes and defensively normalize legacy/corrupt progress on reads. | -| FIN-22 | Partly agree with the operational gap, not the retry remedy | No separate fix | Fail fast; cover CLI pinning in FIN-23 and recovery/health in FIN-24. Do not retry a conflicting migration automatically. | -| FIN-23 | Agree | Yes | **P0.** Use `npm ci` and ship a lockfile-pinned Prisma CLI/runtime path. | -| FIN-24 | Agree | Yes | **P1.** Add a schema-aware health check and tested backup/restore instructions. | -| FIN-25 | Agree | Yes | **P0/P1.** Gate test, build, Prisma validation/drift, and container smoke. Stage lint until its known baseline is resolved. | -| FIN-26 | Agree | Yes | **P1.** Return zero for an invalid zero-correct SATA question and test finite totals. | -| FIN-27 | Agree; severity should be Medium | Yes | **P2.** Persist an immutable result/review snapshot for new attempts, with legacy fallback. | -| FIN-28 | Agree | Yes | **P2.** Reuse a trimmed card-content schema and reject rather than silently drop incomplete Create-tab rows. | -| FIN-29 | Agree | Yes | **P2.** Add reasonable string, array, and request-size limits with clear 400/413 errors. | -| FIN-30 | Agree that the contract is inconsistent | Yes | **P2.** Make SATA imports require at least two correct options, matching the shipped generation instructions; preserve legacy stored data. | -| FIN-31 | Agree | Yes | **P2.** Validate complete request envelopes and map only actual not-found errors to 404. | -| FIN-32 | Agree | Yes | **P2.** Delete/reassign/renumber in one transaction and add deterministic item tie-breaks. | -| FIN-33 | Agree | Yes | **P2.** Add an explicit keyboard-accessible “Move to group” action; do not depend on drag gestures. | -| FIN-34 | Partly agree | Yes | **P2.** Fix the library path that does not await deletion; make deletion session-aware with FIN-14. | -| FIN-35 | Agree | Yes | **P3.** Translate concurrent first-review uniqueness conflicts to 409 and refetch. | -| FIN-36 | Agree as a credible timing race | Yes | **P2.** Disable navigation while grading and cancel/ignore stale animation callbacks. | -| FIN-37 | Agree | Yes | **P3.** Refresh set/deck membership on focus and after local deck-change events. | -| FIN-38 | Factually correct, but Arizona is the current intended boundary | Not now | Keep the existing behavior. Revisit only with an explicit portability requirement and a timezone setting/migration plan. | -| FIN-39 | Agree about the lost clear-cookie response; layout impact is overstated because proxy checks generation | Yes | **P2.** Destroy the cookie on the actual redirect response and test its header. | -| FIN-40 | Deployment-policy gap, not a bug on intentional HTTP | Yes, as documentation/config | **P1.** Document a TLS proxy profile and `SECURE_COOKIES=true`; do not enable Secure cookies on plain HTTP. | -| FIN-41 | Agree | Yes | **P1.** Validate a strict target enum and target existence; reject impossible/null-target rows. | -| FIN-42 | Agree that blobs exist; sensitivity is unproven | Conditional | Do not rewrite history now. Inventory the old DB offline before any public remote; rewrite only with explicit coordination if sensitive data is found. | -| FIN-43 | Partly agree | Yes | **P3.** Ignore `data/` and production DB sidecars. Confirm ownership, then remove unreferenced CSS scratch files. Do not automatically ignore the audit plan. | -| FIN-44 | Agree | Yes | **P2.** Add `res.ok`, error, and retry handling; never present a failed load as an empty dashboard. | -| FIN-45 | Agree | Yes | **P3.** Add abort/error handling to settings/share fetches. | -| FIN-46 | Agree as a credible timing race | Yes | **P2.** Use `AbortController` or a request generation tied to `classSlug`; remove or bound the module cache. | -| FIN-47 | Agree | Yes | **P3.** Show logout failure and retry; do not falsely claim logout if the server call failed. | -| FIN-48 | Partly agree | Yes | **P2.** Guarantee a non-empty unique slug. Keep slugs stable on rename unless redirect/history semantics are deliberately designed. | -| FIN-49 | Agree; impact is same-browser only | Yes | **P3.** Include the share token in local session keys and migrate/ignore old keys safely. | -| FIN-50 | Intentional newest-first convention | No separate fix | Preserve descending named-group order and final Uncategorized placement; restrict arbitrary sort-order writes under FIN-19/31. | -| FIN-51 | Agree, cosmetic only | Not now | Defer unless it can be removed without hydration mismatch or new effect-rule violations. | -| FIN-52 | Expected local-grading design | Warning only | **P3.** Explain in ShareMenu that recipients can inspect correct answers; do not imply answer secrecy. | -| FIN-53 | Agree | Yes | **P1.** Replace the false-confidence assertion and add scoring/scheduling edge cases. | - -## Remediation sequence - -### Phase 0 — safeguards and test foundation - -Do this before behavior or schema changes. - -1. Create a database-backed Vitest harness that always points Prisma at a unique disposable SQLite path. It must refuse to run if the resolved path is `dev.db`, `/app/data/study.db`, or any existing non-test database. -2. Add helpers to apply committed migrations to the disposable database and dispose of it after the test process. -3. Record the current lint baseline separately. Do not make a failing baseline a nominally “green” CI gate, and do not mix unrelated lint cleanup into P0 fixes. -4. Add focused pure tests first for scoring, progress normalization, import schemas, and rate limiting. These give fast feedback before route/service integration tests. -5. Before testing any real deployment database, make and verify a restorable backup. No remediation command should run against the only copy. - -Exit criteria: - -- Tests demonstrably create and use only a disposable DB. -- A deliberate attempt to point the harness at the real DB aborts. -- Existing `npm.cmd test` remains green. - -### Phase 1 — restore a safe, reproducible deployment path - -Addresses FIN-01, FIN-02, FIN-05–10, FIN-23–25, FIN-39, and FIN-40. - -#### 1A. Repair migration drift without breaking already-pushed databases - -1. Generate a new migration; never edit the four applied migrations. It must add: - - `MaterialGroup` with its Class cascade foreign key. - - nullable `groupId` on Deck and QuizSet with `ON DELETE SET NULL`. - - nullable unique `groupId` on ShareLink with `ON DELETE CASCADE`. - - the indexes Prisma expects. -2. Review the generated SQLite table-rebuild SQL by hand for preserved rows, foreign keys, defaults, and unique indexes. -3. Publish a one-time preflight/adoption script that classifies a database as: - - migration-tracked and missing the group schema: apply the migration normally; - - already schema-pushed and exactly matching the intended DDL: after backup and exact introspection, mark the new migration applied with `prisma migrate resolve --applied`; - - partially matching or otherwise inconsistent: stop with diagnostics and require manual recovery; never guess or auto-resolve. -4. Keep the adoption operation explicit. The normal entrypoint should not silently mutate migration history based on table existence. - -Verification: - -- Fresh empty DB: `prisma migrate deploy` succeeds; `prisma migrate diff --from-migrations ... --to-schema ...` is empty; Deck, QuizSet, ShareLink, and MaterialGroup queries succeed. -- Populated pre-group DB: seed classes/decks/quizzes/shares, apply the new migration, and prove every row/count/relationship is preserved. -- Disposable `db push`-style DB: introspect exact equivalence, resolve the migration, run deploy again, and prove an empty schema diff. -- Partial/conflicting DB: preflight exits nonzero without changing schema or `_prisma_migrations`. - -#### 1B. Make session configuration unforgeable and setup explicit - -1. Centralize session option construction so `src/lib/auth.ts` and `src/proxy.ts` cannot drift. -2. Require a nonblank, at-least-32-character production `SESSION_SECRET` at runtime. Do not require or bake the real secret during `docker build`. -3. Make the container entrypoint fail before migrations/server startup when the secret is absent or equals the known fallback. Make Compose use required-variable expansion. -4. Allow a development-only fallback only when `NODE_ENV !== "production"`, clearly label it non-production, and cover both branches with tests. -5. Replace `pathname.includes(".")` with exact public/static rules. Ensure every `/api/*` path other than the intentional auth endpoints remains protected even when the URL contains a dot or encoded dot. -6. Construct the unauthenticated redirect first and bind `getIronSession` to that response before `destroy()`, so the returned redirect carries the clearing cookie. -7. Honor a valid `ADMIN_PASSWORD_HASH` when the database has no configured password, or require an explicit one-time setup flag/token. The default production state must not let the first remote request select the admin password. -8. Prefer a local console/CLI initiation for password reset. If the HTTP request endpoint remains, use a global single-user throttle, never replace an unexpired token, rate-limit verify/complete, and do not trust arbitrary `x-forwarded-for` unless a trusted proxy is explicitly configured. - -Verification: - -- Production startup with missing, blank, short, or known fallback secret exits nonzero before serving. -- A cookie sealed with the old fallback does not authenticate when a real secret is configured. -- Valid login, logout, reset completion, and session-generation invalidation still work. -- Dot-containing protected page/API requests redirect or reject; known static assets still load. -- A stale-generation response contains a `Set-Cookie` deletion header. -- Pre-provisioned hash rejects a different first password and reports setup complete. - -#### 1C. Make the image and Compose definitions deterministic - -1. Add `.dockerignore` for `node_modules`, `.next`, VCS metadata, environment files, databases/data, coverage, temporary caches, and audit scratch artifacts while retaining source, Prisma schema/migrations, lockfile, and public assets. -2. Change the dependency stage to `npm ci`. -3. Remove `npm install prisma@^7.8.0` from the runner. Ship a Prisma CLI/runtime installed from `package-lock.json` at the exact repository version. If Prisma is needed at runtime, classify it as a runtime dependency and prune/copy dependencies deterministically rather than re-resolving them. -4. Map `3000:3726` in production Compose. Keep `PORT=3726` and `EXPOSE 3726` internally unless there is a deliberate decision to standardize everything on 3000. -5. Rename `docker-compose.override.yml` to a non-auto-merged development filename such as `docker-compose.dev.yml`. Document exact production and development invocations. -6. Add a minimal internal health route that performs schema-aware DB checks (including a query that touches `Deck.groupId` and MaterialGroup), returning no sensitive details. The container health check must call the actual internal port. -7. Add a lock-safe SQLite backup command using the SQLite backup API, plus a documented restore drill. A raw copy of only the main DB while WAL writes are active is not an acceptable backup procedure. - -Verification: - -- `docker compose -f docker-compose.yml config` shows the production command, production environment, and `3000:3726` only. -- The explicit development Compose command shows dev mode and source mounts. -- Build on a Windows-host context and run on Linux; requiring `better-sqlite3` and `argon2` succeeds in the final image. -- Fresh-volume container becomes healthy and `/login` returns 200 through host port 3000. -- A migration-drifted disposable volume stays unhealthy with an actionable log. -- Backup a populated disposable volume, destroy the disposable container/volume, restore it, and compare row counts plus representative content. - -#### 1D. Add CI gates in a sequence that can actually be green - -1. Gate `npm ci`, `npm test`, Prisma validate, migration/schema drift, and `npm run build` immediately. -2. Build the production image, start it on a fresh disposable volume with a generated CI secret, wait for health, and smoke `/login` plus setup status. -3. Tag immutable images by commit SHA; optionally move the mutable `latest` tag only after all gates pass. -4. Add strict lint only after the existing 9-error baseline is resolved in an explicit cleanup or after a changed-files lint gate is implemented. Never report baseline lint as passing. - -### Phase 2 — make quiz attempts authoritative and immutable - -Addresses FIN-03, FIN-11–13, FIN-20, FIN-26, FIN-27, and FIN-53. - -1. Replace the client-controlled `isPartialRetake` contract with an explicit ordered `questionIds` scope. The server validates that IDs are unique and belong to the quiz, then derives whether the attempt is partial. -2. Accept a structured answers object at the route boundary and persist only canonical server-serialized JSON. Validate that: - - each key is in the attempted scope; - - every selected option belongs to that question; - - selected IDs are unique; - - multiple-choice has at most one selection; - - missing answers score zero rather than silently disappearing. -3. Make the in-viewer “Retake Missed” and “Retake Full Quiz” set explicit local attempt scope. Do not infer retake state from the original prop after the viewer has transitioned internally. -4. Add an `isFinishing` state/ref, disable the Finish button during submission, and show a retryable error without discarding answers. -5. Move attempt creation and full-attempt SEQUENTIAL-progress deletion into one service transaction. Partial retakes must not overwrite or delete a full in-progress session. -6. Guard zero-correct SATA scoring with zero points and deduplicate selections defensively in the pure scorer even though the route also validates them. -7. Add an optional immutable review snapshot to QuizAttempt for new attempts: attempted question/order, prompt/category/rationale, options and correctness, selections, and per-question points. Render new history from that snapshot; keep a clearly tested legacy fallback for old rows. -8. Stabilize QuizViewer initialization. A parent render that changes topics or another surrounding control must not regenerate option order, clear answers, or reset the index. A deliberate restart/retake must use a new session key. - -Focused verification: - -- Full 5-question attempt scores out of 5 and clears only its matching full progress row. -- Two-question retake scores out of 2, is stored as partial, and leaves full progress untouched. -- An unanswered question in the explicit scope scores zero and remains in the review snapshot. -- Duplicate/unknown question or option IDs, wrong shapes, and client-forged partial flags return 400 without creating an attempt. -- Delayed double-click Finish produces exactly one attempt. -- Changing or deleting current quiz content after an attempt does not change the stored score, category breakdown, or review copy for snapshot-backed attempts. -- Parent rerender preserves question order, option order, current index, and answers; explicit restart changes only the intended session. - -### Phase 3 — make progress and flashcard resume race-safe - -Addresses FIN-04, FIN-14, FIN-15, FIN-21, FIN-34, and FIN-36. - -1. Replace or correct `filterAndClampOrder` with a normalization function that accepts saved order, live IDs, saved index, results/answers, and completion state. It must: - - remove stale and duplicate IDs; - - preserve the saved current ID when it still exists; - - when it was deleted, select the next surviving saved card, otherwise the previous survivor; - - preserve `index === oldOrder.length` as completion by returning `index === newOrder.length`; - - filter result/answer keys to live IDs; - - safely fall back when any JSON field is malformed. -2. Apply normalization before any viewer state initializer calls `JSON.parse`. Treat corrupt legacy progress as recoverable, show a small restore warning, and allow a fresh start. -3. Add Zod schemas for GET/PATCH/DELETE progress inputs, enum values, nonnegative index, content-specific JSON shape, and referenced content existence. -4. Add `sessionId` and monotonic `revision` to progress. The server accepts only a newer revision for the same session; a stale session cannot overwrite or delete a newer session. Serialize client saves but retain server-side revision enforcement because request completion order is not guaranteed. -5. On the last flashcard, persist `currentIndex = order.length`. Restoring that row must show the existing completion summary, not the last card. -6. Await restart deletion before remount/navigation. Make DELETE conditional on the session being cleared so a late old request cannot delete fresh progress. -7. Disable Previous, shuffle, and repeated grading during the 350 ms transition, and clear/cancel the timer on restart/unmount. - -Focused verification: - -- Stale ID before, at, and after the current index all resume on the correct logical card. -- All saved cards deleted yields a clear empty/completed state rather than a blank viewer. -- Completed sessions reopen on the summary; restart opens card 1. -- Invalid JSON and invalid PATCH payloads produce a safe UI fallback or 400, never a render crash/500. -- Deliver revisions 3, 1, and 2 in that order; the DB retains revision 3. -- Delay an old-session DELETE until after a new-session PATCH; the new session remains. -- Grade then immediately try Previous/restart; no index jump or stale result write occurs. - -### Phase 4 — enforce group, ordering, and sharing invariants on the server - -Addresses FIN-18, FIN-19, FIN-32, FIN-33, FIN-41, FIN-49, FIN-50, and FIN-52. - -1. Move reorder logic into focused services. The route must identify the owning class and content type from the database, not trust the client. -2. Validate every target group exists, belongs to the same class, and matches DECK/QUIZ type. Reject duplicate IDs, foreign-class IDs, type mismatches, and unknown IDs. -3. Prefer an ordered list/group assignment contract and compute contiguous `sortOrder` values server-side. If a complete-set contract is required, verify completeness explicitly. -4. In the library, capture origin group before any update, update state immutably, await `res.ok`, disable conflicting mutations in flight, and rollback/refetch on failure. -5. Delete a group in one transaction: capture affected items, delete/reassign through the FK behavior, then renumber Uncategorized items deterministically. Add `createdAt`/`id` tie-breakers to reads so legacy duplicates are stable. -6. Add a keyboard-accessible Move-to-group menu on every item. Preserve drag-and-drop for pointer users and keep drag listeners off action controls. -7. Validate ShareLink target type and target existence. Enforce exactly one populated target in service logic; consider CHECK constraints in a later SQLite migration after compatibility testing. -8. Retain the shared-page class/type/token checks and additionally assert each selected group item has the group’s class and expected content type. -9. Pass the share token/session namespace to shared viewers and include it in localStorage keys. Avoid copying an old item-only session into a different token namespace without explicit user confirmation. -10. Preserve named-group ordering as `sortOrder desc, createdAt desc`, prepend newly created groups locally, and render Uncategorized last. Remove arbitrary `sortOrder` from the ordinary group rename PATCH contract. -11. Add a concise ShareMenu warning that a shared quiz necessarily sends answer/rationale data to the recipient’s browser for local grading. - -Focused verification: - -- Cross-class and cross-type group assignment returns 400/409 and changes no rows. -- A failing reorder restores/refetches the visible order. -- Deleting a group yields unique contiguous Uncategorized item order and does not delete decks/quizzes. -- Newer named groups stay above older groups; Uncategorized remains last in quizzes, flashcards, and import selectors. -- A keyboard-only user can move an item between two groups and hear/see confirmation. -- Invalid share target types and missing IDs create zero rows. -- A tampered group share cannot render a foreign-class item. -- Two tokens for the same content maintain independent local sessions. - -### Phase 5 — validation, SRS integrity, and user-visible error handling - -Addresses FIN-16, FIN-28–31, FIN-35, FIN-37, FIN-43–48, and the measurable part of FIN-17. - -1. Reuse shared Zod schemas for card create/edit, deck/quiz create/edit, classes, imports, and material groups. Trim before minimum checks and add documented maximums for names, descriptions, Markdown content, options, questions, and cards. -2. Reject a Create-tab submission containing any partially filled card; identify the row(s) instead of silently dropping them. -3. Enforce at least two correct options for imported SATA questions while leaving existing stored questions readable. Add a targeted message explaining how to repair invalid generated JSON. -4. Narrow Prisma error mapping: P2025 becomes 404, P2002 becomes 409 where appropriate, validation is 400/422, and unexpected failures remain 500 with non-sensitive server diagnostics. -5. In SRS review, verify the submitted card is the queue-eligible card for the current set/time/new-card allowance (including deliberate learn-ahead). Retain the existing state-version comparison. Map concurrent first-review P2002 to 409 and return/refetch current study state. -6. Refresh SRS membership on focus and after same-tab deck changes. -7. Add abortable, checked fetch helpers or a small consistent pattern for Dashboard, GenerateTab, ShareMenu, logout, and class library requests. Distinguish loading, empty, error, and retry states. -8. Tie library responses to the active `classSlug`; abort or ignore stale responses. Bound or remove module-level caches that can outlive their class data. -9. Guarantee class slug generation produces a non-empty unique slug, for example a stable `class-` fallback. Keep existing slugs stable on rename. -10. Add `data/`, `study.db`, and sidecars to `.gitignore`. Confirm `out.css`, `temp.css`, and `test.css` are unreferenced scratch artifacts before removing them in a separate cleanup change. -11. For performance, first seed a disposable benchmark DB approximating expected upper use (for example 100k activity rows, 10k cards, and several SRS sets). Add date predicates/aggregation and narrower SRS queries only where the measured request/poll budget is exceeded. Preserve long-streak correctness when limiting the activity window. - -Focused verification: - -- Whitespace-only and oversized content is rejected with an actionable client error and no partial write. -- SATA 1-correct import fails; 2+-correct passes; multiple-choice still requires exactly one. -- SRS cannot review a not-due/non-selected/new-limit-exhausted card; valid due and learn-ahead reviews still work. -- Concurrent first review produces one success and one handled conflict, not a generic 500. -- Focus refresh removes a deleted deck from the SRS set UI. -- Failed dashboard/settings/share/logout requests have visible, retryable states and no unhandled promise rejection. -- Rapid A→B class navigation cannot render A data under B. -- Creating a class named only punctuation yields a navigable, unique slug; rename does not break the old URL. -- Performance tests record query counts and latency before/after and prove the 53-week display plus longer current streak remain correct. - -## Cross-cutting release verification - -Run after every phase, with focused tests first and the full suite last. - -Automated gates: - -1. `npm.cmd test` -2. `npx.cmd prisma validate` -3. migration deploy + empty migration/schema diff against a fresh disposable DB whenever schema changes -4. `npm.cmd run build` -5. `git diff --check` -6. production image build, fresh-volume health wait, and HTTP smoke when Docker/deployment files change -7. lint against changed files; full `npm.cmd run lint` only becomes blocking after the known baseline is cleared - -Manual browser matrix for affected phases: - -1. Authenticated routes and corresponding public/shared routes. -2. Fresh, resumed, completed, restarted, partial-retake, and stale/deleted-content sessions. -3. Loading, empty, server-error, malformed-persisted-data, and retry states. -4. Light and dark themes. -5. Narrow mobile viewport with no horizontal overflow. -6. Keyboard-only navigation, including group moves and all modified icon controls. -7. Throttled-network checks for Finish, autosave, restart, class navigation, reorder, and logout. -8. Two-tab checks for progress revisions, SRS conflicts, and focus refresh. - -Release/rollback rules: - -- Back up and restore-test the SQLite database before the first migration-bearing release. -- Deploy schema and code as one versioned release; do not run a newer generated Prisma client against an older database. -- Keep the prior image and verified pre-migration backup until post-deploy smoke and representative data checks pass. -- If migration preflight sees a partial or unknown schema state, stop. Do not run `db push`, edit an applied migration, reset the database, or auto-mark the migration applied. - -## Explicitly deferred or rejected work - -- **FIN-38:** no timezone-setting work without a product requirement to support a non-Arizona study day. -- **FIN-42:** no history rewrite without an offline sensitivity review and explicit remote/clone coordination. -- **FIN-51:** no cosmetic state-initialization change unless it avoids both hydration mismatch and the existing effect-rule class of lint failures. -- **FIN-22 retry proposal:** no automatic migration retry/backoff for schema conflicts; fail fast with health diagnostics and the adoption/recovery runbook. -- No changes to the audit’s already rejected hypotheses (global API auth absence, split-brain SQLite paths, broken cascades/transactions, Markdown XSS, or client/server import violations) unless new evidence appears. - -## Recommended delivery slices - -Keep the implementation reviewable rather than landing all remediation at once: - -1. **Release blocker:** FIN-01/02/05/06/07/23/25 plus migration adoption, container smoke, and backup prerequisites. -2. **Quiz correctness:** FIN-03/11/12/13/20/26/27/53. -3. **Progress integrity:** FIN-04/14/15/21/34/36. -4. **Group/share integrity:** FIN-18/19/32/33/41/49/50/52. -5. **Auth recovery and deployment posture:** FIN-08/09/10/24/39/40. -6. **Validation, SRS, UI resilience, and measured performance:** the remaining accepted items. - -Each slice should be independently releasable, have its own focused regression tests, and finish with the cross-cutting gates above. diff --git a/audit-results/SUBAGENT_REPORTS.md b/audit-results/SUBAGENT_REPORTS.md deleted file mode 100644 index 7fe4973..0000000 --- a/audit-results/SUBAGENT_REPORTS.md +++ /dev/null @@ -1,20 +0,0 @@ -# Subagent Reports - -Full specialist reports were produced by 10 read-only subagents. Each returned a structured markdown report with a findings table and per-finding detail blocks (ID, severity, confidence, files:lines, scenario, expected/actual, root cause, impact, evidence, test coverage, fix direction, complexity, regression test, runtime-confirmation flag). The parent agent re-verified the Critical/High claims directly (see VERIFICATION_LOG.md) and consolidated/deduplicated into `FINDINGS.json` (53 findings). - -## Report index (all reports were reviewed in full by the parent) - -| # | Auditor | Ref | Key output | -|---|---|---|---| -| 1 | DB / migrations / transactions / cascades / drift | sa_20260806_054030_000000000_5ed877563a0d | **DBAUD-01 Critical migration drift** (MaterialGroup/groupId never migrated; commit 7af0935 db-push artifact); DBAUD-02 junk ShareLink rows; DBAUD-03 dev.db in git history; verified-safe: cascades, transactions, path resolution, single-row AuthSecurity | -| 2 | Quiz scoring / attempts / retakes / progress | sa_20260806_054030_000000000_e75a8ce575f9 | **QUIZ-01 High retake-scoring bug** (in-viewer retake never sets retakeIds); QUIZ-02..09 (unvalidated answersJson, double-submit, progress races, dead filter, div-by-zero, historical recompute, multi-tab) | -| 3 | Flashcards / SRS / progress / deletion / ordering | sa_20260806_054030_000000000_a222804c133c | CARD-01..11: dead stale-id filter (stuck sessions), unsequenced saves, completed-session resume, SRS due/limit not enforced, unbounded scans, Arizona day boundary, restart race, P2002, animation race, focus staleness | -| 4 | Auth / protected routes / API authz / sharing | sa_20260806_054030_000000000_aab0c59dbad3 | **AUTH-01 Critical hardcoded session secret**; AUTH-02 dot-bypass; AUTH-03 reset abuse; AUTH-04 first-login takeover + dead ADMIN_PASSWORD_HASH; AUTH-05..10 (cookie-clear loss, cross-class group membership, share validation, secure flag, no auth tests, answer-key exposure); verified proxy compiled as middleware | -| 5 | Imports / exports / generation / Zod / malformed input | sa_20260806_054030_000000000_5e72c2713989 | IMPT-01..06: dead filterAndClampOrder, unvalidated /api/progress + unguarded JSON.parse, card edit endpoints without Zod, no size caps, SATA doc mismatch, override-field 500s; verified: shared schemas, atomic imports, no XSS, no id regeneration | -| 6 | Material groups / ordering / drag-drop / deletion / orphans | sa_20260806_054030_000000000_9ae1f3dfa282 | **GRP-01 Critical drift** (independent confirmation); GRP-02..07: unvalidated groupId/sortOrder, fire-and-forget reorder, sortOrder duplicates after group deletion, keyboard a11y, desc-convention fragility; verified: FKs enforced, uncategorized mapping, cascade safety | -| 7 | Frontend state / refresh / races / localStorage / boundaries | sa_20260806_054030_000000000_0e17fd155702 | FE-01..11: optimistic reorder without rollback, CRUD res.ok ignored, shared quiz reshuffle (unstable prop), finish double-submit, progress race, **FE-06 (later refuted — proxy exists)**, dashboard fetch gap, unguarded JSON.parse, effect fetches, cross-class race, collapse flash; verified: shared viewers read-only, localStorage clean, no boundary violations | -| 8 | Test quality / coverage / false confidence | sa_20260806_054030_000000000_cd3652deb3f7 | TEST-01..10: false-confidence SRS test, zero tests for scoring/auth/services/routes, no DB harness, CI never runs tests; ranked missing-test gaps by impact | -| 9 | Docker / startup / env / scripts / production / recovery | sa_20260806_054030_000000000_f49cdaacaabf | OPS-01..10: port mismatch 3726 vs 3000:3000, entrypoint fragility, session secret in prod path, dead ADMIN_PASSWORD_HASH, non-reproducible installs, no .dockerignore (win32 leak), no healthcheck/backup, override auto-merge, .gitignore gaps, CI without gates; argon2/standalone tracing verified OK | -| 10 | Adversarial review | sa_20260806_055608_000000000_c550c742d5dc | Challenged all Critical/High findings: confirmed drift, secret, retake, port, dockerignore, override; weakened OPS-02 (fresh-volume migrate succeeds) and FE-01 severity; **refuted FE-06** (proxy active — verified in compiled .next artifacts); added ADV-01..10 (retake progress pollution, activity scan, logout, dashboard crash, slugify empty slug, unguarded parses, completed-resume, localStorage key collisions, first-login race, progress validation) | - -All reports are preserved in full in the session; their findings are consolidated (deduplicated, source IDs preserved) in `FINDINGS.json` and `CONFIRMED_FINDINGS.md`. diff --git a/audit-results/TEST_GAPS.md b/audit-results/TEST_GAPS.md deleted file mode 100644 index b77408b..0000000 --- a/audit-results/TEST_GAPS.md +++ /dev/null @@ -1,21 +0,0 @@ -# Test Gaps - -Current suite: 6 files / 30 tests — 20 in `src/lib/arcade/*` (out of scope) + 10 in `src/lib/spacedRepetition.test.ts` (pure scheduling math only). **No test touches a service, an API route, the database, auth, scoring, imports, or progress.** CI never runs `npm test`. Gaps ranked by user-impact × regression-likelihood. - -| ID | Area | Gap | Risk it would fail to catch | Suggested test | -|---|---|---|---|---| -| TEST-GAP-01 | Auth (FIN-02/08/09/10) | `authService.ts` (login, lockout, first-login provisioning, reset token, generation bump) and session config have zero tests | Session-secret fallback regression, lockout bypass, generation-bump loss (stolen sessions stay valid) | Extract + unit-test `getLockoutDuration` thresholds, `parseResetTokenRecord`; service tests with temp SQLite + fake timers: 5 wrong passwords → 423; locked-out login rejected; success resets counter; `completePasswordReset` bumps generation | -| TEST-GAP-02 | Scoring (FIN-03/11/26/27) | `src/lib/scoring.ts` SATA partial credit has zero tests | Wrong grades silently persisted into every attempt — permanent data corruption | `scoreQuestion`: SATA 2/4 correct select both → 1; 1 correct + 1 wrong → 0; over-select → 0; MC multi-select first-wrong → 0; `scoreQuiz` with missing question keys; 0-correct SATA → 0 (not NaN) | -| TEST-GAP-03 | Quiz attempt persistence (FIN-03/12/13) | `POST /api/quizzes/[id]/attempt` + partial-retake filter + `progressService.upsertProgress` asymmetry (DECK writes cardResultsJson, QUIZ writes answersJson) have zero tests | Retake-scoring regression (FIN-03 class), duplicate attempts, progress column clobbering | Route/service test: partial retake answering 2 of 5 → maxScore 2, score from those 2; upsert round-trip preserves the respective JSON columns | -| TEST-GAP-04 | SRS service (FIN-16/35) | `spacedRepetitionService.ts` (reviewCard optimistic concurrency, findNextCard precedence, removeDeck transaction, addDeck validations) has zero tests — only the pure lib is tested | Double-review stale write, due/new-card precedence break, cross-class membership, P2002 → 500 | Seed class→deck→cards→set on temp DB: stale `expectedStateVersion` → 409; due before new before learn-ahead; new card with limit 0 rejected; removeDeck atomically deletes states | -| TEST-GAP-05 | Import/repair pipeline (FIN-28/29/30) | `jsonRepair.ts`, `importSchemas.ts`, `shuffle.ts`/`filterAndClampOrder` have zero tests | AI-import regression (fences/repair), schema constraint drift (MC exactly-one), resume clamp bugs | jsonRepair fence/trailing-comma/broken-JSON cases; schema whitespace/oversize/exactly-one cases; `filterAndClampOrder` stale-id + overflow cases | -| TEST-GAP-06 | Rate limiter (FIN-10) | `rateLimiter.ts` has zero tests | Brute-force guard silently disabled or locking the owner out | Fake timers: 10 allowed / 11th blocked with retryAfterMs; window expiry; per-key isolation; custom limits | -| TEST-GAP-07 | Integration harness (FIN-01/21/25) | No route tests, no DB-backed tests, no setup file; `src/lib/db.ts` defaults to `file:./dev.db` (a naive service test would touch the real dev DB) | Migration drift (FIN-01) — nothing would catch a schema/migration mismatch; progress/share/attempt route validation gaps | `setupFiles` + `DATABASE_URL=file:` + `prisma migrate deploy` in globalSetup; route tests with `NextRequest`: attempt 200/400/404, reviews 409, share unknown token, login 429 | -| TEST-GAP-08 | CI gate (FIN-25) | `.forgejo/workflows/build.yml` runs no tests/lint/prisma validate; no coverage config; no drift check | Entire suite can regress with a green pipeline; FIN-01-class drift ships | Add `npm ci && npm test && npm run lint && npx prisma validate` job; `prisma migrate diff --from-migrations --to-schema-datamodel` gate; container smoke (curl /login on fresh volume); coverage floor | -| TEST-GAP-09 | False confidence (FIN-53) | `spacedRepetition.test.ts:15-23` "distinct valid state for every rating" never compares ratings | `scheduleRating` collapsing all ratings to one schedule passes the suite | Assert pairwise due ordering AGAIN < HARD < GOOD < EASY and distinct due timestamps; graduated-state lapse case | - -## Test-quality positives (verified) - -- `spacedRepetition.test.ts` is deterministic (fixed `now`, `enable_fuzz: false`, no `Math.random`), tests the real pure module, and asserts exact FSRS intervals. -- Arcade tests (out of scope) use seeded shuffles and meaningful assertions. -- No test uses wall-clock time, locale, or network — no flakiness found. diff --git a/audit-results/UNVERIFIED_RISKS.md b/audit-results/UNVERIFIED_RISKS.md deleted file mode 100644 index 371613b..0000000 --- a/audit-results/UNVERIFIED_RISKS.md +++ /dev/null @@ -1,35 +0,0 @@ -# Unverified Risks - -Risks that could not be fully confirmed within audit constraints (need a browser, a container run, a live deployment, or timing-dependent reproduction). All are code-verified as plausible; the missing piece is runtime confirmation. - -| ID | Title | Why unverified | Suggested verification | -|---|---|---|---| -| FIN-02 | Hardcoded fallback `SESSION_SECRET` → session forgery | Code path fully traced (seal/verify semantics verified in iron-session), but no live production deployment exists here to demonstrate an actual forged-cookie login | Boot production build without `SESSION_SECRET`, forge a cookie with the fallback password (iron-webcrypto seal script), assert full access; then with a real secret assert rejection | -| FIN-06 | No `.dockerignore` → win32 modules in Linux image | Mechanics verified (PE32+ DLL present in local node_modules; Dockerfile COPY order), exact failure mode (build error vs `ERR_DLOPEN_FAILED`) not observed | Run `docker build .` on this Windows host; inspect `better_sqlite3.node`/argon2 prebuilds in the image (must be ELF) | -| FIN-13 | Progress PATCH-after-DELETE race | Timing-dependent; needs throttled network | DevTools throttling: submit final answer + Finish immediately; inspect `StudyProgress` after | -| FIN-14 | Out-of-order progress PATCHes | Timing-dependent | DevTools throttling with rapid grading; reload and compare resume point/results | -| FIN-22 | Entrypoint migrate crash loop / runner CLI install | Requires container build + run on `node:22-slim` (docker CLI unavailable in audit environment) | `docker build` + fresh-volume run (assert 200 + `_prisma_migrations`); corrupt-volume run (assert actionable failure, not silent loop) | -| FIN-24 | Healthcheck/backup gaps | Behavior observable only in a real container run | Compose up with a corrupt volume; observe restart loop and absence of health status | -| FIN-32 | Group-delete sortOrder duplicates | Deterministic from code; visual impact needs a browser | Delete a group in the app, inspect Uncategorized order + DB sortOrders | -| FIN-33 | Keyboard cross-group moves impossible | Deterministic from code (empty `handleDragOver`), needs manual keyboard test | Tab to a drag handle, attempt keyboard cross-group move | -| FIN-34 | Restart DELETE race | Timing-dependent | Throttled network: Restart, grade first card, inspect progress row | -| FIN-36 | Previous-card animation race | Timing-dependent (350 ms window) | Grade then immediately click Previous; observe index jump | -| FIN-37 | SRS page stale on focus | Deterministic from code; browser needed to observe | Two tabs: delete deck in one, focus the other | -| FIN-39 | Proxy destroy cookie lost on redirect | Deterministic from code; browser needed to observe Set-Cookie | Stale-generation cookie → follow redirect → inspect response headers | -| FIN-40 | Secure cookie flag off | Deterministic from code (flag logic); deployment-dependent | Inspect `Set-Cookie` in a production container | -| FIN-46 | Cross-class fetch race | Timing-dependent | Throttled network + fast class switching | -| FIN-48 | Empty slug / stale slug on rename | Deterministic from code (slugify verified); UI behavior needs browser | Create class named "!!!", try to navigate to it; rename a class, check old URL | -| FIN-49 | Shared-viewer localStorage key collisions | Code-verified key construction; symptom needs browser | Open two share tokens for the same content, answer in one, reload the other | -| FIN-51 | Collapsed-groups expand flash | Cosmetic; browser-only | Visit library with saved collapsed state | - -## Items verified safe (hypotheses disproven — recorded for completeness) - -- SQLite relative-path split-brain between Prisma CLI and app (both cwd-relative → same `dev.db`; Docker uses absolute path) — verified safe. -- Foreign-key enforcement (better-sqlite3 compiled with `SQLITE_DEFAULT_FOREIGN_KEYS=1`) — cascades/SetNull fire; verified safe. -- Transactionality of deck/quiz/attempt/reorder writes — nested creates + `$transaction`; verified safe. -- `StudyProgress`/`ShareLink` NULL-uniqueness — service-level guards prevent duplicates via app paths; only direct API misuse (FIN-41/FIN-21) can create junk rows. -- Client/server boundary violations — none found (no client import of `@/lib/db`/services/prisma). -- XSS via Markdown — react-markdown without `rehype-raw`; no `dangerouslySetInnerHTML` on user data; verified safe. -- Password-reset nonce/token crypto (192-bit token, SHA-256 digest, `timingSafeEqual`, expiry) — sound; abuse vectors are FIN-10. -- SRS optimistic concurrency (`expectedStateVersion` → 409, rollback via refetch) — correct. -- Migrations are purely additive; no NOT NULL-without-default, no DROP — safe on populated migration-tracked DBs (except FIN-01 drift). diff --git a/audit-results/VERIFICATION_LOG.md b/audit-results/VERIFICATION_LOG.md deleted file mode 100644 index da881a2..0000000 --- a/audit-results/VERIFICATION_LOG.md +++ /dev/null @@ -1,38 +0,0 @@ -# Verification Log - -Chronological record of every command run, its outcome, and classification (baseline / environment / possible defect). - -## Environment notes - -- OS: Windows, shell: bash (git-bash) -- `node_modules` was **absent** at audit start → `npm test` initially failed with `'vitest' is not recognized`. This is an environment condition, not a repo defect. `npm ci` completed successfully during the first audit session (518 packages present). -- No existing application database (`dev.db*`) exists anywhere in the repo — no real user data to protect; the migration-chain test uses a brand-new temp DB under `audit-results/tmp`. -- `src/generated/prisma/` (tracked) hashed BEFORE any prisma command: `audit-results/tmp/generated-before.sha256`. - -## Entries - -| # | Time | Command | Result | Classification | -|---|---|---|---|---| -| 1 | session start | `git status --short` | clean; only `?? .reasonix/` and `?? audit-results/` untracked | baseline | -| 2 | session start | `npm test` (first run, prior session) | FAILED: `'vitest' is not recognized` (no node_modules) | environment | -| 3 | session start | `npm ci` (background, prior session) | completed; 518 packages in node_modules | environment | -| 4 | 22:36 | `npx prisma validate` | PASS — "The schema at prisma\schema.prisma is valid" (exit 0) | baseline | -| 5 | 22:36 | `npm test` | PASS — 6 files, 30 tests, all green (arcade ×20, spacedRepetition ×10) | baseline | -| 6 | 22:37 | `npm run lint` | 28 problems: 9 errors, 19 warnings (react-hooks/set-state-in-effect errors + unused-vars warnings). All pre-existing; matches AGENTS.md known baseline. | baseline | -| 7 | 22:37 | `npm run build` (background) | PASS — route table emitted, no compile errors | baseline | -| 8 | 22:38 | `DATABASE_URL=file:./audit-results/tmp/audit-migration.db npx prisma migrate deploy` | PASS — all 4 migrations applied to brand-new temp DB | baseline | -| 9 | 22:38 | `sha256sum -c audit-results/tmp/generated-before.sha256` | PASS — every generated Prisma file byte-identical (all ": OK") | baseline | -| 10 | 22:38 | `git status --short` | only untracked `.reasonix/`, `audit-results/`; no tracked modifications | baseline | -| 11 | 22:38 | `git diff --check` | clean (no whitespace errors) | baseline | -| 12 | 22:40 | `git log --all --oneline -- prisma/migrations/` + `git show 7af0935 --stat` | Confirms drift: commit 7af0935 changed `prisma/schema.prisma` (+31) and `dev.db` (139264→155648 bytes) with **no migration**; `dev.db` present in 8 commits of history | possible defect (FIN-01, FIN-42) | -| 13 | 22:41 | `node audit-results/tmp/inspect-db.cjs audit-results/tmp/audit-migration.db` | Temp DB (built solely by `migrate deploy`) has **no** `MaterialGroup` table and **no** `groupId` column on Deck/QuizSet/ShareLink | possible defect (FIN-01) | -| 14 | 22:42 | `node audit-results/tmp/reproduce-drift.cjs` | Prisma-shaped queries fail: `no such column: Deck.groupId`, `no such table: MaterialGroup`, `no such column: ShareLink.groupId` — fresh-deploy breakage proven | possible defect (FIN-01) | -| 15 | 22:45 | `node --check audit-results/tmp/inspect-db.cjs && node --check audit-results/tmp/reproduce-drift.cjs` | diagnostic scripts syntax-valid | baseline | -| 16 | 22:50 | `node audit-results/tmp/validate-findings.cjs` | `FINDINGS.json` VALID JSON — 53 findings: 2 Critical, 5 High, 18 Medium, 25 Low, 3 Observation; no duplicate ids | baseline | -| 17 | 23:00 | `git status --short` + `git diff --check` (final) | Only untracked `.reasonix/`, `audit-results/`; no tracked file modified; diff clean | baseline | - -## Classification legend - -- **baseline** — repository behaves as expected; recorded for reference. -- **environment** — failure caused by local machine state, not repo code. -- **possible defect** — outcome may indicate a repo defect; investigated further in findings. diff --git a/audit-results/tmp/audit-migration.db b/audit-results/tmp/audit-migration.db deleted file mode 100644 index 49fb7a2..0000000 Binary files a/audit-results/tmp/audit-migration.db and /dev/null differ diff --git a/audit-results/tmp/generated-before.sha256 b/audit-results/tmp/generated-before.sha256 deleted file mode 100644 index ec530fd..0000000 --- a/audit-results/tmp/generated-before.sha256 +++ /dev/null @@ -1,26 +0,0 @@ -cb737289f5b5f6cdb8b90fc23d2c89ebd41d6602ce99e88bf6dfd751beab7103 *src/generated/prisma/browser.ts -2ea331124cc56c9c9a360b4a85f42f029992811ece7efe9db0ec1300a43f59c3 *src/generated/prisma/client.ts -f1f6280ff65c1e8aab5ce30307c8777ce1795f81bf1a513396263fc1c9a30e5c *src/generated/prisma/commonInputTypes.ts -8bcc37ae19ee9c55424b735ce5f3a0972b7e741958ef86b350c9a54dc2238a0d *src/generated/prisma/enums.ts -d52628a3dc8285d9014e2fda489badce6c88604810e2975310ab6c40ec3c708d *src/generated/prisma/internal/class.ts -91794eb9b3c395e57b96d18f2bae895149f7b70ddf0fda8b817e7aa9da17a950 *src/generated/prisma/internal/prismaNamespace.ts -6645109792ed08507d79da319aaecc2752bffa6e8bd5a3364dccc5ae93e2f23c *src/generated/prisma/internal/prismaNamespaceBrowser.ts -b8ffef1ad4428179847aafe66abee3ad5eb74a84fff46265532551b10ab2537d *src/generated/prisma/models.ts -daecbb1c94a96d19da4accfdf81fe8f11fa73c543958016bc7d6060d454f3943 *src/generated/prisma/models/AnswerOption.ts -87e18727cec17a341d32709bc0c1602c66e6c6dcc57672b9c9fbe38a6ce12801 *src/generated/prisma/models/ArcadeAttempt.ts -0c9de62bdbefafe8b5ff5300d1d764cc73235a077f8e3b3e03e6144287610c9d *src/generated/prisma/models/ArcadePack.ts -67b8ea01f0a866d14db7fdff3178a080e2879990129c5e08690463fb9940f48d *src/generated/prisma/models/AuthSecurity.ts -6885465a57019e8cba28755d9c6abaa087e913d666d02d8912abf0790c41a77b *src/generated/prisma/models/Class.ts -ea873177511c6f7354dc01ec0ad228d9f777bca2fbfb15d281d3f2ab8f817ebf *src/generated/prisma/models/Deck.ts -1082ff29bec6e4f920c93d778032ffffa324c312b05efd6c5fa7ada1be6674f7 *src/generated/prisma/models/Flashcard.ts -de94c2da405c08a0b690aeb1b0cdc03582c94ccddaf913f4c6d1b18d8c413b61 *src/generated/prisma/models/MaterialGroup.ts -941998d2c098e9fe5cd0a888bac470190108238c936c1d7cfce2742ed1e40d8a *src/generated/prisma/models/Question.ts -3d54ed93e516f4d773c200b166a84ab2c9c8b7b0d7f7ddfd61afc3ca057ee755 *src/generated/prisma/models/QuizAttempt.ts -454f19f97a82b8f37fc5c5179e87afe6ea185a79810043471d55c0a8ca26f14a *src/generated/prisma/models/QuizSet.ts -3b884aaaad311e8f22c1390cf24ebe69b74b52a97c213781d2a4cbb56288b8a7 *src/generated/prisma/models/Setting.ts -1ccf32b2f56b1d35c9b7152d2e1817b4c1b7853549a1ff187e9fb7c29fa49f2a *src/generated/prisma/models/ShareLink.ts -cba950d431c702afe67f3ac92d8397275d3aeeebbc59d357fd20dec2123e0ee2 *src/generated/prisma/models/SpacedRepetitionCardState.ts -2ef43d1c5d7e2eb0d85080fb29703ae99c1704ba8f5200703e2bb8e7a617bf04 *src/generated/prisma/models/SpacedRepetitionSet.ts -0bf9eab9f937ef2d621ccf0cea8e209eae9372a9b333dfe6b18267e563a6b2b0 *src/generated/prisma/models/SpacedRepetitionSetDeck.ts -916bda8f95647f0dc3560bc6848c4a97d4c1f37fd5ed54e6736d9d7d45e08adc *src/generated/prisma/models/StudyActivity.ts -36724157e76b49df49e7827eacbcb0ab8336917f35913777701d8a6830770500 *src/generated/prisma/models/StudyProgress.ts diff --git a/audit-results/tmp/inspect-db.cjs b/audit-results/tmp/inspect-db.cjs deleted file mode 100644 index 7e4af48..0000000 --- a/audit-results/tmp/inspect-db.cjs +++ /dev/null @@ -1,52 +0,0 @@ -// Read-only diagnostic: introspect the temp DB created by `prisma migrate deploy` -// to check whether the migrated schema matches schema.prisma (drift check). -// Usage: node audit-results/tmp/inspect-db.cjs -const path = require("node:path"); -const Database = require("better-sqlite3"); - -const dbPath = process.argv[2]; -if (!dbPath) { - console.error("usage: node inspect-db.cjs "); - process.exit(2); -} - -const db = new Database(path.resolve(dbPath), { readonly: true }); - -const tables = db - .prepare("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") - .all() - .map((r) => r.name); - -console.log("=== TABLES ==="); -console.log(tables.join(", ")); -console.log("Has MaterialGroup table:", tables.includes("MaterialGroup")); - -for (const table of ["Deck", "QuizSet", "ShareLink", "MaterialGroup"]) { - if (tables.includes(table)) { - const cols = db - .prepare(`PRAGMA table_info('${table}')`) - .all() - .map((r) => r.name); - console.log(`--- ${table} columns ---`); - console.log(cols.join(", ")); - if (table === "Deck" || table === "QuizSet" || table === "ShareLink") { - console.log(` -> has groupId:`, cols.includes("groupId")); - } - } else { - console.log(`--- ${table}: MISSING TABLE ---`); - } -} - -const indexes = db - .prepare("SELECT name, tbl_name FROM sqlite_master WHERE type='index' ORDER BY name") - .all() - .map((r) => `${r.tbl_name}.${r.name}`); -console.log("=== INDEXES (count) ===", indexes.length); - -// Confirm migrations table -const applied = db.prepare("SELECT migration_name FROM _prisma_migrations ORDER BY started_at").all(); -console.log("=== APPLIED MIGRATIONS ==="); -applied.forEach((m) => console.log(" -", m.migration_name)); - -db.close(); -console.log("DONE"); diff --git a/audit-results/tmp/reproduce-drift.cjs b/audit-results/tmp/reproduce-drift.cjs deleted file mode 100644 index ba3d99f..0000000 --- a/audit-results/tmp/reproduce-drift.cjs +++ /dev/null @@ -1,40 +0,0 @@ -// Read-only diagnostic: execute the exact SQL shape the generated Prisma client -// emits for deck.findMany() / materialGroup.findMany() against a DB built purely -// from `prisma migrate deploy` (predev + Docker entrypoint flow). -// Proves DBAUD-01: migrated DB is incompatible with the shipped client. -const path = require("node:path"); -const Database = require("better-sqlite3"); - -const dbPath = path.resolve("audit-results/tmp/audit-migration.db"); -const db = new Database(dbPath, { readonly: true }); - -// 1. Shape of prisma.deck.findMany({ include: { class: true } }) — Prisma selects -// every scalar field, including groupId (schema.prisma:33-34, generated client). -const deckSql = - 'SELECT "Deck"."id", "Deck"."classId", "Deck"."name", "Deck"."description", "Deck"."sortOrder", "Deck"."createdAt", "Deck"."groupId" FROM "Deck"'; -try { - db.prepare(deckSql).all(); - console.log("deck.findMany SQL: OK"); -} catch (e) { - console.log("deck.findMany SQL: FAILED ->", e.message); -} - -// 2. Shape of prisma.materialGroup.findMany() -const groupSql = 'SELECT "MaterialGroup"."id", "MaterialGroup"."classId", "MaterialGroup"."name", "MaterialGroup"."type", "MaterialGroup"."sortOrder", "MaterialGroup"."createdAt" FROM "MaterialGroup"'; -try { - db.prepare(groupSql).all(); - console.log("materialGroup.findMany SQL: OK"); -} catch (e) { - console.log("materialGroup.findMany SQL: FAILED ->", e.message); -} - -// 3. Shape of prisma.shareLink.findFirst() (share page validation) -const shareSql = 'SELECT "ShareLink"."id", "ShareLink"."targetType", "ShareLink"."deckId", "ShareLink"."quizSetId", "ShareLink"."groupId", "ShareLink"."createdAt" FROM "ShareLink" LIMIT 1'; -try { - db.prepare(shareSql).all(); - console.log("shareLink.findFirst SQL: OK"); -} catch (e) { - console.log("shareLink.findFirst SQL: FAILED ->", e.message); -} - -db.close(); diff --git a/audit-results/tmp/validate-findings.cjs b/audit-results/tmp/validate-findings.cjs deleted file mode 100644 index ffc724a..0000000 --- a/audit-results/tmp/validate-findings.cjs +++ /dev/null @@ -1,27 +0,0 @@ -// Validates audit-results/FINDINGS.json parses and reports counts per severity. -const fs = require("node:fs"); -const path = require("node:path"); - -const file = path.resolve("audit-results/FINDINGS.json"); -const raw = fs.readFileSync(file, "utf8"); -const data = JSON.parse(raw); // throws if invalid - -const findings = data.findings; -const bySeverity = {}; -for (const f of findings) { - bySeverity[f.severity] = (bySeverity[f.severity] || 0) + 1; -} -const byConfidence = {}; -for (const f of findings) { - byConfidence[f.confidence] = (byConfidence[f.confidence] || 0) + 1; -} - -const ids = findings.map((f) => f.id); -const dupes = ids.filter((id, i) => ids.indexOf(id) !== i); -if (dupes.length) throw new Error("duplicate ids: " + dupes.join(",")); - -console.log("FINDINGS.json: VALID JSON"); -console.log("total findings:", findings.length); -console.log("by severity:", JSON.stringify(bySeverity)); -console.log("by confidence:", JSON.stringify(byConfidence)); -console.log("ids:", ids.join(", ")); diff --git a/benchmarks/remediationBenchmark.test.ts b/benchmarks/remediationBenchmark.test.ts deleted file mode 100644 index 73657c3..0000000 --- a/benchmarks/remediationBenchmark.test.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { performance } from "node:perf_hooks"; -import path from "node:path"; -import Database from "better-sqlite3"; -import { afterAll, describe, expect, it } from "vitest"; -import { prisma } from "@/lib/db"; -import { getActivitySummary } from "@/services/activityService"; -import { listClasses } from "@/services/classService"; -import { getStudyAvailabilityByClass } from "@/services/spacedRepetitionService"; - -const ACTIVITY_ROWS = 100_000; -const CARD_ROWS = 10_000; -const SETS = 5; -const REQUEST_BUDGET_MS = 250; -const NOW = new Date("2026-08-07T18:00:00.000Z"); - -afterAll(async () => { - await prisma.$disconnect(); -}); - -function databasePath() { - const url = process.env.DATABASE_URL; - if (!url?.startsWith("file:./")) { - throw new Error("Benchmark requires the disposable Vitest database"); - } - return path.resolve(process.cwd(), url.slice("file:".length)); -} - -function seedRealApplicationSchema() { - const database = new Database(databasePath()); - try { - database.pragma("foreign_keys = ON"); - const insertActivity = database.prepare( - 'INSERT INTO "StudyActivity" ("id", "type", "occurredAt") VALUES (?, ?, ?)' - ); - const insertDeck = database.prepare( - 'INSERT INTO "Deck" ("id", "classId", "name", "sortOrder") VALUES (?, ?, ?, ?)' - ); - const insertSet = database.prepare( - 'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "newCardsPerDay", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?, ?)' - ); - const insertMembership = database.prepare( - 'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)' - ); - const insertCard = database.prepare( - 'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)' - ); - const insertState = database.prepare(` - INSERT INTO "SpacedRepetitionCardState" - ("id", "setId", "flashcardId", "due", "stability", "difficulty", "elapsedDays", "scheduledDays", "learningSteps", "reps", "lapses", "state", "firstReviewedAt", "lastReview", "createdAt", "updatedAt") - VALUES (?, ?, ?, ?, 1, 5, 1, ?, 0, 1, 0, ?, ?, ?, ?, ?) - `); - database.transaction(() => { - database.prepare( - 'INSERT INTO "Class" ("id", "slug", "name", "sortOrder") VALUES (?, ?, ?, 0)' - ).run("benchmark-class", "benchmark-class", "Benchmark Class"); - - for (let index = 0; index < ACTIVITY_ROWS; index += 1) { - const ageDays = index % 730; - const type = index % 2 === 0 ? "FLASHCARD" : "QUIZ_QUESTION"; - insertActivity.run( - `activity-${index}`, - type, - NOW.getTime() - ageDays * 86_400_000 - ); - } - - for (let setIndex = 0; setIndex < SETS; setIndex += 1) { - const deckId = `benchmark-deck-${setIndex}`; - const setId = `benchmark-set-${setIndex}`; - insertDeck.run(deckId, "benchmark-class", `Deck ${setIndex}`, setIndex); - insertSet.run(setId, "benchmark-class", `Set ${setIndex}`, 30, setIndex, NOW.getTime()); - insertMembership.run(setId, deckId, 0); - for (let cardIndex = setIndex; cardIndex < CARD_ROWS; cardIndex += SETS) { - const cardId = `benchmark-card-${cardIndex}`; - const due = NOW.getTime() + ((cardIndex % 200) - 100) * 60_000; - const firstReviewed = NOW.getTime() - (cardIndex % 30) * 86_400_000; - const scheduledDays = cardIndex % 4 === 0 ? 1 : 0; - const state = cardIndex % 4; - insertCard.run(cardId, deckId, `Front ${cardIndex}`, `Back ${cardIndex}`, cardIndex); - insertState.run( - `benchmark-state-${cardIndex}`, - setId, - cardId, - due, - scheduledDays, - state, - firstReviewed, - firstReviewed, - firstReviewed, - NOW.getTime() - ); - } - } - })(); - } finally { - database.close(); - } -} - -async function measure(label: string, operation: () => Promise) { - const start = performance.now(); - const value = await operation(); - return { - label, - latencyMs: Number((performance.now() - start).toFixed(2)), - value, - }; -} - -describe("remediation performance condition", () => { - it("measures actual migrated services at the expected upper-use fixture", async () => { - seedRealApplicationSchema(); - const activity = await measure("getActivitySummary", () => getActivitySummary(NOW)); - const availability = await measure("getStudyAvailabilityByClass", () => - getStudyAvailabilityByClass(NOW) - ); - const classPolling = await measure("listClasses/navbar-poll", () => listClasses()); - const measurements = [activity, availability, classPolling].map((result) => ({ - label: result.label, - latencyMs: result.latencyMs, - })); - - console.info(JSON.stringify({ - fixture: { activityRows: ACTIVITY_ROWS, cards: CARD_ROWS, sets: SETS }, - budgetMs: REQUEST_BUDGET_MS, - prismaServiceOperations: { activity: 1, availability: 1, classPolling: 2 }, - measurements, - activity: { - displayedDays: activity.value.days.length, - currentStreak: activity.value.currentStreak, - }, - readyCards: availability.value["benchmark-class"]?.readyCards, - }, null, 2)); - - expect(activity.value.days).toHaveLength(53 * 7); - expect(activity.value.currentStreak).toBeGreaterThan(53 * 7); - expect(availability.value["benchmark-class"]).toBeDefined(); - expect(classPolling.value).toHaveLength(1); - for (const measurement of measurements) { - expect(measurement.latencyMs, measurement.label).toBeLessThanOrEqual( - REQUEST_BUDGET_MS - ); - } - }); -}); diff --git a/dev.db b/dev.db new file mode 100644 index 0000000..a0c488e Binary files /dev/null and b/dev.db differ diff --git a/docker-compose.dev.yml b/docker-compose.override.yml similarity index 70% rename from docker-compose.dev.yml rename to docker-compose.override.yml index 7721dc8..224ac4a 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.override.yml @@ -1,15 +1,12 @@ services: study-app: build: - context: . target: deps command: npm run dev - ports: - - "3000:3000" - environment: - - DATABASE_URL=file:/app/data/study.db - - NODE_ENV=development volumes: - .:/app - /app/node_modules - - ./data:/app/data + environment: + - NODE_ENV=development + ports: + - "3000:3000" diff --git a/docker-compose.yml b/docker-compose.yml index 3ca99f6..6270daf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,13 +4,11 @@ services: container_name: study-app restart: unless-stopped ports: - - "3000:3726" + - "3000:3000" environment: - DATABASE_URL=file:/app/data/study.db - - SESSION_SECRET=${SESSION_SECRET:?SESSION_SECRET must be set to at least 32 characters} - - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH:-} - - ALLOW_INITIAL_SETUP=${ALLOW_INITIAL_SETUP:-false} - - SECURE_COOKIES=${SECURE_COOKIES:-false} + - SESSION_SECRET=${SESSION_SECRET} + - ADMIN_PASSWORD_HASH=${ADMIN_PASSWORD_HASH} - NODE_ENV=production volumes: - ./data:/app/data diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index dcba759..5e93fe8 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -1,13 +1,4 @@ #!/bin/sh set -e - -DEFAULT_SECRET='dev-session-secret-change-in-production-must-be-32-chars' -TRIMMED_SECRET=$(printf '%s' "${SESSION_SECRET:-}" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') -if [ -z "$TRIMMED_SECRET" ] || [ "${#TRIMMED_SECRET}" -lt 32 ] || [ "$TRIMMED_SECRET" = "$DEFAULT_SECRET" ]; then - echo >&2 "SESSION_SECRET must be a non-default value of at least 32 characters" - exit 1 -fi - -node scripts/migration-preflight.mjs --startup -./node_modules/.bin/prisma migrate deploy +npx prisma migrate deploy exec node server.js diff --git a/eslint.config.mjs b/eslint.config.mjs index 3d021d4..05e726d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -12,7 +12,6 @@ const eslintConfig = defineConfig([ "out/**", "build/**", "next-env.d.ts", - "audit-results/tmp/**", ]), ]); diff --git a/out.css b/out.css new file mode 100644 index 0000000..c837bac --- /dev/null +++ b/out.css @@ -0,0 +1 @@ +:root { --color-primary: #fff; } diff --git a/package-lock.json b/package-lock.json index c9163a8..e127911 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,26 +17,23 @@ "better-sqlite3": "^12.11.1", "iron-session": "^8.0.4", "jsonrepair": "^3.14.1", - "next": "16.3.0", - "prisma": "^7.8.0", + "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "ts-fsrs": "^5.4.1", "zod": "^4.4.3" }, "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" + "typescript": "^5" } }, "node_modules/@alloc/quick-lru": { @@ -83,6 +80,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -309,6 +307,7 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", + "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -349,12 +348,15 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.1.tgz", "integrity": "sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==", - "license": "Apache-2.0" + "devOptional": true, + "license": "Apache-2.0", + "peer": true }, "node_modules/@electric-sql/pglite-socket": { "version": "0.1.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.1.1.tgz", "integrity": "sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==", + "devOptional": true, "license": "Apache-2.0", "bin": { "pglite-server": "dist/scripts/server.js" @@ -367,46 +369,12 @@ "version": "0.3.1", "resolved": "https://registry.npmjs.org/@electric-sql/pglite-tools/-/pglite-tools-0.3.1.tgz", "integrity": "sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==", + "devOptional": true, "license": "Apache-2.0", "peerDependencies": { "@electric-sql/pglite": "0.4.1" } }, - "node_modules/@emnapi/core": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz", - "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.3", - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz", - "integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@emnapi/runtime": { - "version": "1.11.3", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", - "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, "node_modules/@emnapi/wasi-threads": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", @@ -572,6 +540,7 @@ "version": "1.19.11", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz", "integrity": "sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=18.14.1" @@ -657,9 +626,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", - "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", "cpu": [ "arm64" ], @@ -669,19 +638,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.3.2" + "@img/sharp-libvips-darwin-arm64": "1.2.4" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", - "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", "cpu": [ "x64" ], @@ -691,38 +660,19 @@ "darwin" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.3.2" - } - }, - "node_modules/@img/sharp-freebsd-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", - "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", - "license": "Apache-2.0", - "optional": true, - "os": [ - "freebsd" - ], - "dependencies": { - "@img/sharp-wasm32": "0.35.3" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" + "@img/sharp-libvips-darwin-x64": "1.2.4" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", - "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", "cpu": [ "arm64" ], @@ -736,9 +686,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", - "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", "cpu": [ "x64" ], @@ -752,9 +702,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", - "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", "cpu": [ "arm" ], @@ -768,9 +718,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", - "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", "cpu": [ "arm64" ], @@ -784,9 +734,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", - "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", "cpu": [ "ppc64" ], @@ -800,9 +750,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", - "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", "cpu": [ "riscv64" ], @@ -816,9 +766,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", - "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", "cpu": [ "s390x" ], @@ -832,9 +782,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", - "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", "cpu": [ "x64" ], @@ -848,9 +798,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", - "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", "cpu": [ "arm64" ], @@ -864,9 +814,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", - "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", "cpu": [ "x64" ], @@ -880,9 +830,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", - "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", "cpu": [ "arm" ], @@ -892,19 +842,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.3.2" + "@img/sharp-libvips-linux-arm": "1.2.4" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", - "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", "cpu": [ "arm64" ], @@ -914,19 +864,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.3.2" + "@img/sharp-libvips-linux-arm64": "1.2.4" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", - "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", "cpu": [ "ppc64" ], @@ -936,19 +886,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.3.2" + "@img/sharp-libvips-linux-ppc64": "1.2.4" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", - "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", "cpu": [ "riscv64" ], @@ -958,19 +908,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.3.2" + "@img/sharp-libvips-linux-riscv64": "1.2.4" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", - "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", "cpu": [ "s390x" ], @@ -980,19 +930,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.3.2" + "@img/sharp-libvips-linux-s390x": "1.2.4" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", - "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", "cpu": [ "x64" ], @@ -1002,19 +952,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.3.2" + "@img/sharp-libvips-linux-x64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", - "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", "cpu": [ "arm64" ], @@ -1024,19 +974,19 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", - "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", "cpu": [ "x64" ], @@ -1046,54 +996,38 @@ "linux" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.3.2" + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", - "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.11.1" - }, - "engines": { - "node": ">=20.9.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-webcontainers-wasm32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", - "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", "cpu": [ "wasm32" ], - "license": "Apache-2.0", + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@img/sharp-wasm32": "0.35.3" + "@emnapi/runtime": "^1.7.0" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", - "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", "cpu": [ "arm64" ], @@ -1103,16 +1037,16 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", - "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", "cpu": [ "ia32" ], @@ -1122,16 +1056,16 @@ "win32" ], "engines": { - "node": "^20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", - "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", "cpu": [ "x64" ], @@ -1141,7 +1075,7 @@ "win32" ], "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -1201,6 +1135,7 @@ "version": "0.3.4", "resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz", "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", + "devOptional": true, "license": "MIT" }, "node_modules/@napi-rs/wasm-runtime": { @@ -1223,9 +1158,9 @@ } }, "node_modules/@next/env": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", - "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.9.tgz", + "integrity": "sha512-ki5VxxXfzD/9TDe13wyeTKIjQTAwBVpnr8KhRDUr8ltMUq1/NBpWNT5tiPoxiGl+PHM4X2ahSOiPk6iAimIzPg==", "license": "MIT" }, "node_modules/@next/eslint-plugin-next": { @@ -1239,9 +1174,9 @@ } }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", - "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", "cpu": [ "arm64" ], @@ -1255,9 +1190,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", - "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", "cpu": [ "x64" ], @@ -1271,9 +1206,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", - "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", "cpu": [ "arm64" ], @@ -1287,9 +1222,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", - "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", "cpu": [ "arm64" ], @@ -1303,9 +1238,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", - "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", "cpu": [ "x64" ], @@ -1319,9 +1254,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", - "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", "cpu": [ "x64" ], @@ -1335,9 +1270,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", - "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", "cpu": [ "arm64" ], @@ -1351,9 +1286,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", - "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.9.tgz", + "integrity": "sha512-qr2VL3Ce5QrwgO2yh1ujSBawrimjVKX8FGF/cOynmdYKJY0BdHpGVNIRK1tqONB10Vkm25Ub1BD2bkjWs4+96w==", "cpu": [ "x64" ], @@ -1414,16 +1349,6 @@ "node": ">=12.4.0" } }, - "node_modules/@oxc-project/types": { - "version": "0.139.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.139.0.tgz", - "integrity": "sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/Boshen" - } - }, "node_modules/@phc/format": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz", @@ -1477,6 +1402,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/config/-/config-7.8.0.tgz", "integrity": "sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "c12": "3.3.4", @@ -1495,6 +1421,7 @@ "version": "0.24.3", "resolved": "https://registry.npmjs.org/@prisma/dev/-/dev-0.24.3.tgz", "integrity": "sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==", + "devOptional": true, "license": "ISC", "dependencies": { "@electric-sql/pglite": "0.4.1", @@ -1529,6 +1456,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/engines/-/engines-7.8.0.tgz", "integrity": "sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -1542,12 +1470,14 @@ "version": "7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a", "resolved": "https://registry.npmjs.org/@prisma/engines-version/-/engines-version-7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a.tgz", "integrity": "sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/engines/node_modules/@prisma/get-platform": { "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1557,6 +1487,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/fetch-engine/-/fetch-engine-7.8.0.tgz", "integrity": "sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0", @@ -1568,6 +1499,7 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.8.0.tgz", "integrity": "sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.8.0" @@ -1577,6 +1509,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/get-platform/-/get-platform-7.2.0.tgz", "integrity": "sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@prisma/debug": "7.2.0" @@ -1586,18 +1519,21 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/debug/-/debug-7.2.0.tgz", "integrity": "sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/query-plan-executor": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@prisma/query-plan-executor/-/query-plan-executor-7.2.0.tgz", "integrity": "sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/@prisma/streams-local": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/@prisma/streams-local/-/streams-local-0.1.2.tgz", "integrity": "sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "ajv": "^8.12.0", @@ -1614,6 +1550,7 @@ "version": "8.20.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "devOptional": true, "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.3", @@ -1630,12 +1567,14 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "devOptional": true, "license": "MIT" }, "node_modules/@prisma/studio-core": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/@prisma/studio-core/-/studio-core-0.27.3.tgz", "integrity": "sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==", + "devOptional": true, "license": "Apache-2.0", "dependencies": { "@radix-ui/react-toggle": "1.1.10", @@ -1655,12 +1594,14 @@ "version": "1.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "devOptional": true, "license": "MIT" }, "node_modules/@radix-ui/react-compose-refs": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1676,6 +1617,7 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-slot": "1.2.3" @@ -1699,6 +1641,7 @@ "version": "1.2.3", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" @@ -1717,6 +1660,7 @@ "version": "1.1.10", "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-1.1.10.tgz", "integrity": "sha512-lS1odchhFTeZv3xwHH31YPObmJn8gOg7Lq12inrr0+BH/l3Tsq32VfjqH1oh80ARM3mlkfMic15n0kg4sD1poQ==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/primitive": "1.1.3", @@ -1742,6 +1686,7 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", @@ -1761,6 +1706,7 @@ "version": "0.0.2", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "devOptional": true, "license": "MIT", "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" @@ -1779,6 +1725,7 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "*", @@ -1790,293 +1737,6 @@ } } }, - "node_modules/@rolldown/binding-android-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz", - "integrity": "sha512-lZg8fqIv2v7FF237bwMgzGZEJvGL79/s5knJ/i6FmsGF4XXlzccZ4jb+TrFIxtSSxFtIpdsgrPZeMk1I9AFcyQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.5.tgz", - "integrity": "sha512-51Bnx9pNiMRKSUNtBfySkNJ9vMU9Hh3I1ozDd6gyPPYzaXCfnptUcEZxXGYFn+ul2dtcMUiqGR1Yai2K10uoTw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.5.tgz", - "integrity": "sha512-Tm+gbfC0aHu1tBA/JvKQh32S0K6YgCHkiAF4/W6xX0K0RmNuc94VeK419dJoE65R5aRxmo+noZQSWrAMF6yb6g==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.5.tgz", - "integrity": "sha512-JMzDKCCXq93YccG5gz3hvOs1oXRKAf0XYpfOS88e+wZrC8Iugj6j68867vrYZkvpDDpKn/KoKORThmchMpF6TA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.5.tgz", - "integrity": "sha512-uML21j2K5TfPGutKxub+M+nLjZIrWjXQ5Grx4lCe/nimTj9B4L63zHpjXLl4y0L3mcm2htEQIb06oCG/szerNw==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.5.tgz", - "integrity": "sha512-navSiuTMogvnQoZoM/v+l3ZWo50/NTwSHSzheABx/RCnmUPaKwq9qSo4Br2OYRs21+Fz8uFqITZM3H4opOB0/Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.5.tgz", - "integrity": "sha512-lAryqH7IteztmCXQXk0etKj4wBQ7Gx5S6LjKhsgp9zb8I5bsuvU/2llH1hDQcjsFeqIsovMVN339/8pUDDBXxA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.5.tgz", - "integrity": "sha512-fsK/sNBnxzBlL4O1JNrZakVQxPspqpED5dLtNsZS9oOKmtSpdNIzxH2kkol5HYTWJN47sE20ztMJPxfZ89qGOg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.5.tgz", - "integrity": "sha512-gLYb4BIadlfTOYT5gO503n8zQjXflgzpD0FcyKh0Mzx3rqCZKnHoJWV9xe1KXUJ5lx2JfcSHr/mhzS0PC/McAA==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.5.tgz", - "integrity": "sha512-FjcpEKUyJygHgs1o50VYNvkt5+7Le/VEdYt0AkRpkL33MnyQfwr8l5mXwMmfmTbyMPr5vJLC+8/Gd9gXnwU1QQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.5.tgz", - "integrity": "sha512-Me+PfPI2TMeOQk0gYWfLQZtTktrmzbr8cDboqX83XKc7UrgAi55gF+2dUkWdxd19n55Essp2yeca+O9N5rBxHg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.5.tgz", - "integrity": "sha512-yc5WrLzXks6zCQfn9Oxr8pORKyl/pF+QjHmW/Qx3qu0oyrrNC+y2JLTU1E2rcWYAmzlnqngWXHQjy51VzW70Vw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.5.tgz", - "integrity": "sha512-VbQGPX2b4r48TAMIM2cjgluIM1HYutm4pcTEJsle7iEP7sB1dFqtPLBVbdLAZCxy1txCcPxf4QFf4v8uvltPqA==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/core": "1.11.1", - "@emnapi/runtime": "1.11.1", - "@napi-rs/wasm-runtime": "^1.1.6" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", - "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.2", - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.11.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", - "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.5.tgz", - "integrity": "sha512-gHv82k63z4qpV5+Q1y/12KrK0ltWBukVDI8nZcbT7Tt/ZlOIVwppazneq0F93oDxTo3IgAMEDIoQh3E2n6mVsw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.5.tgz", - "integrity": "sha512-tTZuDBPw85tEN5PQi1pnEBzDy0Z49HtScLAbD5t6hyeU92A95pRWaSMw1GZZi/RwgSgUIl0xrSlXIT/9QzvYSA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^20.19.0 || >=22.12.0" - } - }, - "node_modules/@rolldown/pluginutils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", - "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", - "dev": true, - "license": "MIT" - }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -2088,6 +1748,7 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "devOptional": true, "license": "MIT" }, "node_modules/@swc/helpers": { @@ -2322,72 +1983,6 @@ "node": ">=14.0.0" } }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { - "version": "1.10.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@emnapi/wasi-threads": "1.2.1", - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { - "version": "1.10.0", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { - "version": "1.2.1", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.4", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "@tybys/wasm-util": "^0.10.1" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/Brooooooklyn" - }, - "peerDependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { - "version": "0.10.2", - "dev": true, - "inBundle": true, - "license": "MIT", - "optional": true, - "dependencies": { - "tslib": "^2.4.0" - } - }, - "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { - "version": "2.8.1", - "dev": true, - "inBundle": true, - "license": "0BSD", - "optional": true - }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", @@ -2447,27 +2042,6 @@ "tslib": "^2.4.0" } }, - "node_modules/@types/better-sqlite3": { - "version": "9.6.0", - "resolved": "https://registry.npmjs.org/@types/better-sqlite3/-/better-sqlite3-9.6.0.tgz", - "integrity": "sha512-ZEEwBSgMu7GYJOynoagg5X9JbxfL6dTJDsgViJIqh67jV44kyOr9RXfmFjLK5rzC4MWssP06t9hu/JwGDnUbCg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "*" - } - }, - "node_modules/@types/chai": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", - "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/deep-eql": "*", - "assertion-error": "^2.0.1" - } - }, "node_modules/@types/debug": { "version": "4.1.13", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", @@ -2477,13 +2051,6 @@ "@types/ms": "*" } }, - "node_modules/@types/deep-eql": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", - "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/estree": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", @@ -2552,6 +2119,7 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -2560,8 +2128,9 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", - "devOptional": true, + "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2617,6 +2186,7 @@ "integrity": "sha512-dzHeT2gySzZtLDsuqxU9AkYgIsQoHAHtRBpOqM+Ofzx1Bwrd2RcCjQJ+6iQbsHOIR6NS33bF2W1k3blN1zLDrA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.62.0", "@typescript-eslint/types": "8.62.0", @@ -3220,125 +2790,13 @@ "win32" ] }, - "node_modules/@vitest/expect": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.10.tgz", - "integrity": "sha512-YsCn+qAk1GWjQOWFEsEcL2gNQ0zmVmQu3T03qP6UyjhtmdtwtbuI+DASn/7iQB3HGTXkdBwGddzxPlmiql5vlA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@standard-schema/spec": "^1.1.0", - "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "chai": "^6.2.2", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/mocker": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.10.tgz", - "integrity": "sha512-v0xaezt+DKEmKfaxg133ldzADrwLGd7Ze1MfQQTYfvs8OqZIwbxyxaYURivwV7sWy5fqn3rH5uOrSp07bp44Ow==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/spy": "4.1.10", - "estree-walker": "^3.0.3", - "magic-string": "^0.30.21" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "msw": { - "optional": true - }, - "vite": { - "optional": true - } - } - }, - "node_modules/@vitest/pretty-format": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.10.tgz", - "integrity": "sha512-W1HsjSH4MXQ9YfmmhLAoIYf1HRfekQCGngeIgcei6MP5QQGWUe0gkopdZQaVCFO+JDJMrAJGwa5pRpNpvy4P8Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/runner": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.10.tgz", - "integrity": "sha512-IKI6kpIH+LmpROplyLwBBaCfMgOZOMsygVa6BARD6ahA04VRuJSa6OaVG7kRvSEMD870Vd91rSSw0eegtWyLGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/utils": "4.1.10", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/snapshot": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.10.tgz", - "integrity": "sha512-xRkfOT1qpTAi/Ti4Y1LtfRc3kEuqxGw59eN2jN9pRWMtS/XDevekhcFSqvQqjUNGksfjMJu3Y+oJ+4Ypn2OaJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "@vitest/utils": "4.1.10", - "magic-string": "^0.30.21", - "pathe": "^2.0.3" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/spy": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.10.tgz", - "integrity": "sha512-PLf/Ugvoq5wO/b4rwYCR1h2PSIdXz7wnkQFMiUpLdtM7l6pqVFcQIBEHyT1+l+cj7mNwAfZHzqXqDyjvOuwbDw==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://opencollective.com/vitest" - } - }, - "node_modules/@vitest/utils": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.10.tgz", - "integrity": "sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/pretty-format": "4.1.10", - "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.1.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - } - }, "node_modules/acorn": { "version": "8.17.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3582,16 +3040,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/assertion-error": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", - "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - } - }, "node_modules/ast-types-flow": { "version": "0.0.8", "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", @@ -3629,6 +3077,7 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz", "integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 6.0.0" @@ -3707,6 +3156,7 @@ "version": "2.9.2", "resolved": "https://registry.npmjs.org/better-result/-/better-result-2.9.2.tgz", "integrity": "sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==", + "devOptional": true, "license": "MIT" }, "node_modules/better-sqlite3": { @@ -3787,6 +3237,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.38", "caniuse-lite": "^1.0.30001799", @@ -3829,6 +3280,7 @@ "version": "3.3.4", "resolved": "https://registry.npmjs.org/c12/-/c12-3.3.4.tgz", "integrity": "sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==", + "devOptional": true, "license": "MIT", "dependencies": { "chokidar": "^5.0.0", @@ -3943,16 +3395,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -4014,6 +3456,7 @@ "version": "4.5.1", "resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz", "integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==", + "devOptional": true, "license": "MIT", "dependencies": { "@kurkle/color": "^0.3.0" @@ -4026,6 +3469,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "integrity": "sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==", + "devOptional": true, "license": "MIT", "dependencies": { "readdirp": "^5.0.0" @@ -4090,6 +3534,7 @@ "version": "0.2.4", "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.2.4.tgz", "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", + "devOptional": true, "license": "MIT" }, "node_modules/convert-source-map": { @@ -4271,6 +3716,7 @@ "version": "7.1.5", "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "devOptional": true, "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" @@ -4316,12 +3762,14 @@ "version": "6.1.7", "resolved": "https://registry.npmjs.org/defu/-/defu-6.1.7.tgz", "integrity": "sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==", + "devOptional": true, "license": "MIT" }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=0.10" @@ -4340,6 +3788,7 @@ "version": "2.0.5", "resolved": "https://registry.npmjs.org/destr/-/destr-2.0.5.tgz", "integrity": "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA==", + "devOptional": true, "license": "MIT" }, "node_modules/detect-libc": { @@ -4381,6 +3830,7 @@ "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "devOptional": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4408,6 +3858,7 @@ "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", "integrity": "sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==", + "devOptional": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.0.0", @@ -4432,6 +3883,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", "integrity": "sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=14" @@ -4464,6 +3916,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", "integrity": "sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==", + "devOptional": true, "license": "MIT", "engines": { "node": "^12.20.0 || ^14.13.1 || >=16.0.0" @@ -4608,13 +4061,6 @@ "node": ">= 0.4" } }, - "node_modules/es-module-lexer": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz", - "integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==", - "dev": true, - "license": "MIT" - }, "node_modules/es-object-atoms": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", @@ -4707,6 +4153,7 @@ "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -4892,6 +4339,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -5107,16 +4555,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/estree-walker": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", - "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "^1.0.0" - } - }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -5136,20 +4574,11 @@ "node": ">=6" } }, - "node_modules/expect-type": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", - "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=12.0.0" - } - }, "node_modules/exsolve": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.1.0.tgz", "integrity": "sha512-D+42+T12DdIlJM3uepa55qGiL3sYdLBOxIl2ifQCzCHz4c7eiolaHsi3BIqEr7JxBzxv2pYZQX9kw16ziMcEmw==", + "devOptional": true, "license": "MIT" }, "node_modules/extend": { @@ -5162,6 +4591,7 @@ "version": "3.23.2", "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-3.23.2.tgz", "integrity": "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A==", + "devOptional": true, "funding": [ { "type": "individual", @@ -5184,6 +4614,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "devOptional": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -5234,6 +4665,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz", "integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==", + "devOptional": true, "funding": [ { "type": "github", @@ -5346,6 +4778,7 @@ "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "devOptional": true, "license": "ISC", "dependencies": { "cross-spawn": "^7.0.6", @@ -5364,21 +4797,6 @@ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "license": "MIT" }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -5427,6 +4845,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "devOptional": true, "license": "MIT", "dependencies": { "is-property": "^1.0.2" @@ -5481,6 +4900,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/get-port-please/-/get-port-please-3.2.0.tgz", "integrity": "sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==", + "devOptional": true, "license": "MIT" }, "node_modules/get-proto": { @@ -5532,6 +4952,7 @@ "version": "3.3.0", "resolved": "https://registry.npmjs.org/giget/-/giget-3.3.0.tgz", "integrity": "sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==", + "devOptional": true, "license": "MIT", "bin": { "giget": "dist/cli.mjs" @@ -5603,18 +5024,21 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "devOptional": true, "license": "ISC" }, "node_modules/grammex": { "version": "3.1.12", "resolved": "https://registry.npmjs.org/grammex/-/grammex-3.1.12.tgz", "integrity": "sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==", + "devOptional": true, "license": "MIT" }, "node_modules/graphmatch": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/graphmatch/-/graphmatch-1.1.1.tgz", "integrity": "sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==", + "devOptional": true, "license": "MIT" }, "node_modules/has-bigints": { @@ -5772,7 +5196,9 @@ "version": "4.12.27", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.27.tgz", "integrity": "sha512-1yrb/+w6HWQJrUCLkJ2IF5jNIPvvFkblV5RNOYl6bV+OA6p9GLcMpHFFGTosSvHvcAUibuUukRqhlYI4z32C7Q==", + "devOptional": true, "license": "MIT", + "peer": true, "engines": { "node": ">=16.9.0" } @@ -5791,12 +5217,14 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", "integrity": "sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==", + "devOptional": true, "license": "MIT" }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "devOptional": true, "license": "MIT", "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" @@ -6269,6 +5697,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", "integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==", + "devOptional": true, "license": "MIT" }, "node_modules/is-regex": { @@ -6451,6 +5880,7 @@ "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "devOptional": true, "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -6890,6 +6320,7 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "devOptional": true, "license": "Apache-2.0" }, "node_modules/longest-streak": { @@ -6929,6 +6360,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/lru.min/-/lru.min-1.1.4.tgz", "integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==", + "devOptional": true, "license": "MIT", "engines": { "bun": ">=1.0.0", @@ -7889,6 +7321,7 @@ "version": "3.15.3", "resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.15.3.tgz", "integrity": "sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==", + "devOptional": true, "license": "MIT", "dependencies": { "aws-ssl-profiles": "^1.1.1", @@ -7909,6 +7342,7 @@ "version": "1.1.6", "resolved": "https://registry.npmjs.org/named-placeholders/-/named-placeholders-1.1.6.tgz", "integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==", + "devOptional": true, "license": "MIT", "dependencies": { "lru.min": "^1.1.0" @@ -7918,9 +7352,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.18", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", - "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", "funding": [ { "type": "github", @@ -7965,16 +7399,16 @@ "license": "MIT" }, "node_modules/next": { - "version": "16.3.0", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", - "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.9.tgz", + "integrity": "sha512-MEOJiq/UvuezAdqVSceHbqDgZt1kDw2tpGVOlsdIoJsQdbN2JY2hpVG4xnXGkbdJUOEWhnRfiu/O4Hpc9Juwww==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0", + "@next/env": "16.2.9", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.5.23", + "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "bin": { @@ -7984,15 +7418,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0", - "@next/swc-darwin-x64": "16.3.0", - "@next/swc-linux-arm64-gnu": "16.3.0", - "@next/swc-linux-arm64-musl": "16.3.0", - "@next/swc-linux-x64-gnu": "16.3.0", - "@next/swc-linux-x64-musl": "16.3.0", - "@next/swc-win32-arm64-msvc": "16.3.0", - "@next/swc-win32-x64-msvc": "16.3.0", - "sharp": "^0.35.3" + "@next/swc-darwin-arm64": "16.2.9", + "@next/swc-darwin-x64": "16.2.9", + "@next/swc-linux-arm64-gnu": "16.2.9", + "@next/swc-linux-arm64-musl": "16.2.9", + "@next/swc-linux-x64-gnu": "16.2.9", + "@next/swc-linux-x64-musl": "16.2.9", + "@next/swc-win32-arm64-msvc": "16.2.9", + "@next/swc-win32-x64-msvc": "16.2.9", + "sharp": "^0.34.5" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -8018,9 +7452,9 @@ } }, "node_modules/next/node_modules/postcss": { - "version": "8.5.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", - "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "funding": [ { "type": "opencollective", @@ -8037,9 +7471,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.16", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -8241,24 +7675,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/obug": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", - "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", - "dev": true, - "funding": [ - "https://github.com/sponsors/sxzz", - "https://opencollective.com/debug" - ], - "license": "MIT", - "engines": { - "node": ">=12.20.0" - } - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", "integrity": "sha512-RdR9FQrFwNBNXAr4GixM8YaRZRJ5PUWbKYbE5eOsrwAjJW0q2REGcf79oYPsLyskQCZG1PLN+S/K1V00joZAoQ==", + "devOptional": true, "license": "MIT" }, "node_modules/once": { @@ -8406,12 +7827,14 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "devOptional": true, "license": "MIT" }, "node_modules/perfect-debounce": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-2.1.0.tgz", "integrity": "sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==", + "devOptional": true, "license": "MIT" }, "node_modules/picocolors": { @@ -8437,6 +7860,7 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.1.tgz", "integrity": "sha512-y+ichcgc2LrADuhLNAx8DFjVfgz91pRxfZdI3UDhxHvcVEZsenLO+7XaU5vOp0u/7V/wZ+plyuQxtrDlZJ+yeg==", + "devOptional": true, "license": "MIT", "dependencies": { "confbox": "^0.2.4", @@ -8487,6 +7911,7 @@ "version": "3.4.7", "resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.7.tgz", "integrity": "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==", + "devOptional": true, "license": "Unlicense", "engines": { "node": ">=12" @@ -8537,8 +7962,10 @@ "version": "7.8.0", "resolved": "https://registry.npmjs.org/prisma/-/prisma-7.8.0.tgz", "integrity": "sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==", + "devOptional": true, "hasInstallScript": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@prisma/config": "7.8.0", "@prisma/dev": "0.24.3", @@ -8582,6 +8009,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "devOptional": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8593,6 +8021,7 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "devOptional": true, "license": "ISC" }, "node_modules/property-information": { @@ -8629,6 +8058,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "devOptional": true, "funding": [ { "type": "individual", @@ -8690,6 +8120,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/rc9/-/rc9-3.0.1.tgz", "integrity": "sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==", + "devOptional": true, "license": "MIT", "dependencies": { "defu": "^6.1.6", @@ -8701,6 +8132,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8710,6 +8142,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", + "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -8769,6 +8202,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-5.0.0.tgz", "integrity": "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 20.19.0" @@ -8892,6 +8326,7 @@ "version": "2.33.4", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "integrity": "sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==", + "devOptional": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/remeda" @@ -8901,6 +8336,7 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "devOptional": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -8954,6 +8390,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 4" @@ -8970,40 +8407,6 @@ "node": ">=0.10.0" } }, - "node_modules/rolldown": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.5.tgz", - "integrity": "sha512-t9z29cJjXf/vxQ8dyhCSpt6H6aSwHTk8cT5I3iy6SMXuFpk5mB6PL6XfC8PCwrPTx93udwKUm9HRteAlTGBLiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@oxc-project/types": "=0.139.0", - "@rolldown/pluginutils": "^1.0.0" - }, - "bin": { - "rolldown": "bin/cli.mjs" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.1.5", - "@rolldown/binding-darwin-arm64": "1.1.5", - "@rolldown/binding-darwin-x64": "1.1.5", - "@rolldown/binding-freebsd-x64": "1.1.5", - "@rolldown/binding-linux-arm-gnueabihf": "1.1.5", - "@rolldown/binding-linux-arm64-gnu": "1.1.5", - "@rolldown/binding-linux-arm64-musl": "1.1.5", - "@rolldown/binding-linux-ppc64-gnu": "1.1.5", - "@rolldown/binding-linux-s390x-gnu": "1.1.5", - "@rolldown/binding-linux-x64-gnu": "1.1.5", - "@rolldown/binding-linux-x64-musl": "1.1.5", - "@rolldown/binding-openharmony-arm64": "1.1.5", - "@rolldown/binding-wasm32-wasi": "1.1.5", - "@rolldown/binding-win32-arm64-msvc": "1.1.5", - "@rolldown/binding-win32-x64-msvc": "1.1.5" - } - }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -9107,6 +8510,7 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "devOptional": true, "license": "MIT" }, "node_modules/scheduler": { @@ -9128,7 +8532,8 @@ "node_modules/seq-queue": { "version": "0.0.5", "resolved": "https://registry.npmjs.org/seq-queue/-/seq-queue-0.0.5.tgz", - "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==" + "integrity": "sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==", + "devOptional": true }, "node_modules/set-function-length": { "version": "1.2.2", @@ -9180,53 +8585,48 @@ } }, "node_modules/sharp": { - "version": "0.35.3", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", - "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.1.0", + "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", - "semver": "^7.8.5" + "semver": "^7.7.3" }, "engines": { - "node": ">=20.9.0" + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.35.3", - "@img/sharp-darwin-x64": "0.35.3", - "@img/sharp-freebsd-wasm32": "0.35.3", - "@img/sharp-libvips-darwin-arm64": "1.3.2", - "@img/sharp-libvips-darwin-x64": "1.3.2", - "@img/sharp-libvips-linux-arm": "1.3.2", - "@img/sharp-libvips-linux-arm64": "1.3.2", - "@img/sharp-libvips-linux-ppc64": "1.3.2", - "@img/sharp-libvips-linux-riscv64": "1.3.2", - "@img/sharp-libvips-linux-s390x": "1.3.2", - "@img/sharp-libvips-linux-x64": "1.3.2", - "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", - "@img/sharp-libvips-linuxmusl-x64": "1.3.2", - "@img/sharp-linux-arm": "0.35.3", - "@img/sharp-linux-arm64": "0.35.3", - "@img/sharp-linux-ppc64": "0.35.3", - "@img/sharp-linux-riscv64": "0.35.3", - "@img/sharp-linux-s390x": "0.35.3", - "@img/sharp-linux-x64": "0.35.3", - "@img/sharp-linuxmusl-arm64": "0.35.3", - "@img/sharp-linuxmusl-x64": "0.35.3", - "@img/sharp-webcontainers-wasm32": "0.35.3", - "@img/sharp-win32-arm64": "0.35.3", - "@img/sharp-win32-ia32": "0.35.3", - "@img/sharp-win32-x64": "0.35.3" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - } + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" } }, "node_modules/sharp/node_modules/semver": { @@ -9339,17 +8739,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/siginfo": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", - "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", - "dev": true, - "license": "ISC" - }, "node_modules/signal-exit": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -9426,6 +8820,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/sqlstring/-/sqlstring-2.3.3.tgz", "integrity": "sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==", + "devOptional": true, "license": "MIT", "engines": { "node": ">= 0.6" @@ -9438,17 +8833,11 @@ "dev": true, "license": "MIT" }, - "node_modules/stackback": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", - "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", - "dev": true, - "license": "MIT" - }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "devOptional": true, "license": "MIT" }, "node_modules/stop-iteration-iterator": { @@ -9741,23 +9130,6 @@ "node": ">=6" } }, - "node_modules/tinybench": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", - "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", - "dev": true, - "license": "MIT" - }, - "node_modules/tinyexec": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", - "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -9799,6 +9171,7 @@ "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -9806,16 +9179,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/tinyrainbow": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", - "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.0.0" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -9862,15 +9225,6 @@ "typescript": ">=4.8.4" } }, - "node_modules/ts-fsrs": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/ts-fsrs/-/ts-fsrs-5.4.1.tgz", - "integrity": "sha512-mOp9+oexJexBTkwjg/jQI1aSUQRLIAvbimeKHLSmVdNJPwObugFNKmZkoggH5d6kZ0uaWLboP1Al1DnXAfIb9w==", - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -10012,6 +9366,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -10252,6 +9607,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.2.0.tgz", "integrity": "sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==", + "devOptional": true, "license": "MIT", "peerDependencies": { "typescript": ">=5" @@ -10290,236 +9646,6 @@ "url": "https://opencollective.com/unified" } }, - "node_modules/vite": { - "version": "8.1.4", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz", - "integrity": "sha512-bTT9PsdWO+MQMNG9ZXIP/qM9wGh37DFxTV/sPq9cFpHr3w4jkgef032PkAL9jAqhk3Nz8NQw3O8n6/xFkqO4QQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "lightningcss": "^1.32.0", - "picomatch": "^4.0.5", - "postcss": "^8.5.16", - "rolldown": "~1.1.4", - "tinyglobby": "^0.2.17" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^20.19.0 || >=22.12.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.3.0", - "esbuild": "^0.27.0 || ^0.28.0", - "jiti": ">=1.21.0", - "less": "^4.0.0", - "sass": "^1.70.0", - "sass-embedded": "^1.70.0", - "stylus": ">=0.54.8", - "sugarss": "^5.0.0", - "terser": "^5.16.0", - "tsx": "^4.8.1", - "yaml": "^2.4.2" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "@vitejs/devtools": { - "optional": true - }, - "esbuild": { - "optional": true - }, - "jiti": { - "optional": true - }, - "less": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - }, - "tsx": { - "optional": true - }, - "yaml": { - "optional": true - } - } - }, - "node_modules/vite/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vite/node_modules/postcss": { - "version": "8.5.19", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", - "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/vitest": { - "version": "4.1.10", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.10.tgz", - "integrity": "sha512-R9jUTe5S4Qb0HCd4TNqpC7oGcrMssMRGXLW80ubjWsW9VH5GF8y1Y0SFLY9AbqSk6nt0PnOx4H4WNJYZ13GUPw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@vitest/expect": "4.1.10", - "@vitest/mocker": "4.1.10", - "@vitest/pretty-format": "4.1.10", - "@vitest/runner": "4.1.10", - "@vitest/snapshot": "4.1.10", - "@vitest/spy": "4.1.10", - "@vitest/utils": "4.1.10", - "es-module-lexer": "^2.0.0", - "expect-type": "^1.3.0", - "magic-string": "^0.30.21", - "obug": "^2.1.1", - "pathe": "^2.0.3", - "picomatch": "^4.0.3", - "std-env": "^4.0.0-rc.1", - "tinybench": "^2.9.0", - "tinyexec": "^1.0.2", - "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.1.0", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", - "why-is-node-running": "^2.3.0" - }, - "bin": { - "vitest": "vitest.mjs" - }, - "engines": { - "node": "^20.0.0 || ^22.0.0 || >=24.0.0" - }, - "funding": { - "url": "https://opencollective.com/vitest" - }, - "peerDependencies": { - "@edge-runtime/vm": "*", - "@opentelemetry/api": "^1.9.0", - "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.10", - "@vitest/browser-preview": "4.1.10", - "@vitest/browser-webdriverio": "4.1.10", - "@vitest/coverage-istanbul": "4.1.10", - "@vitest/coverage-v8": "4.1.10", - "@vitest/ui": "4.1.10", - "happy-dom": "*", - "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "@edge-runtime/vm": { - "optional": true - }, - "@opentelemetry/api": { - "optional": true - }, - "@types/node": { - "optional": true - }, - "@vitest/browser-playwright": { - "optional": true - }, - "@vitest/browser-preview": { - "optional": true - }, - "@vitest/browser-webdriverio": { - "optional": true - }, - "@vitest/coverage-istanbul": { - "optional": true - }, - "@vitest/coverage-v8": { - "optional": true - }, - "@vitest/ui": { - "optional": true - }, - "happy-dom": { - "optional": true - }, - "jsdom": { - "optional": true - }, - "vite": { - "optional": false - } - } - }, - "node_modules/vitest/node_modules/picomatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz", - "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/vitest/node_modules/std-env": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", - "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", - "dev": true, - "license": "MIT" - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -10624,23 +9750,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/why-is-node-running": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", - "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", - "dev": true, - "license": "MIT", - "dependencies": { - "siginfo": "^2.0.0", - "stackback": "0.0.2" - }, - "bin": { - "why-is-node-running": "cli.js" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -10681,6 +9790,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/zeptomatch/-/zeptomatch-2.1.0.tgz", "integrity": "sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==", + "devOptional": true, "license": "MIT", "dependencies": { "grammex": "^3.1.11", @@ -10692,6 +9802,7 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/package.json b/package.json index 66dbfd0..6ee3ab5 100644 --- a/package.json +++ b/package.json @@ -3,16 +3,10 @@ "version": "0.1.0", "private": true, "scripts": { - "predev": "prisma migrate deploy", "dev": "next dev", "build": "next build", "start": "next start", - "lint": "eslint", - "test": "vitest run", - "benchmark:remediation": "vitest run --config vitest.benchmark.config.ts", - "db:preflight": "node scripts/migration-preflight.mjs", - "db:backup": "node scripts/backup-database.mjs", - "auth:reset": "node scripts/request-password-reset.mjs" + "lint": "eslint" }, "dependencies": { "@dnd-kit/core": "^6.3.1", @@ -24,25 +18,22 @@ "better-sqlite3": "^12.11.1", "iron-session": "^8.0.4", "jsonrepair": "^3.14.1", - "next": "16.3.0", - "prisma": "^7.8.0", + "next": "16.2.9", "react": "19.2.4", "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", - "ts-fsrs": "^5.4.1", "zod": "^4.4.3" }, "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" + "typescript": "^5" } } diff --git a/prisma/migrations/20260712113000_add_study_activity/migration.sql b/prisma/migrations/20260712113000_add_study_activity/migration.sql deleted file mode 100644 index 817ba79..0000000 --- a/prisma/migrations/20260712113000_add_study_activity/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ --- CreateTable -CREATE TABLE "StudyActivity" ( - "id" TEXT NOT NULL PRIMARY KEY, - "type" TEXT NOT NULL, - "occurredAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP -); - --- CreateIndex -CREATE INDEX "StudyActivity_occurredAt_idx" ON "StudyActivity"("occurredAt"); diff --git a/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql b/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql deleted file mode 100644 index abdf670..0000000 --- a/prisma/migrations/20260713200500_add_spaced_repetition/migration.sql +++ /dev/null @@ -1,64 +0,0 @@ --- CreateTable -CREATE TABLE "SpacedRepetitionSet" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "newCardsPerDay" INTEGER NOT NULL DEFAULT 30, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - CONSTRAINT "SpacedRepetitionSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "SpacedRepetitionSetDeck" ( - "setId" TEXT NOT NULL, - "deckId" TEXT NOT NULL, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "addedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - - PRIMARY KEY ("setId", "deckId"), - CONSTRAINT "SpacedRepetitionSetDeck_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "SpacedRepetitionSetDeck_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "SpacedRepetitionCardState" ( - "id" TEXT NOT NULL PRIMARY KEY, - "setId" TEXT NOT NULL, - "flashcardId" TEXT NOT NULL, - "due" DATETIME NOT NULL, - "stability" REAL NOT NULL, - "difficulty" REAL NOT NULL, - "elapsedDays" INTEGER NOT NULL, - "scheduledDays" INTEGER NOT NULL, - "learningSteps" INTEGER NOT NULL DEFAULT 0, - "reps" INTEGER NOT NULL, - "lapses" INTEGER NOT NULL, - "state" INTEGER NOT NULL, - "firstReviewedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "lastReview" DATETIME, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - CONSTRAINT "SpacedRepetitionCardState_setId_fkey" FOREIGN KEY ("setId") REFERENCES "SpacedRepetitionSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "SpacedRepetitionCardState_flashcardId_fkey" FOREIGN KEY ("flashcardId") REFERENCES "Flashcard" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateIndex -CREATE INDEX "SpacedRepetitionSet_classId_sortOrder_idx" ON "SpacedRepetitionSet"("classId", "sortOrder"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionSetDeck_deckId_idx" ON "SpacedRepetitionSetDeck"("deckId"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionCardState_setId_due_idx" ON "SpacedRepetitionCardState"("setId", "due"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionCardState_setId_firstReviewedAt_idx" ON "SpacedRepetitionCardState"("setId", "firstReviewedAt"); - --- CreateIndex -CREATE INDEX "SpacedRepetitionCardState_flashcardId_idx" ON "SpacedRepetitionCardState"("flashcardId"); - --- CreateIndex -CREATE UNIQUE INDEX "SpacedRepetitionCardState_setId_flashcardId_key" ON "SpacedRepetitionCardState"("setId", "flashcardId"); diff --git a/prisma/migrations/20260714010000_add_arcade/migration.sql b/prisma/migrations/20260714010000_add_arcade/migration.sql deleted file mode 100644 index 8c2336d..0000000 --- a/prisma/migrations/20260714010000_add_arcade/migration.sql +++ /dev/null @@ -1,40 +0,0 @@ --- CreateTable -CREATE TABLE "ArcadePack" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "gameType" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "schemaVersion" INTEGER NOT NULL, - "sourceJson" TEXT NOT NULL, - "normalizedJson" TEXT NOT NULL, - "validationReportJson" TEXT NOT NULL, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "updatedAt" DATETIME NOT NULL, - CONSTRAINT "ArcadePack_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateTable -CREATE TABLE "ArcadeAttempt" ( - "id" TEXT NOT NULL PRIMARY KEY, - "arcadePackId" TEXT NOT NULL, - "mode" TEXT NOT NULL, - "score" INTEGER NOT NULL, - "maxScore" INTEGER NOT NULL, - "accuracy" REAL NOT NULL, - "durationSeconds" INTEGER NOT NULL, - "mistakes" INTEGER NOT NULL, - "hintsUsed" INTEGER NOT NULL, - "settingsJson" TEXT NOT NULL, - "resultsJson" TEXT NOT NULL, - "seed" TEXT NOT NULL, - "completedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "ArcadeAttempt_arcadePackId_fkey" FOREIGN KEY ("arcadePackId") REFERENCES "ArcadePack" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- CreateIndex -CREATE INDEX "ArcadePack_classId_gameType_sortOrder_idx" ON "ArcadePack"("classId", "gameType", "sortOrder"); - --- CreateIndex -CREATE INDEX "ArcadeAttempt_arcadePackId_completedAt_idx" ON "ArcadeAttempt"("arcadePackId", "completedAt"); diff --git a/prisma/migrations/20260807090000_add_material_groups/migration.sql b/prisma/migrations/20260807090000_add_material_groups/migration.sql deleted file mode 100644 index 2227e4f..0000000 --- a/prisma/migrations/20260807090000_add_material_groups/migration.sql +++ /dev/null @@ -1,68 +0,0 @@ -PRAGMA defer_foreign_keys=ON; -PRAGMA foreign_keys=OFF; - --- CreateTable -CREATE TABLE "MaterialGroup" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "type" TEXT NOT NULL, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "MaterialGroup_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); - --- RedefineTables -CREATE TABLE "new_Deck" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "groupId" TEXT, - CONSTRAINT "Deck_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "Deck_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE -); -INSERT INTO "new_Deck" ("classId", "createdAt", "description", "id", "name", "sortOrder") -SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "Deck"; -DROP TABLE "Deck"; -ALTER TABLE "new_Deck" RENAME TO "Deck"; - -CREATE TABLE "new_QuizSet" ( - "id" TEXT NOT NULL PRIMARY KEY, - "classId" TEXT NOT NULL, - "name" TEXT NOT NULL, - "description" TEXT, - "sortOrder" INTEGER NOT NULL DEFAULT 0, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - "groupId" TEXT, - CONSTRAINT "QuizSet_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "QuizSet_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE SET NULL ON UPDATE CASCADE -); -INSERT INTO "new_QuizSet" ("classId", "createdAt", "description", "id", "name", "sortOrder") -SELECT "classId", "createdAt", "description", "id", "name", "sortOrder" FROM "QuizSet"; -DROP TABLE "QuizSet"; -ALTER TABLE "new_QuizSet" RENAME TO "QuizSet"; - -CREATE TABLE "new_ShareLink" ( - "id" TEXT NOT NULL PRIMARY KEY, - "targetType" TEXT NOT NULL, - "deckId" TEXT, - "quizSetId" TEXT, - "groupId" TEXT, - "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - CONSTRAINT "ShareLink_deckId_fkey" FOREIGN KEY ("deckId") REFERENCES "Deck" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ShareLink_quizSetId_fkey" FOREIGN KEY ("quizSetId") REFERENCES "QuizSet" ("id") ON DELETE CASCADE ON UPDATE CASCADE, - CONSTRAINT "ShareLink_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "MaterialGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE -); -INSERT INTO "new_ShareLink" ("createdAt", "deckId", "id", "quizSetId", "targetType") -SELECT "createdAt", "deckId", "id", "quizSetId", "targetType" FROM "ShareLink"; -DROP TABLE "ShareLink"; -ALTER TABLE "new_ShareLink" RENAME TO "ShareLink"; -CREATE UNIQUE INDEX "ShareLink_deckId_key" ON "ShareLink"("deckId"); -CREATE UNIQUE INDEX "ShareLink_quizSetId_key" ON "ShareLink"("quizSetId"); -CREATE UNIQUE INDEX "ShareLink_groupId_key" ON "ShareLink"("groupId"); - -PRAGMA foreign_keys=ON; -PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql b/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql deleted file mode 100644 index 289d17a..0000000 --- a/prisma/migrations/20260807091000_add_quiz_attempt_snapshot/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "QuizAttempt" ADD COLUMN "reviewSnapshotJson" TEXT; diff --git a/prisma/migrations/20260807092000_add_progress_revisions/migration.sql b/prisma/migrations/20260807092000_add_progress_revisions/migration.sql deleted file mode 100644 index 792da5c..0000000 --- a/prisma/migrations/20260807092000_add_progress_revisions/migration.sql +++ /dev/null @@ -1,3 +0,0 @@ --- AlterTable -ALTER TABLE "StudyProgress" ADD COLUMN "sessionId" TEXT NOT NULL DEFAULT 'legacy'; -ALTER TABLE "StudyProgress" ADD COLUMN "revision" INTEGER NOT NULL DEFAULT 0; diff --git a/prisma/migrations/20260808100000_remove_arcade/migration.sql b/prisma/migrations/20260808100000_remove_arcade/migration.sql deleted file mode 100644 index 35d0d98..0000000 --- a/prisma/migrations/20260808100000_remove_arcade/migration.sql +++ /dev/null @@ -1,10 +0,0 @@ --- Remove Arcade-generated activity and saved instruction settings. -DELETE FROM "StudyActivity" -WHERE "type" IN ('ARCADE_GROUP', 'ARCADE_WORD'); - -DELETE FROM "Setting" -WHERE "key" IN ('llmInstructionsConnections', 'llmInstructionsCrossword'); - --- Drop the dependent table before its parent while SQLite foreign keys are enabled. -DROP TABLE IF EXISTS "ArcadeAttempt"; -DROP TABLE IF EXISTS "ArcadePack"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 5d7bfda..dc8a941 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -14,10 +14,9 @@ model Class { sortOrder Int @default(0) createdAt DateTime @default(now()) - decks Deck[] - quizSets QuizSet[] - materialGroups MaterialGroup[] - spacedRepetitionSets SpacedRepetitionSet[] + decks Deck[] + quizSets QuizSet[] + materialGroups MaterialGroup[] } model Deck { @@ -28,13 +27,12 @@ model Deck { sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) - groupId String? - group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) - cards Flashcard[] - shareLink ShareLink? - progress StudyProgress[] - spacedRepetitionMemberships SpacedRepetitionSetDeck[] + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + groupId String? + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + cards Flashcard[] + shareLink ShareLink? + progress StudyProgress[] } model Flashcard { @@ -44,8 +42,7 @@ model Flashcard { back String sortOrder Int @default(0) - deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) - spacedRepetitionStates SpacedRepetitionCardState[] + deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) } model QuizSet { @@ -56,9 +53,9 @@ model QuizSet { sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) groupId String? - group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) + group MaterialGroup? @relation(fields: [groupId], references: [id], onDelete: SetNull) questions Question[] attempts QuizAttempt[] shareLink ShareLink? @@ -98,8 +95,6 @@ model StudyProgress { orderJson String // JSON array of card/question ids answersJson String? // in-progress quiz answers cardResultsJson String? // per-card grades for decks - sessionId String @default("legacy") - revision Int @default(0) updatedAt DateTime @updatedAt deck Deck? @relation(fields: [deckId], references: [id], onDelete: Cascade) @@ -110,14 +105,13 @@ model StudyProgress { } model QuizAttempt { - id String @id @default(uuid()) - quizSetId String - score Float - maxScore Int - answersJson String - reviewSnapshotJson String? - isPartialRetake Boolean @default(false) - completedAt DateTime @default(now()) + id String @id @default(uuid()) + quizSetId String + score Float + maxScore Int + answersJson String + isPartialRetake Boolean @default(false) + completedAt DateTime @default(now()) quizSet QuizSet @relation(fields: [quizSetId], references: [id], onDelete: Cascade) } @@ -147,81 +141,16 @@ model Setting { value String } -model StudyActivity { - id String @id @default(uuid()) - type String // "FLASHCARD" | "QUIZ_QUESTION" - occurredAt DateTime @default(now()) - - @@index([occurredAt]) -} - model MaterialGroup { id String @id @default(uuid()) classId String name String - type String // "DECK" | "QUIZ" + type String // "DECK" | "QUIZ" sortOrder Int @default(0) createdAt DateTime @default(now()) - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) + class Class @relation(fields: [classId], references: [id], onDelete: Cascade) decks Deck[] quizSets QuizSet[] shareLink ShareLink? } - -model SpacedRepetitionSet { - id String @id @default(uuid()) - classId String - name String - description String? - newCardsPerDay Int @default(30) - sortOrder Int @default(0) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - class Class @relation(fields: [classId], references: [id], onDelete: Cascade) - decks SpacedRepetitionSetDeck[] - cardStates SpacedRepetitionCardState[] - - @@index([classId, sortOrder]) -} - -model SpacedRepetitionSetDeck { - setId String - deckId String - sortOrder Int @default(0) - addedAt DateTime @default(now()) - - set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade) - deck Deck @relation(fields: [deckId], references: [id], onDelete: Cascade) - - @@id([setId, deckId]) - @@index([deckId]) -} - -model SpacedRepetitionCardState { - id String @id @default(uuid()) - setId String - flashcardId String - due DateTime - stability Float - difficulty Float - elapsedDays Int - scheduledDays Int - learningSteps Int @default(0) - reps Int - lapses Int - state Int - firstReviewedAt DateTime @default(now()) - lastReview DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - - set SpacedRepetitionSet @relation(fields: [setId], references: [id], onDelete: Cascade) - flashcard Flashcard @relation(fields: [flashcardId], references: [id], onDelete: Cascade) - - @@unique([setId, flashcardId]) - @@index([setId, due]) - @@index([setId, firstReviewedAt]) - @@index([flashcardId]) -} diff --git a/scripts/backup-database.mjs b/scripts/backup-database.mjs deleted file mode 100644 index 5f0b8fb..0000000 --- a/scripts/backup-database.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { existsSync } from "node:fs"; -import path from "node:path"; -import { pathToFileURL } from "node:url"; -import Database from "better-sqlite3"; -import { databasePathFromUrl } from "./databasePath.mjs"; - -function argument(name) { - const index = process.argv.indexOf(name); - return index >= 0 ? process.argv[index + 1] : undefined; -} - -export async function backupDatabase(sourcePath, outputPath) { - const source = path.resolve(sourcePath); - const output = path.resolve(outputPath); - if (!existsSync(source)) throw new Error(`Source database does not exist: ${source}`); - if (existsSync(output)) throw new Error(`Backup destination already exists: ${output}`); - if (source === output) throw new Error("Backup destination must differ from source"); - - const database = new Database(source, { readonly: true, fileMustExist: true }); - try { - await database.backup(output); - } finally { - database.close(); - } - - const backup = new Database(output, { readonly: true, fileMustExist: true }); - try { - const integrity = backup.pragma("integrity_check", { simple: true }); - if (integrity !== "ok") throw new Error(`Backup integrity check failed: ${integrity}`); - } finally { - backup.close(); - } - return output; -} - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - const source = argument("--source") ?? databasePathFromUrl(process.env.DATABASE_URL); - const output = argument("--output"); - if (!output) throw new Error("Usage: npm run db:backup -- --output "); - const completedPath = await backupDatabase(source, output); - console.info(`Verified SQLite backup created: ${completedPath}`); -} diff --git a/scripts/databasePath.mjs b/scripts/databasePath.mjs deleted file mode 100644 index 86b9564..0000000 --- a/scripts/databasePath.mjs +++ /dev/null @@ -1,10 +0,0 @@ -import path from "node:path"; - -export function databasePathFromUrl(databaseUrl, cwd = process.cwd()) { - if (!databaseUrl?.startsWith("file:")) { - throw new Error("DATABASE_URL must be a file: SQLite URL"); - } - const rawPath = databaseUrl.slice("file:".length).split("?")[0]; - if (!rawPath) throw new Error("DATABASE_URL does not contain a database path"); - return path.resolve(cwd, rawPath); -} diff --git a/scripts/migration-preflight.mjs b/scripts/migration-preflight.mjs deleted file mode 100644 index 4a762e8..0000000 --- a/scripts/migration-preflight.mjs +++ /dev/null @@ -1,358 +0,0 @@ -import { existsSync, readFileSync, readdirSync } from "node:fs"; -import path from "node:path"; -import { spawnSync } from "node:child_process"; -import { pathToFileURL } from "node:url"; -import Database from "better-sqlite3"; -import { databasePathFromUrl } from "./databasePath.mjs"; - -export const GROUP_MIGRATION = "20260807090000_add_material_groups"; - -function tableExists(database, table) { - return Boolean( - database - .prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?") - .get(table) - ); -} - -function hasColumn(database, table, column) { - if (!tableExists(database, table)) return false; - return database.pragma(`table_info(${table})`).some((item) => item.name === column); -} - -function hasForeignKey(database, table, from, target, onDelete) { - return database - .pragma(`foreign_key_list(${table})`) - .some( - (item) => - item.from === from && item.table === target && item.on_delete === onDelete - ); -} - -function hasUniqueIndex(database, table, indexName, column) { - const index = database - .pragma(`index_list(${table})`) - .find((item) => item.name === indexName && item.unique === 1); - if (!index) return false; - const columns = database.pragma(`index_info(${indexName})`); - return columns.length === 1 && columns[0].name === column; -} - -function schemaSnapshot(database, includeMigrationTable = false) { - const tables = database - .prepare( - `SELECT name FROM sqlite_master - WHERE type = 'table' - AND name NOT LIKE 'sqlite_%' - ${includeMigrationTable ? "" : "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 migrationRecords = tableExists(database, "_prisma_migrations") - ? database - .prepare( - 'SELECT "migration_name", "finished_at", "rolled_back_at" FROM "_prisma_migrations"' - ) - .all() - : []; - const activeRecords = migrationRecords.filter((row) => row.rolled_back_at === null); - const migrationNames = activeRecords - .filter((row) => row.finished_at !== null) - .map((row) => row.migration_name); - const failedMigrationNames = activeRecords - .filter((row) => row.finished_at === null) - .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 groupMigrationIndex = committedMigrations.indexOf(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 priorHistoryComplete = priorMigrations.every((name) => - migrationNames.includes(name) - ); - const knownMigrationNames = new Set(committedMigrations); - const historyHasUnknown = activeRecords.some( - (row) => !knownMigrationNames.has(row.migration_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]) - ); - let historyPrefixLength = 0; - while ( - historyPrefixLength < committedMigrations.length && - migrationNames.includes(committedMigrations[historyPrefixLength]) - ) { - historyPrefixLength += 1; - } - const historyIsExactPrefix = - !historyHasUnknown && - migrationNames.length === historyPrefixLength && - migrationNames.every((name) => - committedMigrations.slice(0, historyPrefixLength).includes(name) - ); - const trackedSchemaExact = - historyIsExactPrefix && - matches(expectedSchemaSnapshot(committedMigrations.slice(0, historyPrefixLength))); - let schemaPrefixLength = -1; - for (let index = 0; index <= committedMigrations.length; index += 1) { - if (matches(expectedSchemaSnapshot(committedMigrations.slice(0, index)))) { - schemaPrefixLength = index; - break; - } - } - const migrationsToAdopt = - schemaPrefixLength > historyPrefixLength - ? committedMigrations.slice(historyPrefixLength, schemaPrefixLength) - : []; - const failuresAreAdoptable = failedMigrationNames.every((name) => - migrationsToAdopt.includes(name) - ); - - let classification = "CONFLICT"; - if ( - historyIsExactPrefix && - failedMigrationNames.length === 0 && - schemaPrefixLength === historyPrefixLength - ) { - classification = - historyPrefixLength === committedMigrations.length ? "CURRENT" : "APPLY"; - } else if ( - historyIsExactPrefix && - priorHistoryComplete && - historyPrefixLength >= groupMigrationIndex && - schemaPrefixLength > historyPrefixLength && - failuresAreAdoptable - ) { - classification = "ADOPT"; - } - - return { - classification, - artifacts, - migrationNames, - failedMigrationNames, - migrationsToAdopt, - priorHistoryComplete, - schemaMigrationPrefix: - schemaPrefixLength >= 0 - ? committedMigrations[schemaPrefixLength - 1] ?? null - : null, - 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, true)) !== - JSON.stringify(schemaSnapshot(source, true)) - ) { - throw new Error("Backup schema does not match the source database"); - } - const sourceCounts = Object.fromEntries( - schemaSnapshot(source, true).map(({ table }) => [ - table, - source.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, - ]) - ); - const backupCounts = Object.fromEntries( - schemaSnapshot(backup, true).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, migrationName) { - const cli = path.join(process.cwd(), "node_modules", "prisma", "build", "index.js"); - const result = spawnSync( - process.execPath, - [cli, "migrate", "resolve", "--applied", migrationName], - { - 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 startup = process.argv.includes("--startup"); - const state = - startup && !existsSync(databasePath) - ? { - classification: "EMPTY", - migrationNames: [], - failedMigrationNames: [], - migrationsToAdopt: [], - } - : 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 - ); - for (const migrationName of state.migrationsToAdopt) { - resolveMigration(databaseUrl, migrationName); - } - console.info( - `Marked ${state.migrationsToAdopt.join(", ")} applied after exact-schema preflight.` - ); - } else if (startup && state.classification === "ADOPT") { - console.error( - "Database schema requires explicit migration adoption. Stop, create and verify a backup, then run db:preflight -- --resolve --backup ." - ); - process.exitCode = 3; - } else if (state.classification === "CONFLICT") { - process.exitCode = 2; - } -} diff --git a/scripts/request-password-reset.mjs b/scripts/request-password-reset.mjs deleted file mode 100644 index 1847eb2..0000000 --- a/scripts/request-password-reset.mjs +++ /dev/null @@ -1,56 +0,0 @@ -import { createHash, randomBytes, randomUUID } from "node:crypto"; -import { existsSync } from "node:fs"; -import { pathToFileURL } from "node:url"; -import Database from "better-sqlite3"; -import { databasePathFromUrl } from "./databasePath.mjs"; - -const PASSWORD_HASH_KEY = "admin_password_hash"; -const RESET_TOKEN_KEY = "admin_password_reset"; -const RESET_TOKEN_LIFETIME_MS = 15 * 60 * 1000; - -function activeRecord(value) { - try { - const record = JSON.parse(value); - return new Date(record.expiresAt).getTime() > Date.now(); - } catch { - return false; - } -} - -export function createResetToken(databasePath) { - if (!existsSync(databasePath)) throw new Error(`Database does not exist: ${databasePath}`); - const database = new Database(databasePath, { fileMustExist: true }); - try { - if (!database.prepare('SELECT 1 FROM "Setting" WHERE "key" = ?').get(PASSWORD_HASH_KEY)) { - throw new Error("Password recovery is unavailable before initial setup"); - } - const existing = database - .prepare('SELECT "value" FROM "Setting" WHERE "key" = ?') - .get(RESET_TOKEN_KEY); - if (existing && activeRecord(existing.value)) { - throw new Error("An unexpired reset token already exists; it was not replaced"); - } - - const token = randomBytes(24).toString("base64url"); - const expiresAt = new Date(Date.now() + RESET_TOKEN_LIFETIME_MS).toISOString(); - const value = JSON.stringify({ - digest: createHash("sha256").update(token, "utf8").digest("hex"), - nonce: randomUUID(), - expiresAt, - }); - database - .prepare( - 'INSERT INTO "Setting" ("key", "value") VALUES (?, ?) ON CONFLICT("key") DO UPDATE SET "value" = excluded."value"' - ) - .run(RESET_TOKEN_KEY, value); - return { token, expiresAt }; - } finally { - database.close(); - } -} - -if (import.meta.url === pathToFileURL(process.argv[1]).href) { - const result = createResetToken(databasePathFromUrl(process.env.DATABASE_URL)); - console.info(`Password reset token: ${result.token}`); - console.info(`Expires: ${result.expiresAt}`); -} diff --git a/scripts/verify-backup-restore.mjs b/scripts/verify-backup-restore.mjs deleted file mode 100644 index b67c924..0000000 --- a/scripts/verify-backup-restore.mjs +++ /dev/null @@ -1,197 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { - copyFileSync, - existsSync, - mkdirSync, - readFileSync, - readdirSync, - rmSync, - statSync, -} from "node:fs"; -import path from "node:path"; -import Database from "better-sqlite3"; -import { backupDatabase } from "./backup-database.mjs"; - -const root = path.join(process.cwd(), ".test-databases"); -const directory = path.join(root, `backup-restore-${randomUUID()}`); -if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) { - throw new Error("Unsafe disposable backup verification path"); -} -mkdirSync(directory, { recursive: true }); -const sourcePath = path.join(directory, "source.test.db"); -const backupPath = path.join(directory, "backup.test.db"); -const restoredPath = path.join(directory, "restored.test.db"); -let source; - -function rowCounts(database) { - const tables = database - .prepare( - "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' ORDER BY name" - ) - .all() - .map((row) => row.name); - return Object.fromEntries( - tables.map((table) => [ - table, - database.prepare(`SELECT COUNT(*) AS count FROM ${JSON.stringify(table)}`).get().count, - ]) - ); -} - -try { - source = new Database(sourcePath); - source.pragma("foreign_keys = ON"); - source.pragma("journal_mode = WAL"); - source.pragma("wal_autocheckpoint = 0"); - const migrations = readdirSync(path.join(process.cwd(), "prisma", "migrations"), { - withFileTypes: true, - }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort(); - for (const migration of migrations) { - source.exec( - readFileSync( - path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), - "utf8" - ) - ); - } - source.transaction(() => { - source - .prepare('INSERT INTO "Class" ("id", "name", "slug", "sortOrder") VALUES (?, ?, ?, ?)') - .run("backup-class", "Representative content", "representative-content", 0); - source - .prepare( - 'INSERT INTO "MaterialGroup" ("id", "classId", "name", "type", "sortOrder") VALUES (?, ?, ?, ?, ?)' - ) - .run("backup-group", "backup-class", "Backup group", "DECK", 0); - source - .prepare( - 'INSERT INTO "Deck" ("id", "classId", "groupId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?, ?)' - ) - .run( - "backup-deck", - "backup-class", - "backup-group", - "Backup deck", - "Restore drill content", - 0 - ); - source - .prepare( - 'INSERT INTO "Flashcard" ("id", "deckId", "front", "back", "sortOrder") VALUES (?, ?, ?, ?, ?)' - ) - .run("backup-card", "backup-deck", "Representative front", "Representative back", 0); - source - .prepare( - 'INSERT INTO "QuizSet" ("id", "classId", "name", "description", "sortOrder") VALUES (?, ?, ?, ?, ?)' - ) - .run("backup-quiz", "backup-class", "Backup quiz", "Quiz relationship", 0); - source - .prepare( - 'INSERT INTO "Question" ("id", "quizSetId", "type", "prompt", "rationale", "category", "sortOrder") VALUES (?, ?, ?, ?, ?, ?, ?)' - ) - .run( - "backup-question", - "backup-quiz", - "MULTIPLE_CHOICE", - "Representative prompt", - "Representative rationale", - "backup", - 0 - ); - source - .prepare( - 'INSERT INTO "AnswerOption" ("id", "questionId", "text", "isCorrect", "sortOrder") VALUES (?, ?, ?, ?, ?)' - ) - .run("backup-option", "backup-question", "Correct option", 1, 0); - source - .prepare( - 'INSERT INTO "SpacedRepetitionSet" ("id", "classId", "name", "sortOrder", "updatedAt") VALUES (?, ?, ?, ?, ?)' - ) - .run("backup-srs", "backup-class", "Backup SRS", 0, Date.now()); - source - .prepare( - 'INSERT INTO "SpacedRepetitionSetDeck" ("setId", "deckId", "sortOrder") VALUES (?, ?, ?)' - ) - .run("backup-srs", "backup-deck", 0); - source - .prepare( - 'INSERT INTO "ShareLink" ("id", "targetType", "groupId") VALUES (?, ?, ?)' - ) - .run("backup-share", "GROUP", "backup-group"); - })(); - - const walPath = `${sourcePath}-wal`; - if (!existsSync(walPath) || statSync(walPath).size === 0) { - throw new Error("Backup drill did not create an active WAL database"); - } - const sourceWalBytes = statSync(walPath).size; - const sourceCounts = rowCounts(source); - await backupDatabase(sourcePath, backupPath); - copyFileSync(backupPath, restoredPath); - source.close(); - source = undefined; - - const restored = new Database(restoredPath, { readonly: true, fileMustExist: true }); - try { - const integrity = restored.pragma("integrity_check", { simple: true }); - const foreignKeyErrors = restored.pragma("foreign_key_check"); - const restoredCounts = rowCounts(restored); - const representative = restored - .prepare(` - SELECT c.name AS className, g.name AS groupName, d.description, - f.front, s.name AS setName, sl.targetType - FROM "Class" c - JOIN "MaterialGroup" g ON g.classId = c.id - JOIN "Deck" d ON d.groupId = g.id - JOIN "Flashcard" f ON f.deckId = d.id - JOIN "SpacedRepetitionSetDeck" sd ON sd.deckId = d.id - JOIN "SpacedRepetitionSet" s ON s.id = sd.setId - JOIN "ShareLink" sl ON sl.groupId = g.id - WHERE d.id = ? - `) - .get("backup-deck"); - const quizRelationship = restored - .prepare(` - SELECT q.name AS quizName, question.prompt, option.text, option.isCorrect - FROM "QuizSet" q - JOIN "Question" question ON question.quizSetId = q.id - JOIN "AnswerOption" option ON option.questionId = question.id - WHERE q.id = ? - `) - .get("backup-quiz"); - if ( - integrity !== "ok" || - foreignKeyErrors.length !== 0 || - JSON.stringify(restoredCounts) !== JSON.stringify(sourceCounts) || - representative?.description !== "Restore drill content" || - representative?.setName !== "Backup SRS" || - representative?.targetType !== "GROUP" || - quizRelationship?.isCorrect !== 1 - ) { - throw new Error("Restored database did not preserve integrity, counts, content, and relationships"); - } - console.info( - JSON.stringify( - { - method: "better-sqlite3 online backup while the WAL source remained open", - sourceWalBytes, - integrity, - foreignKeyErrors, - counts: restoredCounts, - representative, - quizRelationship, - }, - null, - 2 - ) - ); - } finally { - restored.close(); - } -} finally { - source?.close(); - rmSync(directory, { recursive: true, force: true }); -} diff --git a/scripts/verify-http-smoke.mjs b/scripts/verify-http-smoke.mjs deleted file mode 100644 index ba76b12..0000000 --- a/scripts/verify-http-smoke.mjs +++ /dev/null @@ -1,83 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { mkdirSync, readFileSync, readdirSync, rmSync } from "node:fs"; -import path from "node:path"; -import { spawn } from "node:child_process"; -import Database from "better-sqlite3"; - -const root = path.join(process.cwd(), ".test-databases"); -const directory = path.join(root, `http-smoke-${randomUUID()}`); -if (!path.resolve(directory).startsWith(`${path.resolve(root)}${path.sep}`)) { - throw new Error("Unsafe disposable HTTP smoke path"); -} -mkdirSync(directory, { recursive: true }); -const databasePath = path.join(directory, "http-smoke.test.db"); -const database = new Database(databasePath); -try { - database.pragma("foreign_keys = ON"); - for (const migration of readdirSync(path.join(process.cwd(), "prisma", "migrations"), { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => entry.name) - .sort()) { - database.exec(readFileSync(path.join(process.cwd(), "prisma", "migrations", migration, "migration.sql"), "utf8")); - } -} finally { - database.close(); -} - -const port = 3789; -const server = spawn( - process.execPath, - [path.join(process.cwd(), "node_modules", "next", "dist", "bin", "next"), "start", "-p", String(port)], - { - cwd: process.cwd(), - env: { - ...process.env, - NODE_ENV: "production", - DATABASE_URL: `file:${databasePath.replaceAll("\\", "/")}`, - SESSION_SECRET: "http-smoke-secret-0123456789abcdef0123456789", - ALLOW_INITIAL_SETUP: "true", - }, - stdio: ["ignore", "pipe", "pipe"], - windowsHide: true, - } -); -let output = ""; -server.stdout.on("data", (chunk) => { output += chunk.toString(); }); -server.stderr.on("data", (chunk) => { output += chunk.toString(); }); - -try { - let health; - for (let attempt = 0; attempt < 30; attempt += 1) { - try { - health = await fetch(`http://127.0.0.1:${port}/api/health`); - if (health.ok) break; - } catch {} - await new Promise((resolve) => setTimeout(resolve, 500)); - } - if (!health?.ok) throw new Error(`Health check did not become ready.\n${output}`); - const login = await fetch(`http://127.0.0.1:${port}/login`); - const setup = await fetch(`http://127.0.0.1:${port}/api/auth/setup-status`); - const setupBody = await setup.json(); - const protectedDot = await fetch(`http://127.0.0.1:${port}/api/decks/file.json`, { redirect: "manual" }); - if (!login.ok || !setup.ok || setupBody.setupRequired !== true || setupBody.setupAllowed !== true || protectedDot.status !== 307) { - throw new Error("HTTP smoke responses did not match the production contract"); - } - console.info(JSON.stringify({ - health: health.status, - login: login.status, - setup: setupBody, - protectedDot: { status: protectedDot.status, location: protectedDot.headers.get("location") }, - }, null, 2)); - if (process.argv.includes("--stay")) { - console.info(`Browser smoke server ready at http://127.0.0.1:${port}/login`); - await new Promise(() => {}); - } -} finally { - server.kill(); - await new Promise((resolve) => { - if (server.exitCode !== null) return resolve(); - server.once("exit", resolve); - setTimeout(resolve, 2_000); - }); - rmSync(directory, { recursive: true, force: true }); -} diff --git a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx index 8743b87..726de4e 100644 --- a/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/[deckId]/page.tsx @@ -25,8 +25,6 @@ interface DeckData { currentIndex: number; orderJson: string; cardResultsJson: string | null; - sessionId: string; - revision: number; updatedAt: string; }>; } @@ -44,24 +42,19 @@ export default function DeckStudyPage() { async function handleRestart() { if (!deck) return; - const responses = await Promise.all( - deck.progress.map((progress) => - fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "DECK", - contentId: deck.id, - mode: progress.mode, - sessionId: progress.sessionId, - }), - }) - ) - ).catch(() => null); - if (!responses || responses.some((response) => !response.ok)) { - window.alert("The session could not be restarted. Your saved progress was kept."); - return; - } + // Clear progress in database for both modes + await Promise.all([ + fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SEQUENTIAL" }) + }).catch(() => {}), + fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "DECK", contentId: deck.id, mode: "SHUFFLED" }) + }).catch(() => {}) + ]); // Clear locally and force remount setDeck({ ...deck, progress: [] }); @@ -82,8 +75,7 @@ export default function DeckStudyPage() { }, [deckId, classSlug, router]); useEffect(() => { - const timer = window.setTimeout(() => void fetchDeck(), 0); - return () => window.clearTimeout(timer); + fetchDeck(); }, [fetchDeck]); if (loading || !deck) { diff --git a/src/app/(protected)/[classSlug]/flashcards/page.tsx b/src/app/(protected)/[classSlug]/flashcards/page.tsx index d5ce426..21f2fdb 100644 --- a/src/app/(protected)/[classSlug]/flashcards/page.tsx +++ b/src/app/(protected)/[classSlug]/flashcards/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; @@ -13,6 +13,7 @@ import { useSensor, useSensors, DragEndEvent, + DragOverEvent, DragStartEvent, DragOverlay, useDroppable, @@ -26,7 +27,6 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { ReorderItem } from "@/types/study"; interface DeckItem { id: string; @@ -40,8 +40,6 @@ interface DeckItem { currentIndex: number; orderJson: string; cardResultsJson: string | null; - sessionId: string; - revision: number; }>; } @@ -51,25 +49,15 @@ interface MaterialGroup { sortOrder: number; } +const cache: Record = {}; + function getProgressLabel(deck: DeckItem) { if (!deck.progress?.length) return null; const prog = deck.progress[0]; - let order: string[]; - let results: Record; - try { - const parsedOrder: unknown = JSON.parse(prog.orderJson); - const parsedResults: unknown = prog.cardResultsJson - ? JSON.parse(prog.cardResultsJson) - : {}; - if (!Array.isArray(parsedOrder)) throw new Error("Invalid progress order"); - order = parsedOrder.filter((id): id is string => typeof id === "string"); - results = - typeof parsedResults === "object" && parsedResults !== null - ? (parsedResults as Record) - : {}; - } catch { - return "Saved session needs repair"; - } + const order = JSON.parse(prog.orderJson) as string[]; + const results = prog.cardResultsJson + ? (JSON.parse(prog.cardResultsJson) as Record) + : {}; const correctCount = Object.values(results).filter((r) => r === "correct").length; const total = order.length; const current = Math.min(prog.currentIndex + 1, total); @@ -77,25 +65,7 @@ function getProgressLabel(deck: DeckItem) { return `${current}/${total}, ${modeLabel} · ${correctCount} ✓`; } -interface SortableDeckCardProps { - deck: DeckItem; - onEdit: (deck: DeckItem) => void; - onDelete: (deck: DeckItem) => void; - groups: MaterialGroup[]; - onMove: (deckId: string, groupId: string | null) => void; - moveDisabled?: boolean; - classSlug: string; -} - -function SortableDeckCard({ - deck, - onEdit, - onDelete, - groups, - onMove, - moveDisabled = false, - classSlug, -}: SortableDeckCardProps) { +function SortableDeckCard({ deck, onEdit, onDelete, classSlug }: any) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: deck.id }); const style = { transform: CSS.Transform.toString(transform), @@ -129,34 +99,19 @@ function SortableDeckCard({ )} -
-
- {deck.progress?.length ? ( - <> +
+ {deck.progress?.length ? ( + <> Continue - - ) : ( - - Study - - )} -
-
- - - - - -
+ + ) : ( + + Study + + )} + + +
@@ -222,12 +160,10 @@ 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([]); - const [groups, setGroups] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [reloadKey, setReloadKey] = useState(0); - const requestGeneration = useRef(0); + const [decks, setDecks] = useState(cache[classSlug]?.decks || []); + const [groups, setGroups] = useState(cache[classSlug]?.groups || []); + const [loading, setLoading] = useState(!cache[classSlug]); + const [animate] = useState(!cache[classSlug]); const [showImport, setShowImport] = useState(false); const [classId, setClassId] = useState(""); const [editingId, setEditingId] = useState(null); @@ -237,17 +173,13 @@ export default function FlashcardsPage() { const [newGroupName, setNewGroupName] = useState(""); const [editingGroupId, setEditingGroupId] = useState(null); const [editGroupName, setEditGroupName] = useState(""); - const [actionMessage, setActionMessage] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>({}); useEffect(() => { - const timer = window.setTimeout(() => { - const saved = localStorage.getItem('flashcards_collapsed_groups'); - if (saved) { - try { setCollapsedGroups(JSON.parse(saved)); } catch {} - } - }, 0); - return () => window.clearTimeout(timer); + const saved = localStorage.getItem('flashcards_collapsed_groups'); + if (saved) { + try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {} + } }, []); function toggleGroup(id: string) { @@ -258,54 +190,43 @@ export default function FlashcardsPage() { }); } - const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => { + const fetchAll = useCallback(async (cId: string) => { try { const [deckRes, groupRes] = await Promise.all([ - fetch(`/api/decks/list?classId=${cId}`, { signal }), - fetch(`/api/material-groups?classId=${cId}&type=DECK`, { signal }), + fetch(`/api/decks/list?classId=${cId}`), + fetch(`/api/material-groups?classId=${cId}&type=DECK`), ]); - 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; + const ds = deckRes.ok ? await deckRes.json() : []; + const gs = groupRes.ok ? await groupRes.json() : []; setDecks(ds); setGroups(gs); - setLoadError(null); - } catch (error) { - if (signal?.aborted || generation !== requestGeneration.current) return; - setLoadError(error instanceof Error ? error.message : "Unable to load flashcard library"); + cache[classSlug] = { decks: ds, groups: gs }; + } catch { + setDecks([]); + setGroups([]); } finally { - if (generation === requestGeneration.current) setLoading(false); + setLoading(false); } - }, []); + }, [classSlug]); useEffect(() => { - const controller = new AbortController(); - const generation = ++requestGeneration.current; async function init() { try { - setLoading(true); - setLoadError(null); - const classRes = await fetch("/api/classes", { signal: controller.signal }); - if (!classRes.ok) throw new Error("Unable to load class"); + const classRes = await fetch("/api/classes"); const classes = await classRes.json(); const cls = classes.find((c: { slug: string }) => c.slug === classSlug); - if (!cls) throw new Error("Class not found"); - if (generation !== requestGeneration.current) return; + if (!cls) { + setLoading(false); + return; + } setClassId(cls.id); - 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"); + fetchAll(cls.id); + } catch { setLoading(false); } } - const timer = window.setTimeout(() => void init(), 0); - return () => { - window.clearTimeout(timer); - controller.abort(); - }; - }, [classSlug, fetchAll, reloadKey]); + init(); + }, [classSlug, fetchAll]); // Group Management async function handleCreateGroup() { @@ -317,71 +238,48 @@ export default function FlashcardsPage() { }); if (res.ok) { const g = await res.json(); - setGroups((prev) => [g, ...prev]); + setGroups((prev) => [...prev, g]); 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; - const response = await fetch(`/api/material-groups/${id}`, { + 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; - 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.`); + 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))); } // Deck Management async function handleDeleteDeck(deck: DeckItem) { if (!confirm(`Delete "${deck.name}" and all its cards?`)) return; - 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; - } + await fetch(`/api/decks/${deck.id}`, { method: "DELETE" }); 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; - const response = await fetch(`/api/decks/${id}`, { + 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 @@ -391,13 +289,15 @@ export default function FlashcardsPage() { ); const [activeId, setActiveId] = useState(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; @@ -410,6 +310,8 @@ export default function FlashcardsPage() { if (!activeDeck) return; let targetGroupId: string | null = null; + let targetIndex = 0; + const overContainerId = over.data.current?.sortable?.containerId; if (overContainerId) { targetGroupId = overContainerId === "uncategorized" ? null : overContainerId; @@ -422,76 +324,40 @@ export default function FlashcardsPage() { } } - 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 (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; - async function persistDeckReorder( - previousItems: DeckItem[], - proposedItems: DeckItem[], - affectedGroups: Set - ) { - const counters = new Map(); - 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 })); + if (overIndex >= 0 && overIndex !== oldIndex) { + newItems = arrayMove(newItems, oldIndex, overIndex); + } else { + const oldItem = newItems.splice(oldIndex, 1)[0]; + newItems.push(oldItem); + } - setDecks(nextItems); - setIsReordering(true); - try { - const response = await fetch("/api/decks/reorder", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: updates }), + const affectedGroups = new Set([activeDeck.groupId, targetGroupId]); + const updates: any[] = []; + + 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; }); - 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}.`); } } @@ -505,11 +371,6 @@ export default function FlashcardsPage() { return (
- {actionMessage && ( -

- {actionMessage} -

- )} {/* Header */}

Collection

Flashcard decks

@@ -572,23 +433,15 @@ export default function FlashcardsPage() {
)} - {!loading && loadError && ( -
-

Flashcard library could not be loaded.

-

{loadError}

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

No decks yet

Import your first deck or create a group

)} - {!loading && !loadError && ( - + {!loading && ( +
{groupedDecks.map(group => (
@@ -622,7 +475,7 @@ export default function FlashcardsPage() {
Drop decks here
) : ( group.decks.map(deck => ( - { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> )) )} @@ -647,7 +500,7 @@ export default function FlashcardsPage() {
No uncategorized decks
) : ( uncategorizedDecks.map(deck => ( - { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} groups={groups} onMove={handleMoveDeck} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(d.id); setEditName(d.name); setEditDescription(d.description || ""); }} onDelete={handleDeleteDeck} classSlug={classSlug} /> )) )} @@ -659,7 +512,7 @@ export default function FlashcardsPage() { {activeDeck ? (
- {}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} /> + {}} onDelete={()=>{}} classSlug={classSlug} />
) : null}
diff --git a/src/app/(protected)/[classSlug]/layout.tsx b/src/app/(protected)/[classSlug]/layout.tsx index 85d64bd..09c5c86 100644 --- a/src/app/(protected)/[classSlug]/layout.tsx +++ b/src/app/(protected)/[classSlug]/layout.tsx @@ -12,8 +12,8 @@ export default async function ClassLayout(props: LayoutProps<"/[classSlug]">) { return (
- {/* Dynamic class header */} - + {/* Dynamic Header & Tabs */} + {/* Page content */} {props.children} diff --git a/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx b/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx index 2055011..9c57430 100644 --- a/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/[quizId]/page.tsx @@ -27,7 +27,6 @@ interface QuizAttempt { score: number; maxScore: number; answersJson: string; - reviewSnapshotJson?: string | null; isPartialRetake: boolean; completedAt: string; } @@ -43,8 +42,6 @@ interface QuizData { currentIndex: number; orderJson: string; answersJson: string | null; - sessionId: string; - revision: number; }>; } @@ -63,23 +60,13 @@ export default function QuizStudyPage() { async function handleRestart() { if (!quiz) return; - const progress = quiz.progress.find((item) => item.mode === "SEQUENTIAL"); - if (progress) { - const response = await fetch("/api/progress", { - method: "DELETE", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "QUIZ", - contentId: quiz.id, - mode: "SEQUENTIAL", - sessionId: progress.sessionId, - }), - }).catch(() => null); - if (!response?.ok) { - window.alert("The quiz could not be restarted. Your saved progress was kept."); - return; - } - } + + // Clear progress in database + await fetch("/api/progress", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ contentType: "QUIZ", contentId: quiz.id, mode: "SEQUENTIAL" }) + }).catch(() => {}); // Clear locally and force remount setQuiz({ ...quiz, progress: [] }); @@ -117,8 +104,7 @@ export default function QuizStudyPage() { }, [quizId, classSlug, router]); useEffect(() => { - const timer = window.setTimeout(() => void fetchQuizAndAttempts(), 0); - return () => window.clearTimeout(timer); + fetchQuizAndAttempts(); }, [fetchQuizAndAttempts]); const [showTopics, setShowTopics] = useState(false); @@ -235,7 +221,6 @@ export default function QuizStudyPage() { key={restartKey} quiz={quiz} retakeIds={retakeIds} - sessionKey={restartKey} onFinished={() => { router.push(`/${classSlug}/quizzes`); }} diff --git a/src/app/(protected)/[classSlug]/quizzes/page.tsx b/src/app/(protected)/[classSlug]/quizzes/page.tsx index 6ae9949..d007514 100644 --- a/src/app/(protected)/[classSlug]/quizzes/page.tsx +++ b/src/app/(protected)/[classSlug]/quizzes/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useCallback, useRef } from "react"; +import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; import { useParams } from "next/navigation"; import { ImportModal } from "@/components/import/ImportModal"; @@ -13,6 +13,7 @@ import { useSensor, useSensors, DragEndEvent, + DragOverEvent, DragStartEvent, DragOverlay, useDroppable, @@ -26,7 +27,6 @@ import { useSortable, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { ReorderItem } from "@/types/study"; interface QuizItem { id: string; @@ -35,14 +35,6 @@ interface QuizItem { groupId: string | null; sortOrder: number; _count: { questions: number; attempts: number }; - progress: Array<{ - mode: string; - currentIndex: number; - orderJson: string; - answersJson: string | null; - sessionId: string; - revision: number; - }>; } interface MaterialGroup { @@ -51,25 +43,9 @@ interface MaterialGroup { sortOrder: number; } -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; -} +const cache: Record = {}; -function SortableQuizCard({ - quiz, - onEdit, - onDelete, - groups, - onMove, - moveDisabled = false, - classSlug, -}: SortableQuizCardProps) { +function SortableQuizCard({ quiz, onEdit, onDelete, classSlug }: any) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: quiz.id }); const style = { transform: CSS.Transform.toString(transform), @@ -103,73 +79,21 @@ function SortableQuizCard({ )}
-
-
- {quiz.progress?.length ? ( - <> - - Continue - - - - ) : ( - - Start Quiz - - )} -
-
- - - - - -
+
+ + Start Quiz + + + +
@@ -194,12 +118,10 @@ 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([]); - const [groups, setGroups] = useState([]); - const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); - const [reloadKey, setReloadKey] = useState(0); - const requestGeneration = useRef(0); + const [quizzes, setQuizzes] = useState(cache[classSlug]?.quizzes || []); + const [groups, setGroups] = useState(cache[classSlug]?.groups || []); + const [loading, setLoading] = useState(!cache[classSlug]); + const [animate] = useState(!cache[classSlug]); const [showImport, setShowImport] = useState(false); const [classId, setClassId] = useState(""); const [editingId, setEditingId] = useState(null); @@ -209,17 +131,13 @@ export default function QuizzesPage() { const [newGroupName, setNewGroupName] = useState(""); const [editingGroupId, setEditingGroupId] = useState(null); const [editGroupName, setEditGroupName] = useState(""); - const [actionMessage, setActionMessage] = useState(null); const [collapsedGroups, setCollapsedGroups] = useState>({}); useEffect(() => { - const timer = window.setTimeout(() => { - const saved = localStorage.getItem('quizzes_collapsed_groups'); - if (saved) { - try { setCollapsedGroups(JSON.parse(saved)); } catch {} - } - }, 0); - return () => window.clearTimeout(timer); + const saved = localStorage.getItem('quizzes_collapsed_groups'); + if (saved) { + try { setCollapsedGroups(JSON.parse(saved)); } catch(e) {} + } }, []); function toggleGroup(id: string) { @@ -230,54 +148,43 @@ export default function QuizzesPage() { }); } - const fetchAll = useCallback(async (cId: string, generation = requestGeneration.current, signal?: AbortSignal) => { + const fetchAll = useCallback(async (cId: string) => { try { const [quizRes, groupRes] = await Promise.all([ - fetch(`/api/quizzes/list?classId=${cId}`, { signal }), - fetch(`/api/material-groups?classId=${cId}&type=QUIZ`, { signal }), + fetch(`/api/quizzes/list?classId=${cId}`), + fetch(`/api/material-groups?classId=${cId}&type=QUIZ`), ]); - 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; + const qs = quizRes.ok ? await quizRes.json() : []; + const gs = groupRes.ok ? await groupRes.json() : []; setQuizzes(qs); setGroups(gs); - setLoadError(null); - } catch (error) { - if (signal?.aborted || generation !== requestGeneration.current) return; - setLoadError(error instanceof Error ? error.message : "Unable to load quiz library"); + cache[classSlug] = { quizzes: qs, groups: gs }; + } catch { + setQuizzes([]); + setGroups([]); } finally { - if (generation === requestGeneration.current) setLoading(false); + setLoading(false); } - }, []); + }, [classSlug]); useEffect(() => { - const controller = new AbortController(); - const generation = ++requestGeneration.current; async function init() { try { - setLoading(true); - setLoadError(null); - const classRes = await fetch("/api/classes", { signal: controller.signal }); - if (!classRes.ok) throw new Error("Unable to load class"); + const classRes = await fetch("/api/classes"); const classes = await classRes.json(); const cls = classes.find((c: { slug: string }) => c.slug === classSlug); - if (!cls) throw new Error("Class not found"); - if (generation !== requestGeneration.current) return; + if (!cls) { + setLoading(false); + return; + } setClassId(cls.id); - 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"); + fetchAll(cls.id); + } catch { setLoading(false); } } - const timer = window.setTimeout(() => void init(), 0); - return () => { - window.clearTimeout(timer); - controller.abort(); - }; - }, [classSlug, fetchAll, reloadKey]); + init(); + }, [classSlug, fetchAll]); // Group Management async function handleCreateGroup() { @@ -289,70 +196,48 @@ export default function QuizzesPage() { }); if (res.ok) { const g = await res.json(); - setGroups((prev) => [g, ...prev]); + setGroups((prev) => [...prev, g]); 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; - const response = await fetch(`/api/material-groups/${id}`, { + 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; - 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.`); + 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))); } // Quiz Management async function handleDeleteQuiz(quiz: QuizItem) { if (!confirm(`Delete "${quiz.name}" and all its questions and attempts?`)) return; - 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; - } + await fetch(`/api/quizzes/${quiz.id}`, { method: "DELETE" }); setQuizzes((prev) => prev.filter((q) => q.id !== quiz.id)); - setActionMessage(`Deleted quiz ${quiz.name}.`); } async function handleRenameQuiz(id: string) { if (!editName.trim()) return; - const response = await fetch(`/api/quizzes/${id}`, { + 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 @@ -362,13 +247,26 @@ export default function QuizzesPage() { ); const [activeId, setActiveId] = useState(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; @@ -381,6 +279,8 @@ export default function QuizzesPage() { if (!activeQuiz) return; let targetGroupId: string | null = null; + let targetIndex = 0; + // Check if over a group container directly const overContainerId = over.data.current?.sortable?.containerId; if (overContainerId) { @@ -394,76 +294,43 @@ export default function QuizzesPage() { } } - 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 (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; - async function persistQuizReorder( - previousItems: QuizItem[], - proposedItems: QuizItem[], - affectedGroups: Set - ) { - const counters = new Map(); - 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 })); + 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); + } - setQuizzes(nextItems); - setIsReordering(true); - try { - const response = await fetch("/api/quizzes/reorder", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ items: updates }), + // Re-calculate sortOrder for the affected groups to persist + const affectedGroups = new Set([activeQuiz.groupId, targetGroupId]); + const updates: any[] = []; + + 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; }); - 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}.`); } } @@ -478,11 +345,6 @@ export default function QuizzesPage() { return (
- {actionMessage && ( -

- {actionMessage} -

- )} {/* Header */}

Practice

Practice quizzes

@@ -546,23 +408,15 @@ export default function QuizzesPage() {
)} - {!loading && loadError && ( -
-

Quiz library could not be loaded.

-

{loadError}

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

No quizzes yet

Import your first quiz or create a group

)} - {!loading && !loadError && ( - + {!loading && ( +
{groupedQuizzes.map(group => (
@@ -583,7 +437,7 @@ export default function QuizzesPage() { )}
- +
@@ -596,7 +450,7 @@ export default function QuizzesPage() {
Drop quizzes here
) : ( group.quizzes.map(quiz => ( - { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> )) )} @@ -621,7 +475,7 @@ export default function QuizzesPage() {
No uncategorized quizzes
) : ( uncategorizedQuizzes.map(quiz => ( - { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} groups={groups} onMove={handleMoveQuiz} moveDisabled={isReordering} classSlug={classSlug} /> + { setEditingId(q.id); setEditName(q.name); setEditDescription(q.description || ""); }} onDelete={handleDeleteQuiz} classSlug={classSlug} /> )) )} @@ -633,7 +487,7 @@ export default function QuizzesPage() { {activeQuiz ? (
- {}} onDelete={()=>{}} groups={groups} onMove={()=>{}} moveDisabled classSlug={classSlug} /> + {}} onDelete={()=>{}} classSlug={classSlug} />
) : null}
diff --git a/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx b/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx deleted file mode 100644 index 4bd9feb..0000000 --- a/src/app/(protected)/[classSlug]/spaced-repetition/[setId]/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SpacedRepetitionViewer } from "@/components/spaced-repetition/SpacedRepetitionViewer"; - -export default function SpacedRepetitionStudyPage() { - return ; -} diff --git a/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx b/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx deleted file mode 100644 index b07ee3b..0000000 --- a/src/app/(protected)/[classSlug]/spaced-repetition/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { SpacedRepetitionSets } from "@/components/spaced-repetition/SpacedRepetitionSets"; - -export default function SpacedRepetitionPage() { - return ; -} diff --git a/src/app/(protected)/page.tsx b/src/app/(protected)/page.tsx index d411e93..88ece8c 100644 --- a/src/app/(protected)/page.tsx +++ b/src/app/(protected)/page.tsx @@ -2,17 +2,12 @@ import { useEffect, useState } from "react"; import Link from "next/link"; -import { ActivityBanner } from "@/components/activity/ActivityBanner"; interface ClassItem { id: string; slug: string; name: string; _count: { decks: number; quizSets: number }; - spacedRepetitionDueCards: number; - spacedRepetitionLearningCards: number; - spacedRepetitionNewCards: number; - spacedRepetitionReadyCards: number; } const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7"]; @@ -20,7 +15,6 @@ const accents = ["#4f46e5", "#f97360", "#25845f", "#c27718", "#8b5cf6", "#0284c7 export default function HomePage() { const [classes, setClasses] = useState([]); const [loading, setLoading] = useState(true); - const [loadError, setLoadError] = useState(null); const [showCreate, setShowCreate] = useState(false); const [newName, setNewName] = useState(""); const [creating, setCreating] = useState(false); @@ -28,25 +22,13 @@ export default function HomePage() { const [editName, setEditName] = useState(""); const [savingEdit, setSavingEdit] = useState(false); - useEffect(() => { - const controller = new AbortController(); - void fetchClasses(controller.signal); - return () => controller.abort(); - }, []); + useEffect(() => { fetchClasses(); }, []); - async function fetchClasses(signal?: AbortSignal) { - setLoading(true); - setLoadError(null); + async function fetchClasses() { try { - const res = await fetch("/api/classes", { signal }); - if (!res.ok) throw new Error("The server could not load your classes."); + const res = await fetch("/api/classes"); setClasses(await res.json()); - } catch (error) { - if (signal?.aborted) return; - setLoadError(error instanceof Error ? error.message : "Unable to load classes."); - } finally { - if (!signal?.aborted) setLoading(false); - } + } finally { setLoading(false); } } async function handleCreate(e: React.FormEvent) { @@ -55,21 +37,14 @@ export default function HomePage() { setCreating(true); try { const res = await fetch("/api/classes", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: newName.trim() }) }); - if (!res.ok) throw new Error("The class could not be created."); - setNewName(""); setShowCreate(false); void fetchClasses(); - } catch (error) { - setLoadError(error instanceof Error ? error.message : "The class could not be created."); + if (res.ok) { setNewName(""); setShowCreate(false); fetchClasses(); } } finally { setCreating(false); } } async function handleDelete(id: string, name: string) { if (!confirm(`Delete "${name}" and all its decks and quizzes?`)) return; - const response = await fetch(`/api/classes/${id}`, { method: "DELETE" }).catch(() => null); - if (!response?.ok) { - setLoadError("The class could not be deleted. Retry when the server is available."); - return; - } - void fetchClasses(); + await fetch(`/api/classes/${id}`, { method: "DELETE" }); + fetchClasses(); } async function handleSaveEdit(id: string) { @@ -77,16 +52,37 @@ export default function HomePage() { setSavingEdit(true); try { const res = await fetch(`/api/classes/${id}`, { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ name: editName.trim() }) }); - if (!res.ok) throw new Error("The class could not be renamed."); - setEditingId(null); void fetchClasses(); - } catch (error) { - setLoadError(error instanceof Error ? error.message : "The class could not be renamed."); + if (res.ok) { setEditingId(null); fetchClasses(); } } finally { setSavingEdit(false); } } + const deckTotal = classes.reduce((sum, item) => sum + item._count.decks, 0); + const quizTotal = classes.reduce((sum, item) => sum + item._count.quizSets, 0); + return (
- setShowCreate(true)} /> +
+
+
+
+
+

Personal library

+

What are we learning today?

+

Pick up where you left off, sharpen a weak topic, or build something new.

+
+ +
+
+ {[[classes.length, "Classes"], [deckTotal, "Decks"], [quizTotal, "Quizzes"]].map(([value, label]) => ( +
+
{value}
+
{label}
+
+ ))} +
+
{showCreate && (
@@ -116,15 +112,7 @@ export default function HomePage() {
)} - {!loading && loadError && ( -
-

Your study spaces could not be loaded.

-

{loadError}

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

Your desk is ready

@@ -133,7 +121,7 @@ export default function HomePage() {
)} - {!loading && !loadError && classes.length > 0 && ( + {!loading && classes.length > 0 && (
{classes.map((cls, index) => (
@@ -149,14 +137,6 @@ export default function HomePage() {
{cls._count.decks} {cls._count.decks === 1 ? "deck" : "decks"} {cls._count.quizSets} {cls._count.quizSets === 1 ? "quiz" : "quizzes"} - {cls.spacedRepetitionReadyCards > 0 && ( - - {cls.spacedRepetitionReadyCards} {cls.spacedRepetitionReadyCards === 1 ? "card" : "cards"} ready - - )}
diff --git a/src/app/api/activity/route.ts b/src/app/api/activity/route.ts deleted file mode 100644 index 570793f..0000000 --- a/src/app/api/activity/route.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { studyActivitySchema } from "@/lib/validation/activitySchemas"; -import * as activityService from "@/services/activityService"; - -export async function GET() { - return NextResponse.json(await activityService.getActivitySummary()); -} - -export async function POST(request: NextRequest) { - const parsed = studyActivitySchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid activity type" }, { status: 400 }); - } - - await activityService.recordActivity(parsed.data.type); - return NextResponse.json({ success: true }, { status: 201 }); -} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index f557aaa..018a809 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -1,28 +1,116 @@ import { NextRequest, NextResponse } from "next/server"; -import { loginSchema } from "@/lib/validation/authSchemas"; +import { prisma } from "@/lib/db"; +import { createSession } from "@/lib/auth"; import { checkRateLimit } from "@/lib/rateLimiter"; -import { login } from "@/services/authService"; + +// Progressive lockout thresholds from Section 4 +function getLockoutDuration(failedAttempts: number): number { + if (failedAttempts >= 20) return 24 * 60 * 60 * 1000; // 24 hours + if (failedAttempts >= 15) return 30 * 60 * 1000; // 30 minutes + if (failedAttempts >= 10) return 5 * 60 * 1000; // 5 minutes + if (failedAttempts >= 5) return 1 * 60 * 1000; // 1 minute + return 0; +} export async function POST(request: NextRequest) { - if (!checkRateLimit("login:global").allowed) { + // Rate limit by IP + const forwarded = request.headers.get("x-forwarded-for"); + const ip = forwarded?.split(",")[0]?.trim() ?? "unknown"; + const rateCheck = checkRateLimit(ip); + + if (!rateCheck.allowed) { return NextResponse.json( { error: "Too many requests. Try again shortly." }, { status: 429 } ); } - const parsed = loginSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { + const body = await request.json().catch(() => null); + if (!body?.password || typeof body.password !== "string") { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { error: "Password is required" }, { status: 400 } ); } - const result = await login(parsed.data.password); - if (!result.ok) { - return NextResponse.json({ error: result.error }, { status: result.status }); + // Check lockout state + let security = await prisma.authSecurity.findUnique({ where: { id: 1 } }); + if (!security) { + security = await prisma.authSecurity.create({ data: { id: 1 } }); } + if (security.lockedUntil && security.lockedUntil > new Date()) { + const remainingMs = + security.lockedUntil.getTime() - Date.now(); + const remainingMin = Math.ceil(remainingMs / 60000); + return NextResponse.json( + { + error: `Account locked. Try again in ${remainingMin} minute${remainingMin !== 1 ? "s" : ""}.`, + }, + { status: 423 } + ); + } + + // Verify password + const setting = await prisma.setting.findUnique({ + where: { key: "admin_password_hash" }, + }); + + let isValid = false; + + if (!setting) { + // Initial setup: hash and save the new password + const argon2 = await import("argon2"); + const newHash = await argon2.hash(body.password); + await prisma.setting.create({ + data: { + key: "admin_password_hash", + value: newHash, + }, + }); + isValid = true; + } else { + // Verify existing password + const passwordHash = setting.value; + try { + const argon2 = await import("argon2"); + isValid = await argon2.verify(passwordHash, body.password); + } catch { + isValid = body.password === passwordHash; + } + } + + if (!isValid) { + const newFailedAttempts = security.failedAttempts + 1; + const lockoutMs = getLockoutDuration(newFailedAttempts); + const lockedUntil = lockoutMs > 0 ? new Date(Date.now() + lockoutMs) : null; + + await prisma.authSecurity.update({ + where: { id: 1 }, + data: { + failedAttempts: newFailedAttempts, + lockedUntil, + lastAttemptAt: new Date(), + }, + }); + + return NextResponse.json( + { error: "Invalid password" }, + { status: 401 } + ); + } + + // Success — reset lockout, create session + await prisma.authSecurity.update({ + where: { id: 1 }, + data: { + failedAttempts: 0, + lockedUntil: null, + lastAttemptAt: new Date(), + }, + }); + + await createSession(); + return NextResponse.json({ success: true }); } diff --git a/src/app/api/auth/password-reset/complete/route.ts b/src/app/api/auth/password-reset/complete/route.ts deleted file mode 100644 index 8368cbe..0000000 --- a/src/app/api/auth/password-reset/complete/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -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) - ); - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid request" }, - { status: 400 } - ); - } - - if (!(await completePasswordReset(parsed.data.password))) { - return NextResponse.json( - { error: "Reset authorization is invalid or expired" }, - { status: 401 } - ); - } - - return NextResponse.json({ success: true }); -} diff --git a/src/app/api/auth/password-reset/request/route.ts b/src/app/api/auth/password-reset/request/route.ts deleted file mode 100644 index 9cb71bf..0000000 --- a/src/app/api/auth/password-reset/request/route.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { NextResponse } from "next/server"; -import { checkRateLimit } from "@/lib/rateLimiter"; -import { requestPasswordReset } from "@/services/authService"; - -export async function POST() { - if (process.env.NODE_ENV === "production") { - return NextResponse.json( - { error: "Start password recovery from the local server console." }, - { status: 403 } - ); - } - - const rateLimit = checkRateLimit("password-reset-request:global", { - windowMs: 15 * 60_000, - maxRequests: 1, - }); - if (!rateLimit.allowed) { - return NextResponse.json( - { error: "A reset token was requested recently. Try again shortly." }, - { status: 429 } - ); - } - - const result = await requestPasswordReset(); - if (result.status === "missing-password") { - return NextResponse.json( - { error: "Password recovery is unavailable before initial setup." }, - { status: 409 } - ); - } - if (result.status === "active-token") { - return NextResponse.json( - { error: "An unexpired reset token already exists." }, - { status: 409 } - ); - } - return NextResponse.json({ success: true, token: result.token }); -} diff --git a/src/app/api/auth/password-reset/verify/route.ts b/src/app/api/auth/password-reset/verify/route.ts deleted file mode 100644 index 231232e..0000000 --- a/src/app/api/auth/password-reset/verify/route.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { checkRateLimit } from "@/lib/rateLimiter"; -import { resetTokenSchema } from "@/lib/validation/authSchemas"; -import { verifyPasswordResetToken } from "@/services/authService"; - -export async function POST(request: NextRequest) { - if (!checkRateLimit("password-reset-verify:global").allowed) { - return NextResponse.json( - { error: "Too many attempts. Try again shortly." }, - { status: 429 } - ); - } - - const parsed = resetTokenSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 400 }); - } - - if (!(await verifyPasswordResetToken(parsed.data.token))) { - return NextResponse.json({ error: "Invalid or expired reset token" }, { status: 401 }); - } - - return NextResponse.json({ success: true }); -} diff --git a/src/app/api/auth/setup-status/route.ts b/src/app/api/auth/setup-status/route.ts index 99ed0e9..24816e0 100644 --- a/src/app/api/auth/setup-status/route.ts +++ b/src/app/api/auth/setup-status/route.ts @@ -1,9 +1,13 @@ import { NextResponse } from "next/server"; -import { getSetupStatus } from "@/services/authService"; +import { prisma } from "@/lib/db"; export async function GET() { try { - return NextResponse.json(await getSetupStatus()); + const setting = await prisma.setting.findUnique({ + where: { key: "admin_password_hash" }, + }); + + return NextResponse.json({ setupRequired: !setting }); } catch (error) { console.error("Failed to check setup status:", error); return NextResponse.json( diff --git a/src/app/api/cards/[id]/route.ts b/src/app/api/cards/[id]/route.ts index bc7e4f9..a0a5100 100644 --- a/src/app/api/cards/[id]/route.ts +++ b/src/app/api/cards/[id]/route.ts @@ -1,24 +1,21 @@ import { NextRequest, NextResponse } from "next/server"; import * as cardService from "@/services/cardService"; -import { cardUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function PATCH( request: NextRequest, ctx: RouteContext<"/api/cards/[id]"> ) { const { id } = await ctx.params; - const parsed = cardUpdateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid card update", details: parsed.error.issues }, { status: 400 }); - } + const body = await request.json().catch(() => null); + try { - return NextResponse.json(await cardService.updateCard(id, parsed.data)); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Card not found" }, { status: 404 }); - } - console.error("Failed to update card", error); - return NextResponse.json({ error: "Failed to update card" }, { status: 500 }); + const updated = await cardService.updateCard(id, { + front: body?.front?.trim(), + back: body?.back?.trim(), + }); + return NextResponse.json(updated); + } catch { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); } } @@ -27,14 +24,11 @@ export async function DELETE( ctx: RouteContext<"/api/cards/[id]"> ) { const { id } = await ctx.params; + try { await cardService.deleteCard(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Card not found" }, { status: 404 }); - } - console.error("Failed to delete card", error); - return NextResponse.json({ error: "Failed to delete card" }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Card not found" }, { status: 404 }); } } diff --git a/src/app/api/classes/[id]/route.ts b/src/app/api/classes/[id]/route.ts index 89aad57..dc66b93 100644 --- a/src/app/api/classes/[id]/route.ts +++ b/src/app/api/classes/[id]/route.ts @@ -1,24 +1,24 @@ import { NextRequest, NextResponse } from "next/server"; import * as classService from "@/services/classService"; -import { classUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function PATCH( request: NextRequest, ctx: RouteContext<"/api/classes/[id]"> ) { const { id } = await ctx.params; - const parsed = classUpdateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "A valid class name is required" }, { status: 400 }); + const body = await request.json().catch(() => null); + + if (!body || (!body.name && body.name !== "")) { + return NextResponse.json({ error: "Nothing to update" }, { status: 400 }); } + try { - return NextResponse.json(await classService.updateClass(id, parsed.data)); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Class not found" }, { status: 404 }); - } - console.error("Failed to update class", error); - return NextResponse.json({ error: "Failed to update class" }, { status: 500 }); + const updated = await classService.updateClass(id, { + name: body.name?.trim(), + }); + return NextResponse.json(updated); + } catch { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); } } @@ -27,14 +27,11 @@ export async function DELETE( ctx: RouteContext<"/api/classes/[id]"> ) { const { id } = await ctx.params; + try { await classService.deleteClass(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Class not found" }, { status: 404 }); - } - console.error("Failed to delete class", error); - return NextResponse.json({ error: "Failed to delete class" }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Class not found" }, { status: 404 }); } } diff --git a/src/app/api/classes/route.ts b/src/app/api/classes/route.ts index 2be6d0d..9a5b335 100644 --- a/src/app/api/classes/route.ts +++ b/src/app/api/classes/route.ts @@ -1,23 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; import * as classService from "@/services/classService"; -import { classCreateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET() { - return NextResponse.json(await classService.listClasses()); + const classes = await classService.listClasses(); + return NextResponse.json(classes); } export async function POST(request: NextRequest) { - const parsed = classCreateSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "Class name is required and must be 160 characters or fewer" }, { status: 400 }); - } - try { - return NextResponse.json(await classService.createClass(parsed.data.name), { status: 201 }); - } catch (error) { - if (isPrismaError(error, "P2002")) { - return NextResponse.json({ error: "A class with that slug already exists" }, { status: 409 }); - } - console.error("Failed to create class", error); - return NextResponse.json({ error: "Failed to create class" }, { status: 500 }); + const body = await request.json().catch(() => null); + if (!body?.name || typeof body.name !== "string" || !body.name.trim()) { + return NextResponse.json( + { error: "Class name is required" }, + { status: 400 } + ); } + + const newClass = await classService.createClass(body.name.trim()); + return NextResponse.json(newClass, { status: 201 }); } diff --git a/src/app/api/decks/[id]/cards/route.ts b/src/app/api/decks/[id]/cards/route.ts index 7416c0b..4aa5fea 100644 --- a/src/app/api/decks/[id]/cards/route.ts +++ b/src/app/api/decks/[id]/cards/route.ts @@ -1,26 +1,29 @@ import { NextRequest, NextResponse } from "next/server"; import * as cardService from "@/services/cardService"; -import { cardContentSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function POST( request: NextRequest, ctx: RouteContext<"/api/decks/[id]/cards"> ) { const { id: deckId } = await ctx.params; - const parsed = cardContentSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if ( + !body?.front || + !body?.back || + typeof body.front !== "string" || + typeof body.back !== "string" + ) { return NextResponse.json( - { error: "Card front and back are required and must be within the content limit", details: parsed.error.issues }, + { error: "front and back are required" }, { status: 400 } ); } - try { - return NextResponse.json(await cardService.createCard(deckId, parsed.data), { status: 201 }); - } catch (error) { - if (isPrismaError(error, "P2003")) { - return NextResponse.json({ error: "Deck not found" }, { status: 404 }); - } - console.error("Failed to create card", error); - return NextResponse.json({ error: "Failed to create card" }, { status: 500 }); - } + + const card = await cardService.createCard(deckId, { + front: body.front.trim(), + back: body.back.trim(), + }); + + return NextResponse.json(card, { status: 201 }); } diff --git a/src/app/api/decks/[id]/route.ts b/src/app/api/decks/[id]/route.ts index 7fdd14f..b415c00 100644 --- a/src/app/api/decks/[id]/route.ts +++ b/src/app/api/decks/[id]/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import * as deckService from "@/services/deckService"; -import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET( _request: NextRequest, @@ -21,21 +20,16 @@ export async function PATCH( ctx: RouteContext<"/api/decks/[id]"> ) { const { id } = await ctx.params; - const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null)); - - if (!parsed.success) { - return NextResponse.json({ error: "Invalid deck update", details: parsed.error.issues }, { status: 400 }); - } + const body = await request.json().catch(() => null); try { - const updated = await deckService.updateDeck(id, parsed.data); + const updated = await deckService.updateDeck(id, { + name: body?.name?.trim(), + description: body?.description, + }); return NextResponse.json(updated); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Deck not found" }, { status: 404 }); - } - console.error("Failed to update deck", error); - return NextResponse.json({ error: "Failed to update deck" }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); } } @@ -48,11 +42,7 @@ export async function DELETE( try { await deckService.deleteDeck(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Deck not found" }, { status: 404 }); - } - console.error("Failed to delete deck", error); - return NextResponse.json({ error: "Failed to delete deck" }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Deck not found" }, { status: 404 }); } } diff --git a/src/app/api/decks/reorder/route.ts b/src/app/api/decks/reorder/route.ts index 9c090fe..97313be 100644 --- a/src/app/api/decks/reorder/route.ts +++ b/src/app/api/decks/reorder/route.ts @@ -1,29 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; -import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; -import { - ReorderConflictError, - ReorderValidationError, - reorderContent, -} from "@/services/reorderService"; +import { prisma } from "@/lib/db"; export async function PATCH(request: NextRequest) { - const parsed = reorderRequestSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 }); - } try { - await reorderContent("DECK", parsed.data); + const body = await request.json(); + const { items } = body; + + if (!Array.isArray(items)) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + // items should be an array of { id, sortOrder, groupId } + // Using a transaction to perform all updates + await prisma.$transaction( + items.map((item: any) => + prisma.deck.update({ + where: { id: item.id }, + data: { + sortOrder: item.sortOrder, + groupId: item.groupId, + }, + }) + ) + ); + return NextResponse.json({ success: true }); } catch (error) { - if (error instanceof ReorderValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - if (error instanceof ReorderConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }); - } - console.error("Failed to reorder decks", error); return NextResponse.json({ error: "Failed to reorder decks" }, { status: 500 }); } } diff --git a/src/app/api/decks/route.ts b/src/app/api/decks/route.ts index dbc6c62..b33fdbf 100644 --- a/src/app/api/decks/route.ts +++ b/src/app/api/decks/route.ts @@ -1,40 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import * as deckService from "@/services/deckService"; -import { deckImportRequestSchema } from "@/lib/validation/importSchemas"; -import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson"; +import { flashcardImportSchema } from "@/lib/validation/importSchemas"; export async function POST(request: NextRequest) { - let body: unknown; - try { - body = await readLimitedJson(request); - } catch (error) { - if (error instanceof RequestTooLargeError) { - return NextResponse.json({ error: error.message }, { status: 413 }); - } - throw error; + const body = await request.json().catch(() => null); + + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const parsed = deckImportRequestSchema.safeParse(body); + const { classId, data, name, groupId } = body; + + if (!classId || typeof classId !== "string") { + return NextResponse.json({ error: "classId is required" }, { status: 400 }); + } + + // Server-side validation — never trust client + const parsed = flashcardImportSchema.safeParse(data); if (!parsed.success) { return NextResponse.json( - { error: "Invalid deck import", details: parsed.error.issues }, + { error: "Validation failed", details: parsed.error.issues }, { status: 400 } ); } - try { - const deck = await deckService.createDeckFromImport( - parsed.data.classId, - parsed.data.data, - parsed.data.name, - parsed.data.groupId ?? null - ); - return NextResponse.json(deck, { status: 201 }); - } catch (error) { - if (error instanceof deckService.DeckValidationError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - console.error("Failed to create deck", error); - return NextResponse.json({ error: "Failed to create deck" }, { status: 500 }); - } + const deck = await deckService.createDeckFromImport( + classId, + parsed.data, + name?.trim() || undefined, + groupId || null + ); + + return NextResponse.json(deck, { status: 201 }); } diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts deleted file mode 100644 index cf75f33..0000000 --- a/src/app/api/health/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { checkApplicationHealth } from "@/services/healthService"; - -export async function GET() { - try { - await checkApplicationHealth(); - return NextResponse.json({ status: "ok" }); - } catch (error) { - console.error("Health check failed: database schema is unavailable", error); - return NextResponse.json({ status: "unhealthy" }, { status: 503 }); - } -} diff --git a/src/app/api/material-groups/[id]/route.ts b/src/app/api/material-groups/[id]/route.ts index 4e74abd..f2e0316 100644 --- a/src/app/api/material-groups/[id]/route.ts +++ b/src/app/api/material-groups/[id]/route.ts @@ -1,44 +1,41 @@ import { NextRequest, NextResponse } from "next/server"; -import { materialGroupUpdateSchema } from "@/lib/validation/materialGroupSchemas"; -import { - deleteMaterialGroup, - renameMaterialGroup, -} from "@/services/materialGroupService"; +import { prisma } from "@/lib/db"; export async function PATCH( request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - const parsed = materialGroupUpdateSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid group update" }, { status: 400 }); - } try { - return NextResponse.json(await renameMaterialGroup(id, parsed.data.name)); + const { id } = await params; + const body = await request.json(); + const { name, sortOrder } = body; + + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (sortOrder !== undefined) updateData.sortOrder = sortOrder; + + const group = await prisma.materialGroup.update({ + where: { id }, + data: updateData, + }); + + return NextResponse.json(group); } catch (error) { - if ( - typeof error === "object" && - error !== null && - "code" in error && - error.code === "P2025" - ) { - return NextResponse.json({ error: "Group not found" }, { status: 404 }); - } - console.error("Failed to rename material group", error); - return NextResponse.json({ error: "Failed to rename group" }, { status: 500 }); + return NextResponse.json({ error: "Failed to update material group" }, { status: 500 }); } } export async function DELETE( - _request: NextRequest, + request: NextRequest, { params }: { params: Promise<{ id: string }> } ) { - const { id } = await params; - if (!(await deleteMaterialGroup(id))) { - return NextResponse.json({ error: "Group not found" }, { status: 404 }); + try { + const { id } = await params; + await prisma.materialGroup.delete({ + where: { id }, + }); + return NextResponse.json({ success: true }); + } catch (error) { + return NextResponse.json({ error: "Failed to delete material group" }, { status: 500 }); } - return NextResponse.json({ success: true }); } diff --git a/src/app/api/material-groups/route.ts b/src/app/api/material-groups/route.ts index 8164ace..974b8ee 100644 --- a/src/app/api/material-groups/route.ts +++ b/src/app/api/material-groups/route.ts @@ -1,38 +1,56 @@ import { NextRequest, NextResponse } from "next/server"; -import { - materialGroupCreateSchema, - materialGroupQuerySchema, -} from "@/lib/validation/materialGroupSchemas"; -import { - createMaterialGroup, - listMaterialGroups, -} from "@/services/materialGroupService"; +import { prisma } from "@/lib/db"; export async function GET(request: NextRequest) { - const parsed = materialGroupQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid group query" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const classId = searchParams.get("classId"); + const type = searchParams.get("type"); + + if (!classId) { + return NextResponse.json({ error: "classId is required" }, { status: 400 }); + } + + if (type && type !== "DECK" && type !== "QUIZ") { + return NextResponse.json({ error: "Invalid type" }, { status: 400 }); + } + + const whereClause: any = { classId }; + if (type) { + whereClause.type = type; + } + + try { + const groups = await prisma.materialGroup.findMany({ + where: whereClause, + orderBy: { sortOrder: "asc" }, + }); + return NextResponse.json(groups); + } catch (error) { + return NextResponse.json({ error: "Failed to fetch material groups" }, { status: 500 }); } - return NextResponse.json( - await listMaterialGroups(parsed.data.classId, parsed.data.type) - ); } export async function POST(request: NextRequest) { - const parsed = materialGroupCreateSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid group" }, - { status: 400 } - ); + try { + const body = await request.json(); + const { classId, name, type } = body; + + if (!classId || !name || (type !== "DECK" && type !== "QUIZ")) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + const maxOrder = await prisma.materialGroup.aggregate({ + where: { classId, type }, + _max: { sortOrder: true }, + }); + const sortOrder = (maxOrder._max.sortOrder ?? -1) + 1; + + const group = await prisma.materialGroup.create({ + data: { classId, name, type, sortOrder }, + }); + + return NextResponse.json(group, { status: 201 }); + } catch (error) { + return NextResponse.json({ error: "Failed to create material group" }, { status: 500 }); } - const group = await createMaterialGroup(parsed.data); - if (!group) { - return NextResponse.json({ error: "Class not found" }, { status: 404 }); - } - return NextResponse.json(group, { status: 201 }); } diff --git a/src/app/api/progress/route.ts b/src/app/api/progress/route.ts index 1366efb..7b072d6 100644 --- a/src/app/api/progress/route.ts +++ b/src/app/api/progress/route.ts @@ -1,64 +1,66 @@ import { NextRequest, NextResponse } from "next/server"; -import { - progressDeleteSchema, - progressPatchSchema, - progressQuerySchema, -} from "@/lib/validation/progressSchemas"; -import { - ProgressConflictError, - ProgressNotFoundError, - ProgressValidationError, - clearProgress, - getProgress, - saveProgress, -} from "@/services/progressService"; +import * as progressService from "@/services/progressService"; export async function GET(request: NextRequest) { - const parsed = progressQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid progress query" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const contentType = searchParams.get("contentType") as "DECK" | "QUIZ"; + const contentId = searchParams.get("contentId"); + const mode = searchParams.get("mode") as "SEQUENTIAL" | "SHUFFLED"; + + if (!contentType || !contentId || !mode) { + return NextResponse.json( + { error: "contentType, contentId, and mode are required" }, + { status: 400 } + ); } - return NextResponse.json(await getProgress(parsed.data)); + + const progress = await progressService.getProgress( + contentType, + contentId, + mode + ); + + return NextResponse.json(progress); } export async function PATCH(request: NextRequest) { - const parsed = progressPatchSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if (!body?.contentType || !body?.contentId || !body?.mode) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid progress" }, + { error: "contentType, contentId, and mode are required" }, { status: 400 } ); } - try { - return NextResponse.json(await saveProgress(parsed.data)); - } catch (error) { - if (error instanceof ProgressNotFoundError) { - return NextResponse.json({ error: error.message }, { status: 404 }); - } - if (error instanceof ProgressConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }); - } - if (error instanceof ProgressValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - console.error("Failed to save study progress", error); - return NextResponse.json({ error: "Failed to save progress" }, { status: 500 }); - } + + const progress = await progressService.upsertProgress({ + contentType: body.contentType, + contentId: body.contentId, + mode: body.mode, + currentIndex: body.currentIndex ?? 0, + orderJson: body.orderJson, + answersJson: body.answersJson, + cardResultsJson: body.cardResultsJson, + }); + + return NextResponse.json(progress); } export async function DELETE(request: NextRequest) { - const parsed = progressDeleteSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if (!body?.contentType || !body?.contentId || !body?.mode) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid progress deletion" }, + { error: "contentType, contentId, and mode are required" }, { status: 400 } ); } - return NextResponse.json({ cleared: await clearProgress(parsed.data) }); + + await progressService.clearProgress( + body.contentType, + body.contentId, + body.mode + ); + + return NextResponse.json({ success: true }); } diff --git a/src/app/api/quizzes/[id]/attempt/route.ts b/src/app/api/quizzes/[id]/attempt/route.ts index 0b1f301..1f13839 100644 --- a/src/app/api/quizzes/[id]/attempt/route.ts +++ b/src/app/api/quizzes/[id]/attempt/route.ts @@ -1,40 +1,65 @@ import { NextRequest, NextResponse } from "next/server"; -import { - QuizAttemptValidationError, - QuizNotFoundError, - listQuizAttempts, - submitQuizAttempt, -} from "@/services/quizService"; -import { quizAttemptSchema } from "@/lib/validation/attemptSchemas"; +import { getQuizSetWithQuestions, createQuizAttempt, listQuizAttempts } from "@/services/quizService"; +import { scoreQuiz } from "@/lib/scoring"; export async function POST( request: NextRequest, ctx: RouteContext<"/api/quizzes/[id]/attempt"> ) { const { id } = await ctx.params; - const parsed = quizAttemptSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { + const body = await request.json().catch(() => null); + + if (!body || !body.answersJson) { return NextResponse.json( - { error: parsed.error.issues[0]?.message ?? "Invalid attempt" }, + { error: "answersJson is required" }, { status: 400 } ); } - try { - return NextResponse.json(await submitQuizAttempt(id, parsed.data), { - status: 201, - }); - } catch (error) { - if (error instanceof QuizNotFoundError) { - return NextResponse.json({ error: error.message }, { status: 404 }); - } - if (error instanceof QuizAttemptValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - console.error("Failed to submit quiz attempt", error); - return NextResponse.json({ error: "Failed to submit attempt" }, { status: 500 }); + + const quizSet = await getQuizSetWithQuestions(id); + if (!quizSet) { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); } + + // Parse answers + let answers: Record; + try { + answers = JSON.parse(body.answersJson); + } catch { + return NextResponse.json({ error: "Invalid answers JSON" }, { status: 400 }); + } + + // Determine which questions were included in this attempt + const isPartialRetake = body.isPartialRetake === true; + + // If partial retake, we only score the questions that actually had answers provided + // or that were explicitly passed in a questionIds array. + // For simplicity, we filter the quizSet questions down to what's in the answers object + // if it's a partial retake, though the client will only show those anyway. + let questionsToScore = quizSet.questions; + if (isPartialRetake) { + const answeredIds = Object.keys(answers); + questionsToScore = quizSet.questions.filter(q => answeredIds.includes(q.id)); + } + + // Format questions for the scoring utility + const formattedQuestions = questionsToScore.map((q) => ({ + id: q.id, + type: q.type as "MULTIPLE_CHOICE" | "SATA", + options: q.options.map((o) => ({ id: o.id, isCorrect: o.isCorrect })), + })); + + const { total, maxScore } = scoreQuiz(formattedQuestions, answers); + + const attempt = await createQuizAttempt({ + quizSetId: id, + score: total, + maxScore: maxScore, + answersJson: body.answersJson, + isPartialRetake, + }); + + return NextResponse.json(attempt, { status: 201 }); } export async function GET( diff --git a/src/app/api/quizzes/[id]/route.ts b/src/app/api/quizzes/[id]/route.ts index f912d7d..070bb2c 100644 --- a/src/app/api/quizzes/[id]/route.ts +++ b/src/app/api/quizzes/[id]/route.ts @@ -1,6 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; import * as quizService from "@/services/quizService"; -import { contentUpdateSchema, isPrismaError } from "@/lib/validation/contentSchemas"; export async function GET( _request: NextRequest, @@ -21,21 +20,16 @@ export async function PATCH( ctx: RouteContext<"/api/quizzes/[id]"> ) { const { id } = await ctx.params; - const parsed = contentUpdateSchema.safeParse(await request.json().catch(() => null)); - - if (!parsed.success) { - return NextResponse.json({ error: "Invalid quiz update", details: parsed.error.issues }, { status: 400 }); - } + const body = await request.json().catch(() => null); try { - const updated = await quizService.updateQuizSet(id, parsed.data); + const updated = await quizService.updateQuizSet(id, { + name: body?.name?.trim(), + description: body?.description, + }); return NextResponse.json(updated); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); - } - console.error("Failed to update quiz", error); - return NextResponse.json({ error: "Failed to update quiz" }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); } } @@ -48,11 +42,7 @@ export async function DELETE( try { await quizService.deleteQuizSet(id); return NextResponse.json({ success: true }); - } catch (error) { - if (isPrismaError(error, "P2025")) { - return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); - } - console.error("Failed to delete quiz", error); - return NextResponse.json({ error: "Failed to delete quiz" }, { status: 500 }); + } catch { + return NextResponse.json({ error: "Quiz not found" }, { status: 404 }); } } diff --git a/src/app/api/quizzes/reorder/route.ts b/src/app/api/quizzes/reorder/route.ts index a6c3e01..cc6d499 100644 --- a/src/app/api/quizzes/reorder/route.ts +++ b/src/app/api/quizzes/reorder/route.ts @@ -1,29 +1,31 @@ import { NextRequest, NextResponse } from "next/server"; -import { reorderRequestSchema } from "@/lib/validation/reorderSchemas"; -import { - ReorderConflictError, - ReorderValidationError, - reorderContent, -} from "@/services/reorderService"; +import { prisma } from "@/lib/db"; export async function PATCH(request: NextRequest) { - const parsed = reorderRequestSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid reorder input" }, { status: 400 }); - } try { - await reorderContent("QUIZ", parsed.data); + const body = await request.json(); + const { items } = body; + + if (!Array.isArray(items)) { + return NextResponse.json({ error: "Invalid input" }, { status: 400 }); + } + + // items should be an array of { id, sortOrder, groupId } + // Using a transaction to perform all updates + await prisma.$transaction( + items.map((item: any) => + prisma.quizSet.update({ + where: { id: item.id }, + data: { + sortOrder: item.sortOrder, + groupId: item.groupId, + }, + }) + ) + ); + return NextResponse.json({ success: true }); } catch (error) { - if (error instanceof ReorderValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }); - } - if (error instanceof ReorderConflictError) { - return NextResponse.json({ error: error.message }, { status: 409 }); - } - console.error("Failed to reorder quizzes", error); return NextResponse.json({ error: "Failed to reorder quizzes" }, { status: 500 }); } } diff --git a/src/app/api/quizzes/route.ts b/src/app/api/quizzes/route.ts index b14c02f..c6f1564 100644 --- a/src/app/api/quizzes/route.ts +++ b/src/app/api/quizzes/route.ts @@ -1,43 +1,35 @@ import { NextRequest, NextResponse } from "next/server"; import * as quizService from "@/services/quizService"; -import { quizImportRequestSchema } from "@/lib/validation/importSchemas"; -import { readLimitedJson, RequestTooLargeError } from "@/lib/limitedJson"; +import { quizImportSchema } from "@/lib/validation/importSchemas"; export async function POST(request: NextRequest) { - let body: unknown; - try { - body = await readLimitedJson(request); - } catch (error) { - if (error instanceof RequestTooLargeError) { - return NextResponse.json({ error: error.message }, { status: 413 }); - } - throw error; + const body = await request.json().catch(() => null); + + if (!body) { + return NextResponse.json({ error: "Invalid request body" }, { status: 400 }); } - const parsed = quizImportRequestSchema.safeParse(body); + const { classId, data, name, groupId } = body; + + if (!classId || typeof classId !== "string") { + return NextResponse.json({ error: "classId is required" }, { status: 400 }); + } + + // Server-side validation + const parsed = quizImportSchema.safeParse(data); if (!parsed.success) { return NextResponse.json( - { - error: "Invalid quiz import. SATA questions need at least two correct options.", - details: parsed.error.issues, - }, + { error: "Validation failed", details: parsed.error.issues }, { status: 400 } ); } - try { - const quiz = await quizService.createQuizSetFromImport( - parsed.data.classId, - parsed.data.data, - parsed.data.name, - parsed.data.groupId ?? null - ); - return NextResponse.json(quiz, { status: 201 }); - } catch (error) { - if (error instanceof quizService.QuizContentValidationError) { - return NextResponse.json({ error: error.message }, { status: error.status }); - } - console.error("Failed to create quiz", error); - return NextResponse.json({ error: "Failed to create quiz" }, { status: 500 }); - } + const quizSet = await quizService.createQuizSetFromImport( + classId, + parsed.data, + name?.trim() || undefined, + groupId || null + ); + + return NextResponse.json(quizSet, { status: 201 }); } diff --git a/src/app/api/settings/llm-instructions/route.ts b/src/app/api/settings/llm-instructions/route.ts index 527a94b..1e5f7cf 100644 --- a/src/app/api/settings/llm-instructions/route.ts +++ b/src/app/api/settings/llm-instructions/route.ts @@ -1,21 +1,16 @@ import { NextRequest, NextResponse } from "next/server"; import * as settingsService from "@/services/settingsService"; -function getInstructionType(request: NextRequest): settingsService.LlmInstructionType | null { - const type = new URL(request.url).searchParams.get("type") ?? "flashcards"; - return type === "flashcards" || type === "quizzes" ? type : null; -} - export async function GET(request: NextRequest) { - const type = getInstructionType(request); - if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards"; const instructions = await settingsService.getLlmInstructions(type); return NextResponse.json({ value: instructions }); } export async function PATCH(request: NextRequest) { - const type = getInstructionType(request); - if (!type) return NextResponse.json({ error: "Invalid instruction type" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const type = searchParams.get("type") as "flashcards" | "quizzes" || "flashcards"; const body = await request.json().catch(() => null); if (!body || typeof body.value !== "string") { diff --git a/src/app/api/share/route.ts b/src/app/api/share/route.ts index c6642e5..f29f093 100644 --- a/src/app/api/share/route.ts +++ b/src/app/api/share/route.ts @@ -1,21 +1,20 @@ import { NextRequest, NextResponse } from "next/server"; -import { shareTargetSchema } from "@/lib/validation/shareSchemas"; -import { - ShareTargetNotFoundError, - getShareLinkForContent, - isContentSharedViaGroup, - toggleShareLink, -} from "@/services/shareService"; +import { toggleShareLink, getShareLinkForContent, isContentSharedViaGroup } from "@/services/shareService"; export async function GET(request: NextRequest) { - const parsed = shareTargetSchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid share target" }, { status: 400 }); + const { searchParams } = new URL(request.url); + const targetType = searchParams.get("targetType") as "DECK" | "QUIZ" | "GROUP"; + const contentId = searchParams.get("contentId"); + + if (!targetType || !contentId) { + return NextResponse.json( + { error: "targetType and contentId are required" }, + { status: 400 } + ); } - const { targetType, contentId } = parsed.data; + const link = await getShareLinkForContent(targetType, contentId); + if (targetType === "DECK" || targetType === "QUIZ") { const groupShare = await isContentSharedViaGroup(targetType, contentId); if (groupShare) { @@ -26,27 +25,20 @@ export async function GET(request: NextRequest) { }); } } - return NextResponse.json({ token: link?.id ?? null, isGroupShared: false }); + + return NextResponse.json({ token: link?.id || null, isGroupShared: false }); } export async function POST(request: NextRequest) { - const parsed = shareTargetSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid share target" }, { status: 400 }); - } - try { - const link = await toggleShareLink( - parsed.data.targetType, - parsed.data.contentId + const body = await request.json().catch(() => null); + + if (!body?.targetType || !body?.contentId) { + return NextResponse.json( + { error: "targetType and contentId are required" }, + { status: 400 } ); - return NextResponse.json({ token: link?.id ?? null }); - } catch (error) { - if (error instanceof ShareTargetNotFoundError) { - return NextResponse.json({ error: error.message }, { status: 404 }); - } - console.error("Failed to update share link", error); - return NextResponse.json({ error: "Failed to update sharing" }, { status: 500 }); } + + const link = await toggleShareLink(body.targetType as "DECK" | "QUIZ" | "GROUP", body.contentId); + return NextResponse.json({ token: link?.id || null }); } diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts deleted file mode 100644 index 8abeee4..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/decks/[deckId]/route.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function DELETE( - _request: NextRequest, - context: { params: Promise<{ id: string; deckId: string }> } -) { - const { id, deckId } = await context.params; - try { - await spacedRepetitionService.removeDeck(id, deckId); - return NextResponse.json({ success: true }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/decks/route.ts b/src/app/api/spaced-repetition-sets/[id]/decks/route.ts deleted file mode 100644 index 805af67..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/decks/route.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { - addSpacedRepetitionDeckSchema, - reorderSpacedRepetitionDecksSchema, -} from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function POST( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = addSpacedRepetitionDeckSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid deck" }, { status: 400 }); - try { - return NextResponse.json( - await spacedRepetitionService.addDeck(id, parsed.data.deckId), - { status: 201 } - ); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = reorderSpacedRepetitionDecksSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid deck order" }, { status: 400 }); - try { - await spacedRepetitionService.reorderDecks(id, parsed.data.deckIds); - return NextResponse.json({ success: true }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts b/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts deleted file mode 100644 index 4e11cf8..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/reviews/route.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { reviewSpacedRepetitionCardSchema } from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function POST( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = reviewSpacedRepetitionCardSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid review" }, { status: 400 }); - try { - return NextResponse.json( - await spacedRepetitionService.reviewCard({ setId: id, ...parsed.data }) - ); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/route.ts b/src/app/api/spaced-repetition-sets/[id]/route.ts deleted file mode 100644 index 721f616..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/route.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { updateSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function GET( - _request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - try { - return NextResponse.json(await spacedRepetitionService.getSet(id)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function PATCH( - request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - const parsed = updateSpacedRepetitionSetSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) return NextResponse.json({ error: "Invalid update" }, { status: 400 }); - try { - return NextResponse.json(await spacedRepetitionService.updateSet(id, parsed.data)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function DELETE( - _request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - try { - await spacedRepetitionService.deleteSet(id); - return NextResponse.json({ success: true }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/[id]/study/route.ts b/src/app/api/spaced-repetition-sets/[id]/study/route.ts deleted file mode 100644 index 5addeed..0000000 --- a/src/app/api/spaced-repetition-sets/[id]/study/route.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function GET( - _request: NextRequest, - context: { params: Promise<{ id: string }> } -) { - const { id } = await context.params; - try { - return NextResponse.json(await spacedRepetitionService.getStudyState(id)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/api/spaced-repetition-sets/route.ts b/src/app/api/spaced-repetition-sets/route.ts deleted file mode 100644 index bcc2ec3..0000000 --- a/src/app/api/spaced-repetition-sets/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { createSpacedRepetitionSetSchema } from "@/lib/validation/spacedRepetitionSchemas"; -import { spacedRepetitionErrorResponse } from "@/lib/spacedRepetitionApi"; -import * as spacedRepetitionService from "@/services/spacedRepetitionService"; - -export async function GET(request: NextRequest) { - const classId = new URL(request.url).searchParams.get("classId"); - if (!classId) return NextResponse.json({ error: "classId is required" }, { status: 400 }); - try { - return NextResponse.json(await spacedRepetitionService.listSetsByClass(classId)); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} - -export async function POST(request: NextRequest) { - const parsed = createSpacedRepetitionSetSchema.safeParse( - await request.json().catch(() => null) - ); - if (!parsed.success) { - return NextResponse.json({ error: "Invalid repetition set" }, { status: 400 }); - } - try { - return NextResponse.json(await spacedRepetitionService.createSet(parsed.data), { - status: 201, - }); - } catch (error) { - return spacedRepetitionErrorResponse(error); - } -} diff --git a/src/app/globals.css b/src/app/globals.css index bfb7cad..53aab02 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,7 +1,5 @@ @import "tailwindcss"; -@custom-variant dark (&:where(.dark, .dark *)); - @layer base { :root { --theme-bg-base: #f5f2ea; @@ -172,65 +170,6 @@ .animate-slide-up { animation: slideUp .32s var(--ease-spring); } .animate-subtle-pulse { animation: subtlePulse 1.7s ease-in-out infinite; } -/* Flashcard streaks. Flame colours are deliberately theme-independent: the same - yellow-to-orange ramp reads correctly on both the light and dark surfaces. */ -:root { - --flame-hot: #fbbf24; - --flame-mid: #fb923c; - --flame-core: #ea580c; - --flame-ink: #451a03; -} - -@keyframes streakBarFlow { from { background-position: 0% 50%; } to { background-position: -200% 50%; } } -@keyframes streakChipIn { from { opacity: 0; transform: translateY(-4px) scale(.9); } to { opacity: 1; transform: translateY(0) scale(1); } } -@keyframes streakFlicker { - 0%, 100% { transform: scale(1) rotate(0); } - 25% { transform: scale(1.07) rotate(-2.5deg); } - 60% { transform: scale(.96) rotate(2deg); } -} -@keyframes streakPop { - 0% { opacity: 0; transform: translateY(26px) scale(.55); } - 28% { opacity: 1; transform: translateY(-2px) scale(1.1); } - 44% { transform: translateY(0) scale(1); } - 74% { opacity: 1; transform: translateY(-5px) scale(1); } - 100% { opacity: 0; transform: translateY(-38px) scale(.9); } -} - -.streak-bar-flame { - background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-mid) 35%, var(--flame-core) 55%, var(--flame-mid) 75%, var(--flame-hot)); - background-size: 200% 100%; - animation: streakBarFlow 2.6s linear infinite; - box-shadow: 0 0 12px color-mix(in srgb, var(--flame-core) 55%, transparent); -} - -.streak-chip { animation: streakChipIn .28s var(--ease-spring); } -.streak-chip-flame { - color: var(--flame-ink); - background-image: linear-gradient(100deg, var(--flame-hot), var(--flame-core)); - box-shadow: 0 2px 12px color-mix(in srgb, var(--flame-core) 40%, transparent); -} - -/* Softens the card behind a burst so the milestone stays readable. */ -@keyframes streakScrim { 0% { opacity: 0; } 14% { opacity: 1; } 76% { opacity: 1; } 100% { opacity: 0; } } -.streak-scrim { - background: radial-gradient( - circle at 50% 46%, - color-mix(in srgb, var(--color-bg-surface) 94%, transparent) 0%, - color-mix(in srgb, var(--color-bg-surface) 78%, transparent) 42%, - transparent 76% - ); - animation: streakScrim var(--streak-pop-duration, 1.8s) ease-out forwards; -} - -.streak-pop { animation: streakPop var(--streak-pop-duration, 1.8s) var(--ease-spring) forwards; } -.streak-pop-flame { animation: streakFlicker .9s ease-in-out infinite; transform-origin: 50% 85%; } -.streak-pop-heading { - background-image: linear-gradient(180deg, var(--flame-hot), var(--flame-core)); - background-clip: text; - -webkit-background-clip: text; - color: transparent; -} - .markdown-content h1 { font-size: 1.5rem; font-weight: 700; margin-bottom: .5rem; color: var(--color-text-heading); } .markdown-content h2 { font-size: 1.25rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); } .markdown-content h3 { font-size: 1.125rem; font-weight: 650; margin-bottom: .5rem; color: var(--color-text-heading); } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 6fbaecf..f92c5fb 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Manrope, Newsreader } from "next/font/google"; +import Script from "next/script"; import "./globals.css"; const manrope = Manrope({ @@ -25,12 +26,10 @@ export default function RootLayout({ children: React.ReactNode; }>) { return ( - - - {children} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 1dea071..30b8ab0 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,354 +1,158 @@ "use client"; -import { useEffect, useState } from "react"; +import { useState, useEffect } from "react"; +import { useRouter } from "next/navigation"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; -type LoginStage = "login" | "token" | "password" | "success"; - -async function postJson>(path: string, body?: object): Promise { - const response = await fetch(path, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body ?? {}), - }); - const data = await response.json() as T & { error?: string }; - if (!response.ok) throw new Error(data.error || "Request failed"); - return data; -} - export default function LoginPage() { - const [stage, setStage] = useState("login"); const [password, setPassword] = useState(""); - const [confirmPassword, setConfirmPassword] = useState(""); - const [token, setToken] = useState(""); const [error, setError] = useState(""); const [loading, setLoading] = useState(false); - const [setupRequired, setSetupRequired] = useState(null); - const [setupAllowed, setSetupAllowed] = useState(false); - const [statusError, setStatusError] = useState(""); + const [isSetup, setIsSetup] = useState(null); + const router = useRouter(); useEffect(() => { - const controller = new AbortController(); - fetch("/api/auth/setup-status", { signal: controller.signal }) - .then((response) => { - if (!response.ok) throw new Error("Setup status could not be loaded."); - return response.json(); - }) - .then((data) => { - setSetupRequired(Boolean(data.setupRequired)); - setSetupAllowed(Boolean(data.setupAllowed)); - }) - .catch((caught) => { - if (controller.signal.aborted) return; - setStatusError(caught instanceof Error ? caught.message : "Setup status could not be loaded."); - setSetupRequired(false); - }); - return () => controller.abort(); + fetch("/api/auth/setup-status") + .then((res) => res.json()) + .then((data) => setIsSetup(data.setupRequired)) + .catch(() => setIsSetup(false)); // fallback }, []); - async function run(action: () => Promise) { + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); setError(""); setLoading(true); + try { - await action(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "Network error. Please try again."); + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }); + + const data = await res.json(); + + if (!res.ok) { + setError(data.error || "Login failed"); + return; + } + + window.location.href = "/"; + } catch { + setError("Network error. Please try again."); } finally { setLoading(false); } } - function handleLogin(event: React.FormEvent) { - event.preventDefault(); - void run(async () => { - await postJson("/api/auth/login", { password }); - window.location.href = "/"; - }); - } - - function beginReset() { - void run(async () => { - const response = await postJson<{ token: string }>("/api/auth/password-reset/request"); - setToken(response.token); - setStage("token"); - }); - } - - function handleToken(event: React.FormEvent) { - event.preventDefault(); - void run(async () => { - await postJson("/api/auth/password-reset/verify", { token: token.trim() }); - setPassword(""); - setConfirmPassword(""); - setStage("password"); - }); - } - - function handlePasswordReset(event: React.FormEvent) { - event.preventDefault(); - void run(async () => { - await postJson("/api/auth/password-reset/complete", { - password, - confirmPassword, - }); - setToken(""); - setPassword(""); - setConfirmPassword(""); - setStage("success"); - }); - } - - function returnToLogin() { - setStage("login"); - setError(""); - setToken(""); - setPassword(""); - setConfirmPassword(""); - } - - if (setupRequired === null) { + if (isSetup === null) { return (
-
+
+
+
); } - const title = - stage === "token" - ? "Enter reset token" - : stage === "password" - ? "Choose a new password" - : stage === "success" - ? "Password updated" - : setupRequired - ? "Welcome to Study Desk" - : "Welcome back"; - - const description = - stage === "token" - ? "Find the token in the Study Desk Docker logs. It expires after 15 minutes." - : stage === "password" - ? "Use at least eight characters and enter the password twice." - : stage === "success" - ? "Your old password and existing sessions are no longer valid." - : setupRequired - ? "Please set your admin password for the first time." - : "Open your notes, decks, and practice quizzes."; - return (
- -
-
+
+ {/* Logo / Title area */} +
S

Personal workspace

-

{title}

-

{description}

-
- -
- {stage === "login" && ( -
- {setupRequired && !setupAllowed && ( -
- Initial setup is disabled. Provision ADMIN_PASSWORD_HASH or temporarily enable ALLOW_INITIAL_SETUP on the local server. -
- )} - {statusError && } - - - - {setupRequired ? "Save Password & Login" : "Sign In"} - - {!setupRequired && ( - - )} - - )} - - {stage === "token" && ( -
-
- - setToken(event.target.value)} - autoComplete="one-time-code" - autoFocus - className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary" - placeholder="Paste the token from Docker logs" - /> -
- - Validate Token - Back to sign in - - )} - - {stage === "password" && ( -
-
- - = 8} - validText="Password meets the length requirement." - invalidText="Password must be at least 8 characters." - /> -
-
- 0 && confirmPassword !== password} - /> - {confirmPassword && ( - - )} -
- - - Set New Password - - Cancel - - )} - - {stage === "success" && ( -
-
- Your password has been reset successfully. -
- Sign In -
- )} +

+ {isSetup ? "Welcome to Study Desk" : "Welcome back"} +

+

+ {isSetup + ? "Please set your admin password for the first time." + : "Open your notes, decks, and practice quizzes."} +

-
+ + {/* Login card */} +
+
+
+ + setPassword(e.target.value)} + placeholder={isSetup ? "Create a new password" : "Enter your password"} + autoFocus + className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" + /> +
+ + {error && ( +
+ + + + {error} +
+ )} + + +
+
+
); } - -function PasswordField({ - id, - label, - value, - onChange, - placeholder, - describedBy, - invalid = false, - autoFocus = false, -}: { - id: string; - label: string; - value: string; - onChange: (value: string) => void; - placeholder?: string; - describedBy?: string; - invalid?: boolean; - autoFocus?: boolean; -}) { - return ( -
- - onChange(event.target.value)} - placeholder={placeholder} - autoComplete={id === "password" ? "current-password" : "new-password"} - aria-describedby={describedBy} - aria-invalid={invalid} - autoFocus={autoFocus} - className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary" - /> -
- ); -} - -function ValidationMessage({ id, valid, validText, invalidText }: { - id: string; - valid: boolean; - validText: string; - invalidText: string; -}) { - return ( -

- {valid ? validText : invalidText} -

- ); -} - -function ErrorMessage({ message }: { message: string }) { - if (!message) return null; - return
{message}
; -} - -function PrimaryButton({ loading, disabled = false, onClick, children }: { - loading: boolean; - disabled?: boolean; - onClick?: () => void; - children: React.ReactNode; -}) { - return ( - - ); -} - -function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) { - return ( - - ); -} diff --git a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx index 62711f1..a49d137 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/SharedGroupViewer.tsx @@ -4,9 +4,8 @@ import { useState, useEffect } from "react"; import Link from "next/link"; import { usePathname, useRouter } from "next/navigation"; -import type { SharedGroupData } from "@/types/study"; -export function SharedGroupViewer({ data, token }: { data: SharedGroupData; token: string }) { +export function SharedGroupViewer({ data }: { data: any }) { const pathname = usePathname(); const router = useRouter(); @@ -16,34 +15,29 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke const [progressMap, setProgressMap] = useState>({}); useEffect(() => { - const timer = window.setTimeout(() => { - const newProgress: Record = {}; - items.forEach((item) => { - const key = data.type === "DECK" - ? `flashcard_progress_${token}_${item.id}` - : `quiz_progress_${token}_${item.id}`; - const saved = localStorage.getItem(key); - if (saved) { - try { - const parsed = JSON.parse(saved); - const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0); - newProgress[item.id] = { - currentIndex: parsed.currentIndex || 0, - total, - }; - } catch {} - } - }); - setProgressMap(newProgress); - }, 0); - return () => window.clearTimeout(timer); - }, [items, data.type, token]); + if (typeof window === 'undefined') return; + + const newProgress: Record = {}; + items.forEach((item: any) => { + const key = data.type === "DECK" ? `flashcard_progress_${item.id}` : `quiz_progress_${item.id}`; + const saved = localStorage.getItem(key); + if (saved) { + try { + const parsed = JSON.parse(saved); + const total = data.type === "DECK" ? (parsed.order?.length || item.cards?.length || 0) : (parsed.order?.length || item.questions?.length || 0); + newProgress[item.id] = { + currentIndex: parsed.currentIndex || 0, + total, + }; + } catch(e) {} + } + }); + setProgressMap(newProgress); + }, [items, data.type]); const handleRestart = (e: React.MouseEvent, itemId: string) => { e.preventDefault(); - const key = data.type === "DECK" - ? `flashcard_progress_${token}_${itemId}` - : `quiz_progress_${token}_${itemId}`; + const key = data.type === "DECK" ? `flashcard_progress_${itemId}` : `quiz_progress_${itemId}`; localStorage.removeItem(key); setProgressMap(prev => { const next = { ...prev }; @@ -77,7 +71,7 @@ export function SharedGroupViewer({ data, token }: { data: SharedGroupData; toke
- {items.map((item) => ( + {items.map((item: any) => (
("study"); const [hasSavedSession, setHasSavedSession] = useState(false); useEffect(() => { - const timer = window.setTimeout(() => { - const key = type === "flashcards" - ? `flashcard_progress_${token}_${data.id}` - : `quiz_progress_${token}_${data.id}`; - setHasSavedSession(Boolean(localStorage.getItem(key))); - }, 0); - return () => window.clearTimeout(timer); - }, [type, data.id, restartKey, token]); + // Check if there's a saved session for this item + const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; + if (localStorage.getItem(key)) { + setHasSavedSession(true); + } + }, [type, data.id, restartKey]); function handleRestart() { - const key = type === "flashcards" - ? `flashcard_progress_${token}_${data.id}` - : `quiz_progress_${token}_${data.id}`; + const key = type === "flashcards" ? `flashcard_progress_${data.id}` : `quiz_progress_${data.id}`; localStorage.removeItem(key); setHasSavedSession(false); setRestartKey(k => k + 1); @@ -42,7 +36,7 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie const [showTopics, setShowTopics] = useState(false); const categories = type === "quizzes" && data.questions - ? Array.from(new Set(data.questions.map((q) => q.category.toUpperCase()))).join(" · ") + ? Array.from(new Set(data.questions.map((q: any) => q.category.toUpperCase()))).join(" · ") : ""; return ( @@ -143,26 +137,22 @@ export function SharedViewer({ type, data, groupMode = false, token }: SharedVie view === "study" ? ( ) : ( - + ) ) : ( )}
diff --git a/src/app/shared/[classSlug]/[type]/[token]/page.tsx b/src/app/shared/[classSlug]/[type]/[token]/page.tsx index e62a7e9..a0b02ba 100644 --- a/src/app/shared/[classSlug]/[type]/[token]/page.tsx +++ b/src/app/shared/[classSlug]/[type]/[token]/page.tsx @@ -1,14 +1,9 @@ -import { getShareLink, getShareLinkMeta } from "@/services/shareService"; +import { getShareLink } from "@/services/shareService"; import { notFound, redirect } from "next/navigation"; import { ThemeToggle } from "@/components/ui/ThemeToggle"; import Link from "next/link"; import { SharedViewer } from "./SharedViewer"; import { SharedGroupViewer } from "./SharedGroupViewer"; -import { buildShareMetadata } from "@/lib/shareMetadata"; -import type { Metadata } from "next"; -import type { SharedGroupData, SharedStudyItem } from "@/types/study"; - -type SharedContentType = "flashcards" | "quizzes"; interface SharedPageProps { params: Promise<{ @@ -18,20 +13,6 @@ interface SharedPageProps { }>; } -export async function generateMetadata( - props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }, -): Promise { - const { classSlug, type, token } = await props.params; - const { itemId } = await props.searchParams; - - if (type !== "flashcards" && type !== "quizzes" && type !== "groups") { - return buildShareMetadata(null); - } - - const link = await getShareLinkMeta(token); - return buildShareMetadata(link, { classSlug, itemId, pathType: type }); -} - export default async function SharedPage(props: SharedPageProps & { searchParams: Promise<{ itemId?: string }> }) { const { classSlug, type, token } = await props.params; const { itemId } = await props.searchParams; @@ -67,40 +48,27 @@ export default async function SharedPage(props: SharedPageProps & { searchParams notFound(); } - let targetData: SharedStudyItem | SharedGroupData | null = null; - let targetType: SharedContentType = type === "flashcards" ? "flashcards" : "quizzes"; + let targetData: any = null; + let targetType = type; if (type === "groups" && link.group) { - const groupClassSlug = link.group.class.slug; - const validDecks = link.group.decks.filter( - (deck) => link.group?.type === "DECK" && deck.class.slug === groupClassSlug - ); - const validQuizSets = link.group.quizSets.filter( - (quiz) => link.group?.type === "QUIZ" && quiz.class.slug === groupClassSlug - ); if (itemId) { // Find the specific item inside the group if (link.group.type === "DECK") { - targetData = validDecks.find(d => d.id === itemId) ?? null; + targetData = link.group.decks.find(d => d.id === itemId); targetType = "flashcards"; } else { - targetData = validQuizSets.find(q => q.id === itemId) ?? null; + targetData = link.group.quizSets.find(q => q.id === itemId); targetType = "quizzes"; } if (!targetData) notFound(); } else { - targetData = { - ...link.group, - decks: validDecks, - quizSets: validQuizSets, - }; + targetData = link.group; } } else { targetData = type === "flashcards" ? link.deck : link.quizSet; } - if (!targetData) notFound(); - return (
{/* Read-only Header */} @@ -137,13 +105,12 @@ export default async function SharedPage(props: SharedPageProps & { searchParams
{type === "groups" && !itemId ? ( - + ) : ( )}
diff --git a/src/components/activity/ActivityBanner.tsx b/src/components/activity/ActivityBanner.tsx deleted file mode 100644 index a058535..0000000 --- a/src/components/activity/ActivityBanner.tsx +++ /dev/null @@ -1,188 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useRef, useState } from "react"; - -interface DailyActivity { - date: string; - flashcards: number; - questions: number; - total: number; - level: 0 | 1 | 2 | 3 | 4; -} - -interface ActivitySummary { - days: DailyActivity[]; - today: string; - currentStreak: number; -} - -const levelClasses = [ - "bg-text-heading/8 dark:bg-white/10", - "bg-primary/30 dark:bg-primary/45", - "bg-primary/70 dark:bg-primary", - "bg-accent/75 dark:bg-accent", - "bg-[#e9a91b] dark:bg-[#fbbf24]", -] as const; - -export function ActivityBanner({ onNewClass }: { onNewClass: () => void }) { - const [summary, setSummary] = useState(null); - const [error, setError] = useState(false); - - useEffect(() => { - fetch("/api/activity") - .then((response) => { - if (!response.ok) throw new Error("Failed to load activity"); - return response.json(); - }) - .then(setSummary) - .catch(() => setError(true)); - }, []); - - return ( -
-
-
-
-
-
-

Personal library

-

Study activity

-
- -
- - {error && ( -
- Study activity could not be loaded. Your library is still available below. -
- )} - {!summary && !error &&
} - {summary && ( -
-
- -
- -
- )} -
- ); -} - -function ActivityHeatmap({ days, today }: { days: DailyActivity[]; today: string }) { - const scrollRef = useRef(null); - const [activeDay, setActiveDay] = useState(null); - const weeks = useMemo( - () => Array.from({ length: 53 }, (_, index) => days.slice(index * 7, index * 7 + 7)), - [days] - ); - - useEffect(() => { - const container = scrollRef.current; - if (container) container.scrollLeft = container.scrollWidth; - }, [days]); - - return ( -
-
-

Last 53 weeks · Arizona time

-
- {activeDay && } -
-
-
-
-
-
-
Mon
-
-
Wed
-
-
Fri
-
-
-
- {weeks.map((week, index) => ( -
- {getMonthLabel(week, weeks[index - 1])} -
- ))} -
-
- {weeks.map((week, weekIndex) => ( -
- {week.map((day) => day.date > today ? ( - - ) : ( -
- ))} -
-
-
-
-
- Less - {levelClasses.map((className, index) => )} - More -
-
- ); -} - -function DayTooltip({ day }: { day: DailyActivity }) { - const formatted = new Date(`${day.date}T12:00:00Z`).toLocaleDateString("en-US", { - timeZone: "America/Phoenix", - month: "short", - day: "numeric", - year: "numeric", - }); - return ( -
-
{formatted} · {day.total} total
-
{day.flashcards} flashcards · {day.questions} questions
-
- ); -} - -function StreakDisplay({ streak }: { streak: number }) { - const size = 24 + Math.min(streak, 30) * 0.6; - const color = streak === 0 ? "text-text-muted dark:text-white/30" : streak < 7 ? "text-accent" : streak < 30 ? "text-[#e6602f] dark:text-[#ff7a36]" : "text-[#d4930d] dark:text-[#fbbf24]"; - return ( -
- - - -
-
{streak}
-
day streak
-
20 activities per day
-
-
- ); -} - -function getMonthLabel(week: DailyActivity[], previousWeek?: DailyActivity[]) { - const firstOfMonth = week.find((day) => Number(day.date.slice(8, 10)) <= 7); - if (!firstOfMonth) return ""; - const month = firstOfMonth.date.slice(5, 7); - if (previousWeek?.some((day) => day.date.slice(5, 7) === month)) return ""; - return new Date(`${firstOfMonth.date}T12:00:00Z`).toLocaleDateString("en-US", { month: "short", timeZone: "America/Phoenix" }); -} - -function getDayAriaLabel(day: DailyActivity) { - return `${day.date}: ${day.flashcards} flashcards, ${day.questions} questions, ${day.total} total activities`; -} diff --git a/src/components/flashcards/CardManager.tsx b/src/components/flashcards/CardManager.tsx index b372669..f98bcb7 100644 --- a/src/components/flashcards/CardManager.tsx +++ b/src/components/flashcards/CardManager.tsx @@ -41,7 +41,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) body: JSON.stringify({ front: editFront, back: editBack }), }); setEditingId(null); - window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } finally { setSaving(false); @@ -51,7 +50,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) async function deleteCard(id: string) { if (!confirm("Delete this card?")) return; await fetch(`/api/cards/${id}`, { method: "DELETE" }); - window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } @@ -67,7 +65,6 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps) setNewFront(""); setNewBack(""); setShowAdd(false); - window.dispatchEvent(new Event("study-decks-changed")); onCardsChanged(); } finally { setSaving(false); @@ -92,7 +89,7 @@ export function CardManager({ cards, deckId, onCardsChanged }: CardManagerProps)
{/* Card list */} - {cards.map((card) => ( + {cards.map((card, index) => (
card.id), - currentIndex: 0, - data: {} as Record, - completed: cards.length === 0, - wasRecovered: false, - }; - } - return normalizeProgress({ - orderJson: progress.orderJson, - currentIndex: progress.currentIndex, - dataJson: progress.cardResultsJson, - liveIds: cards.map((card) => card.id), - isValidValue: (value): value is CardResult => - value === "correct" || value === "missed", - }); -} - -export function FlashcardViewer({ - cards, - deckId, - isShared = false, - storageNamespace, - initialProgress, -}: FlashcardViewerProps) { - const sharedStorageKey = `flashcard_progress_${storageNamespace ?? "shared"}_${deckId}`; - const [initialState] = useState(() => initialFlashcardState(cards, initialProgress)); +export function FlashcardViewer({ cards, deckId, isShared = false, initialProgress }: FlashcardViewerProps) { const [isLoaded, setIsLoaded] = useState(!isShared); - const [order, setOrder] = useState(initialState.order); - const [currentIndex, setCurrentIndex] = useState(initialState.currentIndex); + const [order, setOrder] = useState( + initialProgress ? JSON.parse(initialProgress.orderJson) : cards.map((c) => c.id) + ); + const [currentIndex, setCurrentIndex] = useState( + initialProgress ? initialProgress.currentIndex : 0 + ); const [isFlipped, setIsFlipped] = useState(false); const [hasFlippedOnce, setHasFlippedOnce] = useState(false); - const [results, setResults] = useState>(initialState.data); + const [results, setResults] = useState>( + initialProgress && initialProgress.cardResultsJson + ? JSON.parse(initialProgress.cardResultsJson) + : {} + ); const [isShuffled, setIsShuffled] = useState( initialProgress ? initialProgress.mode === "SHUFFLED" : false ); const [toastMessage, setToastMessage] = useState(null); - const [restoreWarning, setRestoreWarning] = useState( - initialState.wasRecovered - ? "Some saved progress was invalid or referenced deleted cards, so it was safely repaired." - : null - ); - const [isTransitioning, setIsTransitioning] = useState(false); // If progress is provided and index is already at or past the end, it means completed - const [completed, setCompleted] = useState(initialState.completed); + const [completed, setCompleted] = useState( + initialProgress + ? initialProgress.currentIndex >= JSON.parse(initialProgress.orderJson).length + : false + ); const [swipeClass, setSwipeClass] = useState(""); const cardRef = useRef(null); - const gradingRef = useRef(false); - const transitionTimerRef = useRef | null>(null); - const sessionIdRef = useRef( - initialProgress?.sessionId ?? globalThis.crypto.randomUUID() - ); - const revisionRef = useRef(initialProgress?.revision ?? 0); - const saveQueueRef = useRef(Promise.resolve()); // Touch/drag state const dragRef = useRef({ startX: 0, currentX: 0, isDragging: false }); @@ -105,48 +62,7 @@ export function FlashcardViewer({ const currentCard = order.length > 0 ? cards.find((c) => c.id === order[currentIndex]) : null; const correctCount = Object.values(results).filter((r) => r === "correct").length; const missedCount = Object.values(results).filter((r) => r === "missed").length; - - // Current run of consecutive correct cards, derived by walking back from the - // current position. Deriving it (rather than storing it) keeps resumed - // sessions and the "previous card" button correct without extra persistence. - const streak = useMemo(() => { - let run = 0; - for (let i = currentIndex - 1; i >= 0; i--) { - if (results[order[i]] !== "correct") break; - run++; - } - return run; - }, [order, currentIndex, results]); - - const isOnFire = streak >= STREAK_FLAME_THRESHOLD; - - // Milestone celebration. The first settled streak is only a baseline, so - // resuming a session mid-streak never fires a burst on arrival. - const [burst, setBurst] = useState<{ streak: number; tier: StreakTier; id: number } | null>(null); - const prevStreakRef = useRef(0); - const streakBaselineRef = useRef(false); - - useEffect(() => { - if (!isLoaded) return; - - const previous = prevStreakRef.current; - prevStreakRef.current = streak; - - if (!streakBaselineRef.current) { - streakBaselineRef.current = true; - return; - } - - if (streak > previous && isStreakMilestone(streak)) { - setBurst({ streak, tier: streakTier(streak), id: Date.now() }); - } - }, [streak, isLoaded]); - - useEffect(() => { - if (!burst) return; - const timer = setTimeout(() => setBurst(null), streakBurstDuration(burst.tier)); - return () => clearTimeout(timer); - }, [burst]); + const totalGraded = correctCount + missedCount; // Auto-hide toast useEffect(() => { @@ -158,104 +74,29 @@ export function FlashcardViewer({ // Load from localStorage if shared useEffect(() => { - if (!isShared) return; - const timer = window.setTimeout(() => { + if (isShared) { try { - const saved = localStorage.getItem(sharedStorageKey); + const saved = localStorage.getItem(`flashcard_progress_${deckId}`); if (saved) { - const parsed: unknown = JSON.parse(saved); - const record = - typeof parsed === "object" && parsed !== null - ? (parsed as Record) - : {}; - const restored = normalizeProgress({ - orderJson: record.order, - currentIndex: record.currentIndex, - dataJson: record.results, - liveIds: cards.map((card) => card.id), - isValidValue: (value): value is CardResult => - value === "correct" || value === "missed", - }); - setOrder(restored.order); - setCurrentIndex(restored.currentIndex); - setResults(restored.data); - setIsShuffled(record.mode === "SHUFFLED"); - setCompleted(restored.completed); - if (restored.wasRecovered) { - setRestoreWarning( - "Some saved progress was invalid or referenced deleted cards, so it was safely repaired." - ); + const parsed = JSON.parse(saved); + setOrder(parsed.order || cards.map(c => c.id)); + setCurrentIndex(parsed.currentIndex || 0); + setResults(parsed.results || {}); + setIsShuffled(parsed.mode === "SHUFFLED"); + + if (parsed.currentIndex >= (parsed.order?.length || cards.length)) { + setCompleted(true); } setToastMessage("Session restored"); } - } catch { - setRestoreWarning( - "Saved progress could not be read. A fresh session was started safely." - ); + } catch (e) { + // Fallback to defaults } setIsLoaded(true); - }, 0); - return () => window.clearTimeout(timer); - }, [isShared, sharedStorageKey, cards]); - - useEffect(() => { - return () => { - if (transitionTimerRef.current) clearTimeout(transitionTimerRef.current); - }; - }, []); - - const saveProgress = useCallback( - ( - orderArr: string[], - index: number, - cardResults: Record, - mode: "SEQUENTIAL" | "SHUFFLED" - ) => { - if (isShared) { - localStorage.setItem( - sharedStorageKey, - JSON.stringify({ - mode, - currentIndex: index, - order: orderArr, - results: cardResults, - }) - ); - return; - } - - const revision = ++revisionRef.current; - saveQueueRef.current = saveQueueRef.current - .then(async () => { - const response = await fetch("/api/progress", { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - contentType: "DECK", - contentId: deckId, - mode, - currentIndex: index, - order: orderArr, - cardResults, - sessionId: sessionIdRef.current, - revision, - }), - }); - if (response.status === 409) { - setToastMessage("Progress changed in another session; reload to continue safely."); - return; - } - if (!response.ok) throw new Error("Progress save failed"); - }) - .catch(() => { - setToastMessage("Progress could not be saved. Your current view is unchanged."); - }); - }, - [deckId, isShared, sharedStorageKey] - ); + } + }, [isShared, deckId, cards]); function toggleShuffle() { - if (isTransitioning) return; const newShuffled = !isShuffled; setIsShuffled(newShuffled); @@ -279,13 +120,7 @@ export function FlashcardViewer({ // Grade the current card const gradeCard = useCallback( (grade: CardResult) => { - if (!currentCard || !hasFlippedOnce || gradingRef.current) return; - gradingRef.current = true; - setIsTransitioning(true); - - if (!isShared) { - recordStudyActivity("FLASHCARD").catch(() => {}); - } + if (!currentCard || !hasFlippedOnce) return; setToastMessage(null); const newResults = { ...results, [currentCard.id]: grade }; @@ -294,17 +129,14 @@ export function FlashcardViewer({ // Animate setSwipeClass(grade === "correct" ? "animate-swipe-right" : "animate-swipe-left"); - transitionTimerRef.current = setTimeout(() => { - gradingRef.current = false; - setIsTransitioning(false); + setTimeout(() => { setSwipeClass(""); setIsFlipped(false); setHasFlippedOnce(false); if (currentIndex + 1 >= order.length) { setCompleted(true); - setCurrentIndex(order.length); - saveProgress(order, order.length, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); + saveProgress(order, currentIndex, newResults, isShuffled ? "SHUFFLED" : "SEQUENTIAL"); } else { const nextIndex = currentIndex + 1; setCurrentIndex(nextIndex); @@ -312,13 +144,13 @@ export function FlashcardViewer({ } }, 350); }, - [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled, isShared, saveProgress] + [currentCard, hasFlippedOnce, results, currentIndex, order, isShuffled] ); // Keyboard shortcuts useEffect(() => { function handleKeyDown(e: KeyboardEvent) { - if (completed || isTransitioning) return; + if (completed) return; if (e.key === " " || e.key === "Enter" || e.key === "ArrowUp" || e.key === "ArrowDown") { e.preventDefault(); @@ -336,7 +168,7 @@ export function FlashcardViewer({ window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [completed, isFlipped, gradeCard, hasFlippedOnce, isTransitioning]); + }, [completed, isFlipped, gradeCard]); // Touch handlers for swipe function handleTouchStart(e: React.TouchEvent) { @@ -371,10 +203,39 @@ export function FlashcardViewer({ } } + // Save progress (debounced / fire-and-forget) + function saveProgress( + orderArr: string[], + index: number, + cardResults: Record, + m: "SEQUENTIAL" | "SHUFFLED" + ) { + if (isShared) { + localStorage.setItem(`flashcard_progress_${deckId}`, JSON.stringify({ + mode: m, + currentIndex: index, + order: orderArr, + results: cardResults, + })); + return; + } + + fetch("/api/progress", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + contentType: "DECK", + contentId: deckId, + mode: m, + currentIndex: index, + orderJson: JSON.stringify(orderArr), + cardResultsJson: JSON.stringify(cardResults), + }), + }).catch(() => {}); // fire-and-forget + } + // Restart handlers function restartFullSet() { - if (isTransitioning) return; - gradingRef.current = false; let newOrder: string[]; if (isShuffled) { newOrder = [...cards.map((c) => c.id)].sort(() => Math.random() - 0.5); @@ -391,7 +252,6 @@ export function FlashcardViewer({ } function redoMissed() { - if (isTransitioning) return; const missedIds = Object.entries(results) .filter(([, r]) => r === "missed") .map(([id]) => id); @@ -417,11 +277,6 @@ export function FlashcardViewer({ if (completed) { return (
- {restoreWarning && ( -
- {restoreWarning} -
- )}
@@ -478,27 +333,21 @@ export function FlashcardViewer({ // Study view return (
- {restoreWarning && ( -
- {restoreWarning} -
- )} {/* Running tally */}
-
-
+
+
{correctCount} ✓ {missedCount} ✗ - {streak >= 2 && }
{currentIndex + 1} of {order.length}
- {/* Progress bar — turns to flame once the streak is hot */} + {/* Progress bar */}
@@ -511,7 +360,6 @@ export function FlashcardViewer({ ref={cardRef} className={`perspective-1000 cursor-pointer select-none relative ${swipeClass}`} onClick={() => { - if (isTransitioning) return; setToastMessage(null); setIsFlipped(!isFlipped); if (!isFlipped) setHasFlippedOnce(true); @@ -576,9 +424,6 @@ export function FlashcardViewer({
)} - {/* Streak milestone burst */} - {burst && } - {/* Toast Notification */} {toastMessage && (
@@ -597,8 +442,7 @@ export function FlashcardViewer({
-
- ); - } - return (
- {loadError &&

{loadError}

}

Copy these instructions and paste them into an LLM chat along with your study material. The LLM will generate JSON you can paste into the Import tab.