Compare commits
No commits in common. '84cb0ec0f65a3e7b2104c6ecea76e71b8456d7b6' and 'c8ff07b490ec1de0ca1ce04c04f11278d6fa2849' have entirely different histories.
84cb0ec0f6
...
c8ff07b490
File diff suppressed because it is too large
Load Diff
@ -1,364 +0,0 @@
|
||||
import 'dotenv/config';
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { createClient } from '@supabase/supabase-js';
|
||||
import * as decksApi from '../src/lib/api/decks-core.js';
|
||||
|
||||
const app = express();
|
||||
app.use(cors({ origin: true, credentials: true }));
|
||||
app.use(express.json());
|
||||
|
||||
const supabaseUrl = process.env.SUPABASE_URL || process.env.VITE_SUPABASE_URL;
|
||||
const supabaseAnonKey = process.env.SUPABASE_ANON_KEY || process.env.VITE_SUPABASE_ANON_KEY;
|
||||
if (!supabaseUrl || !supabaseAnonKey) {
|
||||
console.warn('Missing SUPABASE_URL or SUPABASE_ANON_KEY (or VITE_*). API may fail.');
|
||||
}
|
||||
|
||||
function supabaseClient(accessToken = null) {
|
||||
const options = { db: { schema: 'omotomo' } };
|
||||
if (accessToken) {
|
||||
options.global = { headers: { Authorization: `Bearer ${accessToken}` } };
|
||||
}
|
||||
return createClient(supabaseUrl ?? '', supabaseAnonKey ?? '', options);
|
||||
}
|
||||
|
||||
const anonSupabase = () => supabaseClient();
|
||||
|
||||
async function resolveEmailFromUsername(username) {
|
||||
const trimmed = (username ?? '').trim();
|
||||
if (!trimmed) return null;
|
||||
const { data, error } = await anonSupabase()
|
||||
.from('profiles')
|
||||
.select('email')
|
||||
.ilike('display_name', trimmed)
|
||||
.limit(1);
|
||||
if (error || !data?.length || !data[0]?.email) return null;
|
||||
return data[0].email;
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
if (!token) {
|
||||
res.status(401).json({ error: 'Missing or invalid Authorization header' });
|
||||
return;
|
||||
}
|
||||
const supabase = supabaseClient(token);
|
||||
supabase.auth.getUser(token).then(({ data: { user }, error }) => {
|
||||
if (error || !user) {
|
||||
res.status(401).json({ error: 'Invalid or expired token' });
|
||||
return;
|
||||
}
|
||||
req.supabase = supabase;
|
||||
req.userId = user.id;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
function optionalAuth(req, res, next) {
|
||||
const authHeader = req.headers.authorization;
|
||||
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
||||
if (!token) {
|
||||
req.supabase = anonSupabase();
|
||||
req.userId = null;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
const supabase = supabaseClient(token);
|
||||
supabase.auth.getUser(token).then(({ data: { user }, error }) => {
|
||||
req.supabase = supabase;
|
||||
req.userId = error || !user ? null : user.id;
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.post('/api/auth/login', express.json(), async (req, res) => {
|
||||
try {
|
||||
const { email_or_username, password } = req.body || {};
|
||||
if (!email_or_username || !password) {
|
||||
res.status(400).json({ error: 'email_or_username and password required' });
|
||||
return;
|
||||
}
|
||||
let email = String(email_or_username).trim();
|
||||
if (!email.includes('@')) {
|
||||
const resolved = await resolveEmailFromUsername(email);
|
||||
if (!resolved) {
|
||||
res.status(401).json({ error: 'No account with that username' });
|
||||
return;
|
||||
}
|
||||
email = resolved;
|
||||
}
|
||||
const { data, error } = await anonSupabase().auth.signInWithPassword({ email, password });
|
||||
if (error) {
|
||||
res.status(401).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
res.json({
|
||||
access_token: data.session?.access_token,
|
||||
refresh_token: data.session?.refresh_token,
|
||||
expires_at: data.session?.expires_at,
|
||||
user: data.user,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e?.message ?? 'Login failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/auth/register', express.json(), async (req, res) => {
|
||||
try {
|
||||
const { email, password, display_name } = req.body || {};
|
||||
if (!email || !password) {
|
||||
res.status(400).json({ error: 'email and password required' });
|
||||
return;
|
||||
}
|
||||
const { data, error } = await anonSupabase().auth.signUp({
|
||||
email: String(email).trim(),
|
||||
password,
|
||||
options: { data: { display_name: (display_name ?? '').trim() || null } },
|
||||
});
|
||||
if (error) {
|
||||
res.status(400).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
const needsConfirmation = data?.user && !data?.session;
|
||||
res.status(201).json({
|
||||
access_token: data.session?.access_token ?? null,
|
||||
refresh_token: data.session?.refresh_token ?? null,
|
||||
expires_at: data.session?.expires_at ?? null,
|
||||
user: data.user,
|
||||
needs_confirmation: needsConfirmation,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ error: e?.message ?? 'Registration failed' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/auth/session', requireAuth, (req, res) => {
|
||||
res.json({ user: { id: req.userId } });
|
||||
});
|
||||
|
||||
app.get('/api/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' });
|
||||
}
|
||||
});
|
||||
|
||||
const PORT = process.env.PORT || 3001;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`API server listening on http://localhost:${PORT}`);
|
||||
});
|
||||
@ -1,517 +0,0 @@
|
||||
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,3 +1,519 @@
|
||||
import { supabase } from '../supabase.js';
|
||||
|
||||
export * from './decks-core.js';
|
||||
export async function fetchMyDecks(userId) {
|
||||
const { data, error } = await supabase
|
||||
.from('decks')
|
||||
.select('id, title, description, config, published, created_at, updated_at, copied_from_deck_id, version, copied_from_version')
|
||||
.eq('owner_id', userId)
|
||||
.order('updated_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
const decks = data ?? [];
|
||||
if (decks.length === 0) return [];
|
||||
|
||||
const deckIds = decks.map((d) => d.id);
|
||||
const sourceDeckIds = [...new Set(decks.map((d) => d.copied_from_deck_id).filter(Boolean))];
|
||||
const ratingDeckIds = [...new Set([...deckIds, ...sourceDeckIds])];
|
||||
|
||||
const [questionCounts, ratingsRows, sourceTitlesRows] = await Promise.all([
|
||||
Promise.all(
|
||||
decks.map(async (d) => {
|
||||
const { count, error: e } = await supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('deck_id', d.id);
|
||||
if (e) return 0;
|
||||
return count ?? 0;
|
||||
})
|
||||
),
|
||||
ratingDeckIds.length > 0
|
||||
? supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', ratingDeckIds)
|
||||
: Promise.resolve({ data: [] }),
|
||||
sourceDeckIds.length > 0
|
||||
? supabase.from('decks').select('id, title, version, updated_at').in('id', sourceDeckIds)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
const ratingByDeck = new Map();
|
||||
for (const r of ratingsRows.data ?? []) {
|
||||
if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []);
|
||||
ratingByDeck.get(r.deck_id).push(r.rating);
|
||||
}
|
||||
const getRating = (deckId) => {
|
||||
const arr = ratingByDeck.get(deckId);
|
||||
if (!arr?.length) return { average_rating: 0, rating_count: 0 };
|
||||
const sum = arr.reduce((a, b) => a + b, 0);
|
||||
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
|
||||
};
|
||||
|
||||
const sourceById = new Map((sourceTitlesRows.data ?? []).map((d) => [d.id, d]));
|
||||
|
||||
return decks.map((d, i) => {
|
||||
const showRatingDeckId = d.copied_from_deck_id || d.id;
|
||||
const rating = getRating(showRatingDeckId);
|
||||
const canRate = !!d.copied_from_deck_id;
|
||||
const sourceDeck = d.copied_from_deck_id ? sourceById.get(d.copied_from_deck_id) : null;
|
||||
const source_deck_title = sourceDeck?.title ?? 'Deck';
|
||||
const source_version = sourceDeck?.version ?? 1;
|
||||
const my_version = d.copied_from_version ?? 0;
|
||||
const versionOutdated = source_version > my_version;
|
||||
const sourceNewerByTime =
|
||||
sourceDeck?.updated_at &&
|
||||
d.updated_at &&
|
||||
new Date(sourceDeck.updated_at).getTime() > new Date(d.updated_at).getTime();
|
||||
const needs_update =
|
||||
!!d.copied_from_deck_id && (versionOutdated || sourceNewerByTime);
|
||||
return {
|
||||
...d,
|
||||
question_count: questionCounts[i],
|
||||
...rating,
|
||||
can_rate: canRate,
|
||||
rateable_deck_id: d.copied_from_deck_id || null,
|
||||
source_deck_title,
|
||||
source_version,
|
||||
needs_update,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string | null | undefined} [userId] - Current user id; when set, each deck gets user_has_this (true if user owns it or already has a copy).
|
||||
*/
|
||||
export async function fetchPublishedDecks(userId) {
|
||||
const { data, error } = await supabase
|
||||
.from('decks')
|
||||
.select('id, title, description, config, published, created_at, updated_at, owner_id')
|
||||
.eq('published', true)
|
||||
.order('updated_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
const decks = data ?? [];
|
||||
if (decks.length === 0) return [];
|
||||
|
||||
const deckIds = decks.map((d) => d.id);
|
||||
const ownerIds = [...new Set(decks.map((d) => d.owner_id).filter(Boolean))];
|
||||
|
||||
const promises = [
|
||||
Promise.all(
|
||||
decks.map(async (d) => {
|
||||
const { count, error: e } = await supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('deck_id', d.id);
|
||||
if (e) return 0;
|
||||
return count ?? 0;
|
||||
})
|
||||
),
|
||||
ownerIds.length > 0
|
||||
? supabase.from('profiles').select('id, display_name, email').in('id', ownerIds)
|
||||
: Promise.resolve({ data: [] }),
|
||||
supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds),
|
||||
];
|
||||
|
||||
if (userId) {
|
||||
promises.push(
|
||||
supabase
|
||||
.from('decks')
|
||||
.select('copied_from_deck_id')
|
||||
.eq('owner_id', userId)
|
||||
.not('copied_from_deck_id', 'is', null)
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const [questionCounts, profilesRows, ratingsRows, myDecksRows] = results;
|
||||
|
||||
const myCopiedFromIds = new Set();
|
||||
if (userId && myDecksRows?.data) {
|
||||
for (const row of myDecksRows.data) {
|
||||
if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id);
|
||||
}
|
||||
}
|
||||
|
||||
const profilesById = new Map(
|
||||
(profilesRows.data ?? []).map((p) => [p.id, { display_name: p.display_name, email: p.email }])
|
||||
);
|
||||
const ratingByDeck = new Map();
|
||||
for (const r of ratingsRows.data ?? []) {
|
||||
if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []);
|
||||
ratingByDeck.get(r.deck_id).push(r.rating);
|
||||
}
|
||||
const getRating = (deckId) => {
|
||||
const arr = ratingByDeck.get(deckId);
|
||||
if (!arr?.length) return { average_rating: 0, rating_count: 0 };
|
||||
const sum = arr.reduce((a, b) => a + b, 0);
|
||||
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
|
||||
};
|
||||
|
||||
return decks.map((d, i) => {
|
||||
const profile = profilesById.get(d.owner_id);
|
||||
const owner_email = profile?.email ?? profile?.display_name ?? 'User';
|
||||
const user_has_this =
|
||||
!!userId && (d.owner_id === userId || myCopiedFromIds.has(d.id));
|
||||
return {
|
||||
...d,
|
||||
question_count: questionCounts[i],
|
||||
owner_display_name: profile?.display_name ?? 'User',
|
||||
owner_email,
|
||||
...getRating(d.id),
|
||||
user_has_this,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} ownerId - Owner of the decks to list.
|
||||
* @param {string | null | undefined} [viewerId] - Current user id; when set, each deck gets user_has_this (true if viewer owns it or already has a copy).
|
||||
*/
|
||||
export async function fetchPublishedDecksByOwner(ownerId, viewerId) {
|
||||
const { data: decks, error } = await supabase
|
||||
.from('decks')
|
||||
.select('id, title, description, config, published, created_at, updated_at, owner_id')
|
||||
.eq('published', true)
|
||||
.eq('owner_id', ownerId)
|
||||
.order('updated_at', { ascending: false });
|
||||
if (error) throw error;
|
||||
const deckList = decks ?? [];
|
||||
if (deckList.length === 0) {
|
||||
const { data: profile } = await supabase.from('profiles').select('id, display_name, email').eq('id', ownerId).single();
|
||||
return {
|
||||
decks: [],
|
||||
owner_email: profile?.email ?? profile?.display_name ?? 'User',
|
||||
owner_display_name: profile?.display_name ?? profile?.email ?? 'User',
|
||||
};
|
||||
}
|
||||
|
||||
const deckIds = deckList.map((d) => d.id);
|
||||
const promises = [
|
||||
Promise.all(
|
||||
deckList.map(async (d) => {
|
||||
const { count, error: e } = await supabase
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('deck_id', d.id);
|
||||
if (e) return 0;
|
||||
return count ?? 0;
|
||||
})
|
||||
),
|
||||
supabase.from('deck_ratings').select('deck_id, rating').in('deck_id', deckIds),
|
||||
];
|
||||
|
||||
if (viewerId && viewerId !== ownerId) {
|
||||
promises.push(
|
||||
supabase
|
||||
.from('decks')
|
||||
.select('copied_from_deck_id')
|
||||
.eq('owner_id', viewerId)
|
||||
.not('copied_from_deck_id', 'is', null)
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
const [questionCounts, ratingsRows, myDecksRows] = results;
|
||||
|
||||
const myCopiedFromIds = new Set();
|
||||
if (viewerId && viewerId !== ownerId && myDecksRows?.data) {
|
||||
for (const row of myDecksRows.data) {
|
||||
if (row.copied_from_deck_id) myCopiedFromIds.add(row.copied_from_deck_id);
|
||||
}
|
||||
}
|
||||
|
||||
const ratingByDeck = new Map();
|
||||
for (const r of ratingsRows.data ?? []) {
|
||||
if (!ratingByDeck.has(r.deck_id)) ratingByDeck.set(r.deck_id, []);
|
||||
ratingByDeck.get(r.deck_id).push(r.rating);
|
||||
}
|
||||
const getRating = (deckId) => {
|
||||
const arr = ratingByDeck.get(deckId);
|
||||
if (!arr?.length) return { average_rating: 0, rating_count: 0 };
|
||||
const sum = arr.reduce((a, b) => a + b, 0);
|
||||
return { average_rating: Math.round((sum / arr.length) * 100) / 100, rating_count: arr.length };
|
||||
};
|
||||
|
||||
const { data: profile } = await supabase.from('profiles').select('id, display_name, email').eq('id', ownerId).single();
|
||||
const owner_email = profile?.email ?? profile?.display_name ?? 'User';
|
||||
const owner_display_name = profile?.display_name ?? profile?.email ?? 'User';
|
||||
|
||||
const decksWithMeta = deckList.map((d, i) => {
|
||||
const user_has_this =
|
||||
!!viewerId &&
|
||||
(viewerId === ownerId || myCopiedFromIds.has(d.id));
|
||||
return {
|
||||
...d,
|
||||
question_count: questionCounts[i],
|
||||
owner_email,
|
||||
owner_display_name,
|
||||
...getRating(d.id),
|
||||
user_has_this,
|
||||
};
|
||||
});
|
||||
|
||||
return { decks: decksWithMeta, owner_email, owner_display_name };
|
||||
}
|
||||
|
||||
export async function fetchDeckWithQuestions(deckId) {
|
||||
const { data: deck, error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.select('*')
|
||||
.eq('id', deckId)
|
||||
.single();
|
||||
if (deckError || !deck) throw deckError || new Error('Deck not found');
|
||||
const { data: questions, error: qError } = await supabase
|
||||
.from('questions')
|
||||
.select('*')
|
||||
.eq('deck_id', deckId)
|
||||
.order('sort_order', { ascending: true });
|
||||
if (qError) throw qError;
|
||||
return { ...deck, questions: questions ?? [] };
|
||||
}
|
||||
|
||||
/** Copy a published deck (and its questions) into the current user's account. New deck is unpublished. */
|
||||
export async function copyDeckToUser(deckId, userId) {
|
||||
const source = await fetchDeckWithQuestions(deckId);
|
||||
if (!source.published) throw new Error('Deck is not available to copy');
|
||||
const questions = (source.questions ?? []).map((q) => ({
|
||||
prompt: q.prompt ?? '',
|
||||
explanation: q.explanation ?? '',
|
||||
answers: Array.isArray(q.answers) ? q.answers : [],
|
||||
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
|
||||
}));
|
||||
return createDeck(userId, {
|
||||
title: source.title,
|
||||
description: source.description ?? '',
|
||||
config: source.config ?? {},
|
||||
questions,
|
||||
copiedFromDeckId: deckId,
|
||||
copiedFromVersion: source.version ?? 1,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createDeck(ownerId, { title, description, config, questions, copiedFromDeckId, copiedFromVersion }) {
|
||||
const row = {
|
||||
owner_id: ownerId,
|
||||
title: title.trim(),
|
||||
description: (description ?? '').trim(),
|
||||
config: config ?? {},
|
||||
};
|
||||
if (copiedFromDeckId != null) row.copied_from_deck_id = copiedFromDeckId;
|
||||
if (copiedFromVersion != null) row.copied_from_version = copiedFromVersion;
|
||||
const { data: deck, error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.insert(row)
|
||||
.select('id')
|
||||
.single();
|
||||
if (deckError || !deck) throw deckError || new Error('Failed to create deck');
|
||||
if (questions && questions.length > 0) {
|
||||
const rows = questions.map((q, i) => ({
|
||||
deck_id: deck.id,
|
||||
sort_order: i,
|
||||
prompt: (q.prompt ?? '').trim(),
|
||||
explanation: (q.explanation ?? '').trim() || null,
|
||||
answers: Array.isArray(q.answers) ? q.answers : [],
|
||||
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
|
||||
}));
|
||||
const { error: qError } = await supabase.from('questions').insert(rows);
|
||||
if (qError) throw qError;
|
||||
}
|
||||
return deck.id;
|
||||
}
|
||||
|
||||
export async function updateDeck(deckId, { title, description, config, questions }) {
|
||||
const { data: current, error: fetchErr } = await supabase
|
||||
.from('decks')
|
||||
.select('version, published')
|
||||
.eq('id', deckId)
|
||||
.single();
|
||||
if (fetchErr) throw fetchErr;
|
||||
const bumpVersion = current?.published === true;
|
||||
const nextVersion = bumpVersion ? (current?.version ?? 1) + 1 : (current?.version ?? 1);
|
||||
const updatePayload = {
|
||||
title: title.trim(),
|
||||
description: (description ?? '').trim(),
|
||||
config: config ?? {},
|
||||
version: nextVersion,
|
||||
};
|
||||
const { error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.update(updatePayload)
|
||||
.eq('id', deckId);
|
||||
if (deckError) throw deckError;
|
||||
const { error: delError } = await supabase.from('questions').delete().eq('deck_id', deckId);
|
||||
if (delError) throw delError;
|
||||
if (questions && questions.length > 0) {
|
||||
const rows = questions.map((q, i) => ({
|
||||
deck_id: deckId,
|
||||
sort_order: i,
|
||||
prompt: (q.prompt ?? '').trim(),
|
||||
explanation: (q.explanation ?? '').trim() || null,
|
||||
answers: Array.isArray(q.answers) ? q.answers : [],
|
||||
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
|
||||
}));
|
||||
const { error: qError } = await supabase.from('questions').insert(rows);
|
||||
if (qError) throw qError;
|
||||
}
|
||||
}
|
||||
|
||||
export async function togglePublished(deckId, published) {
|
||||
const { error } = await supabase.from('decks').update({ published }).eq('id', deckId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
export async function deleteDeck(deckId) {
|
||||
const { error } = await supabase.from('decks').delete().eq('id', deckId);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source deck and copy deck with questions for the "Update from community" preview.
|
||||
* Copy must be owned by userId and have copied_from_deck_id.
|
||||
* @returns {{ source: object, copy: object, changes: string[] }}
|
||||
*/
|
||||
export async function getSourceUpdatePreview(copyDeckId, userId) {
|
||||
const { data: copy, error: copyErr } = await supabase
|
||||
.from('decks')
|
||||
.select('*')
|
||||
.eq('id', copyDeckId)
|
||||
.eq('owner_id', userId)
|
||||
.single();
|
||||
if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy');
|
||||
const sourceId = copy.copied_from_deck_id;
|
||||
const [source, copyQuestionsRows] = await Promise.all([
|
||||
fetchDeckWithQuestions(sourceId),
|
||||
supabase.from('questions').select('*').eq('deck_id', copyDeckId).order('sort_order', { ascending: true }),
|
||||
]);
|
||||
if (!source.published) throw new Error('Source deck is no longer available');
|
||||
const copyQuestions = copyQuestionsRows.data ?? [];
|
||||
const copyWithQuestions = { ...copy, questions: copyQuestions };
|
||||
|
||||
const changes = [];
|
||||
if ((source.title ?? '').trim() !== (copy.title ?? '').trim()) {
|
||||
changes.push(`Title: "${(copy.title ?? '').trim()}" → "${(source.title ?? '').trim()}"`);
|
||||
}
|
||||
if ((source.description ?? '').trim() !== (copy.description ?? '').trim()) {
|
||||
changes.push('Description updated');
|
||||
}
|
||||
const srcLen = (source.questions ?? []).length;
|
||||
const copyLen = copyQuestions.length;
|
||||
if (srcLen !== copyLen) {
|
||||
changes.push(`Questions: ${copyLen} → ${srcLen}`);
|
||||
} else {
|
||||
const anyDifferent = (source.questions ?? []).some((sq, i) => {
|
||||
const cq = copyQuestions[i];
|
||||
if (!cq) return true;
|
||||
return (sq.prompt ?? '') !== (cq.prompt ?? '') || (sq.explanation ?? '') !== (cq.explanation ?? '');
|
||||
});
|
||||
if (anyDifferent) changes.push('Some question content updated');
|
||||
}
|
||||
if (changes.length === 0) changes.push('Content is in sync (version metadata will update)');
|
||||
|
||||
return { source, copy: copyWithQuestions, changes };
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a community copy to match the current source deck (title, description, config, questions, copied_from_version).
|
||||
*/
|
||||
export async function applySourceUpdate(copyDeckId, userId) {
|
||||
const { data: copy, error: copyErr } = await supabase
|
||||
.from('decks')
|
||||
.select('id, copied_from_deck_id')
|
||||
.eq('id', copyDeckId)
|
||||
.eq('owner_id', userId)
|
||||
.single();
|
||||
if (copyErr || !copy?.copied_from_deck_id) throw new Error('Deck not found or not a copy');
|
||||
const source = await fetchDeckWithQuestions(copy.copied_from_deck_id);
|
||||
if (!source.published) throw new Error('Source deck is no longer available');
|
||||
|
||||
const { error: deckError } = await supabase
|
||||
.from('decks')
|
||||
.update({
|
||||
title: source.title ?? '',
|
||||
description: source.description ?? '',
|
||||
config: source.config ?? {},
|
||||
copied_from_version: source.version ?? 1,
|
||||
})
|
||||
.eq('id', copyDeckId);
|
||||
if (deckError) throw deckError;
|
||||
|
||||
const { error: delError } = await supabase.from('questions').delete().eq('deck_id', copyDeckId);
|
||||
if (delError) throw delError;
|
||||
const questions = source.questions ?? [];
|
||||
if (questions.length > 0) {
|
||||
const rows = questions.map((q, i) => ({
|
||||
deck_id: copyDeckId,
|
||||
sort_order: i,
|
||||
prompt: (q.prompt ?? '').trim(),
|
||||
explanation: (q.explanation ?? '').trim() || null,
|
||||
answers: Array.isArray(q.answers) ? q.answers : [],
|
||||
correct_answer_indices: Array.isArray(q.correct_answer_indices) ? q.correct_answer_indices : [],
|
||||
}));
|
||||
const { error: qError } = await supabase.from('questions').insert(rows);
|
||||
if (qError) throw qError;
|
||||
}
|
||||
}
|
||||
|
||||
/** True if the user already has this deck (owns it or has added a copy). */
|
||||
export async function userHasDeck(deckId, userId) {
|
||||
if (!userId) return false;
|
||||
const { data, error } = await supabase
|
||||
.from('decks')
|
||||
.select('id')
|
||||
.eq('owner_id', userId)
|
||||
.or(`id.eq.${deckId},copied_from_deck_id.eq.${deckId}`)
|
||||
.limit(1);
|
||||
if (error) throw error;
|
||||
return (data?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
/** Get all reviews (ratings + comments) for a deck with reviewer display names. */
|
||||
export async function getDeckReviews(deckId) {
|
||||
const { data: ratings, error: rError } = await supabase
|
||||
.from('deck_ratings')
|
||||
.select('user_id, rating, comment, created_at')
|
||||
.eq('deck_id', deckId)
|
||||
.order('created_at', { ascending: false });
|
||||
if (rError) throw rError;
|
||||
const list = ratings ?? [];
|
||||
if (list.length === 0) return [];
|
||||
const userIds = [...new Set(list.map((r) => r.user_id).filter(Boolean))];
|
||||
const { data: profiles } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, display_name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
const byId = new Map((profiles ?? []).map((p) => [p.id, p]));
|
||||
return list.map((r) => {
|
||||
const p = byId.get(r.user_id);
|
||||
return {
|
||||
rating: r.rating,
|
||||
comment: r.comment || null,
|
||||
user_id: r.user_id,
|
||||
display_name: p?.display_name ?? null,
|
||||
email: p?.email ?? null,
|
||||
avatar_url: p?.avatar_url ?? null,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Get the current user's rating (and comment) for a deck, or null if none. */
|
||||
export async function getMyDeckRating(deckId, userId) {
|
||||
if (!userId) return null;
|
||||
const { data, error } = await supabase
|
||||
.from('deck_ratings')
|
||||
.select('rating, comment')
|
||||
.eq('deck_id', deckId)
|
||||
.eq('user_id', userId)
|
||||
.maybeSingle();
|
||||
if (error) throw error;
|
||||
return data;
|
||||
}
|
||||
|
||||
/** Submit or update rating (and optional comment) for a deck. Upserts by (deck_id, user_id). */
|
||||
export async function submitDeckRating(deckId, userId, { rating, comment }) {
|
||||
const row = {
|
||||
deck_id: deckId,
|
||||
user_id: userId,
|
||||
rating: Math.min(5, Math.max(1, Math.round(rating))),
|
||||
comment: (comment ?? '').trim() || null,
|
||||
};
|
||||
const { error } = await supabase.from('deck_ratings').upsert(row, {
|
||||
onConflict: 'deck_id,user_id',
|
||||
});
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
Loading…
Reference in new issue