Compare commits
11
Commits
0b271bc352
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dc723477b | ||
|
|
729fc90365 | ||
|
|
44f54b4793 | ||
|
|
af6b7e01cd | ||
|
|
aa3480af17 | ||
|
|
84cb0ec0f6 | ||
|
|
d870b5238a | ||
|
|
d99b051a07 | ||
|
|
c8ff07b490 | ||
|
|
5cecb98484 | ||
|
|
d4b7683147 |
@@ -4,9 +4,17 @@
|
|||||||
VITE_SUPABASE_URL=https://your-project.supabase.co
|
VITE_SUPABASE_URL=https://your-project.supabase.co
|
||||||
VITE_SUPABASE_ANON_KEY=your-anon-key
|
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
|
# Optional: for OAuth / magic link redirects
|
||||||
# VITE_SUPABASE_REDIRECT_URL=http://localhost:5173
|
# VITE_SUPABASE_REDIRECT_URL=http://localhost:5173
|
||||||
|
|
||||||
|
# Set to "true" to disable new user registration (login still works)
|
||||||
|
# VITE_DISABLE_REGISTRATION=false
|
||||||
|
|
||||||
# App uses schema "omotomo" for decks/questions. Ensure the API exposes it:
|
# App uses schema "omotomo" for decks/questions. Ensure the API exposes it:
|
||||||
# Self-hosted: add "omotomo" to PostgREST db-schemas (e.g. in config).
|
# Self-hosted: add "omotomo" to PostgREST db-schemas (e.g. in config).
|
||||||
# Cloud: Dashboard > Project Settings > API > Exposed schemas.
|
# Cloud: Dashboard > Project Settings > API > Exposed schemas.
|
||||||
|
|||||||
@@ -2,6 +2,55 @@
|
|||||||
|
|
||||||
This template should help get you started developing with Svelte in Vite.
|
This template should help get you started developing with Svelte in Vite.
|
||||||
|
|
||||||
|
## Running the app
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
This starts both the Vite dev server (frontend) and the API server (default port 3001). To run only the frontend: `npm run dev:web`.
|
||||||
|
|
||||||
|
Copy `.env.sample` to `.env` and set `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` for the Svelte app and (optionally) the API server.
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the frontend to `dist/` and starts the server. The same process serves both the API and the built SPA at `http://localhost:3001` (or your `PORT`). Without a `dist/` folder (e.g. API-only), the server still runs and serves only the API.
|
||||||
|
|
||||||
|
## 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`, `npm run start`, or `npm run start:api`. When `dist/` exists, the server also serves the frontend.
|
||||||
|
- **Auth**: `POST /api/auth/login`, `POST /api/auth/register`, `GET /api/auth/session`, `GET /api/auth/profile` (Bearer token for protected routes; profile includes `display_name`, `email`, `avatar_url`)
|
||||||
|
- **Decks**: `GET /api/decks/mine`, `GET /api/decks/published`, `GET /api/decks/:id`, `POST /api/decks`, `PATCH /api/decks/:id`, `DELETE /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):
|
||||||
|
`npm run test`
|
||||||
|
|
||||||
|
- **Single run** (CI-friendly):
|
||||||
|
`npm run test:run`
|
||||||
|
|
||||||
|
- **Coverage report** (HTML in `coverage/`, summary in terminal):
|
||||||
|
`npm run test:coverage`
|
||||||
|
|
||||||
|
- **Run a specific test file**:
|
||||||
|
`npm run test:run -- src/lib/api/decks.test.js`
|
||||||
|
|
||||||
|
- **Verify all tests pass**:
|
||||||
|
`npm run test:run` — exit code 0 means all passed.
|
||||||
|
|
||||||
|
Tests use Vitest and `@testing-library/svelte`. Place test files next to the code they cover (e.g. `Component.test.js` beside `Component.svelte`) or in a `__tests__` directory.
|
||||||
|
|
||||||
|
**Integration-style tests** (in `src/lib/api/decks.test.js`) cover deck assignment and visibility: decks in “My decks” are only those owned by the user; community lists only published decks; publish/unpublish toggles visibility; “In My decks” (`user_has_this`) is correct when the user owns the deck or has a copy. Run them with `npm run test:run` or `npm run test:run -- src/lib/api/decks.test.js`.
|
||||||
|
|
||||||
## Recommended IDE Setup
|
## Recommended IDE Setup
|
||||||
|
|
||||||
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode).
|
||||||
|
|||||||
Generated
+1194
-2
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -4,18 +4,23 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "concurrently -n vite,api -c blue,green \"vite\" \"node server/index.js\"",
|
||||||
|
"dev:web": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage",
|
||||||
|
"dev:server": "node server/index.js",
|
||||||
|
"start": "node server/index.js",
|
||||||
|
"start:api": "node server/index.js"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
"@sveltejs/vite-plugin-svelte": "^6.2.1",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/svelte": "^5.3.1",
|
"@testing-library/svelte": "^5.3.1",
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
|
"concurrently": "^9.2.1",
|
||||||
"jsdom": "^27.0.1",
|
"jsdom": "^27.0.1",
|
||||||
"svelte": "^5.45.2",
|
"svelte": "^5.45.2",
|
||||||
"vite": "^7.3.1",
|
"vite": "^7.3.1",
|
||||||
@@ -23,6 +28,9 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@supabase/supabase-js": "^2.95.3",
|
"@supabase/supabase-js": "^2.95.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.5",
|
||||||
|
"express": "^4.21.0",
|
||||||
"svelte-spa-router": "^4.0.1"
|
"svelte-spa-router": "^4.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+385
@@ -0,0 +1,385 @@
|
|||||||
|
import 'dotenv/config';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import fs from 'fs';
|
||||||
|
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 __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
const distPath = path.join(projectRoot, 'dist');
|
||||||
|
|
||||||
|
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/auth/profile', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { data, error } = await req.supabase
|
||||||
|
.from('profiles')
|
||||||
|
.select('id, display_name, email, avatar_url')
|
||||||
|
.eq('id', req.userId)
|
||||||
|
.single();
|
||||||
|
if (error && error.code !== 'PGRST116') {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!data) {
|
||||||
|
res.status(404).json({ error: 'Profile not found' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.json(data);
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e?.message ?? 'Failed to load profile' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
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, express.json(), 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;
|
||||||
|
}
|
||||||
|
const published = req.body?.published !== false;
|
||||||
|
await decksApi.togglePublished(req.supabase, req.params.id, published);
|
||||||
|
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' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/decks/:id', 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.deleteDeck(req.supabase, req.params.id);
|
||||||
|
res.status(204).send();
|
||||||
|
} catch (e) {
|
||||||
|
res.status(500).json({ error: e?.message ?? 'Failed to delete deck' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Serve built frontend when dist/ exists (production)
|
||||||
|
if (fs.existsSync(distPath)) {
|
||||||
|
app.use(express.static(distPath));
|
||||||
|
app.get('*', (req, res, next) => {
|
||||||
|
if (req.path.startsWith('/api')) return next();
|
||||||
|
res.sendFile(path.join(distPath, 'index.html'), (err) => {
|
||||||
|
if (err) next(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 3001;
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`API server listening on http://localhost:${PORT}`);
|
||||||
|
if (fs.existsSync(distPath)) {
|
||||||
|
console.log(`Serving frontend from dist/ (open http://localhost:${PORT})`);
|
||||||
|
}
|
||||||
|
});
|
||||||
+11
-5
@@ -3,12 +3,15 @@
|
|||||||
import { push, location } from 'svelte-spa-router';
|
import { push, location } from 'svelte-spa-router';
|
||||||
import { auth } from './stores/auth.js';
|
import { auth } from './stores/auth.js';
|
||||||
import { navContext } from './stores/navContext.js';
|
import { navContext } from './stores/navContext.js';
|
||||||
|
import { supabase } from './supabase.js';
|
||||||
import { getProfile } from './api/profile.js';
|
import { getProfile } from './api/profile.js';
|
||||||
|
|
||||||
$: loc = $location || '';
|
$: loc = $location || '';
|
||||||
$: myDecksActive = loc === '/' || loc === '/decks/new' || /^\/decks\/[^/]+\/edit$/.test(loc) || $navContext === 'my-decks';
|
$: myDecksActive = loc === '/' || loc === '/decks/new' || /^\/decks\/[^/]+\/edit$/.test(loc) || $navContext === 'my-decks';
|
||||||
$: communityActive = loc === '/community' || loc.startsWith('/community/') || $navContext === 'community';
|
$: communityActive = loc === '/community' || loc.startsWith('/community/') || $navContext === 'community';
|
||||||
|
|
||||||
|
const registrationDisabled = import.meta.env.VITE_DISABLE_REGISTRATION === 'true';
|
||||||
|
|
||||||
let showPopup = false;
|
let showPopup = false;
|
||||||
let mode = 'login'; // 'login' | 'register'
|
let mode = 'login'; // 'login' | 'register'
|
||||||
let email = '';
|
let email = '';
|
||||||
@@ -62,9 +65,10 @@
|
|||||||
submitting = false;
|
submitting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleLogout() {
|
async function handleLogout() {
|
||||||
userMenuOpen = false;
|
userMenuOpen = false;
|
||||||
auth.logout();
|
profile = null;
|
||||||
|
await auth.logout();
|
||||||
push('/');
|
push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@
|
|||||||
userMenuOpen = !userMenuOpen;
|
userMenuOpen = !userMenuOpen;
|
||||||
if (userMenuOpen && $auth.user?.id && !profileLoading) {
|
if (userMenuOpen && $auth.user?.id && !profileLoading) {
|
||||||
profileLoading = true;
|
profileLoading = true;
|
||||||
getProfile($auth.user.id).then((p) => {
|
getProfile(supabase, $auth.user.id).then((p) => {
|
||||||
profile = p;
|
profile = p;
|
||||||
profileLoading = false;
|
profileLoading = false;
|
||||||
}).catch(() => { profileLoading = false; });
|
}).catch(() => { profileLoading = false; });
|
||||||
@@ -98,7 +102,7 @@
|
|||||||
/** Load profile (including avatar) when user is logged in, so avatar shows after refresh */
|
/** Load profile (including avatar) when user is logged in, so avatar shows after refresh */
|
||||||
$: if (!$auth.loading && $auth.user?.id && !profile && !profileLoading) {
|
$: if (!$auth.loading && $auth.user?.id && !profile && !profileLoading) {
|
||||||
profileLoading = true;
|
profileLoading = true;
|
||||||
getProfile($auth.user.id)
|
getProfile(supabase, $auth.user.id)
|
||||||
.then((p) => {
|
.then((p) => {
|
||||||
profile = p;
|
profile = p;
|
||||||
profileLoading = false;
|
profileLoading = false;
|
||||||
@@ -204,6 +208,7 @@
|
|||||||
>
|
>
|
||||||
Log in
|
Log in
|
||||||
</button>
|
</button>
|
||||||
|
{#if !registrationDisabled}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="tab"
|
class="tab"
|
||||||
@@ -212,6 +217,7 @@
|
|||||||
>
|
>
|
||||||
Register
|
Register
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if registerSuccess && !$auth.user}
|
{#if registerSuccess && !$auth.user}
|
||||||
@@ -248,7 +254,7 @@
|
|||||||
/>
|
/>
|
||||||
{#if $auth.error}
|
{#if $auth.error}
|
||||||
<p class="auth-error">{$auth.error}</p>
|
<p class="auth-error">{$auth.error}</p>
|
||||||
{#if mode === 'login'}
|
{#if mode === 'login' && !registrationDisabled}
|
||||||
<p class="auth-switch">
|
<p class="auth-switch">
|
||||||
Do you have an account? <button type="button" class="auth-link" onclick={() => { mode = 'register'; auth.clearError(); }}>Register here</button>.
|
Do you have an account? <button type="button" class="auth-link" onclick={() => { mode = 'register'; auth.clearError(); }}>Register here</button>.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('Navbar', () => {
|
|||||||
expect(screen.getByRole('menu')).toBeInTheDocument()
|
expect(screen.getByRole('menu')).toBeInTheDocument()
|
||||||
expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument()
|
expect(screen.getByRole('menuitem', { name: 'Settings' })).toBeInTheDocument()
|
||||||
expect(screen.getByRole('menuitem', { name: 'Logout' })).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 () => {
|
it('goSettings: Settings link calls push and closes menu', async () => {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
+1
-517
@@ -1,519 +1,3 @@
|
|||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../supabase.js';
|
||||||
|
|
||||||
export async function fetchMyDecks(userId) {
|
export * from './decks-core.js';
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|||||||
+141
-28
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { supabase } from '../supabase.js'
|
||||||
import * as decksApi from './decks.js'
|
import * as decksApi from './decks.js'
|
||||||
|
|
||||||
const nextResults = []
|
const nextResults = []
|
||||||
@@ -53,7 +54,7 @@ describe('decks API', () => {
|
|||||||
describe('fetchMyDecks', () => {
|
describe('fetchMyDecks', () => {
|
||||||
it('returns empty array when no decks', async () => {
|
it('returns empty array when no decks', async () => {
|
||||||
nextResults.push({ data: [], error: null })
|
nextResults.push({ data: [], error: null })
|
||||||
const result = await decksApi.fetchMyDecks('user1')
|
const result = await decksApi.fetchMyDecks(supabase, 'user1')
|
||||||
expect(result).toEqual([])
|
expect(result).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -63,7 +64,7 @@ describe('decks API', () => {
|
|||||||
]
|
]
|
||||||
nextResults.push({ data: decks, error: null }, { data: [], error: null }, { data: [], error: null })
|
nextResults.push({ data: decks, error: null }, { data: [], error: null }, { data: [], error: null })
|
||||||
countResults.push({ count: 3, 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).toHaveLength(1)
|
||||||
expect(result[0].question_count).toBe(3)
|
expect(result[0].question_count).toBe(3)
|
||||||
expect(result[0].title).toBe('Deck 1')
|
expect(result[0].title).toBe('Deck 1')
|
||||||
@@ -73,7 +74,7 @@ describe('decks API', () => {
|
|||||||
describe('fetchPublishedDecks', () => {
|
describe('fetchPublishedDecks', () => {
|
||||||
it('returns empty array when no published decks', async () => {
|
it('returns empty array when no published decks', async () => {
|
||||||
nextResults.push({ data: [], error: null })
|
nextResults.push({ data: [], error: null })
|
||||||
const result = await decksApi.fetchPublishedDecks()
|
const result = await decksApi.fetchPublishedDecks(supabase)
|
||||||
expect(result).toEqual([])
|
expect(result).toEqual([])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -83,91 +84,91 @@ describe('decks API', () => {
|
|||||||
const deck = { id: 'd1', title: 'Deck', version: 1 }
|
const deck = { id: 'd1', title: 'Deck', version: 1 }
|
||||||
const questions = [{ id: 'q1', prompt: 'Q?', sort_order: 0 }]
|
const questions = [{ id: 'q1', prompt: 'Q?', sort_order: 0 }]
|
||||||
nextResults.push({ data: deck, error: null }, { data: questions, error: null })
|
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).toMatchObject({ ...deck, questions })
|
||||||
expect(result.questions).toHaveLength(1)
|
expect(result.questions).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when deck not found', async () => {
|
it('throws when deck not found', async () => {
|
||||||
nextResults.push({ data: null, error: new Error('Not found') })
|
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', () => {
|
describe('userHasDeck', () => {
|
||||||
it('returns false when userId is null', async () => {
|
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 () => {
|
it('returns true when user has deck', async () => {
|
||||||
nextResults.push({ data: [{ id: 'd1' }], error: null })
|
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 () => {
|
it('returns false when no match', async () => {
|
||||||
nextResults.push({ data: [], error: null })
|
nextResults.push({ data: [], error: null })
|
||||||
expect(await decksApi.userHasDeck('d1', 'user1')).toBe(false)
|
expect(await decksApi.userHasDeck(supabase, 'd1', 'user1')).toBe(false)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('togglePublished', () => {
|
describe('togglePublished', () => {
|
||||||
it('calls update and throws on error', async () => {
|
it('calls update and throws on error', async () => {
|
||||||
nextResults.push({ error: new Error('DB error') })
|
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 () => {
|
it('succeeds when no error', async () => {
|
||||||
nextResults.push({ error: null })
|
nextResults.push({ error: null })
|
||||||
await expect(decksApi.togglePublished('d1', false)).resolves.toBeUndefined()
|
await expect(decksApi.togglePublished(supabase, 'd1', false)).resolves.toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('deleteDeck', () => {
|
describe('deleteDeck', () => {
|
||||||
it('throws on error', async () => {
|
it('throws on error', async () => {
|
||||||
nextResults.push({ error: new Error('FK violation') })
|
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 () => {
|
it('succeeds when no error', async () => {
|
||||||
nextResults.push({ error: null })
|
nextResults.push({ error: null })
|
||||||
await expect(decksApi.deleteDeck('d1')).resolves.toBeUndefined()
|
await expect(decksApi.deleteDeck(supabase, 'd1')).resolves.toBeUndefined()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('getMyDeckRating', () => {
|
describe('getMyDeckRating', () => {
|
||||||
it('returns null when userId is null', async () => {
|
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 () => {
|
it('returns rating and comment when present', async () => {
|
||||||
nextResults.push({ data: { rating: 4, comment: 'Great!' }, error: null })
|
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!' })
|
expect(result).toEqual({ rating: 4, comment: 'Great!' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when no rating', async () => {
|
it('returns null when no rating', async () => {
|
||||||
nextResults.push({ data: null, error: null })
|
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', () => {
|
describe('submitDeckRating', () => {
|
||||||
it('clamps rating to 1-5 and trims comment', async () => {
|
it('clamps rating to 1-5 and trims comment', async () => {
|
||||||
nextResults.push({ error: null })
|
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)
|
expect(nextResults.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws on error', async () => {
|
it('throws on error', async () => {
|
||||||
nextResults.push({ error: new Error('Unique violation') })
|
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', () => {
|
describe('getDeckReviews', () => {
|
||||||
it('returns empty array when no ratings', async () => {
|
it('returns empty array when no ratings', async () => {
|
||||||
nextResults.push({ data: [], error: null })
|
nextResults.push({ data: [], error: null })
|
||||||
const result = await decksApi.getDeckReviews('d1')
|
const result = await decksApi.getDeckReviews(supabase, 'd1')
|
||||||
expect(result).toEqual([])
|
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: [{ user_id: 'u1', rating: 5, comment: 'Nice', created_at: '2025-01-01' }], error: null },
|
||||||
{ data: [{ id: 'u1', display_name: 'Alice', email: '[email protected]', avatar_url: null }], error: null }
|
{ data: [{ id: 'u1', display_name: 'Alice', email: '[email protected]', avatar_url: null }], error: null }
|
||||||
)
|
)
|
||||||
const result = await decksApi.getDeckReviews('d1')
|
const result = await decksApi.getDeckReviews(supabase, 'd1')
|
||||||
expect(result).toHaveLength(1)
|
expect(result).toHaveLength(1)
|
||||||
expect(result[0]).toMatchObject({ rating: 5, comment: 'Nice', display_name: 'Alice', email: '[email protected]' })
|
expect(result[0]).toMatchObject({ rating: 5, comment: 'Nice', display_name: 'Alice', email: '[email protected]' })
|
||||||
})
|
})
|
||||||
@@ -185,13 +186,13 @@ describe('decks API', () => {
|
|||||||
describe('createDeck', () => {
|
describe('createDeck', () => {
|
||||||
it('creates deck and returns id', async () => {
|
it('creates deck and returns id', async () => {
|
||||||
nextResults.push({ data: { id: 'new-id' }, error: null })
|
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')
|
expect(id).toBe('new-id')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when insert fails', async () => {
|
it('throws when insert fails', async () => {
|
||||||
nextResults.push({ data: null, error: new Error('Failed') })
|
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 },
|
||||||
{ 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)
|
expect(nextResults.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -212,7 +213,7 @@ describe('decks API', () => {
|
|||||||
{ error: null },
|
{ error: null },
|
||||||
{ 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)
|
expect(nextResults.length).toBe(0)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -223,7 +224,7 @@ describe('decks API', () => {
|
|||||||
{ data: { id: 's1', title: 'S', published: false, questions: [] }, error: null },
|
{ data: { id: 's1', title: 'S', published: false, questions: [] }, error: null },
|
||||||
{ data: [], 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 () => {
|
it('creates copy and returns new deck id', async () => {
|
||||||
@@ -232,7 +233,7 @@ describe('decks API', () => {
|
|||||||
{ data: [], error: null },
|
{ data: [], error: null },
|
||||||
{ data: { id: 'new-id' }, 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')
|
expect(id).toBe('new-id')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -240,7 +241,7 @@ describe('decks API', () => {
|
|||||||
describe('getSourceUpdatePreview', () => {
|
describe('getSourceUpdatePreview', () => {
|
||||||
it('throws when deck not found or not a copy', async () => {
|
it('throws when deck not found or not a copy', async () => {
|
||||||
nextResults.push({ data: null, error: new Error('Not found') })
|
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 () => {
|
it('returns source, copy and changes', async () => {
|
||||||
@@ -251,7 +252,7 @@ describe('decks API', () => {
|
|||||||
{ data: sourceDeck, error: null },
|
{ data: sourceDeck, error: null },
|
||||||
{ data: [{ prompt: 'Q' }], 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.source).toBeDefined()
|
||||||
expect(result.copy).toBeDefined()
|
expect(result.copy).toBeDefined()
|
||||||
expect(result.changes).toEqual(expect.any(Array))
|
expect(result.changes).toEqual(expect.any(Array))
|
||||||
@@ -261,7 +262,119 @@ describe('decks API', () => {
|
|||||||
describe('applySourceUpdate', () => {
|
describe('applySourceUpdate', () => {
|
||||||
it('throws when deck not a copy', async () => {
|
it('throws when deck not a copy', async () => {
|
||||||
nextResults.push({ data: null, error: new Error('Not found') })
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('Deck assignment and visibility (integration)', () => {
|
||||||
|
it('fetchMyDecks returns only decks owned by the given user', async () => {
|
||||||
|
const myDecks = [
|
||||||
|
{ id: 'd1', title: 'My Deck', copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-01' },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: myDecks, error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 1, error: null })
|
||||||
|
const result = await decksApi.fetchMyDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].id).toBe('d1')
|
||||||
|
expect(result[0].title).toBe('My Deck')
|
||||||
|
// Supabase mock was queried with owner_id filter; only owned decks in result
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith('decks')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchMyDecks includes both published and unpublished owned decks', async () => {
|
||||||
|
const myDecks = [
|
||||||
|
{ id: 'd1', title: 'Published', published: true, copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-01' },
|
||||||
|
{ id: 'd2', title: 'Unpublished', published: false, copied_from_deck_id: null, copied_from_version: null, updated_at: '2025-01-02' },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: myDecks, error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 2, error: null }, { count: 1, error: null })
|
||||||
|
const result = await decksApi.fetchMyDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(2)
|
||||||
|
expect(result.map((d) => d.title)).toEqual(['Published', 'Unpublished'])
|
||||||
|
expect(result[0].published).toBe(true)
|
||||||
|
expect(result[1].published).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks returns only published decks with user_has_this when user owns deck', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Public Deck', owner_id: 'user1', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'user1', display_name: 'Me', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 5, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].published).toBe(true)
|
||||||
|
expect(result[0].user_has_this).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks sets user_has_this true when user has a copy (In My decks)', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Other User Deck', owner_id: 'otherUser', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'otherUser', display_name: 'Other', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [{ copied_from_deck_id: 'pub1' }], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 3, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].owner_id).toBe('otherUser')
|
||||||
|
expect(result[0].user_has_this).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks sets user_has_this false when user does not own and has no copy', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Stranger Deck', owner_id: 'stranger', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'stranger', display_name: 'Stranger', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 2, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase, 'user1')
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].user_has_this).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('fetchPublishedDecks without userId returns user_has_this false for all', async () => {
|
||||||
|
const publishedDecks = [
|
||||||
|
{ id: 'pub1', title: 'Any Deck', owner_id: 'owner1', published: true },
|
||||||
|
]
|
||||||
|
nextResults.push(
|
||||||
|
{ data: publishedDecks, error: null },
|
||||||
|
{ data: [{ id: 'owner1', display_name: 'Owner', email: '[email protected]' }], error: null },
|
||||||
|
{ data: [], error: null }
|
||||||
|
)
|
||||||
|
countResults.push({ count: 1, error: null })
|
||||||
|
const result = await decksApi.fetchPublishedDecks(supabase)
|
||||||
|
expect(result).toHaveLength(1)
|
||||||
|
expect(result[0].user_has_this).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('togglePublished updates published flag so deck can appear or disappear from community', async () => {
|
||||||
|
nextResults.push({ error: null })
|
||||||
|
await decksApi.togglePublished(supabase, 'd1', true)
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith('decks')
|
||||||
|
nextResults.push({ error: null })
|
||||||
|
await decksApi.togglePublished(supabase, 'd1', false)
|
||||||
|
expect(supabase.from).toHaveBeenCalledWith('decks')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { supabase } from '../supabase.js';
|
import { supabase } from '../supabase.js';
|
||||||
|
|
||||||
/** Resolve email for login when user enters a username (display_name). Returns null if not found. */
|
/** 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();
|
const trimmed = (username ?? '').trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
const { data, error } = await supabase
|
const { data, error } = await client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('email')
|
.select('email')
|
||||||
.ilike('display_name', trimmed)
|
.ilike('display_name', trimmed)
|
||||||
@@ -13,8 +13,8 @@ export async function getEmailForUsername(username) {
|
|||||||
return data[0].email;
|
return data[0].email;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProfile(userId) {
|
export async function getProfile(client, userId) {
|
||||||
const { data, error } = await supabase
|
const { data, error } = await client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.select('id, display_name, email, avatar_url')
|
.select('id, display_name, email, avatar_url')
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
@@ -23,13 +23,13 @@ export async function getProfile(userId) {
|
|||||||
return data ?? null;
|
return data ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateProfile(userId, { display_name, avatar_url }) {
|
export async function updateProfile(client, userId, { display_name, avatar_url }) {
|
||||||
const updates = {};
|
const updates = {};
|
||||||
if (display_name !== undefined) updates.display_name = display_name?.trim() || null;
|
if (display_name !== undefined) updates.display_name = display_name?.trim() || null;
|
||||||
if (avatar_url !== undefined) updates.avatar_url = avatar_url || null;
|
if (avatar_url !== undefined) updates.avatar_url = avatar_url || null;
|
||||||
updates.updated_at = new Date().toISOString();
|
updates.updated_at = new Date().toISOString();
|
||||||
|
|
||||||
const { data, error } = await supabase
|
const { data, error } = await client
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update(updates)
|
.update(updates)
|
||||||
.eq('id', userId)
|
.eq('id', userId)
|
||||||
|
|||||||
+14
-13
@@ -1,4 +1,5 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||||
|
import { supabase } from '../supabase.js'
|
||||||
import * as profileApi from './profile.js'
|
import * as profileApi from './profile.js'
|
||||||
|
|
||||||
const nextResults = []
|
const nextResults = []
|
||||||
@@ -38,24 +39,24 @@ describe('profile API', () => {
|
|||||||
|
|
||||||
describe('getEmailForUsername', () => {
|
describe('getEmailForUsername', () => {
|
||||||
it('returns null for empty or whitespace username', async () => {
|
it('returns null for empty or whitespace username', async () => {
|
||||||
expect(await profileApi.getEmailForUsername('')).toBe(null)
|
expect(await profileApi.getEmailForUsername(supabase, '')).toBe(null)
|
||||||
expect(await profileApi.getEmailForUsername(' ')).toBe(null)
|
expect(await profileApi.getEmailForUsername(supabase, ' ')).toBe(null)
|
||||||
expect(await profileApi.getEmailForUsername(null)).toBe(null)
|
expect(await profileApi.getEmailForUsername(supabase, null)).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns email when profile found', async () => {
|
it('returns email when profile found', async () => {
|
||||||
nextResults.push({ data: [{ email: '[email protected]' }], error: null })
|
nextResults.push({ data: [{ email: '[email protected]' }], error: null })
|
||||||
const email = await profileApi.getEmailForUsername('johndoe')
|
const email = await profileApi.getEmailForUsername(supabase, 'johndoe')
|
||||||
expect(email).toBe('[email protected]')
|
expect(email).toBe('[email protected]')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when error or no data', async () => {
|
it('returns null when error or no data', async () => {
|
||||||
nextResults.push({ data: [], error: null })
|
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' } })
|
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 })
|
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 () => {
|
it('returns profile when found', async () => {
|
||||||
const p = { id: 'u1', display_name: 'Alice', email: '[email protected]', avatar_url: null }
|
const p = { id: 'u1', display_name: 'Alice', email: '[email protected]', avatar_url: null }
|
||||||
nextResults.push({ data: p, error: null })
|
nextResults.push({ data: p, error: null })
|
||||||
const result = await profileApi.getProfile('u1')
|
const result = await profileApi.getProfile(supabase, 'u1')
|
||||||
expect(result).toEqual(p)
|
expect(result).toEqual(p)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns null when not found (PGRST116)', async () => {
|
it('returns null when not found (PGRST116)', async () => {
|
||||||
nextResults.push({ data: null, error: { code: 'PGRST116' } })
|
nextResults.push({ data: null, error: { code: 'PGRST116' } })
|
||||||
const result = await profileApi.getProfile('u1')
|
const result = await profileApi.getProfile(supabase, 'u1')
|
||||||
expect(result).toBe(null)
|
expect(result).toBe(null)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws when other error', async () => {
|
it('throws when other error', async () => {
|
||||||
nextResults.push({ data: null, error: new Error('Network error') })
|
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 () => {
|
it('updates display_name and returns data', async () => {
|
||||||
const updated = { id: 'u1', display_name: 'New Name', email: '[email protected]', avatar_url: null }
|
const updated = { id: 'u1', display_name: 'New Name', email: '[email protected]', avatar_url: null }
|
||||||
nextResults.push({ data: updated, error: 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)
|
expect(result).toEqual(updated)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('trims display_name and allows avatar_url', async () => {
|
it('trims display_name and allows avatar_url', async () => {
|
||||||
nextResults.push({ data: { id: 'u1' }, error: null })
|
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)
|
expect(nextResults.length).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('throws on error', async () => {
|
it('throws on error', async () => {
|
||||||
nextResults.push({ data: null, error: new Error('DB error') })
|
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')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ function createAuthStore() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (!email.includes('@')) {
|
if (!email.includes('@')) {
|
||||||
const resolved = await getEmailForUsername(email);
|
const resolved = await getEmailForUsername(supabase, email);
|
||||||
if (!resolved) {
|
if (!resolved) {
|
||||||
update((s) => ({ ...s, error: 'No account with that username.' }));
|
update((s) => ({ ...s, error: 'No account with that username.' }));
|
||||||
return false;
|
return false;
|
||||||
@@ -79,7 +79,7 @@ function createAuthStore() {
|
|||||||
update((s) => ({ ...s, error: null }));
|
update((s) => ({ ...s, error: null }));
|
||||||
if (data?.user?.id && data?.session && (displayName || '').trim()) {
|
if (data?.user?.id && data?.session && (displayName || '').trim()) {
|
||||||
try {
|
try {
|
||||||
await updateProfile(data.user.id, { display_name: (displayName || '').trim() });
|
await updateProfile(supabase, data.user.id, { display_name: (displayName || '').trim() });
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
const needsConfirmation = data?.user && !data?.session;
|
const needsConfirmation = data?.user && !data?.session;
|
||||||
@@ -90,8 +90,8 @@ function createAuthStore() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
logout: async () => {
|
logout: async () => {
|
||||||
await supabase.auth.signOut();
|
|
||||||
set({ user: null, loading: false, error: null });
|
set({ user: null, loading: false, error: null });
|
||||||
|
await supabase.auth.signOut();
|
||||||
},
|
},
|
||||||
clearError: () => update((s) => ({ ...s, error: null })),
|
clearError: () => update((s) => ({ ...s, error: null })),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('auth store', () => {
|
|||||||
mockGetEmailForUsername.mockResolvedValue('[email protected]')
|
mockGetEmailForUsername.mockResolvedValue('[email protected]')
|
||||||
mockSignInWithPassword.mockResolvedValue({ error: null })
|
mockSignInWithPassword.mockResolvedValue({ error: null })
|
||||||
const result = await auth.login('username', 'pass')
|
const result = await auth.login('username', 'pass')
|
||||||
expect(mockGetEmailForUsername).toHaveBeenCalledWith('username')
|
expect(mockGetEmailForUsername).toHaveBeenCalledWith(expect.anything(), 'username')
|
||||||
expect(mockSignInWithPassword).toHaveBeenCalledWith({
|
expect(mockSignInWithPassword).toHaveBeenCalledWith({
|
||||||
email: '[email protected]',
|
email: '[email protected]',
|
||||||
password: 'pass',
|
password: 'pass',
|
||||||
@@ -112,7 +112,7 @@ describe('auth store', () => {
|
|||||||
mockSignUp.mockResolvedValue({ data: { user, session: {} }, error: null })
|
mockSignUp.mockResolvedValue({ data: { user, session: {} }, error: null })
|
||||||
mockUpdateProfile.mockResolvedValue(undefined)
|
mockUpdateProfile.mockResolvedValue(undefined)
|
||||||
await auth.register('[email protected]', 'pass', 'Alice')
|
await auth.register('[email protected]', '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 () => {
|
it('register with signUp error returns success false', async () => {
|
||||||
|
|||||||
+9
-5
@@ -1,9 +1,13 @@
|
|||||||
import { mount } from 'svelte'
|
|
||||||
import './app.css'
|
import './app.css'
|
||||||
import App from './App.svelte'
|
|
||||||
|
|
||||||
const app = mount(App, {
|
// Only load and mount the app in a real browser (document + mount available).
|
||||||
target: document.getElementById('app'),
|
// Avoids "mount() is not available on the server" when the bundle runs in SSR/workers.
|
||||||
})
|
let app = null
|
||||||
|
const target = typeof document !== 'undefined' && document.getElementById('app')
|
||||||
|
if (target) {
|
||||||
|
const { mount } = await import('svelte')
|
||||||
|
const { default: App } = await import('./App.svelte')
|
||||||
|
app = mount(App, { target })
|
||||||
|
}
|
||||||
|
|
||||||
export default app
|
export default app
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
import { navContext } from '../lib/stores/navContext.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 { fetchPublishedDecks, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
|
||||||
import RatingModal from '../lib/RatingModal.svelte';
|
import RatingModal from '../lib/RatingModal.svelte';
|
||||||
|
|
||||||
@@ -88,7 +89,7 @@
|
|||||||
useError = null;
|
useError = null;
|
||||||
ratingDeck = { id: deck.id, title: deck.title };
|
ratingDeck = { id: deck.id, title: deck.title };
|
||||||
try {
|
try {
|
||||||
const data = await getMyDeckRating(deck.id, userId);
|
const data = await getMyDeckRating(supabase, deck.id, userId);
|
||||||
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
ratingInitial = { rating: 0, comment: '' };
|
ratingInitial = { rating: 0, comment: '' };
|
||||||
@@ -102,7 +103,7 @@
|
|||||||
async function handleRatingSubmit(payload) {
|
async function handleRatingSubmit(payload) {
|
||||||
if (!ratingDeck || !userId) return;
|
if (!ratingDeck || !userId) return;
|
||||||
try {
|
try {
|
||||||
await submitDeckRating(ratingDeck.id, userId, payload);
|
await submitDeckRating(supabase, ratingDeck.id, userId, payload);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useError = e?.message ?? 'Failed to save rating';
|
useError = e?.message ?? 'Failed to save rating';
|
||||||
@@ -118,7 +119,7 @@
|
|||||||
if (addingDeckId) return;
|
if (addingDeckId) return;
|
||||||
addingDeckId = deckId;
|
addingDeckId = deckId;
|
||||||
try {
|
try {
|
||||||
await copyDeckToUser(deckId, userId);
|
await copyDeckToUser(supabase, deckId, userId);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useError = e?.message ?? 'Failed to add deck';
|
useError = e?.message ?? 'Failed to add deck';
|
||||||
@@ -131,7 +132,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
decks = await fetchPublishedDecks(userId ?? undefined);
|
decks = await fetchPublishedDecks(supabase, userId ?? undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e?.message ?? 'Failed to load community decks';
|
error = e?.message ?? 'Failed to load community decks';
|
||||||
decks = [];
|
decks = [];
|
||||||
@@ -249,8 +250,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 1200px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ describe('Community', () => {
|
|||||||
render(Community)
|
render(Community)
|
||||||
await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByRole('button', { name: 'Add' })).toBeInTheDocument())
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Add' }))
|
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())
|
await waitFor(() => expect(screen.getByText('In My decks')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
import { navContext } from '../lib/stores/navContext.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 { fetchPublishedDecksByOwner, copyDeckToUser, getMyDeckRating, submitDeckRating } from '../lib/api/decks.js';
|
||||||
import RatingModal from '../lib/RatingModal.svelte';
|
import RatingModal from '../lib/RatingModal.svelte';
|
||||||
|
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
useError = null;
|
useError = null;
|
||||||
ratingDeck = { id: deck.id, title: deck.title };
|
ratingDeck = { id: deck.id, title: deck.title };
|
||||||
try {
|
try {
|
||||||
const data = await getMyDeckRating(deck.id, userId);
|
const data = await getMyDeckRating(supabase, deck.id, userId);
|
||||||
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
ratingInitial = { rating: 0, comment: '' };
|
ratingInitial = { rating: 0, comment: '' };
|
||||||
@@ -60,7 +61,7 @@
|
|||||||
async function handleRatingSubmit(payload) {
|
async function handleRatingSubmit(payload) {
|
||||||
if (!ratingDeck || !userId) return;
|
if (!ratingDeck || !userId) return;
|
||||||
try {
|
try {
|
||||||
await submitDeckRating(ratingDeck.id, userId, payload);
|
await submitDeckRating(supabase, ratingDeck.id, userId, payload);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useError = e?.message ?? 'Failed to save rating';
|
useError = e?.message ?? 'Failed to save rating';
|
||||||
@@ -76,7 +77,7 @@
|
|||||||
if (addingDeckId) return;
|
if (addingDeckId) return;
|
||||||
addingDeckId = deckId;
|
addingDeckId = deckId;
|
||||||
try {
|
try {
|
||||||
await copyDeckToUser(deckId, userId);
|
await copyDeckToUser(supabase, deckId, userId);
|
||||||
await load();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
useError = e?.message ?? 'Failed to add deck';
|
useError = e?.message ?? 'Failed to add deck';
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const result = await fetchPublishedDecksByOwner(ownerId, userId ?? undefined);
|
const result = await fetchPublishedDecksByOwner(supabase, ownerId, userId ?? undefined);
|
||||||
decks = result.decks ?? [];
|
decks = result.decks ?? [];
|
||||||
ownerName = result.owner_display_name ?? result.owner_email ?? 'User';
|
ownerName = result.owner_display_name ?? result.owner_email ?? 'User';
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -186,8 +187,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 1200px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ describe('CommunityUser', () => {
|
|||||||
render(CommunityUser, { props: { params: { id: 'owner1' } } })
|
render(CommunityUser, { props: { params: { id: 'owner1' } } })
|
||||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||||
await waitFor(() => expect(screen.getByText(/Decks by Test User/)).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 () => {
|
it('shows error when fetch fails', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
import { navContext } from '../lib/stores/navContext.js';
|
import { navContext } from '../lib/stores/navContext.js';
|
||||||
|
import { supabase } from '../lib/supabase.js';
|
||||||
import { createDeck } from '../lib/api/decks.js';
|
import { createDeck } from '../lib/api/decks.js';
|
||||||
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
|
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
|
||||||
import QuestionEditor from '../lib/QuestionEditor.svelte';
|
import QuestionEditor from '../lib/QuestionEditor.svelte';
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const id = await createDeck(userId, {
|
const id = await createDeck(supabase, userId, {
|
||||||
title: t,
|
title: t,
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
config,
|
config,
|
||||||
@@ -129,19 +130,44 @@
|
|||||||
function cancel() {
|
function cancel() {
|
||||||
push('/');
|
push('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let copied = false;
|
||||||
|
function copyJson() {
|
||||||
|
const payload = {
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
config,
|
||||||
|
questions: questions.map((q) => ({
|
||||||
|
prompt: q.prompt,
|
||||||
|
explanation: q.explanation,
|
||||||
|
answers: q.answers,
|
||||||
|
correct_answer_indices: q.correct_answer_indices,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
navigator.clipboard.writeText(JSON.stringify(payload, null, 2)).then(() => {
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => { copied = false; }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="page">
|
<header class="page-header page-header-fixed">
|
||||||
<header class="page-header">
|
<div class="page-header-inner">
|
||||||
<h1>Create deck</h1>
|
<h1 class="page-header-title">Create deck</h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
|
{#if view === 'loading'}
|
||||||
|
<span class="header-spinner" aria-hidden="true"></span>
|
||||||
|
{:else}
|
||||||
<button type="button" class="btn btn-secondary" onclick={cancel} disabled={saving}>Cancel</button>
|
<button type="button" class="btn btn-secondary" onclick={cancel} disabled={saving}>Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}>
|
<button type="button" class="btn btn-primary" onclick={save} disabled={saving}>
|
||||||
{saving ? 'Saving…' : 'Save deck'}
|
{saving ? 'Saving…' : 'Save deck'}
|
||||||
</button>
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<div class="page page-with-fixed-header">
|
||||||
{#if view === 'loading'}
|
{#if view === 'loading'}
|
||||||
<p class="auth-required">Loading…</p>
|
<p class="auth-required">Loading…</p>
|
||||||
{:else if view === 'signin'}
|
{:else if view === 'signin'}
|
||||||
@@ -159,7 +185,10 @@
|
|||||||
placeholder="Paste deck JSON (title, description, config, questions)"
|
placeholder="Paste deck JSON (title, description, config, questions)"
|
||||||
rows="8"
|
rows="8"
|
||||||
></textarea>
|
></textarea>
|
||||||
|
<div class="json-actions">
|
||||||
<button type="button" class="btn btn-secondary" onclick={loadFromJson}>Load from JSON</button>
|
<button type="button" class="btn btn-secondary" onclick={loadFromJson}>Load from JSON</button>
|
||||||
|
<button type="button" class="btn btn-secondary" onclick={copyJson}>{copied ? 'Copied!' : 'Copy JSON'}</button>
|
||||||
|
</div>
|
||||||
{#if jsonError}
|
{#if jsonError}
|
||||||
<p class="error">{jsonError}</p>
|
<p class="error">{jsonError}</p>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -195,16 +224,33 @@
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-with-fixed-header {
|
||||||
|
padding-top: calc(var(--navbar-height, 60px) + 3.5rem);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-fixed {
|
||||||
|
position: fixed;
|
||||||
|
top: var(--navbar-height, 60px);
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 50;
|
||||||
|
background: var(--bg, #0f0f0f);
|
||||||
|
border-bottom: 1px solid var(--border, #333);
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header-inner {
|
||||||
|
max-width: 700px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-bottom: 1.5rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header h1 {
|
.page-header-title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -214,6 +260,23 @@
|
|||||||
.header-actions {
|
.header-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 1.25rem;
|
||||||
|
height: 1.25rem;
|
||||||
|
border: 2px solid var(--border, #333);
|
||||||
|
border-top-color: var(--text-primary, #f0f0f0);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: header-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes header-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.auth-required {
|
.auth-required {
|
||||||
@@ -250,6 +313,12 @@
|
|||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.json-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ describe('CreateDeck', () => {
|
|||||||
await fireEvent.input(screen.getByPlaceholderText('Answer 1'), { target: { value: 'Yes' } })
|
await fireEvent.input(screen.getByPlaceholderText('Answer 1'), { target: { value: 'Yes' } })
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Save deck' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'Save deck' }))
|
||||||
await waitFor(() => expect(mockCreateDeck).toHaveBeenCalled())
|
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('/')
|
expect(mockPush).toHaveBeenCalledWith('/')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
import { navContext } from '../lib/stores/navContext.js';
|
import { navContext } from '../lib/stores/navContext.js';
|
||||||
|
import { supabase } from '../lib/supabase.js';
|
||||||
import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js';
|
import { fetchDeckWithQuestions, copyDeckToUser, userHasDeck } from '../lib/api/decks.js';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
@@ -28,7 +29,7 @@
|
|||||||
addError = null;
|
addError = null;
|
||||||
userAlreadyHasDeck = false;
|
userAlreadyHasDeck = false;
|
||||||
try {
|
try {
|
||||||
deck = await fetchDeckWithQuestions(deckId);
|
deck = await fetchDeckWithQuestions(supabase, deckId);
|
||||||
const isMyCopy = !!(userId && deck.owner_id === userId && deck.copied_from_deck_id);
|
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 isOwnDeckLoad = !!(userId && deck.owner_id === userId && !deck.copied_from_deck_id);
|
||||||
const canView = deck.published || isMyCopy || isOwnDeckLoad;
|
const canView = deck.published || isMyCopy || isOwnDeckLoad;
|
||||||
@@ -40,7 +41,7 @@
|
|||||||
if (isMyCopy) {
|
if (isMyCopy) {
|
||||||
userAlreadyHasDeck = true;
|
userAlreadyHasDeck = true;
|
||||||
} else if (userId) {
|
} else if (userId) {
|
||||||
userAlreadyHasDeck = await userHasDeck(deckId, userId);
|
userAlreadyHasDeck = await userHasDeck(supabase, deckId, userId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -77,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
adding = true;
|
adding = true;
|
||||||
try {
|
try {
|
||||||
await copyDeckToUser(deckId, userId);
|
await copyDeckToUser(supabase, deckId, userId);
|
||||||
userAlreadyHasDeck = true;
|
userAlreadyHasDeck = true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
addError = e?.message ?? 'Failed to add deck';
|
addError = e?.message ?? 'Failed to add deck';
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ describe('DeckPreview', () => {
|
|||||||
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
render(DeckPreview, { props: { params: { id: 'd1' } } })
|
||||||
expect(screen.getByText('Loading deck…')).toBeInTheDocument()
|
expect(screen.getByText('Loading deck…')).toBeInTheDocument()
|
||||||
await waitFor(() => expect(screen.getByText('Preview 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 () => {
|
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 })
|
const addBtn = screen.getByRole('button', { name: /Add to My decks/i })
|
||||||
mockCopyDeckToUser.mockResolvedValue(undefined)
|
mockCopyDeckToUser.mockResolvedValue(undefined)
|
||||||
await fireEvent.click(addBtn)
|
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 () => {
|
it('addToMyDecks without userId shows sign in error', async () => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
|
import { supabase } from '../lib/supabase.js';
|
||||||
import { fetchDeckWithQuestions, getDeckReviews } from '../lib/api/decks.js';
|
import { fetchDeckWithQuestions, getDeckReviews } from '../lib/api/decks.js';
|
||||||
|
|
||||||
export let params = {};
|
export let params = {};
|
||||||
@@ -22,8 +23,8 @@
|
|||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const [deckData, reviewsData] = await Promise.all([
|
const [deckData, reviewsData] = await Promise.all([
|
||||||
fetchDeckWithQuestions(deckId),
|
fetchDeckWithQuestions(supabase, deckId),
|
||||||
getDeckReviews(deckId),
|
getDeckReviews(supabase, deckId),
|
||||||
]);
|
]);
|
||||||
if (!deckData.published) {
|
if (!deckData.published) {
|
||||||
error = 'This deck is not available.';
|
error = 'This deck is not available.';
|
||||||
|
|||||||
@@ -31,8 +31,8 @@ describe('DeckReviews', () => {
|
|||||||
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
expect(screen.getByText('Loading…')).toBeInTheDocument()
|
||||||
await waitFor(() => expect(screen.getByText('Test Deck')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('Test Deck')).toBeInTheDocument())
|
||||||
expect(screen.getByText('No reviews yet.')).toBeInTheDocument()
|
expect(screen.getByText('No reviews yet.')).toBeInTheDocument()
|
||||||
expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith('d1')
|
expect(mockFetchDeckWithQuestions).toHaveBeenCalledWith(expect.anything(), 'd1')
|
||||||
expect(mockGetDeckReviews).toHaveBeenCalledWith('d1')
|
expect(mockGetDeckReviews).toHaveBeenCalledWith(expect.anything(), 'd1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error when deck fetch fails', async () => {
|
it('shows error when deck fetch fails', async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
import { navContext } from '../lib/stores/navContext.js';
|
import { navContext } from '../lib/stores/navContext.js';
|
||||||
|
import { supabase } from '../lib/supabase.js';
|
||||||
import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js';
|
import { fetchDeckWithQuestions, updateDeck, togglePublished, deleteDeck } from '../lib/api/decks.js';
|
||||||
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
|
import { DEFAULT_DECK_CONFIG } from '../lib/deckConfig.js';
|
||||||
import QuestionEditor from '../lib/QuestionEditor.svelte';
|
import QuestionEditor from '../lib/QuestionEditor.svelte';
|
||||||
@@ -46,7 +47,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
const deck = await fetchDeckWithQuestions(deckId);
|
const deck = await fetchDeckWithQuestions(supabase, deckId);
|
||||||
if (deck.owner_id !== userId) {
|
if (deck.owner_id !== userId) {
|
||||||
push('/');
|
push('/');
|
||||||
return;
|
return;
|
||||||
@@ -112,7 +113,7 @@
|
|||||||
saving = true;
|
saving = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
await updateDeck(deckId, {
|
await updateDeck(supabase, deckId, {
|
||||||
title: t,
|
title: t,
|
||||||
description: description.trim(),
|
description: description.trim(),
|
||||||
config,
|
config,
|
||||||
@@ -129,7 +130,7 @@
|
|||||||
async function togglePublish() {
|
async function togglePublish() {
|
||||||
if (!deckId) return;
|
if (!deckId) return;
|
||||||
try {
|
try {
|
||||||
await togglePublished(deckId, !published);
|
await togglePublished(supabase, deckId, !published);
|
||||||
published = !published;
|
published = !published;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e?.message ?? 'Failed to update publish status';
|
error = e?.message ?? 'Failed to update publish status';
|
||||||
@@ -149,7 +150,7 @@
|
|||||||
if (!deckId) return;
|
if (!deckId) return;
|
||||||
closeDeleteConfirm();
|
closeDeleteConfirm();
|
||||||
try {
|
try {
|
||||||
await deleteDeck(deckId);
|
await deleteDeck(supabase, deckId);
|
||||||
push('/');
|
push('/');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e?.message ?? 'Failed to delete deck';
|
error = e?.message ?? 'Failed to delete deck';
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ describe('EditDeck', () => {
|
|||||||
render(EditDeck, { props: { params: { id: 'd1' } } })
|
render(EditDeck, { props: { params: { id: 'd1' } } })
|
||||||
await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByDisplayValue('My Deck')).toBeInTheDocument())
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() => expect(mockUpdateDeck).toHaveBeenCalledWith('d1', expect.any(Object)))
|
await waitFor(() => expect(mockUpdateDeck).toHaveBeenCalledWith(expect.anything(), 'd1', expect.any(Object)))
|
||||||
expect(mockPush).toHaveBeenCalledWith('/')
|
expect(mockPush).toHaveBeenCalledWith('/')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+10
-10
@@ -3,6 +3,7 @@
|
|||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
import { navContext } from '../lib/stores/navContext.js';
|
import { navContext } from '../lib/stores/navContext.js';
|
||||||
|
import { supabase } from '../lib/supabase.js';
|
||||||
import { fetchMyDecks, togglePublished, getMyDeckRating, submitDeckRating, getSourceUpdatePreview, applySourceUpdate, deleteDeck } from '../lib/api/decks.js';
|
import { fetchMyDecks, togglePublished, getMyDeckRating, submitDeckRating, getSourceUpdatePreview, applySourceUpdate, deleteDeck } from '../lib/api/decks.js';
|
||||||
import RatingModal from '../lib/RatingModal.svelte';
|
import RatingModal from '../lib/RatingModal.svelte';
|
||||||
import ConfirmModal from '../lib/ConfirmModal.svelte';
|
import ConfirmModal from '../lib/ConfirmModal.svelte';
|
||||||
@@ -38,7 +39,7 @@
|
|||||||
if (!deck.can_rate || !deck.rateable_deck_id || !userId) return;
|
if (!deck.can_rate || !deck.rateable_deck_id || !userId) return;
|
||||||
ratingDeck = { id: deck.rateable_deck_id, title: deck.source_deck_title ?? 'Deck' };
|
ratingDeck = { id: deck.rateable_deck_id, title: deck.source_deck_title ?? 'Deck' };
|
||||||
try {
|
try {
|
||||||
const data = await getMyDeckRating(deck.rateable_deck_id, userId);
|
const data = await getMyDeckRating(supabase, deck.rateable_deck_id, userId);
|
||||||
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
ratingInitial = { rating: data?.rating ?? 0, comment: data?.comment ?? '' };
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
ratingInitial = { rating: 0, comment: '' };
|
ratingInitial = { rating: 0, comment: '' };
|
||||||
@@ -52,7 +53,7 @@
|
|||||||
async function handleRatingSubmit(payload) {
|
async function handleRatingSubmit(payload) {
|
||||||
if (!ratingDeck || !userId) return;
|
if (!ratingDeck || !userId) return;
|
||||||
try {
|
try {
|
||||||
await submitDeckRating(ratingDeck.id, userId, payload);
|
await submitDeckRating(supabase, ratingDeck.id, userId, payload);
|
||||||
await load();
|
await load();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
@@ -71,7 +72,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
decks = await fetchMyDecks(userId);
|
decks = await fetchMyDecks(supabase, userId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error = e?.message ?? 'Failed to load decks';
|
error = e?.message ?? 'Failed to load decks';
|
||||||
decks = [];
|
decks = [];
|
||||||
@@ -95,7 +96,7 @@
|
|||||||
if (togglingId === deck.id) return;
|
if (togglingId === deck.id) return;
|
||||||
togglingId = deck.id;
|
togglingId = deck.id;
|
||||||
try {
|
try {
|
||||||
await togglePublished(deck.id, true);
|
await togglePublished(supabase, deck.id, true);
|
||||||
decks = decks.map((d) => (d.id === deck.id ? { ...d, published: true } : d));
|
decks = decks.map((d) => (d.id === deck.id ? { ...d, published: true } : d));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err?.message ?? 'Failed to update publish status';
|
error = err?.message ?? 'Failed to update publish status';
|
||||||
@@ -122,7 +123,7 @@
|
|||||||
closeUnpublishConfirm();
|
closeUnpublishConfirm();
|
||||||
togglingId = deck.id;
|
togglingId = deck.id;
|
||||||
try {
|
try {
|
||||||
await togglePublished(deck.id, false);
|
await togglePublished(supabase, deck.id, false);
|
||||||
decks = decks.map((d) => (d.id === deck.id ? { ...d, published: false } : d));
|
decks = decks.map((d) => (d.id === deck.id ? { ...d, published: false } : d));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err?.message ?? 'Failed to update publish status';
|
error = err?.message ?? 'Failed to update publish status';
|
||||||
@@ -141,7 +142,7 @@
|
|||||||
updateError = null;
|
updateError = null;
|
||||||
updateLoading = true;
|
updateLoading = true;
|
||||||
try {
|
try {
|
||||||
updatePreview = await getSourceUpdatePreview(deck.id, userId);
|
updatePreview = await getSourceUpdatePreview(supabase, deck.id, userId);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
updateError = err?.message ?? 'Failed to load update preview';
|
updateError = err?.message ?? 'Failed to load update preview';
|
||||||
} finally {
|
} finally {
|
||||||
@@ -162,7 +163,7 @@
|
|||||||
updateApplying = true;
|
updateApplying = true;
|
||||||
updateError = null;
|
updateError = null;
|
||||||
try {
|
try {
|
||||||
await applySourceUpdate(updateDeck.id, userId);
|
await applySourceUpdate(supabase, updateDeck.id, userId);
|
||||||
await load();
|
await load();
|
||||||
closeUpdateModal();
|
closeUpdateModal();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -190,7 +191,7 @@
|
|||||||
const deck = deckToRemove;
|
const deck = deckToRemove;
|
||||||
closeRemoveConfirm();
|
closeRemoveConfirm();
|
||||||
try {
|
try {
|
||||||
await deleteDeck(deck.id);
|
await deleteDeck(supabase, deck.id);
|
||||||
await load();
|
await load();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err?.message ?? 'Failed to remove deck';
|
error = err?.message ?? 'Failed to remove deck';
|
||||||
@@ -411,8 +412,7 @@
|
|||||||
<style>
|
<style>
|
||||||
.page {
|
.page {
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
max-width: 1200px;
|
width: 100%;
|
||||||
margin: 0 auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe('MyDecks', () => {
|
|||||||
render(MyDecks)
|
render(MyDecks)
|
||||||
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
expect(screen.getByText(/Loading/)).toBeInTheDocument()
|
||||||
await waitFor(() => expect(screen.getByText(/No decks yet/)).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText(/No decks yet/)).toBeInTheDocument())
|
||||||
expect(mockFetchMyDecks).toHaveBeenCalledWith('u1')
|
expect(mockFetchMyDecks).toHaveBeenCalledWith(expect.anything(), 'u1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('shows error when fetch fails', async () => {
|
it('shows error when fetch fails', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { push } from 'svelte-spa-router';
|
import { push } from 'svelte-spa-router';
|
||||||
import { auth } from '../lib/stores/auth.js';
|
import { auth } from '../lib/stores/auth.js';
|
||||||
|
import { supabase } from '../lib/supabase.js';
|
||||||
import { getProfile, updateProfile, uploadAvatar } from '../lib/api/profile.js';
|
import { getProfile, updateProfile, uploadAvatar } from '../lib/api/profile.js';
|
||||||
|
|
||||||
let profile = null;
|
let profile = null;
|
||||||
@@ -29,7 +30,7 @@
|
|||||||
loading = true;
|
loading = true;
|
||||||
error = null;
|
error = null;
|
||||||
try {
|
try {
|
||||||
profile = await getProfile(userId);
|
profile = await getProfile(supabase, userId);
|
||||||
displayName = profile?.display_name ?? '';
|
displayName = profile?.display_name ?? '';
|
||||||
avatarPreview = profile?.avatar_url ?? null;
|
avatarPreview = profile?.avatar_url ?? null;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -73,11 +74,11 @@
|
|||||||
} else if (avatarPreview === null && profile?.avatar_url) {
|
} else if (avatarPreview === null && profile?.avatar_url) {
|
||||||
avatarUrl = null;
|
avatarUrl = null;
|
||||||
}
|
}
|
||||||
await updateProfile(userId, {
|
await updateProfile(supabase, userId, {
|
||||||
display_name: displayName.trim() || null,
|
display_name: displayName.trim() || null,
|
||||||
avatar_url: avatarUrl,
|
avatar_url: avatarUrl,
|
||||||
});
|
});
|
||||||
profile = await getProfile(userId);
|
profile = await getProfile(supabase, userId);
|
||||||
displayName = profile?.display_name ?? '';
|
displayName = profile?.display_name ?? '';
|
||||||
avatarPreview = profile?.avatar_url ?? null;
|
avatarPreview = profile?.avatar_url ?? null;
|
||||||
avatarFile = null;
|
avatarFile = null;
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ describe('Settings', () => {
|
|||||||
it('loads profile and shows display name', async () => {
|
it('loads profile and shows display name', async () => {
|
||||||
render(Settings)
|
render(Settings)
|
||||||
await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByDisplayValue('Test User')).toBeInTheDocument())
|
||||||
expect(mockGetProfile).toHaveBeenCalledWith('u1')
|
expect(mockGetProfile).toHaveBeenCalledWith(expect.anything(), 'u1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('Cancel button calls push to home', async () => {
|
it('Cancel button calls push to home', async () => {
|
||||||
@@ -69,7 +69,7 @@ describe('Settings', () => {
|
|||||||
await fireEvent.input(nameInput, { target: { value: 'New Name' } })
|
await fireEvent.input(nameInput, { target: { value: 'New Name' } })
|
||||||
await fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
await fireEvent.click(screen.getByRole('button', { name: 'Save' }))
|
||||||
await waitFor(() =>
|
await waitFor(() =>
|
||||||
expect(mockUpdateProfile).toHaveBeenCalledWith('u1', { display_name: 'New Name', avatar_url: null })
|
expect(mockUpdateProfile).toHaveBeenCalledWith(expect.anything(), 'u1', { display_name: 'New Name', avatar_url: null })
|
||||||
)
|
)
|
||||||
await waitFor(() => expect(screen.getByText('Settings saved.')).toBeInTheDocument())
|
await waitFor(() => expect(screen.getByText('Settings saved.')).toBeInTheDocument())
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -5,8 +5,8 @@ import { svelte } from '@sveltejs/vite-plugin-svelte'
|
|||||||
export default defineConfig(({ mode }) => ({
|
export default defineConfig(({ mode }) => ({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
resolve: {
|
resolve: {
|
||||||
// Use browser Svelte build in tests so mount() is available (avoid "lifecycle_function_unavailable")
|
// Prefer browser Svelte build so mount() is available (avoid "lifecycle_function_unavailable" on server/workers)
|
||||||
conditions: mode === 'test' ? ['browser'] : [],
|
conditions: ['browser'],
|
||||||
},
|
},
|
||||||
test: {
|
test: {
|
||||||
include: ['src/**/*.{test,spec}.{js,ts}'],
|
include: ['src/**/*.{test,spec}.{js,ts}'],
|
||||||
|
|||||||
Reference in New Issue
Block a user