From d99b051a074f2cfb8e48a6a50a09a73062b1a41b Mon Sep 17 00:00:00 2001 From: gitea Date: Sat, 14 Feb 2026 15:13:26 +0100 Subject: [PATCH] rest API added --- .env.sample | 5 + README.md | 10 + package-lock.json | 900 ++++++++++++++++++++++++++++++- package.json | 11 +- server/index.js | 328 +++++++++++ src/lib/Navbar.svelte | 5 +- src/lib/Navbar.test.js | 2 +- src/lib/api/decks-core.js | 517 ++++++++++++++++++ src/lib/api/decks.js | 518 +----------------- src/lib/api/decks.test.js | 57 +- src/lib/api/profile.js | 12 +- src/lib/api/profile.test.js | 27 +- src/lib/stores/auth.js | 4 +- src/lib/stores/auth.test.js | 4 +- src/routes/Community.svelte | 9 +- src/routes/Community.test.js | 2 +- src/routes/CommunityUser.svelte | 9 +- src/routes/CommunityUser.test.js | 2 +- src/routes/CreateDeck.svelte | 3 +- src/routes/CreateDeck.test.js | 2 +- src/routes/DeckPreview.svelte | 7 +- src/routes/DeckPreview.test.js | 4 +- src/routes/DeckReviews.svelte | 5 +- src/routes/DeckReviews.test.js | 4 +- src/routes/EditDeck.svelte | 9 +- src/routes/EditDeck.test.js | 2 +- src/routes/MyDecks.svelte | 17 +- src/routes/MyDecks.test.js | 2 +- src/routes/Settings.svelte | 7 +- src/routes/Settings.test.js | 4 +- 30 files changed, 1872 insertions(+), 616 deletions(-) create mode 100644 server/index.js create mode 100644 src/lib/api/decks-core.js diff --git a/.env.sample b/.env.sample index 84aecc5..5974f32 100644 --- a/.env.sample +++ b/.env.sample @@ -4,6 +4,11 @@ VITE_SUPABASE_URL=https://your-project.supabase.co VITE_SUPABASE_ANON_KEY=your-anon-key +# API server (optional; falls back to VITE_* if unset) +# SUPABASE_URL=https://your-project.supabase.co +# SUPABASE_ANON_KEY=your-anon-key +# PORT=3001 + # Optional: for OAuth / magic link redirects # VITE_SUPABASE_REDIRECT_URL=http://localhost:5173 diff --git a/README.md b/README.md index ced6146..b68bdd1 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,16 @@ npm install npm run dev ``` +Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` for the Svelte app and (optionally) the API server. + +## API server (REST API for mobile / other clients) + +The Express API uses the same Supabase backend. Optional env: `SUPABASE_URL`, `SUPABASE_ANON_KEY` (default to `VITE_*`), `PORT` (default `3001`). + +- **Run**: `npm run dev:server` or `npm run start:api` +- **Auth**: `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/session` (Bearer token for protected routes) +- **Decks**: `GET /api/decks/mine`, `GET /api/decks/published`, `GET /api/decks/:id`, `POST /api/decks`, `PATCH /api/decks/:id`, `POST /api/decks/:id/publish`, `POST /api/decks/:id/copy`, `GET /api/decks/:id/update-preview`, `POST /api/decks/:id/apply-update` + ## Running tests - **Watch mode** (re-runs on file changes): diff --git a/package-lock.json b/package-lock.json index 52ccb47..4f6db41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,9 @@ "version": "0.0.0", "dependencies": { "@supabase/supabase-js": "^2.95.3", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0", "svelte-spa-router": "^4.0.1" }, "devDependencies": { @@ -1607,6 +1610,19 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", @@ -1663,6 +1679,12 @@ "node": ">= 0.4" } }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -1719,6 +1741,57 @@ "require-from-string": "^2.0.2" } }, + "node_modules/body-parser": { + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/body-parser/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", @@ -1729,6 +1802,15 @@ "balanced-match": "^1.0.0" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/cac": { "version": "6.7.14", "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", @@ -1739,6 +1821,35 @@ "node": ">=8" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -1796,6 +1907,59 @@ "dev": true, "license": "MIT" }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "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/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -1917,6 +2081,15 @@ "node": ">=0.10.0" } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/dequal": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", @@ -1927,6 +2100,16 @@ "node": ">=6" } }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, "node_modules/devalue": { "version": "5.6.2", "resolved": "https://registry.npmjs.org/devalue/-/devalue-5.6.2.tgz", @@ -1941,6 +2124,32 @@ "dev": true, "license": "MIT" }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1948,6 +2157,12 @@ "dev": true, "license": "MIT" }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/emoji-regex": { "version": "9.2.2", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", @@ -1955,6 +2170,15 @@ "dev": true, "license": "MIT" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/entities": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", @@ -1968,6 +2192,24 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/es-module-lexer": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", @@ -1975,6 +2217,18 @@ "dev": true, "license": "MIT" }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.27.3", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", @@ -2017,6 +2271,12 @@ "@esbuild/win32-x64": "0.27.3" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, "node_modules/esm-env": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", @@ -2044,6 +2304,15 @@ "@types/estree": "^1.0.0" } }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -2054,6 +2323,67 @@ "node": ">=12.0.0" } }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2072,6 +2402,39 @@ } } }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/finalhandler/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -2089,6 +2452,24 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2104,6 +2485,52 @@ "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", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/glob": { "version": "10.5.0", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", @@ -2126,6 +2553,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -2136,6 +2575,30 @@ "node": ">=8" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/html-encoding-sniffer": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", @@ -2156,6 +2619,26 @@ "dev": true, "license": "MIT" }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/http-proxy-agent": { "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", @@ -2216,6 +2699,21 @@ "node": ">=8" } }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -2439,6 +2937,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/mdn-data": { "version": "2.12.2", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", @@ -2446,6 +2953,66 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -2486,7 +3053,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2508,6 +3074,36 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -2519,6 +3115,18 @@ ], "license": "MIT" }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/package-json-from-dist": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", @@ -2539,6 +3147,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -2573,6 +3190,12 @@ "dev": true, "license": "ISC" }, + "node_modules/path-to-regexp": { + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==", + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -2654,6 +3277,19 @@ "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -2664,6 +3300,57 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/react-is": { "version": "17.0.2", "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", @@ -2756,11 +3443,30 @@ "dev": true, "license": "MIT" }, + "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/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "dev": true, "license": "MIT" }, "node_modules/saxes": { @@ -2789,6 +3495,66 @@ "node": ">=10" } }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/send/node_modules/debug/node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -2812,6 +3578,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/siginfo": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", @@ -2849,6 +3687,15 @@ "dev": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -3141,6 +3988,15 @@ "dev": true, "license": "MIT" }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tough-cookie": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", @@ -3173,12 +4029,52 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/undici-types": { "version": "7.16.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "7.3.1", "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", diff --git a/package.json b/package.json index 1fc918f..47aed56 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,9 @@ "preview": "vite preview", "test": "vitest", "test:run": "vitest run", - "test:coverage": "vitest run --coverage" + "test:coverage": "vitest run --coverage", + "dev:server": "node server/index.js", + "start:api": "node server/index.js" }, "devDependencies": { "@sveltejs/vite-plugin-svelte": "^6.2.1", @@ -23,6 +25,9 @@ }, "dependencies": { "@supabase/supabase-js": "^2.95.3", - "svelte-spa-router": "^4.0.1" + "svelte-spa-router": "^4.0.1", + "cors": "^2.8.5", + "dotenv": "^16.4.5", + "express": "^4.21.0" } -} +} \ No newline at end of file diff --git a/server/index.js b/server/index.js new file mode 100644 index 0000000..daf9309 --- /dev/null +++ b/server/index.js @@ -0,0 +1,328 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import { createClient } from '@supabase/supabase-js'; +import * as decksApi from '../src/lib/api/decks-core.js'; + +const app = express(); +app.use(cors({ origin: true, credentials: true })); +app.use(express.json()); + +const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL; +const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY; +if (!supabaseUrl || !supabaseAnonKey) { + console.warn('Missing SUPABASE_URL or SUPABASE_ANON_KEY (or VITE_*). API may fail.'); +} + +function supabaseClient(accessToken = null) { + const options = { db: { schema: 'omotomo' } }; + if (accessToken) { + options.global = { headers: { Authorization: `Bearer ${accessToken}` } }; + } + return createClient(supabaseUrl ?? '', supabaseAnonKey ?? '', options); +} + +const anonSupabase = () => supabaseClient(); + +async function resolveEmailFromUsername(username) { + const trimmed = (username ?? '').trim(); + if (!trimmed) return null; + const { data, error } = await anonSupabase() + .from('profiles') + .select('email') + .ilike('display_name', trimmed) + .limit(1); + if (error || !data?.length || !data[0]?.email) return null; + return data[0].email; +} + +function requireAuth(req, res, next) { + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!token) { + res.status(401).json({ error: 'Missing or invalid Authorization header' }); + return; + } + const supabase = supabaseClient(token); + supabase.auth.getUser(token).then(({ data: { user }, error }) => { + if (error || !user) { + res.status(401).json({ error: 'Invalid or expired token' }); + return; + } + req.supabase = supabase; + req.userId = user.id; + next(); + }); +} + +function optionalAuth(req, res, next) { + const authHeader = req.headers.authorization; + const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null; + if (!token) { + req.supabase = anonSupabase(); + req.userId = null; + next(); + return; + } + const supabase = supabaseClient(token); + supabase.auth.getUser(token).then(({ data: { user }, error }) => { + req.supabase = supabase; + req.userId = error || !user ? null : user.id; + next(); + }); +} + +app.post('/api/auth/login', express.json(), async (req, res) => { + try { + const { email_or_username, password } = req.body || {}; + if (!email_or_username || !password) { + res.status(400).json({ error: 'email_or_username and password required' }); + return; + } + let email = String(email_or_username).trim(); + if (!email.includes('@')) { + const resolved = await resolveEmailFromUsername(email); + if (!resolved) { + res.status(401).json({ error: 'No account with that username' }); + return; + } + email = resolved; + } + const { data, error } = await anonSupabase().auth.signInWithPassword({ email, password }); + if (error) { + res.status(401).json({ error: error.message }); + return; + } + res.json({ + access_token: data.session?.access_token, + refresh_token: data.session?.refresh_token, + expires_at: data.session?.expires_at, + user: data.user, + }); + } catch (e) { + res.status(500).json({ error: e?.message ?? 'Login failed' }); + } +}); + +app.post('/api/auth/register', express.json(), async (req, res) => { + try { + const { email, password, display_name } = req.body || {}; + if (!email || !password) { + res.status(400).json({ error: 'email and password required' }); + return; + } + const { data, error } = await anonSupabase().auth.signUp({ + email: String(email).trim(), + password, + options: { data: { display_name: (display_name ?? '').trim() || null } }, + }); + if (error) { + res.status(400).json({ error: error.message }); + return; + } + const needsConfirmation = data?.user && !data?.session; + res.status(201).json({ + access_token: data.session?.access_token ?? null, + refresh_token: data.session?.refresh_token ?? null, + expires_at: data.session?.expires_at ?? null, + user: data.user, + needs_confirmation: needsConfirmation, + }); + } catch (e) { + res.status(500).json({ error: e?.message ?? 'Registration failed' }); + } +}); + +app.get('/api/auth/session', requireAuth, (req, res) => { + res.json({ user: { id: req.userId } }); +}); + +app.get('/api/decks/mine', requireAuth, async (req, res) => { + try { + const decks = await decksApi.fetchMyDecks(req.supabase, req.userId); + res.json(decks); + } catch (e) { + res.status(500).json({ error: e?.message ?? 'Failed to load decks' }); + } +}); + +app.get('/api/decks/published', optionalAuth, async (req, res) => { + try { + const decks = await decksApi.fetchPublishedDecks(req.supabase, req.userId ?? undefined); + res.json(decks); + } catch (e) { + res.status(500).json({ error: e?.message ?? 'Failed to load decks' }); + } +}); + +app.get('/api/decks/:id', optionalAuth, async (req, res) => { + try { + const deck = await decksApi.fetchDeckWithQuestions(req.supabase, req.params.id); + const isMyCopy = !!(req.userId && deck.owner_id === req.userId && deck.copied_from_deck_id); + const isOwn = !!(req.userId && deck.owner_id === req.userId && !deck.copied_from_deck_id); + const canView = deck.published || isMyCopy || isOwn; + if (!canView) { + res.status(404).json({ error: 'This deck is not available' }); + return; + } + res.json(deck); + } catch (e) { + if (e?.message === 'Deck not found') { + res.status(404).json({ error: e.message }); + return; + } + res.status(500).json({ error: e?.message ?? 'Failed to load deck' }); + } +}); + +app.post('/api/decks', requireAuth, express.json(), async (req, res) => { + try { + const { title, description, config, questions } = req.body || {}; + if (!title || typeof title !== 'string' || !title.trim()) { + res.status(400).json({ error: 'title required' }); + return; + } + const normalized = Array.isArray(questions) + ? questions + .map((q) => { + const answers = (q.answers ?? []).map((a) => String(a).trim()).filter(Boolean); + if (answers.length === 0) return null; + const indices = (q.correct_answer_indices ?? [0]).filter((i) => i >= 0 && i < answers.length); + return { + prompt: (q.prompt ?? '').trim(), + explanation: (q.explanation ?? '').trim() || null, + answers, + correct_answer_indices: indices.length ? indices : [0], + }; + }) + .filter(Boolean) + : []; + if (normalized.length === 0) { + res.status(400).json({ error: 'At least one question with one answer required' }); + return; + } + const id = await decksApi.createDeck(req.supabase, req.userId, { + title: title.trim(), + description: (description ?? '').trim(), + config: config ?? {}, + questions: normalized, + }); + res.status(201).json({ id }); + } catch (e) { + res.status(500).json({ error: e?.message ?? 'Failed to create deck' }); + } +}); + +app.patch('/api/decks/:id', requireAuth, express.json(), async (req, res) => { + try { + const full = await decksApi.fetchDeckWithQuestions(req.supabase, req.params.id); + if (full.owner_id !== req.userId) { + res.status(404).json({ error: 'Deck not found' }); + return; + } + const { title, description, config, questions } = req.body || {}; + const newTitle = title !== undefined ? String(title).trim() : (full.title ?? ''); + const newDesc = description !== undefined ? String(description).trim() : (full.description ?? ''); + const newConfig = config !== undefined ? (config ?? {}) : (full.config ?? {}); + let qList = questions; + if (qList === undefined) { + qList = (full.questions ?? []).map((q) => ({ + prompt: q.prompt ?? '', + explanation: q.explanation ?? '', + answers: q.answers ?? [], + correct_answer_indices: q.correct_answer_indices ?? [0], + })); + } else { + qList = Array.isArray(qList) + ? qList + .map((q) => { + const answers = (q.answers ?? []).map((a) => String(a).trim()).filter(Boolean); + if (answers.length === 0) return null; + const indices = (q.correct_answer_indices ?? [0]).filter((i) => i >= 0 && i < answers.length); + return { + prompt: (q.prompt ?? '').trim(), + explanation: (q.explanation ?? '').trim() || null, + answers, + correct_answer_indices: indices.length ? indices : [0], + }; + }) + .filter(Boolean) + : []; + if (qList.length === 0) { + res.status(400).json({ error: 'At least one question with one answer required' }); + return; + } + } + await decksApi.updateDeck(req.supabase, req.params.id, { + title: newTitle, + description: newDesc, + config: newConfig, + questions: qList, + }); + res.json({ ok: true }); + } catch (e) { + if (e?.message === 'Deck not found') { + res.status(404).json({ error: e.message }); + return; + } + res.status(500).json({ error: e?.message ?? 'Failed to update deck' }); + } +}); + +app.post('/api/decks/:id/publish', requireAuth, async (req, res) => { + try { + const { data: deck } = await req.supabase.from('decks').select('owner_id').eq('id', req.params.id).single(); + if (!deck || deck.owner_id !== req.userId) { + res.status(404).json({ error: 'Deck not found' }); + return; + } + await decksApi.togglePublished(req.supabase, req.params.id, true); + res.json({ ok: true }); + } catch (e) { + res.status(500).json({ error: e?.message ?? 'Failed to publish' }); + } +}); + +app.post('/api/decks/:id/copy', requireAuth, async (req, res) => { + try { + const id = await decksApi.copyDeckToUser(req.supabase, req.params.id, req.userId); + res.status(201).json({ id }); + } catch (e) { + if (e?.message?.includes('not available to copy')) { + res.status(400).json({ error: e.message }); + return; + } + res.status(500).json({ error: e?.message ?? 'Failed to copy deck' }); + } +}); + +app.get('/api/decks/:id/update-preview', requireAuth, async (req, res) => { + try { + const preview = await decksApi.getSourceUpdatePreview(req.supabase, req.params.id, req.userId); + res.json(preview); + } catch (e) { + if (e?.message?.includes('not found or not a copy')) { + res.status(404).json({ error: e.message }); + return; + } + res.status(500).json({ error: e?.message ?? 'Failed to load preview' }); + } +}); + +app.post('/api/decks/:id/apply-update', requireAuth, async (req, res) => { + try { + await decksApi.applySourceUpdate(req.supabase, req.params.id, req.userId); + res.json({ ok: true }); + } catch (e) { + if (e?.message?.includes('not found or not a copy') || e?.message?.includes('no longer available')) { + res.status(400).json({ error: e.message }); + return; + } + res.status(500).json({ error: e?.message ?? 'Failed to apply update' }); + } +}); + +const PORT = process.env.PORT || 3001; +app.listen(PORT, () => { + console.log(`API server listening on http://localhost:${PORT}`); +}); diff --git a/src/lib/Navbar.svelte b/src/lib/Navbar.svelte index ee0b7b5..7a7f275 100644 --- a/src/lib/Navbar.svelte +++ b/src/lib/Navbar.svelte @@ -3,6 +3,7 @@ import { push, location } from 'svelte-spa-router'; import { auth } from './stores/auth.js'; import { navContext } from './stores/navContext.js'; + import { supabase } from './supabase.js'; import { getProfile } from './api/profile.js'; $: loc = $location || ''; @@ -78,7 +79,7 @@ userMenuOpen = !userMenuOpen; if (userMenuOpen && $auth.user?.id && !profileLoading) { profileLoading = true; - getProfile($auth.user.id).then((p) => { + getProfile(supabase, $auth.user.id).then((p) => { profile = p; profileLoading = false; }).catch(() => { profileLoading = false; }); @@ -98,7 +99,7 @@ /** Load profile (including avatar) when user is logged in, so avatar shows after refresh */ $: if (!$auth.loading && $auth.user?.id && !profile && !profileLoading) { profileLoading = true; - getProfile($auth.user.id) + getProfile(supabase, $auth.user.id) .then((p) => { profile = p; profileLoading = false; diff --git a/src/lib/Navbar.test.js b/src/lib/Navbar.test.js index 8a6bc5d..58ba708 100644 --- a/src/lib/Navbar.test.js +++ b/src/lib/Navbar.test.js @@ -119,7 +119,7 @@ describe('Navbar', () => { expect(screen.getByRole('menu')).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument() expect(screen.getByRole('menuitem', { name: 'Logout' })).toBeInTheDocument() - await waitFor(() => expect(getProfile).toHaveBeenCalledWith('u1')) + await waitFor(() => expect(getProfile).toHaveBeenCalledWith(expect.anything(), 'u1')) }) it('goSettings: Settings link calls push and closes menu', async () => { diff --git a/src/lib/api/decks-core.js b/src/lib/api/decks-core.js new file mode 100644 index 0000000..d3325dd --- /dev/null +++ b/src/lib/api/decks-core.js @@ -0,0 +1,517 @@ +export async function fetchMyDecks(client, userId) { + const { data, error } = await client + .from('decks') + .select('id, title, description, config, published, created_at, updated_at, copied_from_deck_id, version, copied_from_version') + .eq('owner_id', userId) + .order('updated_at', { ascending: false }); + if (error) throw error; + const decks = data ?? []; + if (decks.length === 0) return []; + + const deckIds = decks.map((d) => d.id); + const sourceDeckIds = [...new Set(decks.map((d) => d.copied_from_deck_id).filter(Boolean))]; + const ratingDeckIds = [...new Set([...deckIds, ...sourceDeckIds])]; + + const [questionCounts, ratingsRows, sourceTitlesRows] = await Promise.all([ + Promise.all( + decks.map(async (d) => { + const { count, error: e } = await client + .from('questions') + .select('*', { count: 'exact', head: true }) + .eq('deck_id', d.id); + if (e) return 0; + return count ?? 0; + }) + ), + ratingDeckIds.length > 0 + ? client.from('deck_ratings').select('deck_id, rating').in('deck_id', ratingDeckIds) + : Promise.resolve({ data: [] }), + sourceDeckIds.length > 0 + ? client.from('decks').select('id, title, version, updated_at').in('id', sourceDeckIds) + : Promise.resolve({ data: [] }), + ]); + + const ratingByDeck = new Map(); + for (const r of ratingsRows.data ?? []) { + if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []); + ratingByDeck.get(r.deck_id).push(r.rating); + } + const getRating = (deckId) => { + const arr = ratingByDeck.get(deckId); + if (!arr?.length) return { average_rating: 0, rating_count: 0 }; + const sum = arr.reduce((a, b) => a + b, 0); + return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; + }; + + const sourceById = new Map((sourceTitlesRows.data ?? []).map((d) => [d.id, d])); + + return decks.map((d, i) => { + const showRatingDeckId = d.copied_from_deck_id || d.id; + const rating = getRating(showRatingDeckId); + const canRate = !!d.copied_from_deck_id; + const sourceDeck = d.copied_from_deck_id ? sourceById.get(d.copied_from_deck_id) : null; + const source_deck_title = sourceDeck?.title ?? 'Deck'; + const source_version = sourceDeck?.version ?? 1; + const my_version = d.copied_from_version ?? 0; + const versionOutdated = source_version > my_version; + const sourceNewerByTime = + sourceDeck?.updated_at && + d.updated_at && + new Date(sourceDeck.updated_at).getTime() > new Date(d.updated_at).getTime(); + const needs_update = + !!d.copied_from_deck_id && (versionOutdated || sourceNewerByTime); + return { + ...d, + question_count: questionCounts[i], + ...rating, + can_rate: canRate, + rateable_deck_id: d.copied_from_deck_id || null, + source_deck_title, + source_version, + needs_update, + }; + }); +} + +/** + * @param {string | null | undefined} [userId] - Current user id; when set, each deck gets user_has_this (true if user owns it or already has a copy). + */ +export async function fetchPublishedDecks(client, userId) { + const { data, error } = await client + .from('decks') + .select('id, title, description, config, published, created_at, updated_at, owner_id') + .eq('published', true) + .order('updated_at', { ascending: false }); + if (error) throw error; + const decks = data ?? []; + if (decks.length === 0) return []; + + const deckIds = decks.map((d) => d.id); + const ownerIds = [...new Set(decks.map((d) => d.owner_id).filter(Boolean))]; + + const promises = [ + Promise.all( + decks.map(async (d) => { + const { count, error: e } = await client + .from('questions') + .select('*', { count: 'exact', head: true }) + .eq('deck_id', d.id); + if (e) return 0; + return count ?? 0; + }) + ), + ownerIds.length > 0 + ? client.from('profiles').select('id, display_name, email').in('id', ownerIds) + : Promise.resolve({ data: [] }), + client.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds), + ]; + + if (userId) { + promises.push( + client + .from('decks') + .select('copied_from_deck_id') + .eq('owner_id', userId) + .not('copied_from_deck_id', 'is', null) + ); + } + + const results = await Promise.all(promises); + const [questionCounts, profilesRows, ratingsRows, myDecksRows] = results; + + const myCopiedFromIds = new Set(); + if (userId && myDecksRows?.data) { + for (const row of myDecksRows.data) { + if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id); + } + } + + const profilesById = new Map( + (profilesRows.data ?? []).map((p) => [p.id, { display_name: p.display_name, email: p.email }]) + ); + const ratingByDeck = new Map(); + for (const r of ratingsRows.data ?? []) { + if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []); + ratingByDeck.get(r.deck_id).push(r.rating); + } + const getRating = (deckId) => { + const arr = ratingByDeck.get(deckId); + if (!arr?.length) return { average_rating: 0, rating_count: 0 }; + const sum = arr.reduce((a, b) => a + b, 0); + return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; + }; + + return decks.map((d, i) => { + const profile = profilesById.get(d.owner_id); + const owner_email = profile?.email ?? profile?.display_name ?? 'User'; + const user_has_this = + !!userId && (d.owner_id === userId || myCopiedFromIds.has(d.id)); + return { + ...d, + question_count: questionCounts[i], + owner_display_name: profile?.display_name ?? 'User', + owner_email, + ...getRating(d.id), + user_has_this, + }; + }); +} + +/** + * @param {string} ownerId - Owner of the decks to list. + * @param {string | null | undefined} [viewerId] - Current user id; when set, each deck gets user_has_this (true if viewer owns it or already has a copy). + */ +export async function fetchPublishedDecksByOwner(client, ownerId, viewerId) { + const { data: decks, error } = await client + .from('decks') + .select('id, title, description, config, published, created_at, updated_at, owner_id') + .eq('published', true) + .eq('owner_id', ownerId) + .order('updated_at', { ascending: false }); + if (error) throw error; + const deckList = decks ?? []; + if (deckList.length === 0) { + const { data: profile } = await client.from('profiles').select('id, display_name, email').eq('id', ownerId).single(); + return { + decks: [], + owner_email: profile?.email ?? profile?.display_name ?? 'User', + owner_display_name: profile?.display_name ?? profile?.email ?? 'User', + }; + } + + const deckIds = deckList.map((d) => d.id); + const promises = [ + Promise.all( + deckList.map(async (d) => { + const { count, error: e } = await client + .from('questions') + .select('*', { count: 'exact', head: true }) + .eq('deck_id', d.id); + if (e) return 0; + return count ?? 0; + }) + ), + client.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds), + ]; + + if (viewerId && viewerId !== ownerId) { + promises.push( + client + .from('decks') + .select('copied_from_deck_id') + .eq('owner_id', viewerId) + .not('copied_from_deck_id', 'is', null) + ); + } + + const results = await Promise.all(promises); + const [questionCounts, ratingsRows, myDecksRows] = results; + + const myCopiedFromIds = new Set(); + if (viewerId && viewerId !== ownerId && myDecksRows?.data) { + for (const row of myDecksRows.data) { + if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id); + } + } + + const ratingByDeck = new Map(); + for (const r of ratingsRows.data ?? []) { + if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []); + ratingByDeck.get(r.deck_id).push(r.rating); + } + const getRating = (deckId) => { + const arr = ratingByDeck.get(deckId); + if (!arr?.length) return { average_rating: 0, rating_count: 0 }; + const sum = arr.reduce((a, b) => a + b, 0); + return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; + }; + + const { data: profile } = await client.from('profiles').select('id, display_name, email').eq('id', ownerId).single(); + const owner_email = profile?.email ?? profile?.display_name ?? 'User'; + const owner_display_name = profile?.display_name ?? profile?.email ?? 'User'; + + const decksWithMeta = deckList.map((d, i) => { + const user_has_this = + !!viewerId && + (viewerId === ownerId || myCopiedFromIds.has(d.id)); + return { + ...d, + question_count: questionCounts[i], + owner_email, + owner_display_name, + ...getRating(d.id), + user_has_this, + }; + }); + + return { decks: decksWithMeta, owner_email, owner_display_name }; +} + +export async function fetchDeckWithQuestions(client, deckId) { + const { data: deck, error: deckError } = await client + .from('decks') + .select('*') + .eq('id', deckId) + .single(); + if (deckError || !deck) throw deckError || new Error('Deck not found'); + const { data: questions, error: qError } = await client + .from('questions') + .select('*') + .eq('deck_id', deckId) + .order('sort_order', { ascending: true }); + if (qError) throw qError; + return { ...deck, questions: questions ?? [] }; +} + +/** Copy a published deck (and its questions) into the current user's account. New deck is unpublished. */ +export async function copyDeckToUser(client, deckId, userId) { + const source = await fetchDeckWithQuestions(client, deckId); + if (!source.published) throw new Error('Deck is not available to copy'); + const questions = (source.questions ?? []).map((q) => ({ + prompt: q.prompt ?? '', + explanation: q.explanation ?? '', + answers: Array.isArray(q.answers) ? q.answers : [], + correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], + })); + return createDeck(client, userId, { + title: source.title, + description: source.description ?? '', + config: source.config ?? {}, + questions, + copiedFromDeckId: deckId, + copiedFromVersion: source.version ?? 1, + }); +} + +export async function createDeck(client, ownerId, { title, description, config, questions, copiedFromDeckId, copiedFromVersion }) { + const row = { + owner_id: ownerId, + title: title.trim(), + description: (description ?? '').trim(), + config: config ?? {}, + }; + if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId; + if (copiedFromVersion != null) row.copied_from_version = copiedFromVersion; + const { data: deck, error: deckError } = await client + .from('decks') + .insert(row) + .select('id') + .single(); + if (deckError || !deck) throw deckError || new Error('Failed to create deck'); + if (questions && questions.length > 0) { + const rows = questions.map((q, i) => ({ + deck_id: deck.id, + sort_order: i, + prompt: (q.prompt ?? '').trim(), + explanation: (q.explanation ?? '').trim() || null, + answers: Array.isArray(q.answers) ? q.answers : [], + correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], + })); + const { error: qError } = await client.from('questions').insert(rows); + if (qError) throw qError; + } + return deck.id; +} + +export async function updateDeck(client, deckId, { title, description, config, questions }) { + const { data: current, error: fetchErr } = await client + .from('decks') + .select('version, published') + .eq('id', deckId) + .single(); + if (fetchErr) throw fetchErr; + const bumpVersion = current?.published === true; + const nextVersion = bumpVersion ? (current?.version ?? 1) + 1 : (current?.version ?? 1); + const updatePayload = { + title: title.trim(), + description: (description ?? '').trim(), + config: config ?? {}, + version: nextVersion, + }; + const { error: deckError } = await client + .from('decks') + .update(updatePayload) + .eq('id', deckId); + if (deckError) throw deckError; + const { error: delError } = await client.from('questions').delete().eq('deck_id', deckId); + if (delError) throw delError; + if (questions && questions.length > 0) { + const rows = questions.map((q, i) => ({ + deck_id: deckId, + sort_order: i, + prompt: (q.prompt ?? '').trim(), + explanation: (q.explanation ?? '').trim() || null, + answers: Array.isArray(q.answers) ? q.answers : [], + correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], + })); + const { error: qError } = await client.from('questions').insert(rows); + if (qError) throw qError; + } +} + +export async function togglePublished(client, deckId, published) { + const { error } = await client.from('decks').update({ published }).eq('id', deckId); + if (error) throw error; +} + +export async function deleteDeck(client, deckId) { + const { error } = await client.from('decks').delete().eq('id', deckId); + if (error) throw error; +} + +/** + * Get source deck and copy deck with questions for the "Update from community" preview. + * Copy must be owned by userId and have copied_from_deck_id. + * @returns {{ source: object, copy: object, changes: string[] }} + */ +export async function getSourceUpdatePreview(client, copyDeckId, userId) { + const { data: copy, error: copyErr } = await client + .from('decks') + .select('*') + .eq('id', copyDeckId) + .eq('owner_id', userId) + .single(); + if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy'); + const sourceId = copy.copied_from_deck_id; + const [source, copyQuestionsRows] = await Promise.all([ + fetchDeckWithQuestions(client, sourceId), + client.from('questions').select('*').eq('deck_id', copyDeckId).order('sort_order', { ascending: true }), + ]); + if (!source.published) throw new Error('Source deck is no longer available'); + const copyQuestions = copyQuestionsRows.data ?? []; + const copyWithQuestions = { ...copy, questions: copyQuestions }; + + const changes = []; + if ((source.title ?? '').trim() !== (copy.title ?? '').trim()) { + changes.push(`Title: "${(copy.title ?? '').trim()}" → "${(source.title ?? '').trim()}"`); + } + if ((source.description ?? '').trim() !== (copy.description ?? '').trim()) { + changes.push('Description updated'); + } + const srcLen = (source.questions ?? []).length; + const copyLen = copyQuestions.length; + if (srcLen !== copyLen) { + changes.push(`Questions: ${copyLen} → ${srcLen}`); + } else { + const anyDifferent = (source.questions ?? []).some((sq, i) => { + const cq = copyQuestions[i]; + if (!cq) return true; + return (sq.prompt ?? '') !== (cq.prompt ?? '') || (sq.explanation ?? '') !== (cq.explanation ?? ''); + }); + if (anyDifferent) changes.push('Some question content updated'); + } + if (changes.length === 0) changes.push('Content is in sync (version metadata will update)'); + + return { source, copy: copyWithQuestions, changes }; +} + +/** + * Update a community copy to match the current source deck (title, description, config, questions, copied_from_version). + */ +export async function applySourceUpdate(client, copyDeckId, userId) { + const { data: copy, error: copyErr } = await client + .from('decks') + .select('id, copied_from_deck_id') + .eq('id', copyDeckId) + .eq('owner_id', userId) + .single(); + if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy'); + const source = await fetchDeckWithQuestions(client, copy.copied_from_deck_id); + if (!source.published) throw new Error('Source deck is no longer available'); + + const { error: deckError } = await client + .from('decks') + .update({ + title: source.title ?? '', + description: source.description ?? '', + config: source.config ?? {}, + copied_from_version: source.version ?? 1, + }) + .eq('id', copyDeckId); + if (deckError) throw deckError; + + const { error: delError } = await client.from('questions').delete().eq('deck_id', copyDeckId); + if (delError) throw delError; + const questions = source.questions ?? []; + if (questions.length > 0) { + const rows = questions.map((q, i) => ({ + deck_id: copyDeckId, + sort_order: i, + prompt: (q.prompt ?? '').trim(), + explanation: (q.explanation ?? '').trim() || null, + answers: Array.isArray(q.answers) ? q.answers : [], + correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], + })); + const { error: qError } = await client.from('questions').insert(rows); + if (qError) throw qError; + } +} + +/** True if the user already has this deck (owns it or has added a copy). */ +export async function userHasDeck(client, deckId, userId) { + if (!userId) return false; + const { data, error } = await client + .from('decks') + .select('id') + .eq('owner_id', userId) + .or(`id.eq.${deckId},copied_from_deck_id.eq.${deckId}`) + .limit(1); + if (error) throw error; + return (data?.length ?? 0) > 0; +} + +/** Get all reviews (ratings + comments) for a deck with reviewer display names. */ +export async function getDeckReviews(client, deckId) { + const { data: ratings, error: rError } = await client + .from('deck_ratings') + .select('user_id, rating, comment, created_at') + .eq('deck_id', deckId) + .order('created_at', { ascending: false }); + if (rError) throw rError; + const list = ratings ?? []; + if (list.length === 0) return []; + const userIds = [...new Set(list.map((r) => r.user_id).filter(Boolean))]; + const { data: profiles } = await client + .from('profiles') + .select('id, display_name, email, avatar_url') + .in('id', userIds); + const byId = new Map((profiles ?? []).map((p) => [p.id, p])); + return list.map((r) => { + const p = byId.get(r.user_id); + return { + rating: r.rating, + comment: r.comment || null, + user_id: r.user_id, + display_name: p?.display_name ?? null, + email: p?.email ?? null, + avatar_url: p?.avatar_url ?? null, + }; + }); +} + +/** Get the current user's rating (and comment) for a deck, or null if none. */ +export async function getMyDeckRating(client, deckId, userId) { + if (!userId) return null; + const { data, error } = await client + .from('deck_ratings') + .select('rating, comment') + .eq('deck_id', deckId) + .eq('user_id', userId) + .maybeSingle(); + if (error) throw error; + return data; +} + +/** Submit or update rating (and optional comment) for a deck. Upserts by (deck_id, user_id). */ +export async function submitDeckRating(client, deckId, userId, { rating, comment }) { + const row = { + deck_id: deckId, + user_id: userId, + rating: Math.min(5, Math.max(1, Math.round(rating))), + comment: (comment ?? '').trim() || null, + }; + const { error } = await client.from('deck_ratings').upsert(row, { + onConflict: 'deck_id,user_id', + }); + if (error) throw error; +} diff --git a/src/lib/api/decks.js b/src/lib/api/decks.js index fbbfe94..f8e8194 100644 --- a/src/lib/api/decks.js +++ b/src/lib/api/decks.js @@ -1,519 +1,3 @@ import { supabase } from '../supabase.js'; -export async function fetchMyDecks(userId) { - const { data, error } = await supabase - .from('decks') - .select('id, title, description, config, published, created_at, updated_at, copied_from_deck_id, version, copied_from_version') - .eq('owner_id', userId) - .order('updated_at', { ascending: false }); - if (error) throw error; - const decks = data ?? []; - if (decks.length === 0) return []; - - const deckIds = decks.map((d) => d.id); - const sourceDeckIds = [...new Set(decks.map((d) => d.copied_from_deck_id).filter(Boolean))]; - const ratingDeckIds = [...new Set([...deckIds, ...sourceDeckIds])]; - - const [questionCounts, ratingsRows, sourceTitlesRows] = await Promise.all([ - Promise.all( - decks.map(async (d) => { - const { count, error: e } = await supabase - .from('questions') - .select('*', { count: 'exact', head: true }) - .eq('deck_id', d.id); - if (e) return 0; - return count ?? 0; - }) - ), - ratingDeckIds.length > 0 - ? supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', ratingDeckIds) - : Promise.resolve({ data: [] }), - sourceDeckIds.length > 0 - ? supabase.from('decks').select('id, title, version, updated_at').in('id', sourceDeckIds) - : Promise.resolve({ data: [] }), - ]); - - const ratingByDeck = new Map(); - for (const r of ratingsRows.data ?? []) { - if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []); - ratingByDeck.get(r.deck_id).push(r.rating); - } - const getRating = (deckId) => { - const arr = ratingByDeck.get(deckId); - if (!arr?.length) return { average_rating: 0, rating_count: 0 }; - const sum = arr.reduce((a, b) => a + b, 0); - return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; - }; - - const sourceById = new Map((sourceTitlesRows.data ?? []).map((d) => [d.id, d])); - - return decks.map((d, i) => { - const showRatingDeckId = d.copied_from_deck_id || d.id; - const rating = getRating(showRatingDeckId); - const canRate = !!d.copied_from_deck_id; - const sourceDeck = d.copied_from_deck_id ? sourceById.get(d.copied_from_deck_id) : null; - const source_deck_title = sourceDeck?.title ?? 'Deck'; - const source_version = sourceDeck?.version ?? 1; - const my_version = d.copied_from_version ?? 0; - const versionOutdated = source_version > my_version; - const sourceNewerByTime = - sourceDeck?.updated_at && - d.updated_at && - new Date(sourceDeck.updated_at).getTime() > new Date(d.updated_at).getTime(); - const needs_update = - !!d.copied_from_deck_id && (versionOutdated || sourceNewerByTime); - return { - ...d, - question_count: questionCounts[i], - ...rating, - can_rate: canRate, - rateable_deck_id: d.copied_from_deck_id || null, - source_deck_title, - source_version, - needs_update, - }; - }); -} - -/** - * @param {string | null | undefined} [userId] - Current user id; when set, each deck gets user_has_this (true if user owns it or already has a copy). - */ -export async function fetchPublishedDecks(userId) { - const { data, error } = await supabase - .from('decks') - .select('id, title, description, config, published, created_at, updated_at, owner_id') - .eq('published', true) - .order('updated_at', { ascending: false }); - if (error) throw error; - const decks = data ?? []; - if (decks.length === 0) return []; - - const deckIds = decks.map((d) => d.id); - const ownerIds = [...new Set(decks.map((d) => d.owner_id).filter(Boolean))]; - - const promises = [ - Promise.all( - decks.map(async (d) => { - const { count, error: e } = await supabase - .from('questions') - .select('*', { count: 'exact', head: true }) - .eq('deck_id', d.id); - if (e) return 0; - return count ?? 0; - }) - ), - ownerIds.length > 0 - ? supabase.from('profiles').select('id, display_name, email').in('id', ownerIds) - : Promise.resolve({ data: [] }), - supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds), - ]; - - if (userId) { - promises.push( - supabase - .from('decks') - .select('copied_from_deck_id') - .eq('owner_id', userId) - .not('copied_from_deck_id', 'is', null) - ); - } - - const results = await Promise.all(promises); - const [questionCounts, profilesRows, ratingsRows, myDecksRows] = results; - - const myCopiedFromIds = new Set(); - if (userId && myDecksRows?.data) { - for (const row of myDecksRows.data) { - if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id); - } - } - - const profilesById = new Map( - (profilesRows.data ?? []).map((p) => [p.id, { display_name: p.display_name, email: p.email }]) - ); - const ratingByDeck = new Map(); - for (const r of ratingsRows.data ?? []) { - if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []); - ratingByDeck.get(r.deck_id).push(r.rating); - } - const getRating = (deckId) => { - const arr = ratingByDeck.get(deckId); - if (!arr?.length) return { average_rating: 0, rating_count: 0 }; - const sum = arr.reduce((a, b) => a + b, 0); - return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; - }; - - return decks.map((d, i) => { - const profile = profilesById.get(d.owner_id); - const owner_email = profile?.email ?? profile?.display_name ?? 'User'; - const user_has_this = - !!userId && (d.owner_id === userId || myCopiedFromIds.has(d.id)); - return { - ...d, - question_count: questionCounts[i], - owner_display_name: profile?.display_name ?? 'User', - owner_email, - ...getRating(d.id), - user_has_this, - }; - }); -} - -/** - * @param {string} ownerId - Owner of the decks to list. - * @param {string | null | undefined} [viewerId] - Current user id; when set, each deck gets user_has_this (true if viewer owns it or already has a copy). - */ -export async function fetchPublishedDecksByOwner(ownerId, viewerId) { - const { data: decks, error } = await supabase - .from('decks') - .select('id, title, description, config, published, created_at, updated_at, owner_id') - .eq('published', true) - .eq('owner_id', ownerId) - .order('updated_at', { ascending: false }); - if (error) throw error; - const deckList = decks ?? []; - if (deckList.length === 0) { - const { data: profile } = await supabase.from('profiles').select('id, display_name, email').eq('id', ownerId).single(); - return { - decks: [], - owner_email: profile?.email ?? profile?.display_name ?? 'User', - owner_display_name: profile?.display_name ?? profile?.email ?? 'User', - }; - } - - const deckIds = deckList.map((d) => d.id); - const promises = [ - Promise.all( - deckList.map(async (d) => { - const { count, error: e } = await supabase - .from('questions') - .select('*', { count: 'exact', head: true }) - .eq('deck_id', d.id); - if (e) return 0; - return count ?? 0; - }) - ), - supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds), - ]; - - if (viewerId && viewerId !== ownerId) { - promises.push( - supabase - .from('decks') - .select('copied_from_deck_id') - .eq('owner_id', viewerId) - .not('copied_from_deck_id', 'is', null) - ); - } - - const results = await Promise.all(promises); - const [questionCounts, ratingsRows, myDecksRows] = results; - - const myCopiedFromIds = new Set(); - if (viewerId && viewerId !== ownerId && myDecksRows?.data) { - for (const row of myDecksRows.data) { - if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id); - } - } - - const ratingByDeck = new Map(); - for (const r of ratingsRows.data ?? []) { - if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []); - ratingByDeck.get(r.deck_id).push(r.rating); - } - const getRating = (deckId) => { - const arr = ratingByDeck.get(deckId); - if (!arr?.length) return { average_rating: 0, rating_count: 0 }; - const sum = arr.reduce((a, b) => a + b, 0); - return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length }; - }; - - const { data: profile } = await supabase.from('profiles').select('id, display_name, email').eq('id', ownerId).single(); - const owner_email = profile?.email ?? profile?.display_name ?? 'User'; - const owner_display_name = profile?.display_name ?? profile?.email ?? 'User'; - - const decksWithMeta = deckList.map((d, i) => { - const user_has_this = - !!viewerId && - (viewerId === ownerId || myCopiedFromIds.has(d.id)); - return { - ...d, - question_count: questionCounts[i], - owner_email, - owner_display_name, - ...getRating(d.id), - user_has_this, - }; - }); - - return { decks: decksWithMeta, owner_email, owner_display_name }; -} - -export async function fetchDeckWithQuestions(deckId) { - const { data: deck, error: deckError } = await supabase - .from('decks') - .select('*') - .eq('id', deckId) - .single(); - if (deckError || !deck) throw deckError || new Error('Deck not found'); - const { data: questions, error: qError } = await supabase - .from('questions') - .select('*') - .eq('deck_id', deckId) - .order('sort_order', { ascending: true }); - if (qError) throw qError; - return { ...deck, questions: questions ?? [] }; -} - -/** Copy a published deck (and its questions) into the current user's account. New deck is unpublished. */ -export async function copyDeckToUser(deckId, userId) { - const source = await fetchDeckWithQuestions(deckId); - if (!source.published) throw new Error('Deck is not available to copy'); - const questions = (source.questions ?? []).map((q) => ({ - prompt: q.prompt ?? '', - explanation: q.explanation ?? '', - answers: Array.isArray(q.answers) ? q.answers : [], - correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], - })); - return createDeck(userId, { - title: source.title, - description: source.description ?? '', - config: source.config ?? {}, - questions, - copiedFromDeckId: deckId, - copiedFromVersion: source.version ?? 1, - }); -} - -export async function createDeck(ownerId, { title, description, config, questions, copiedFromDeckId, copiedFromVersion }) { - const row = { - owner_id: ownerId, - title: title.trim(), - description: (description ?? '').trim(), - config: config ?? {}, - }; - if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId; - if (copiedFromVersion != null) row.copied_from_version = copiedFromVersion; - const { data: deck, error: deckError } = await supabase - .from('decks') - .insert(row) - .select('id') - .single(); - if (deckError || !deck) throw deckError || new Error('Failed to create deck'); - if (questions && questions.length > 0) { - const rows = questions.map((q, i) => ({ - deck_id: deck.id, - sort_order: i, - prompt: (q.prompt ?? '').trim(), - explanation: (q.explanation ?? '').trim() || null, - answers: Array.isArray(q.answers) ? q.answers : [], - correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], - })); - const { error: qError } = await supabase.from('questions').insert(rows); - if (qError) throw qError; - } - return deck.id; -} - -export async function updateDeck(deckId, { title, description, config, questions }) { - const { data: current, error: fetchErr } = await supabase - .from('decks') - .select('version, published') - .eq('id', deckId) - .single(); - if (fetchErr) throw fetchErr; - const bumpVersion = current?.published === true; - const nextVersion = bumpVersion ? (current?.version ?? 1) + 1 : (current?.version ?? 1); - const updatePayload = { - title: title.trim(), - description: (description ?? '').trim(), - config: config ?? {}, - version: nextVersion, - }; - const { error: deckError } = await supabase - .from('decks') - .update(updatePayload) - .eq('id', deckId); - if (deckError) throw deckError; - const { error: delError } = await supabase.from('questions').delete().eq('deck_id', deckId); - if (delError) throw delError; - if (questions && questions.length > 0) { - const rows = questions.map((q, i) => ({ - deck_id: deckId, - sort_order: i, - prompt: (q.prompt ?? '').trim(), - explanation: (q.explanation ?? '').trim() || null, - answers: Array.isArray(q.answers) ? q.answers : [], - correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], - })); - const { error: qError } = await supabase.from('questions').insert(rows); - if (qError) throw qError; - } -} - -export async function togglePublished(deckId, published) { - const { error } = await supabase.from('decks').update({ published }).eq('id', deckId); - if (error) throw error; -} - -export async function deleteDeck(deckId) { - const { error } = await supabase.from('decks').delete().eq('id', deckId); - if (error) throw error; -} - -/** - * Get source deck and copy deck with questions for the "Update from community" preview. - * Copy must be owned by userId and have copied_from_deck_id. - * @returns {{ source: object, copy: object, changes: string[] }} - */ -export async function getSourceUpdatePreview(copyDeckId, userId) { - const { data: copy, error: copyErr } = await supabase - .from('decks') - .select('*') - .eq('id', copyDeckId) - .eq('owner_id', userId) - .single(); - if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy'); - const sourceId = copy.copied_from_deck_id; - const [source, copyQuestionsRows] = await Promise.all([ - fetchDeckWithQuestions(sourceId), - supabase.from('questions').select('*').eq('deck_id', copyDeckId).order('sort_order', { ascending: true }), - ]); - if (!source.published) throw new Error('Source deck is no longer available'); - const copyQuestions = copyQuestionsRows.data ?? []; - const copyWithQuestions = { ...copy, questions: copyQuestions }; - - const changes = []; - if ((source.title ?? '').trim() !== (copy.title ?? '').trim()) { - changes.push(`Title: "${(copy.title ?? '').trim()}" → "${(source.title ?? '').trim()}"`); - } - if ((source.description ?? '').trim() !== (copy.description ?? '').trim()) { - changes.push('Description updated'); - } - const srcLen = (source.questions ?? []).length; - const copyLen = copyQuestions.length; - if (srcLen !== copyLen) { - changes.push(`Questions: ${copyLen} → ${srcLen}`); - } else { - const anyDifferent = (source.questions ?? []).some((sq, i) => { - const cq = copyQuestions[i]; - if (!cq) return true; - return (sq.prompt ?? '') !== (cq.prompt ?? '') || (sq.explanation ?? '') !== (cq.explanation ?? ''); - }); - if (anyDifferent) changes.push('Some question content updated'); - } - if (changes.length === 0) changes.push('Content is in sync (version metadata will update)'); - - return { source, copy: copyWithQuestions, changes }; -} - -/** - * Update a community copy to match the current source deck (title, description, config, questions, copied_from_version). - */ -export async function applySourceUpdate(copyDeckId, userId) { - const { data: copy, error: copyErr } = await supabase - .from('decks') - .select('id, copied_from_deck_id') - .eq('id', copyDeckId) - .eq('owner_id', userId) - .single(); - if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy'); - const source = await fetchDeckWithQuestions(copy.copied_from_deck_id); - if (!source.published) throw new Error('Source deck is no longer available'); - - const { error: deckError } = await supabase - .from('decks') - .update({ - title: source.title ?? '', - description: source.description ?? '', - config: source.config ?? {}, - copied_from_version: source.version ?? 1, - }) - .eq('id', copyDeckId); - if (deckError) throw deckError; - - const { error: delError } = await supabase.from('questions').delete().eq('deck_id', copyDeckId); - if (delError) throw delError; - const questions = source.questions ?? []; - if (questions.length > 0) { - const rows = questions.map((q, i) => ({ - deck_id: copyDeckId, - sort_order: i, - prompt: (q.prompt ?? '').trim(), - explanation: (q.explanation ?? '').trim() || null, - answers: Array.isArray(q.answers) ? q.answers : [], - correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [], - })); - const { error: qError } = await supabase.from('questions').insert(rows); - if (qError) throw qError; - } -} - -/** True if the user already has this deck (owns it or has added a copy). */ -export async function userHasDeck(deckId, userId) { - if (!userId) return false; - const { data, error } = await supabase - .from('decks') - .select('id') - .eq('owner_id', userId) - .or(`id.eq.${deckId},copied_from_deck_id.eq.${deckId}`) - .limit(1); - if (error) throw error; - return (data?.length ?? 0) > 0; -} - -/** Get all reviews (ratings + comments) for a deck with reviewer display names. */ -export async function getDeckReviews(deckId) { - const { data: ratings, error: rError } = await supabase - .from('deck_ratings') - .select('user_id, rating, comment, created_at') - .eq('deck_id', deckId) - .order('created_at', { ascending: false }); - if (rError) throw rError; - const list = ratings ?? []; - if (list.length === 0) return []; - const userIds = [...new Set(list.map((r) => r.user_id).filter(Boolean))]; - const { data: profiles } = await supabase - .from('profiles') - .select('id, display_name, email, avatar_url') - .in('id', userIds); - const byId = new Map((profiles ?? []).map((p) => [p.id, p])); - return list.map((r) => { - const p = byId.get(r.user_id); - return { - rating: r.rating, - comment: r.comment || null, - user_id: r.user_id, - display_name: p?.display_name ?? null, - email: p?.email ?? null, - avatar_url: p?.avatar_url ?? null, - }; - }); -} - -/** Get the current user's rating (and comment) for a deck, or null if none. */ -export async function getMyDeckRating(deckId, userId) { - if (!userId) return null; - const { data, error } = await supabase - .from('deck_ratings') - .select('rating, comment') - .eq('deck_id', deckId) - .eq('user_id', userId) - .maybeSingle(); - if (error) throw error; - return data; -} - -/** Submit or update rating (and optional comment) for a deck. Upserts by (deck_id, user_id). */ -export async function submitDeckRating(deckId, userId, { rating, comment }) { - const row = { - deck_id: deckId, - user_id: userId, - rating: Math.min(5, Math.max(1, Math.round(rating))), - comment: (comment ?? '').trim() || null, - }; - const { error } = await supabase.from('deck_ratings').upsert(row, { - onConflict: 'deck_id,user_id', - }); - if (error) throw error; -} +export * from './decks-core.js'; diff --git a/src/lib/api/decks.test.js b/src/lib/api/decks.test.js index 102fcbe..a0919a2 100644 --- a/src/lib/api/decks.test.js +++ b/src/lib/api/decks.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { supabase } from '../supabase.js' import * as decksApi from './decks.js' const nextResults = [] @@ -53,7 +54,7 @@ describe('decks API', () => { describe('fetchMyDecks', () => { it('returns empty array when no decks', async () => { nextResults.push({ data: [], error: null }) - const result = await decksApi.fetchMyDecks('user1') + const result = await decksApi.fetchMyDecks(supabase, 'user1') expect(result).toEqual([]) }) @@ -63,7 +64,7 @@ describe('decks API', () => { ] nextResults.push({ data: decks, error: null }, { data: [], error: null }, { data: [], error: null }) countResults.push({ count: 3, error: null }) - const result = await decksApi.fetchMyDecks('user1') + const result = await decksApi.fetchMyDecks(supabase, 'user1') expect(result).toHaveLength(1) expect(result[0].question_count).toBe(3) expect(result[0].title).toBe('Deck 1') @@ -73,7 +74,7 @@ describe('decks API', () => { describe('fetchPublishedDecks', () => { it('returns empty array when no published decks', async () => { nextResults.push({ data: [], error: null }) - const result = await decksApi.fetchPublishedDecks() + const result = await decksApi.fetchPublishedDecks(supabase) expect(result).toEqual([]) }) }) @@ -83,91 +84,91 @@ describe('decks API', () => { const deck = { id: 'd1', title: 'Deck', version: 1 } const questions = [{ id: 'q1', prompt: 'Q?', sort_order: 0 }] nextResults.push({ data: deck, error: null }, { data: questions, error: null }) - const result = await decksApi.fetchDeckWithQuestions('d1') + const result = await decksApi.fetchDeckWithQuestions(supabase, 'd1') expect(result).toMatchObject({ ...deck, questions }) expect(result.questions).toHaveLength(1) }) it('throws when deck not found', async () => { nextResults.push({ data: null, error: new Error('Not found') }) - await expect(decksApi.fetchDeckWithQuestions('bad')).rejects.toThrow() + await expect(decksApi.fetchDeckWithQuestions(supabase, 'bad')).rejects.toThrow() }) }) describe('userHasDeck', () => { it('returns false when userId is null', async () => { - expect(await decksApi.userHasDeck('d1', null)).toBe(false) + expect(await decksApi.userHasDeck(supabase, 'd1', null)).toBe(false) }) it('returns true when user has deck', async () => { nextResults.push({ data: [{ id: 'd1' }], error: null }) - expect(await decksApi.userHasDeck('d1', 'user1')).toBe(true) + expect(await decksApi.userHasDeck(supabase, 'd1', 'user1')).toBe(true) }) it('returns false when no match', async () => { nextResults.push({ data: [], error: null }) - expect(await decksApi.userHasDeck('d1', 'user1')).toBe(false) + expect(await decksApi.userHasDeck(supabase, 'd1', 'user1')).toBe(false) }) }) describe('togglePublished', () => { it('calls update and throws on error', async () => { nextResults.push({ error: new Error('DB error') }) - await expect(decksApi.togglePublished('d1', true)).rejects.toThrow('DB error') + await expect(decksApi.togglePublished(supabase, 'd1', true)).rejects.toThrow('DB error') }) it('succeeds when no error', async () => { nextResults.push({ error: null }) - await expect(decksApi.togglePublished('d1', false)).resolves.toBeUndefined() + await expect(decksApi.togglePublished(supabase, 'd1', false)).resolves.toBeUndefined() }) }) describe('deleteDeck', () => { it('throws on error', async () => { nextResults.push({ error: new Error('FK violation') }) - await expect(decksApi.deleteDeck('d1')).rejects.toThrow('FK violation') + await expect(decksApi.deleteDeck(supabase, 'd1')).rejects.toThrow('FK violation') }) it('succeeds when no error', async () => { nextResults.push({ error: null }) - await expect(decksApi.deleteDeck('d1')).resolves.toBeUndefined() + await expect(decksApi.deleteDeck(supabase, 'd1')).resolves.toBeUndefined() }) }) describe('getMyDeckRating', () => { it('returns null when userId is null', async () => { - expect(await decksApi.getMyDeckRating('d1', null)).toBe(null) + expect(await decksApi.getMyDeckRating(supabase, 'd1', null)).toBe(null) }) it('returns rating and comment when present', async () => { nextResults.push({ data: { rating: 4, comment: 'Great!' }, error: null }) - const result = await decksApi.getMyDeckRating('d1', 'user1') + const result = await decksApi.getMyDeckRating(supabase, 'd1', 'user1') expect(result).toEqual({ rating: 4, comment: 'Great!' }) }) it('returns null when no rating', async () => { nextResults.push({ data: null, error: null }) - expect(await decksApi.getMyDeckRating('d1', 'user1')).toBe(null) + expect(await decksApi.getMyDeckRating(supabase, 'd1', 'user1')).toBe(null) }) }) describe('submitDeckRating', () => { it('clamps rating to 1-5 and trims comment', async () => { nextResults.push({ error: null }) - await decksApi.submitDeckRating('d1', 'user1', { rating: 10, comment: ' ok ' }) + await decksApi.submitDeckRating(supabase, 'd1', 'user1', { rating: 10, comment: ' ok ' }) expect(nextResults.length).toBe(0) }) it('throws on error', async () => { nextResults.push({ error: new Error('Unique violation') }) - await expect(decksApi.submitDeckRating('d1', 'user1', { rating: 3 })).rejects.toThrow('Unique violation') + await expect(decksApi.submitDeckRating(supabase, 'd1', 'user1', { rating: 3 })).rejects.toThrow('Unique violation') }) }) describe('getDeckReviews', () => { it('returns empty array when no ratings', async () => { nextResults.push({ data: [], error: null }) - const result = await decksApi.getDeckReviews('d1') + const result = await decksApi.getDeckReviews(supabase, 'd1') expect(result).toEqual([]) }) @@ -176,7 +177,7 @@ describe('decks API', () => { { data: [{ user_id: 'u1', rating: 5, comment: 'Nice', created_at: '2025-01-01' }], error: null }, { data: [{ id: 'u1', display_name: 'Alice', email: 'a@b.com', avatar_url: null }], error: null } ) - const result = await decksApi.getDeckReviews('d1') + const result = await decksApi.getDeckReviews(supabase, 'd1') expect(result).toHaveLength(1) expect(result[0]).toMatchObject({ rating: 5, comment: 'Nice', display_name: 'Alice', email: 'a@b.com' }) }) @@ -185,13 +186,13 @@ describe('decks API', () => { describe('createDeck', () => { it('creates deck and returns id', async () => { nextResults.push({ data: { id: 'new-id' }, error: null }) - const id = await decksApi.createDeck('user1', { title: 'Title', description: 'Desc', config: {} }) + const id = await decksApi.createDeck(supabase, 'user1', { title: 'Title', description: 'Desc', config: {} }) expect(id).toBe('new-id') }) it('throws when insert fails', async () => { nextResults.push({ data: null, error: new Error('Failed') }) - await expect(decksApi.createDeck('user1', { title: 'T', description: '', config: {} })).rejects.toThrow() + await expect(decksApi.createDeck(supabase, 'user1', { title: 'T', description: '', config: {} })).rejects.toThrow() }) }) @@ -202,7 +203,7 @@ describe('decks API', () => { { error: null }, { error: null } ) - await decksApi.updateDeck('d1', { title: 'New', description: '', config: {}, questions: [] }) + await decksApi.updateDeck(supabase, 'd1', { title: 'New', description: '', config: {}, questions: [] }) expect(nextResults.length).toBe(0) }) @@ -212,7 +213,7 @@ describe('decks API', () => { { error: null }, { error: null } ) - await decksApi.updateDeck('d1', { title: 'T', description: '', config: {}, questions: [] }) + await decksApi.updateDeck(supabase, 'd1', { title: 'T', description: '', config: {}, questions: [] }) expect(nextResults.length).toBe(0) }) }) @@ -223,7 +224,7 @@ describe('decks API', () => { { data: { id: 's1', title: 'S', published: false, questions: [] }, error: null }, { data: [], error: null } ) - await expect(decksApi.copyDeckToUser('s1', 'user1')).rejects.toThrow('not available to copy') + await expect(decksApi.copyDeckToUser(supabase, 's1', 'user1')).rejects.toThrow('not available to copy') }) it('creates copy and returns new deck id', async () => { @@ -232,7 +233,7 @@ describe('decks API', () => { { data: [], error: null }, { data: { id: 'new-id' }, error: null } ) - const id = await decksApi.copyDeckToUser('s1', 'user1') + const id = await decksApi.copyDeckToUser(supabase, 's1', 'user1') expect(id).toBe('new-id') }) }) @@ -240,7 +241,7 @@ describe('decks API', () => { describe('getSourceUpdatePreview', () => { it('throws when deck not found or not a copy', async () => { nextResults.push({ data: null, error: new Error('Not found') }) - await expect(decksApi.getSourceUpdatePreview('d1', 'user1')).rejects.toThrow('not found or not a copy') + await expect(decksApi.getSourceUpdatePreview(supabase, 'd1', 'user1')).rejects.toThrow('not found or not a copy') }) it('returns source, copy and changes', async () => { @@ -251,7 +252,7 @@ describe('decks API', () => { { data: sourceDeck, error: null }, { data: [{ prompt: 'Q' }], error: null } ) - const result = await decksApi.getSourceUpdatePreview('c1', 'user1') + const result = await decksApi.getSourceUpdatePreview(supabase, 'c1', 'user1') expect(result.source).toBeDefined() expect(result.copy).toBeDefined() expect(result.changes).toEqual(expect.any(Array)) @@ -261,7 +262,7 @@ describe('decks API', () => { describe('applySourceUpdate', () => { it('throws when deck not a copy', async () => { nextResults.push({ data: null, error: new Error('Not found') }) - await expect(decksApi.applySourceUpdate('d1', 'user1')).rejects.toThrow() + await expect(decksApi.applySourceUpdate(supabase, 'd1', 'user1')).rejects.toThrow() }) }) }) diff --git a/src/lib/api/profile.js b/src/lib/api/profile.js index 393a0b4..0c0548f 100644 --- a/src/lib/api/profile.js +++ b/src/lib/api/profile.js @@ -1,10 +1,10 @@ import { supabase } from '../supabase.js'; /** Resolve email for login when user enters a username (display_name). Returns null if not found. */ -export async function getEmailForUsername(username) { +export async function getEmailForUsername(client, username) { const trimmed = (username ?? '').trim(); if (!trimmed) return null; - const { data, error } = await supabase + const { data, error } = await client .from('profiles') .select('email') .ilike('display_name', trimmed) @@ -13,8 +13,8 @@ export async function getEmailForUsername(username) { return data[0].email; } -export async function getProfile(userId) { - const { data, error } = await supabase +export async function getProfile(client, userId) { + const { data, error } = await client .from('profiles') .select('id, display_name, email, avatar_url') .eq('id', userId) @@ -23,13 +23,13 @@ export async function getProfile(userId) { return data ?? null; } -export async function updateProfile(userId, { display_name, avatar_url }) { +export async function updateProfile(client, userId, { display_name, avatar_url }) { const updates = {}; if (display_name !== undefined) updates.display_name = display_name?.trim() || null; if (avatar_url !== undefined) updates.avatar_url = avatar_url || null; updates.updated_at = new Date().toISOString(); - const { data, error } = await supabase + const { data, error } = await client .from('profiles') .update(updates) .eq('id', userId) diff --git a/src/lib/api/profile.test.js b/src/lib/api/profile.test.js index de6e061..801fc76 100644 --- a/src/lib/api/profile.test.js +++ b/src/lib/api/profile.test.js @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' +import { supabase } from '../supabase.js' import * as profileApi from './profile.js' const nextResults = [] @@ -38,24 +39,24 @@ describe('profile API', () => { describe('getEmailForUsername', () => { it('returns null for empty or whitespace username', async () => { - expect(await profileApi.getEmailForUsername('')).toBe(null) - expect(await profileApi.getEmailForUsername(' ')).toBe(null) - expect(await profileApi.getEmailForUsername(null)).toBe(null) + expect(await profileApi.getEmailForUsername(supabase, '')).toBe(null) + expect(await profileApi.getEmailForUsername(supabase, ' ')).toBe(null) + expect(await profileApi.getEmailForUsername(supabase, null)).toBe(null) }) it('returns email when profile found', async () => { nextResults.push({ data: [{ email: 'user@example.com' }], error: null }) - const email = await profileApi.getEmailForUsername('johndoe') + const email = await profileApi.getEmailForUsername(supabase, 'johndoe') expect(email).toBe('user@example.com') }) it('returns null when error or no data', async () => { nextResults.push({ data: [], error: null }) - expect(await profileApi.getEmailForUsername('x')).toBe(null) + expect(await profileApi.getEmailForUsername(supabase, 'x')).toBe(null) nextResults.push({ data: null, error: { message: 'err' } }) - expect(await profileApi.getEmailForUsername('y')).toBe(null) + expect(await profileApi.getEmailForUsername(supabase, 'y')).toBe(null) nextResults.push({ data: [{}], error: null }) - expect(await profileApi.getEmailForUsername('z')).toBe(null) + expect(await profileApi.getEmailForUsername(supabase, 'z')).toBe(null) }) }) @@ -63,19 +64,19 @@ describe('profile API', () => { it('returns profile when found', async () => { const p = { id: 'u1', display_name: 'Alice', email: 'a@b.com', avatar_url: null } nextResults.push({ data: p, error: null }) - const result = await profileApi.getProfile('u1') + const result = await profileApi.getProfile(supabase, 'u1') expect(result).toEqual(p) }) it('returns null when not found (PGRST116)', async () => { nextResults.push({ data: null, error: { code: 'PGRST116' } }) - const result = await profileApi.getProfile('u1') + const result = await profileApi.getProfile(supabase, 'u1') expect(result).toBe(null) }) it('throws when other error', async () => { nextResults.push({ data: null, error: new Error('Network error') }) - await expect(profileApi.getProfile('u1')).rejects.toThrow('Network error') + await expect(profileApi.getProfile(supabase, 'u1')).rejects.toThrow('Network error') }) }) @@ -83,19 +84,19 @@ describe('profile API', () => { it('updates display_name and returns data', async () => { const updated = { id: 'u1', display_name: 'New Name', email: 'a@b.com', avatar_url: null } nextResults.push({ data: updated, error: null }) - const result = await profileApi.updateProfile('u1', { display_name: 'New Name' }) + const result = await profileApi.updateProfile(supabase, 'u1', { display_name: 'New Name' }) expect(result).toEqual(updated) }) it('trims display_name and allows avatar_url', async () => { nextResults.push({ data: { id: 'u1' }, error: null }) - await profileApi.updateProfile('u1', { display_name: ' Trim ', avatar_url: 'path/to/av.jpg' }) + await profileApi.updateProfile(supabase, 'u1', { display_name: ' Trim ', avatar_url: 'path/to/av.jpg' }) expect(nextResults.length).toBe(0) }) it('throws on error', async () => { nextResults.push({ data: null, error: new Error('DB error') }) - await expect(profileApi.updateProfile('u1', { display_name: 'X' })).rejects.toThrow('DB error') + await expect(profileApi.updateProfile(supabase, 'u1', { display_name: 'X' })).rejects.toThrow('DB error') }) }) diff --git a/src/lib/stores/auth.js b/src/lib/stores/auth.js index 1c7b6aa..89b9732 100644 --- a/src/lib/stores/auth.js +++ b/src/lib/stores/auth.js @@ -44,7 +44,7 @@ function createAuthStore() { return false; } if (!email.includes('@')) { - const resolved = await getEmailForUsername(email); + const resolved = await getEmailForUsername(supabase, email); if (!resolved) { update((s) => ({ ...s, error: 'No account with that username.' })); return false; @@ -79,7 +79,7 @@ function createAuthStore() { update((s) => ({ ...s, error: null })); if (data?.user?.id && data?.session && (displayName || '').trim()) { try { - await updateProfile(data.user.id, { display_name: (displayName || '').trim() }); + await updateProfile(supabase, data.user.id, { display_name: (displayName || '').trim() }); } catch (_) {} } const needsConfirmation = data?.user && !data?.session; diff --git a/src/lib/stores/auth.test.js b/src/lib/stores/auth.test.js index 6dccfa6..dc0f082 100644 --- a/src/lib/stores/auth.test.js +++ b/src/lib/stores/auth.test.js @@ -67,7 +67,7 @@ describe('auth store', () => { mockGetEmailForUsername.mockResolvedValue('user@example.com') mockSignInWithPassword.mockResolvedValue({ error: null }) const result = await auth.login('username', 'pass') - expect(mockGetEmailForUsername).toHaveBeenCalledWith('username') + expect(mockGetEmailForUsername).toHaveBeenCalledWith(expect.anything(), 'username') expect(mockSignInWithPassword).toHaveBeenCalledWith({ email: 'user@example.com', password: 'pass', @@ -112,7 +112,7 @@ describe('auth store', () => { mockSignUp.mockResolvedValue({ data: { user, session: {} }, error: null }) mockUpdateProfile.mockResolvedValue(undefined) await auth.register('a@b.com', 'pass', 'Alice') - expect(mockUpdateProfile).toHaveBeenCalledWith('u1', { display_name: 'Alice' }) + expect(mockUpdateProfile).toHaveBeenCalledWith(expect.anything(), 'u1', { display_name: 'Alice' }) }) it('register with signUp error returns success false', async () => { diff --git a/src/routes/Community.svelte b/src/routes/Community.svelte index f13fe18..00522d8 100644 --- a/src/routes/Community.svelte +++ b/src/routes/Community.svelte @@ -3,6 +3,7 @@ import { push } from 'svelte-spa-router'; import { auth } from '../lib/stores/auth.js'; import { navContext } from '../lib/stores/navContext.js'; + import { supabase } from '../lib/supabase.js'; import { fetchPublishedDecks, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js'; import RatingModal from '../lib/RatingModal.svelte'; @@ -88,7 +89,7 @@ useError = null; ratingDeck = { id: deck.id, title: deck.title }; try { - const data = await getMyDeckRating(deck.id, userId); + const data = await getMyDeckRating(supabase, deck.id, userId); ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' }; } catch (_) { ratingInitial = { rating: 0, comment: '' }; @@ -102,7 +103,7 @@ async function handleRatingSubmit(payload) { if (!ratingDeck || !userId) return; try { - await submitDeckRating(ratingDeck.id, userId, payload); + await submitDeckRating(supabase, ratingDeck.id, userId, payload); await load(); } catch (e) { useError = e?.message ?? 'Failed to save rating'; @@ -118,7 +119,7 @@ if (addingDeckId) return; addingDeckId = deckId; try { - await copyDeckToUser(deckId, userId); + await copyDeckToUser(supabase, deckId, userId); await load(); } catch (e) { useError = e?.message ?? 'Failed to add deck'; @@ -131,7 +132,7 @@ loading = true; error = null; try { - decks = await fetchPublishedDecks(userId ?? undefined); + decks = await fetchPublishedDecks(supabase, userId ?? undefined); } catch (e) { error = e?.message ?? 'Failed to load community decks'; decks = []; diff --git a/src/routes/Community.test.js b/src/routes/Community.test.js index ec90dbe..c0de44c 100644 --- a/src/routes/Community.test.js +++ b/src/routes/Community.test.js @@ -86,7 +86,7 @@ describe('Community', () => { render(Community) await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument()) await fireEvent.click(screen.getByRole('button', { name: 'Add' })) - await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith('d1', 'u1')) + await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith(expect.anything(), 'd1', 'u1')) await waitFor(() => expect(screen.getByText('In My decks')).toBeInTheDocument()) }) }) diff --git a/src/routes/CommunityUser.svelte b/src/routes/CommunityUser.svelte index 30739e1..5a1f5e6 100644 --- a/src/routes/CommunityUser.svelte +++ b/src/routes/CommunityUser.svelte @@ -3,6 +3,7 @@ import { push } from 'svelte-spa-router'; import { auth } from '../lib/stores/auth.js'; import { navContext } from '../lib/stores/navContext.js'; + import { supabase } from '../lib/supabase.js'; import { fetchPublishedDecksByOwner, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js'; import RatingModal from '../lib/RatingModal.svelte'; @@ -46,7 +47,7 @@ useError = null; ratingDeck = { id: deck.id, title: deck.title }; try { - const data = await getMyDeckRating(deck.id, userId); + const data = await getMyDeckRating(supabase, deck.id, userId); ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' }; } catch (_) { ratingInitial = { rating: 0, comment: '' }; @@ -60,7 +61,7 @@ async function handleRatingSubmit(payload) { if (!ratingDeck || !userId) return; try { - await submitDeckRating(ratingDeck.id, userId, payload); + await submitDeckRating(supabase, ratingDeck.id, userId, payload); await load(); } catch (e) { useError = e?.message ?? 'Failed to save rating'; @@ -76,7 +77,7 @@ if (addingDeckId) return; addingDeckId = deckId; try { - await copyDeckToUser(deckId, userId); + await copyDeckToUser(supabase, deckId, userId); await load(); } catch (e) { useError = e?.message ?? 'Failed to add deck'; @@ -95,7 +96,7 @@ loading = true; error = null; try { - const result = await fetchPublishedDecksByOwner(ownerId, userId ?? undefined); + const result = await fetchPublishedDecksByOwner(supabase, ownerId, userId ?? undefined); decks = result.decks ?? []; ownerName = result.owner_display_name ?? result.owner_email ?? 'User'; } catch (e) { diff --git a/src/routes/CommunityUser.test.js b/src/routes/CommunityUser.test.js index a7f11a8..7e053bf 100644 --- a/src/routes/CommunityUser.test.js +++ b/src/routes/CommunityUser.test.js @@ -40,7 +40,7 @@ describe('CommunityUser', () => { render(CommunityUser, { props: { params: { id: 'owner1' } } }) expect(screen.getByText('Loading…')).toBeInTheDocument() await waitFor(() => expect(screen.getByText(/Decks by Test User/)).toBeInTheDocument()) - expect(mockFetchPublishedDecksByOwner).toHaveBeenCalledWith('owner1', 'u1') + expect(mockFetchPublishedDecksByOwner).toHaveBeenCalledWith(expect.anything(), 'owner1', 'u1') }) it('shows error when fetch fails', async () => { diff --git a/src/routes/CreateDeck.svelte b/src/routes/CreateDeck.svelte index 9e9d072..db5efe5 100644 --- a/src/routes/CreateDeck.svelte +++ b/src/routes/CreateDeck.svelte @@ -3,6 +3,7 @@ import { push } from 'svelte-spa-router'; import { auth } from '../lib/stores/auth.js'; import { navContext } from '../lib/stores/navContext.js'; + import { supabase } from '../lib/supabase.js'; import { createDeck } from '../lib/api/decks.js'; import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js'; import QuestionEditor from '../lib/QuestionEditor.svelte'; @@ -112,7 +113,7 @@ saving = true; error = null; try { - const id = await createDeck(userId, { + const id = await createDeck(supabase, userId, { title: t, description: description.trim(), config, diff --git a/src/routes/CreateDeck.test.js b/src/routes/CreateDeck.test.js index 6dfe2a9..6302163 100644 --- a/src/routes/CreateDeck.test.js +++ b/src/routes/CreateDeck.test.js @@ -94,7 +94,7 @@ describe('CreateDeck', () => { await fireEvent.input(screen.getByPlaceholderText('Answer 1'), { target: { value: 'Yes' } }) await fireEvent.click(screen.getByRole('button', { name: 'Save deck' })) await waitFor(() => expect(mockCreateDeck).toHaveBeenCalled()) - expect(mockCreateDeck).toHaveBeenCalledWith('u1', expect.objectContaining({ title: 'My Deck' })) + expect(mockCreateDeck).toHaveBeenCalledWith(expect.anything(), 'u1', expect.objectContaining({ title: 'My Deck' })) expect(mockPush).toHaveBeenCalledWith('/') }) diff --git a/src/routes/DeckPreview.svelte b/src/routes/DeckPreview.svelte index 340850c..eff46de 100644 --- a/src/routes/DeckPreview.svelte +++ b/src/routes/DeckPreview.svelte @@ -2,6 +2,7 @@ import { push } from 'svelte-spa-router'; import { auth } from '../lib/stores/auth.js'; import { navContext } from '../lib/stores/navContext.js'; + import { supabase } from '../lib/supabase.js'; import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js'; export let params = {}; @@ -28,7 +29,7 @@ addError = null; userAlreadyHasDeck = false; try { - deck = await fetchDeckWithQuestions(deckId); + deck = await fetchDeckWithQuestions(supabase, deckId); const isMyCopy = !!(userId && deck.owner_id === userId && deck.copied_from_deck_id); const isOwnDeckLoad = !!(userId && deck.owner_id === userId && !deck.copied_from_deck_id); const canView = deck.published || isMyCopy || isOwnDeckLoad; @@ -40,7 +41,7 @@ if (isMyCopy) { userAlreadyHasDeck = true; } else if (userId) { - userAlreadyHasDeck = await userHasDeck(deckId, userId); + userAlreadyHasDeck = await userHasDeck(supabase, deckId, userId); } } } catch (e) { @@ -77,7 +78,7 @@ } adding = true; try { - await copyDeckToUser(deckId, userId); + await copyDeckToUser(supabase, deckId, userId); userAlreadyHasDeck = true; } catch (e) { addError = e?.message ?? 'Failed to add deck'; diff --git a/src/routes/DeckPreview.test.js b/src/routes/DeckPreview.test.js index cdde33a..39fbf60 100644 --- a/src/routes/DeckPreview.test.js +++ b/src/routes/DeckPreview.test.js @@ -43,7 +43,7 @@ describe('DeckPreview', () => { render(DeckPreview, { props: { params: { id: 'd1' } } }) expect(screen.getByText('Loading deck…')).toBeInTheDocument() await waitFor(() => expect(screen.getByText('Preview Deck')).toBeInTheDocument()) - expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith('d1') + expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith(expect.anything(), 'd1') }) it('shows error when deck not available', async () => { @@ -71,7 +71,7 @@ describe('DeckPreview', () => { const addBtn = screen.getByRole('button', { name: /Add to My decks/i }) mockCopyDeckToUser.mockResolvedValue(undefined) await fireEvent.click(addBtn) - await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith('d1', 'u1')) + await waitFor(() => expect(mockCopyDeckToUser).toHaveBeenCalledWith(expect.anything(), 'd1', 'u1')) }) it('addToMyDecks without userId shows sign in error', async () => { diff --git a/src/routes/DeckReviews.svelte b/src/routes/DeckReviews.svelte index dad064a..bbddad1 100644 --- a/src/routes/DeckReviews.svelte +++ b/src/routes/DeckReviews.svelte @@ -1,5 +1,6 @@