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; }