Files
omotomo_site/src/lib/api/decks-core.js
T
2026-02-14 15:13:26 +01:00

518 lines
18 KiB
JavaScript

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