diff --git a/.env.example b/.env.example index 7c53313..22361b8 100644 --- a/.env.example +++ b/.env.example @@ -32,12 +32,7 @@ CRON_SECRET="" # auto-filled by `npm run setup` — sh # --- Logging -------------------------------------------------- LOG_LEVEL="info" # debug | info | warn | error -# --- Push Notifications (Web Push / VAPID) -------------------- -VAPID_PUBLIC_KEY="" # generate with: npx web-push generate-vapid-keys -VAPID_PRIVATE_KEY="" -VAPID_SUBJECT="mailto:admin@studybot.app" - -# --- Email Reminders ------------------------------------------ +# --- Email ---------------------------------------------------- EMAIL_PROVIDER="console" # console | smtp SMTP_HOST="" SMTP_PORT="587" @@ -45,6 +40,12 @@ SMTP_USER="" SMTP_PASS="" SMTP_FROM="Study Bot " +# Require users to verify their email address before signing in. +# Default: off (unset/false) — new accounts can sign in immediately. +# Only enable this with a real SMTP provider configured above; with the +# console provider, verification links are only printed to the server logs. +# REQUIRE_EMAIL_VERIFICATION="true" + # --- Google Calendar Integration ------------------------------ GOOGLE_CLIENT_ID="" GOOGLE_CLIENT_SECRET="" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72e388f..ffcb0d7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,8 +81,8 @@ jobs: - run: npm ci - run: npx prisma generate - - name: Push schema to test DB - run: npx prisma db push --force-reset --accept-data-loss + - name: Apply migration history to test DB + run: npx prisma migrate deploy - name: Apply pgvector migration run: psql "${DATABASE_URL%%\?*}" -f prisma/manual-scripts/001_pgvector.sql - run: npm run test:integration @@ -121,8 +121,8 @@ jobs: - run: npm ci - run: npx prisma generate - - name: Push schema to test DB - run: npx prisma db push --force-reset --accept-data-loss + - name: Apply migration history to test DB + run: npx prisma migrate deploy - name: Apply pgvector migration run: psql "${DATABASE_URL%%\?*}" -f prisma/manual-scripts/001_pgvector.sql - run: npx playwright install --with-deps chromium diff --git a/README.md b/README.md index dd34c54..85711d5 100644 --- a/README.md +++ b/README.md @@ -24,6 +24,8 @@ The full treatment, with effect sizes, primary citations, and the failure modes ## Quick start +Prerequisites: Node 20.19 or newer, plus Docker Desktop (or your own PostgreSQL server). + ``` git clone && cd Study-Bot npm install @@ -31,7 +33,9 @@ npm run setup npm run dev ``` -Then open http://localhost:3000. The defaults need no API keys or external accounts: the AI provider is a deterministic mock, email logs to the console, and calendar sync uses a local fake. For real AI generated questions and feedback, set `AI_PROVIDER` to `openai` and add your `OPENAI_API_KEY` in `.env`. +Then open http://localhost:3000. The defaults need no API keys or external accounts: the AI provider is a deterministic mock, email logs to the console, and calendar sync uses a local fake. + +To be blunt about the default: mock mode exists for tests. With `AI_PROVIDER` set to `mock`, sessions get template questions and canned feedback, which exercises the machinery but teaches you nothing. To actually study, set `AI_PROVIDER` to `openai` and add an `OPENAI_API_KEY` in `.env`. What `npm run setup` does: copies `.env.example` to `.env`, generates `NEXTAUTH_SECRET`, `TOKEN_ENC_KEY`, and `CRON_SECRET`, starts PostgreSQL through Docker, and applies all migrations. It is idempotent and never overwrites a value you set yourself. @@ -139,6 +143,13 @@ Each technique below is implemented in the product. Citations are shortened here One warning: `ALLOW_TEST_AUTH` makes the app trust a header as the authenticated identity. It exists only for automated tests and must never be set in production. +## Deployment and ops notes + +* **Auth gates.** `REQUIRE_EMAIL_VERIFICATION` is off by default, so new accounts can sign in immediately; enable it only once real SMTP is configured, because the console email provider prints verification links to server logs only. `ALLOW_TEST_AUTH` must never be set in production. `TOKEN_ENC_KEY` is required in production (setup generates one for local use). +* **One long lived Node process.** The app is not serverless safe. Feedback is generated eagerly when an attempt lands, and the AI rate limiter and circuit breaker hold their state in process, so run it as a single persistent Node server via `npm start` rather than as per request functions. +* **The port 3000 trap.** If something else already holds port 3000, Next silently starts on 3001 while `NEXTAUTH_URL` still points at 3000, and sign in breaks with baffling redirects. Free port 3000, or update `NEXTAUTH_URL` (plus `BASE_URL` and `NEXT_PUBLIC_APP_URL`) to the port actually in use. +* **Admin routes.** The `/admin` pages and admin APIs are gated by `ADMIN_USER_IDS`, a comma separated list of user UUIDs. Until you set it, nobody is an admin. + ## Development * `npm run dev`, `npm run build`, and `npm start` cover the dev server and production. diff --git a/e2e/knowledge-layer.spec.ts b/e2e/knowledge-layer.spec.ts index eb2c454..84a640d 100644 --- a/e2e/knowledge-layer.spec.ts +++ b/e2e/knowledge-layer.spec.ts @@ -5,10 +5,9 @@ const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; const USER_ID = "e2e_test_user"; const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL }); -// Override extraHTTPHeaders to remove X-User-Id — we set it via localStorage -// and the client JS adds it to fetch calls. Having Playwright ALSO inject it -// causes duplicate headers which break the ownership check. -test.use({ extraHTTPHeaders: {} }); +// Playwright context headers ride on every browser request, so app fetches +// authenticate as the test user (clients no longer send identity headers). +test.use({ extraHTTPHeaders: { "X-User-Id": USER_ID } }); test.describe.serial("Knowledge Layer — Leak Prevention", () => { const COURSE = "E2E_CS_KL"; @@ -78,10 +77,6 @@ test.describe.serial("Knowledge Layer — Leak Prevention", () => { test("review panel NOT visible before submitting answer", async ({ page }) => { const sessionUrl = `${BASE_URL}/s/${sessionId}`; await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); @@ -98,10 +93,6 @@ test.describe.serial("Knowledge Layer — Leak Prevention", () => { test("review panel appears after INCORRECT scoring with deferred feedback", async ({ page }) => { const sessionUrl = `${BASE_URL}/s/${sessionId}`; await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); diff --git a/e2e/session-runner.spec.ts b/e2e/session-runner.spec.ts index bd16c86..3a48898 100644 --- a/e2e/session-runner.spec.ts +++ b/e2e/session-runner.spec.ts @@ -23,10 +23,9 @@ const SESSION_PAYLOAD = { let sessionId: string; let sessionUrl: string; -// Override extraHTTPHeaders to remove X-User-Id — we set it via localStorage -// and the client JS adds it to fetch calls. Having Playwright ALSO inject it -// causes duplicate headers which break the ownership check. -test.use({ extraHTTPHeaders: {} }); +// Playwright context headers ride on every browser request, so app fetches +// authenticate as the test user (clients no longer send identity headers). +test.use({ extraHTTPHeaders: { "X-User-Id": USER_ID } }); test.describe.serial("E2E: Full Retrieval Session Runner", () => { test.beforeAll(async () => { @@ -80,11 +79,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { }); test("start session and see first prompt", async ({ page }) => { - // Need to set the user ID in localStorage before starting - await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); await page.goto(sessionUrl); await page.getByRole("button", { name: /start session/i }).click(); @@ -96,10 +90,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("submit a CORRECT answer", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); @@ -125,10 +115,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("submit an INCORRECT answer with error log", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); await expect(page.locator("textarea")).toBeVisible({ timeout: 5000 }); @@ -160,10 +146,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("refresh page mid-run preserves progress", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); // Should show Resume (not Start) since we have an active run await expect(page.getByRole("button", { name: /resume session/i })).toBeVisible({ @@ -178,10 +160,6 @@ test.describe.serial("E2E: Full Retrieval Session Runner", () => { test("complete final prompt and see end screen", async ({ page }) => { await page.goto(sessionUrl); - await page.evaluate((uid) => { - localStorage.setItem("study_bot_user_id", uid); - }, USER_ID); - await page.goto(sessionUrl); await page.getByRole("button", { name: /start session|resume session/i }).click(); // The review panel renders reflection textareas too, so target the diff --git a/package-lock.json b/package-lock.json index ed7f2e5..4b4b4ed 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,7 +19,6 @@ "pg": "^8.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -31,12 +30,10 @@ "@types/react": "^19.2.14", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.1", + "dotenv": "^17.4.2", "eslint": "^8.57.1", "eslint-config-next": "^14.2.35", - "fast-glob": "^3.3.3", "prisma": "^7.6.0", - "sharp": "^0.34.5", - "svgo": "^4.0.1", "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.2" @@ -783,496 +780,6 @@ "dev": true, "license": "BSD-3-Clause" }, - "node_modules/@img/colour": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", - "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/@img/sharp-darwin-arm64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-darwin-x64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-libvips-darwin-arm64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-darwin-x64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "darwin" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-arm64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-ppc64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-riscv64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-s390x": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linux-x64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "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" - ], - "dev": true, - "license": "LGPL-3.0-or-later", - "optional": true, - "os": [ - "linux" - ], - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-linux-arm": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-arm64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-ppc64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-riscv64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-s390x": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" - } - }, - "node_modules/@img/sharp-linux-x64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-arm64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" - } - }, - "node_modules/@img/sharp-linuxmusl-x64": { - "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" - ], - "dev": true, - "license": "Apache-2.0", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" - } - }, - "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", - "optional": true, - "dependencies": { - "@emnapi/runtime": "^1.7.0" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-arm64": { - "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" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-ia32": { - "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" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, - "node_modules/@img/sharp-win32-x64": { - "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" - ], - "dev": true, - "license": "Apache-2.0 AND LGPL-3.0-or-later", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - } - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3224,15 +2731,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -3453,18 +2951,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/asn1.js": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", - "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.0.0", - "inherits": "^2.0.1", - "minimalistic-assert": "^1.0.0", - "safer-buffer": "^2.1.0" - } - }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -3567,19 +3053,6 @@ "better-result": "bin/cli.mjs" } }, - "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", - "license": "MIT" - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, "node_modules/brace-expansion": { "version": "1.1.13", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", @@ -3591,25 +3064,6 @@ "concat-map": "0.0.1" } }, - "node_modules/braces": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", - "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, - "license": "MIT", - "dependencies": { - "fill-range": "^7.1.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/buffer-equal-constant-time": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", - "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", - "license": "BSD-3-Clause" - }, "node_modules/busboy": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz", @@ -3650,6 +3104,19 @@ } } }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/call-bind": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz", @@ -3822,16 +3289,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -3856,117 +3313,37 @@ "node": "^14.18.0 || >=16.10.0" } }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/cookie": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", - "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/cross-spawn": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", - "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "devOptional": true, - "license": "MIT", - "dependencies": { - "path-key": "^3.1.0", - "shebang-command": "^2.0.0", - "which": "^2.0.1" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-tree": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", - "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mdn-data": "2.27.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/csso": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/csso/-/csso-5.0.5.tgz", - "integrity": "sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==", + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", - "dependencies": { - "css-tree": "~2.2.0" - }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">= 0.6" } }, - "node_modules/csso/node_modules/css-tree": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.2.1.tgz", - "integrity": "sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==", - "dev": true, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "devOptional": true, "license": "MIT", "dependencies": { - "mdn-data": "2.0.28", - "source-map-js": "^1.0.1" + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" }, "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0", - "npm": ">=7.0.0" + "node": ">= 8" } }, - "node_modules/csso/node_modules/mdn-data": { - "version": "2.0.28", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.0.28.tgz", - "integrity": "sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==", - "dev": true, - "license": "CC0-1.0" - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -4144,70 +3521,11 @@ "node": ">=0.10.0" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, "node_modules/dotenv": { - "version": "16.6.1", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", - "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", - "devOptional": true, + "version": "17.4.2", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", + "integrity": "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==", + "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=12" @@ -4238,15 +3556,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ecdsa-sig-formatter": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", - "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", - "license": "Apache-2.0", - "dependencies": { - "safe-buffer": "^5.0.1" - } - }, "node_modules/effect": { "version": "3.20.0", "resolved": "https://registry.npmjs.org/effect/-/effect-3.20.0.tgz", @@ -4275,19 +3584,6 @@ "node": ">=14" } }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/env-paths": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-3.0.0.tgz", @@ -5120,36 +4416,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/fast-glob": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", - "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@nodelib/fs.stat": "^2.0.2", - "@nodelib/fs.walk": "^1.2.3", - "glob-parent": "^5.1.2", - "merge2": "^1.3.0", - "micromatch": "^4.0.8" - }, - "engines": { - "node": ">=8.6.0" - } - }, - "node_modules/fast-glob/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -5222,19 +4488,6 @@ "node": "^10.12.0 || >=12.0.0" } }, - "node_modules/fill-range": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", - "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, - "license": "MIT", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/find-up": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", @@ -5681,15 +4934,6 @@ "node": ">=16.9.0" } }, - "node_modules/http_ece": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.2.0.tgz", - "integrity": "sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==", - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/http-status-codes": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/http-status-codes/-/http-status-codes-2.3.0.tgz", @@ -5697,36 +4941,6 @@ "devOptional": true, "license": "MIT" }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/iconv-lite": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", @@ -5797,6 +5011,7 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, "license": "ISC" }, "node_modules/internal-slot": { @@ -6067,16 +5282,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.12.0" - } - }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -6382,27 +5587,6 @@ "node": ">=4.0" } }, - "node_modules/jwa": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", - "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", - "license": "MIT", - "dependencies": { - "buffer-equal-constant-time": "^1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", - "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", - "license": "MIT", - "dependencies": { - "jwa": "^2.0.1", - "safe-buffer": "^5.0.1" - } - }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -6793,56 +5977,6 @@ "node": ">= 0.4" } }, - "node_modules/mdn-data": { - "version": "2.27.1", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", - "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", - "dev": true, - "license": "CC0-1.0" - }, - "node_modules/merge2": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", - "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 8" - } - }, - "node_modules/micromatch": { - "version": "4.0.8", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", - "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "braces": "^3.0.3", - "picomatch": "^2.3.1" - }, - "engines": { - "node": ">=8.6" - } - }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, "node_modules/minimatch": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", @@ -6860,6 +5994,7 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/ljharb" @@ -7079,19 +6214,6 @@ "node": ">=6.0.0" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, "node_modules/nypm": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.5.tgz", @@ -8184,26 +7306,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8243,18 +7345,9 @@ "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/sax": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz", - "integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": ">=11.0.0" - } - }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -8329,64 +7422,6 @@ "node": ">= 0.4" } }, - "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "dev": true, - "hasInstallScript": true, - "license": "Apache-2.0", - "dependencies": { - "@img/colour": "^1.0.0", - "detect-libc": "^2.1.2", - "semver": "^7.7.3" - }, - "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" - }, - "funding": { - "url": "https://opencollective.com/libvips" - }, - "optionalDependencies": { - "@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": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -8866,32 +7901,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/svgo": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.1.tgz", - "integrity": "sha512-XDpWUOPC6FEibaLzjfe0ucaV0YrOjYotGJO1WpF0Zd+n6ZGEQUsSugaoLq9QkEZtAfQIxT42UChcssDVPP3+/w==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^11.1.0", - "css-select": "^5.1.0", - "css-tree": "^3.0.1", - "css-what": "^6.1.0", - "csso": "^5.0.5", - "picocolors": "^1.1.1", - "sax": "^1.5.0" - }, - "bin": { - "svgo": "bin/svgo.js" - }, - "engines": { - "node": ">=16" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/svgo" - } - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -8943,19 +7952,6 @@ "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", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-number": "^7.0.0" - }, - "engines": { - "node": ">=8.0" - } - }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -9429,25 +8425,6 @@ "dev": true, "license": "MIT" }, - "node_modules/web-push": { - "version": "3.6.7", - "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.6.7.tgz", - "integrity": "sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==", - "license": "MPL-2.0", - "dependencies": { - "asn1.js": "^5.3.0", - "http_ece": "1.2.0", - "https-proxy-agent": "^7.0.0", - "jws": "^4.0.0", - "minimist": "^1.2.5" - }, - "bin": { - "web-push": "src/cli.js" - }, - "engines": { - "node": ">= 16" - } - }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index e722a61..eee670b 100644 --- a/package.json +++ b/package.json @@ -16,9 +16,6 @@ "test:integration": "vitest run src/__tests__/integration/", "test:watch": "vitest", "test:e2e": "playwright test", - "assets:build": "tsx scripts/assets-build.ts", - "assets:check": "tsx scripts/assets-check.ts", - "prebuild": "npm run assets:build", "worker": "tsx scripts/worker.ts", "db:up": "docker compose up -d db", "db:down": "docker compose down", @@ -40,7 +37,6 @@ "pg": "^8.20.0", "react": "^18.3.1", "react-dom": "^18.3.1", - "web-push": "^3.6.7", "zod": "^4.3.6" }, "devDependencies": { @@ -52,12 +48,10 @@ "@types/react": "^19.2.14", "@types/web-push": "^3.6.4", "@vitejs/plugin-react": "^6.0.1", + "dotenv": "^17.4.2", "eslint": "^8.57.1", "eslint-config-next": "^14.2.35", - "fast-glob": "^3.3.3", "prisma": "^7.6.0", - "sharp": "^0.34.5", - "svgo": "^4.0.1", "tsx": "^4.21.0", "typescript": "^6.0.2", "vitest": "^4.1.2" diff --git a/prisma/migrations/20260716030000_slim_and_reconcile/migration.sql b/prisma/migrations/20260716030000_slim_and_reconcile/migration.sql new file mode 100644 index 0000000..4741f3f --- /dev/null +++ b/prisma/migrations/20260716030000_slim_and_reconcile/migration.sql @@ -0,0 +1,113 @@ +-- Slim & reconcile: drop dead feature tables (push notifications, scheduled +-- reminders, practice sets) and fix schema/migration drift so +-- `prisma migrate diff --from-migrations --to-schema` is clean. + +-- --------------------------------------------------------------------------- +-- 1. Drop dead feature tables (feature code already removed) +-- --------------------------------------------------------------------------- + +-- DropForeignKey +ALTER TABLE "practice_questions" DROP CONSTRAINT "practice_questions_practice_set_id_fkey"; + +-- DropForeignKey +ALTER TABLE "push_subscriptions" DROP CONSTRAINT "push_subscriptions_user_id_fkey"; + +-- DropForeignKey +ALTER TABLE "scheduled_reminders" DROP CONSTRAINT "scheduled_reminders_user_id_fkey"; + +-- DropTable +DROP TABLE "practice_questions"; + +-- DropTable +DROP TABLE "practice_sets"; + +-- DropTable +DROP TABLE "push_subscriptions"; + +-- DropTable +DROP TABLE "scheduled_reminders"; + +-- --------------------------------------------------------------------------- +-- 2. Drift: session_error_logs.user_id carried a stray DEFAULT '' +-- --------------------------------------------------------------------------- + +-- AlterTable +ALTER TABLE "session_error_logs" ALTER COLUMN "user_id" DROP DEFAULT; + +-- --------------------------------------------------------------------------- +-- 3. Drift: foreign keys created with ON DELETE RESTRICT, schema declares +-- onDelete: Cascade +-- --------------------------------------------------------------------------- + +-- DropForeignKey +ALTER TABLE "session_runs" DROP CONSTRAINT "session_runs_session_id_fkey"; + +-- DropForeignKey +ALTER TABLE "session_attempts" DROP CONSTRAINT "session_attempts_run_id_fkey"; + +-- DropForeignKey +ALTER TABLE "session_error_logs" DROP CONSTRAINT "session_error_logs_run_id_fkey"; + +-- DropForeignKey +ALTER TABLE "study_plan_items" DROP CONSTRAINT "study_plan_items_plan_id_fkey"; + +-- DropForeignKey +ALTER TABLE "plan_calendar_publications" DROP CONSTRAINT "plan_calendar_publications_plan_id_fkey"; + +-- DropForeignKey +ALTER TABLE "plan_item_external_events" DROP CONSTRAINT "plan_item_external_events_plan_item_id_fkey"; + +-- AddForeignKey +ALTER TABLE "session_runs" ADD CONSTRAINT "session_runs_session_id_fkey" FOREIGN KEY ("session_id") REFERENCES "sessions"("session_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "session_attempts" ADD CONSTRAINT "session_attempts_run_id_fkey" FOREIGN KEY ("run_id") REFERENCES "session_runs"("run_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "session_error_logs" ADD CONSTRAINT "session_error_logs_run_id_fkey" FOREIGN KEY ("run_id") REFERENCES "session_runs"("run_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "study_plan_items" ADD CONSTRAINT "study_plan_items_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "study_plans"("plan_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "plan_calendar_publications" ADD CONSTRAINT "plan_calendar_publications_plan_id_fkey" FOREIGN KEY ("plan_id") REFERENCES "study_plans"("plan_id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "plan_item_external_events" ADD CONSTRAINT "plan_item_external_events_plan_item_id_fkey" FOREIGN KEY ("plan_item_id") REFERENCES "study_plan_items"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- --------------------------------------------------------------------------- +-- 4. Drift: indexes declared in the schema but never migrated +-- --------------------------------------------------------------------------- + +-- CreateIndex +CREATE INDEX "evidence_cards_strength_idx" ON "evidence_cards"("strength"); + +-- CreateIndex +CREATE INDEX "session_runs_user_created_idx" ON "session_runs"("user_id", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "sessions_user_created_idx" ON "sessions"("user_id", "created_at" DESC); + +-- CreateIndex +CREATE INDEX "study_plans_user_exam_date_idx" ON "study_plans"("user_id", "exam_date"); + +-- Dedupe flashcard ordinals before adding the unique index: reassign a dense +-- 0-based ordinal per deck, preserving existing order (ordinal, then insert +-- order) so decks with duplicate or gappy ordinals migrate cleanly. +WITH ranked AS ( + SELECT + "id", + ROW_NUMBER() OVER ( + PARTITION BY "deck_id" + ORDER BY "ordinal", "created_at", "id" + ) - 1 AS new_ordinal + FROM "flashcards" +) +UPDATE "flashcards" f +SET "ordinal" = ranked.new_ordinal +FROM ranked +WHERE f."id" = ranked."id" + AND f."ordinal" IS DISTINCT FROM ranked.new_ordinal; + +-- CreateIndex +CREATE UNIQUE INDEX "flashcards_deck_ordinal_key" ON "flashcards"("deck_id", "ordinal"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index accf84a..cb2a95e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -231,36 +231,6 @@ model ObjectiveAnchor { @@map("objective_anchors") } -model PracticeSet { - id String @id @default(uuid()) - userId String @map("user_id") - courseName String @map("course_name") - examName String? @map("exam_name") - title String - createdAt DateTime @default(now()) @map("created_at") - - questions PracticeQuestion[] - - @@index([userId, courseName, examName], map: "practice_sets_user_course_exam_idx") - @@map("practice_sets") -} - -model PracticeQuestion { - id String @id @default(uuid()) - practiceSetId String @map("practice_set_id") - kind String // SHORT_ANSWER | MCQ | CODING - promptText String @map("prompt_text") - answerKey String? @map("answer_key") - solutionSteps String? @map("solution_steps") - tags Json? - createdAt DateTime @default(now()) @map("created_at") - - practiceSet PracticeSet @relation(fields: [practiceSetId], references: [id], onDelete: Cascade) - - @@index([practiceSetId]) - @@map("practice_questions") -} - model EvidencePaper { id String @id @default(uuid()) userId String @map("user_id") @@ -647,31 +617,13 @@ model User { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - pushSubscriptions PushSubscription[] notificationPreference NotificationPreference? chatMessages ChatMessage[] - scheduledReminders ScheduledReminder[] verificationTokens VerificationToken[] @@map("users") } -// ── Push Notifications ────────────────────────────────────────── - -model PushSubscription { - id String @id @default(uuid()) - userId String @map("user_id") - endpoint String - p256dh String - auth String - createdAt DateTime @default(now()) @map("created_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@unique([userId, endpoint]) - @@map("push_subscriptions") -} - // ── Chat Persistence ──────────────────────────────────────────── model ChatMessage { @@ -708,24 +660,6 @@ model NotificationPreference { @@map("notification_preferences") } -// ── Email Reminders (scheduled) ───────────────────────────────── - -model ScheduledReminder { - id String @id @default(uuid()) - userId String @map("user_id") - type String // STUDY_REMINDER | STREAK_WARNING | WEEKLY_DIGEST - scheduledFor DateTime @map("scheduled_for") - sentAt DateTime? @map("sent_at") - payload Json? // extra data (plan items, streak count, etc.) - createdAt DateTime @default(now()) @map("created_at") - - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([scheduledFor, sentAt]) - @@index([userId]) - @@map("scheduled_reminders") -} - // ── Verification Tokens (email verify, password reset) ────────── model VerificationToken { diff --git a/public/sw.js b/public/sw.js deleted file mode 100644 index 0bbc025..0000000 --- a/public/sw.js +++ /dev/null @@ -1,50 +0,0 @@ -// ── Study Bot Service Worker ──────────────────────────────────── -// Handles push notifications and notification click events. - -self.addEventListener("activate", (event) => { - // Claim all open clients so the SW takes effect immediately - event.waitUntil(self.clients.claim()); -}); - -self.addEventListener("push", (event) => { - if (!event.data) return; - - let payload; - try { - payload = event.data.json(); - } catch { - payload = { title: "Study Bot", body: event.data.text() }; - } - - const { title = "Study Bot", body = "", url = "/", icon = "/assets/icon-192.png" } = payload; - - event.waitUntil( - self.registration.showNotification(title, { - body, - icon, - badge: icon, - data: { url }, - }), - ); -}); - -self.addEventListener("notificationclick", (event) => { - event.notification.close(); - - const targetUrl = event.notification.data?.url || "/"; - - event.waitUntil( - self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((clientList) => { - // If there's already an open tab, focus it and navigate - for (const client of clientList) { - if (client.url.includes(self.location.origin) && "focus" in client) { - client.focus(); - client.navigate(targetUrl); - return; - } - } - // Otherwise open a new window - return self.clients.openWindow(targetUrl); - }), - ); -}); diff --git a/scripts/assets-build.ts b/scripts/assets-build.ts deleted file mode 100644 index 0fd1f30..0000000 --- a/scripts/assets-build.ts +++ /dev/null @@ -1,291 +0,0 @@ -/** - * Asset optimization pipeline. - * - * Reads raw exports from design/exports-raw/, - * outputs optimized assets to public/assets/ui/, - * generates src/ui/assets/manifest.ts. - * - * Usage: npx tsx scripts/assets-build.ts - * - * PNG → AVIF (q55) + WebP (q80), strip metadata, optional @2x→@1x downscale - * SVG → SVGO optimized - */ -import { mkdir, writeFile, rm } from "fs/promises"; -import { existsSync } from "fs"; -import path from "path"; -import sharp from "sharp"; -import { optimize } from "svgo"; -import { readFile } from "fs/promises"; -import fg from "fast-glob"; - -// ---- Config ---- - -const RAW_DIR = "design/exports-raw"; -const OUT_DIR = "public/assets/ui"; -const MANIFEST_PATH = "src/ui/assets/manifest.ts"; - -const AVIF_QUALITY = 55; -const WEBP_QUALITY = 80; - -const MAX_FILE_SIZE = 300 * 1024; // 300KB -const MAX_DIMENSION = 2000; -const MAX_PIXELS = 4_000_000; - -// Assets allowed to exceed pixel budget (e.g. hero backgrounds) -const PIXEL_ALLOWLIST: string[] = []; - -// ---- Types ---- - -interface AssetEntry { - key: string; - avif?: string; - webp?: string; - svg?: string; - width?: number; - height?: number; -} - -interface BuildResult { - assets: AssetEntry[]; - warnings: string[]; - errors: string[]; -} - -// ---- SVG optimization config ---- - -const SVGO_CONFIG = { - plugins: [ - { name: "removeDoctype" as const }, - { name: "removeXMLProcInst" as const }, - { name: "removeComments" as const }, - { name: "removeMetadata" as const }, - { name: "removeEditorsNSData" as const }, - { name: "removeDesc" as const, params: { removeAny: true } }, - { name: "removeUselessDefs" as const }, - { name: "cleanupAttrs" as const }, - { name: "removeEmptyAttrs" as const }, - { name: "removeEmptyContainers" as const }, - { name: "sortAttrs" as const }, - { name: "removeDimensions" as const, active: false }, - ], -}; - -// ---- Main ---- - -export async function buildAssets(): Promise { - const warnings: string[] = []; - const errors: string[] = []; - const assets: AssetEntry[] = []; - - // Clean and recreate output dir - if (existsSync(OUT_DIR)) { - await rm(OUT_DIR, { recursive: true }); - } - await mkdir(OUT_DIR, { recursive: true }); - - // Scan raw exports - const pngFiles = await fg("**/*.png", { cwd: RAW_DIR }); - const svgFiles = await fg("**/*.svg", { cwd: RAW_DIR }); - - // Sort for deterministic output - pngFiles.sort(); - svgFiles.sort(); - - // Process PNGs - for (const file of pngFiles) { - const fullPath = path.join(RAW_DIR, file); - const basename = path.basename(file, ".png"); - - try { - const img = sharp(fullPath); - const meta = await img.metadata(); - const w = meta.width!; - const h = meta.height!; - - // Dimension warnings - if (w > MAX_DIMENSION || h > MAX_DIMENSION) { - warnings.push(`${file}: dimensions ${w}x${h} exceed ${MAX_DIMENSION}px`); - } - - // Pixel budget - if (w * h > MAX_PIXELS && !PIXEL_ALLOWLIST.includes(basename)) { - errors.push(`${file}: ${w * h} pixels exceeds ${MAX_PIXELS} limit`); - continue; - } - - // Generate AVIF + WebP - const avifPath = path.join(OUT_DIR, `${basename}.avif`); - const webpPath = path.join(OUT_DIR, `${basename}.webp`); - - await sharp(fullPath) - .avif({ quality: AVIF_QUALITY }) - .toFile(avifPath); - - await sharp(fullPath) - .webp({ quality: WEBP_QUALITY }) - .toFile(webpPath); - - assets.push({ - key: basename, - avif: `/assets/ui/${basename}.avif`, - webp: `/assets/ui/${basename}.webp`, - width: w, - height: h, - }); - - // Handle @2x → @1x downscale - if (basename.endsWith("@2x")) { - const baseKey = basename.replace(/@2x$/, ""); - const halfW = Math.round(w / 2); - const halfH = Math.round(h / 2); - - const avif1x = path.join(OUT_DIR, `${baseKey}@1x.avif`); - const webp1x = path.join(OUT_DIR, `${baseKey}@1x.webp`); - - await sharp(fullPath) - .resize(halfW, halfH) - .avif({ quality: AVIF_QUALITY }) - .toFile(avif1x); - - await sharp(fullPath) - .resize(halfW, halfH) - .webp({ quality: WEBP_QUALITY }) - .toFile(webp1x); - - assets.push({ - key: `${baseKey}@1x`, - avif: `/assets/ui/${baseKey}@1x.avif`, - webp: `/assets/ui/${baseKey}@1x.webp`, - width: halfW, - height: halfH, - }); - } - } catch (err) { - errors.push(`${file}: processing failed — ${err}`); - } - } - - // Process SVGs - for (const file of svgFiles) { - const fullPath = path.join(RAW_DIR, file); - const basename = path.basename(file, ".svg"); - - try { - const raw = await readFile(fullPath, "utf-8"); - const result = optimize(raw, { - path: fullPath, - ...SVGO_CONFIG, - }); - - const outPath = path.join(OUT_DIR, `${basename}.svg`); - await writeFile(outPath, result.data, "utf-8"); - - // Parse width/height from SVG if present - const wMatch = raw.match(/width="(\d+)"/); - const hMatch = raw.match(/height="(\d+)"/); - - assets.push({ - key: basename, - svg: `/assets/ui/${basename}.svg`, - width: wMatch ? parseInt(wMatch[1], 10) : undefined, - height: hMatch ? parseInt(hMatch[1], 10) : undefined, - }); - } catch (err) { - errors.push(`${file}: SVG optimization failed — ${err}`); - } - } - - // Sort assets by key for deterministic manifest - assets.sort((a, b) => a.key.localeCompare(b.key)); - - // Budget check: output file sizes - const outputFiles = await fg("**/*", { cwd: OUT_DIR, stats: true }); - for (const entry of outputFiles) { - // fast-glob with stats returns stats on the entry - const filePath = path.join(OUT_DIR, entry.path); - const { size } = await import("fs").then((fs) => - fs.promises.stat(filePath) - ); - if (size > MAX_FILE_SIZE) { - errors.push( - `${entry.path}: ${(size / 1024).toFixed(1)}KB exceeds ${MAX_FILE_SIZE / 1024}KB budget` - ); - } - } - - // Generate manifest - await generateManifest(assets); - - return { assets, warnings, errors }; -} - -// ---- Manifest generator ---- - -async function generateManifest(assets: AssetEntry[]): Promise { - await mkdir(path.dirname(MANIFEST_PATH), { recursive: true }); - - const keys = assets.map((a) => ` | "${a.key}"`).join("\n"); - - const entries = assets - .map((a) => { - const fields: string[] = []; - if (a.avif) fields.push(`avif: "${a.avif}"`); - if (a.webp) fields.push(`webp: "${a.webp}"`); - if (a.svg) fields.push(`svg: "${a.svg}"`); - if (a.width != null) fields.push(`width: ${a.width}`); - if (a.height != null) fields.push(`height: ${a.height}`); - return ` "${a.key}": { ${fields.join(", ")} }`; - }) - .join(",\n"); - - const code = `/** - * AUTO-GENERATED by scripts/assets-build.ts — do not edit manually. - * Run \`npm run assets:build\` to regenerate. - */ - -export type AssetKey = -${keys}; - -export interface AssetMeta { - avif?: string; - webp?: string; - svg?: string; - width?: number; - height?: number; -} - -export const assets: Record = { -${entries}, -}; - -/** - * Get the best available source URL for an asset. - * Preference: avif > webp > svg. - */ -export function assetSrc(key: AssetKey): string { - const a = assets[key]; - return a.avif ?? a.webp ?? a.svg ?? ""; -} -`; - - await writeFile(MANIFEST_PATH, code, "utf-8"); -} - -// ---- CLI entry ---- - -if (process.argv[1]?.endsWith("assets-build.ts") || process.argv[1]?.endsWith("assets-build")) { - buildAssets().then((result) => { - for (const w of result.warnings) { - console.warn(`⚠ ${w}`); - } - for (const e of result.errors) { - console.error(`✗ ${e}`); - } - console.log( - `✓ ${result.assets.length} assets built (${result.errors.length} errors, ${result.warnings.length} warnings)` - ); - if (result.errors.length > 0) { - process.exit(1); - } - }); -} diff --git a/scripts/assets-check.ts b/scripts/assets-check.ts deleted file mode 100644 index 4937950..0000000 --- a/scripts/assets-check.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Asset budget checker. - * - * Validates that all optimized assets meet size/dimension budgets. - * Run via: npx tsx scripts/assets-check.ts - * Used in CI to prevent regressions. - */ -import { stat } from "fs/promises"; -import path from "path"; -import fg from "fast-glob"; -import sharp from "sharp"; - -const OUT_DIR = "public/assets/ui"; - -const MAX_FILE_SIZE = 300 * 1024; // 300KB -const MAX_DIMENSION = 2000; -const MAX_PIXELS = 4_000_000; -const PIXEL_ALLOWLIST: string[] = []; - -export interface CheckResult { - total: number; - warnings: string[]; - errors: string[]; -} - -export async function checkAssets(): Promise { - const warnings: string[] = []; - const errors: string[] = []; - - const files = await fg("**/*", { cwd: OUT_DIR }); - files.sort(); - - for (const file of files) { - const filePath = path.join(OUT_DIR, file); - const stats = await stat(filePath); - - // Size budget - if (stats.size > MAX_FILE_SIZE) { - errors.push( - `${file}: ${(stats.size / 1024).toFixed(1)}KB exceeds ${MAX_FILE_SIZE / 1024}KB limit` - ); - } - - // Dimension checks for raster images - const ext = path.extname(file).toLowerCase(); - if (ext === ".avif" || ext === ".webp" || ext === ".png") { - try { - const meta = await sharp(filePath).metadata(); - const w = meta.width ?? 0; - const h = meta.height ?? 0; - - if (w > MAX_DIMENSION || h > MAX_DIMENSION) { - warnings.push(`${file}: ${w}x${h} exceeds ${MAX_DIMENSION}px`); - } - - const basename = path.basename(file, ext); - if (w * h > MAX_PIXELS && !PIXEL_ALLOWLIST.includes(basename)) { - errors.push(`${file}: ${w * h} pixels exceeds ${MAX_PIXELS} limit`); - } - } catch { - // Non-image file, skip dimension check - } - } - } - - return { total: files.length, warnings, errors }; -} - -// CLI entry -if (process.argv[1]?.endsWith("assets-check.ts") || process.argv[1]?.endsWith("assets-check")) { - checkAssets().then((result) => { - for (const w of result.warnings) { - console.warn(`⚠ ${w}`); - } - for (const e of result.errors) { - console.error(`✗ ${e}`); - } - console.log(`✓ ${result.total} assets checked (${result.errors.length} errors, ${result.warnings.length} warnings)`); - if (result.errors.length > 0) { - process.exit(1); - } - }); -} diff --git a/scripts/setup.mjs b/scripts/setup.mjs index 66f441b..5bbd201 100644 --- a/scripts/setup.mjs +++ b/scripts/setup.mjs @@ -25,6 +25,17 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const envPath = join(root, ".env"); const examplePath = join(root, ".env.example"); +// --------------------------------------------------------------------------- +// 0. Node version check (Next.js 15 + this script need Node 20.19+) +// --------------------------------------------------------------------------- +const [nodeMajor, nodeMinor] = process.versions.node.split(".").map(Number); +if (nodeMajor < 20 || (nodeMajor === 20 && nodeMinor < 19)) { + console.error( + `Setup failed: Node ${process.versions.node} is too old — this project requires Node 20.19 or newer, so install a current Node LTS and re-run npm run setup.`, + ); + process.exit(1); +} + // On Windows, docker/npx are .cmd shims and need a shell to resolve. const useShell = process.platform === "win32"; @@ -121,7 +132,8 @@ function parseEnv(text) { return result; } -for (const [key, value] of Object.entries(parseEnv(envText))) { +const parsedEnv = parseEnv(envText); +for (const [key, value] of Object.entries(parsedEnv)) { if (process.env[key] === undefined) process.env[key] = value; } @@ -238,6 +250,12 @@ if (!migrated || !generated) { } if (!generated) log(" - npx prisma generate"); } +if (parsedEnv.AI_PROVIDER === "mock") { + log(""); + log("Heads up: AI_PROVIDER is \"mock\" in .env. Mock mode exists for tests —"); + log("sessions get template questions and canned feedback. For real studying,"); + log("set AI_PROVIDER=openai and add an OPENAI_API_KEY in .env."); +} log(""); log("Next steps:"); log(" npm run dev start the app -> http://localhost:3000"); diff --git a/src/__tests__/assets-pipeline.test.ts b/src/__tests__/assets-pipeline.test.ts deleted file mode 100644 index fdbcb14..0000000 --- a/src/__tests__/assets-pipeline.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -/** - * Tests for the asset optimization pipeline. - * - * Verifies: - * - Manifest generation with stable keys - * - Budget enforcement (file size, dimensions, pixels) - * - Deterministic output (running twice yields same manifest) - * - @2x → @1x downscaling - * - SVG optimization (metadata stripped) - */ -import { describe, it, expect, beforeAll } from "vitest"; -import { existsSync, readFileSync, statSync } from "fs"; -import path from "path"; - -// Build assets before tests (the pipeline should already have been run) -// If not, tests will verify against whatever is in public/assets/ui - -const OUT_DIR = "public/assets/ui"; -const MANIFEST_PATH = "src/ui/assets/manifest.ts"; -const MAX_FILE_SIZE = 300 * 1024; - -describe("Asset Pipeline", () => { - let manifestContent: string; - let manifestModule: any; - - beforeAll(async () => { - // Build assets fresh - const { buildAssets } = await import("../../scripts/assets-build"); - const result = await buildAssets(); - expect(result.errors).toHaveLength(0); - - manifestContent = readFileSync(MANIFEST_PATH, "utf-8"); - manifestModule = await import("@/ui/assets/manifest"); - }); - - describe("manifest generation", () => { - it("generates a valid TypeScript manifest file", () => { - expect(existsSync(MANIFEST_PATH)).toBe(true); - expect(manifestContent).toContain("export type AssetKey"); - expect(manifestContent).toContain("export const assets"); - expect(manifestContent).toContain("export function assetSrc"); - }); - - it("produces stable keys sorted alphabetically", () => { - const keyMatches = manifestContent.match(/\| "([^"]+)"/g); - expect(keyMatches).toBeTruthy(); - const keys = keyMatches!.map((m) => m.replace('| "', "").replace('"', "")); - const sorted = [...keys].sort(); - expect(keys).toEqual(sorted); - }); - - it("includes all expected fixture assets", () => { - const { assets } = manifestModule; - expect(assets["btn-primary"]).toBeDefined(); - expect(assets["icon-phone-off"]).toBeDefined(); - expect(assets["icon-check"]).toBeDefined(); - expect(assets["icon-session"]).toBeDefined(); - }); - - it("raster assets have avif and webp paths", () => { - const { assets } = manifestModule; - const btn = assets["btn-primary"]; - expect(btn.avif).toMatch(/\.avif$/); - expect(btn.webp).toMatch(/\.webp$/); - expect(btn.width).toBeGreaterThan(0); - expect(btn.height).toBeGreaterThan(0); - }); - - it("SVG assets have svg path", () => { - const { assets } = manifestModule; - const icon = assets["icon-phone-off"]; - expect(icon.svg).toMatch(/\.svg$/); - expect(icon.avif).toBeUndefined(); - expect(icon.webp).toBeUndefined(); - }); - - it("assetSrc prefers avif for raster assets", () => { - const { assetSrc } = manifestModule; - expect(assetSrc("btn-primary")).toMatch(/\.avif$/); - }); - - it("assetSrc returns svg for SVG-only assets", () => { - const { assetSrc } = manifestModule; - expect(assetSrc("icon-check")).toMatch(/\.svg$/); - }); - }); - - describe("@2x downscaling", () => { - it("generates @1x variant from @2x input", () => { - const { assets } = manifestModule; - expect(assets["btn-primary@2x"]).toBeDefined(); - expect(assets["btn-primary@1x"]).toBeDefined(); - }); - - it("@1x dimensions are half of @2x", () => { - const { assets } = manifestModule; - const x2 = assets["btn-primary@2x"]; - const x1 = assets["btn-primary@1x"]; - expect(x1.width).toBe(Math.round(x2.width / 2)); - expect(x1.height).toBe(Math.round(x2.height / 2)); - }); - }); - - describe("SVG optimization", () => { - it("strips metadata and comments from SVGs", () => { - const svgPath = path.join(OUT_DIR, "icon-phone-off.svg"); - const content = readFileSync(svgPath, "utf-8"); - expect(content).not.toContain(""); - expect(content).not.toContain("