getLearnDone was een deprecated stub die altijd false teruggaf nadat de learn_progress-collectie is gedropt. Hierdoor toonde het dashboard permanent 'Complete your theme session first', zelfs als de theme session al voltooid was, en werd cycle progress nooit opgeteld. Alle drie call-sites vervangen door getThemeSessionCompletion (de huidige bron van waarheid), en de dode stubs verwijderd uit db.js. Closes #9 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
670 lines
23 KiB
JavaScript
670 lines
23 KiB
JavaScript
import { pb } from './pb';
|
||
import { storage } from './storage';
|
||
|
||
// Upsert helper: update existing record if found, otherwise create.
|
||
async function pbUpsert(collection, filter, updateData, createData) {
|
||
try {
|
||
const r = await pb.collection(collection).getFirstListItem(filter);
|
||
return pb.collection(collection).update(r.id, updateData);
|
||
} catch {
|
||
return pb.collection(collection).create(createData);
|
||
}
|
||
}
|
||
|
||
// ── Topics ──────────────────────────────────────────────────────────────────
|
||
|
||
export async function getTopics() {
|
||
return pb.collection('topics').getFullList();
|
||
}
|
||
|
||
export async function saveTopics(topics) {
|
||
const existing = await pb.collection('topics').getFullList({ fields: 'id' });
|
||
for (const r of existing) {
|
||
await pb.collection('topics').delete(r.id, { requestKey: null });
|
||
}
|
||
for (const t of topics) {
|
||
await pb.collection('topics').create({
|
||
id: t.id,
|
||
label: t.label,
|
||
type: t.type,
|
||
description: t.description,
|
||
learning_relevance: t.learning_relevance || 'standard',
|
||
relevance_locked: t.relevance_locked === true,
|
||
theme: t.theme || '',
|
||
complexity_weight: t.complexity_weight || 3,
|
||
difficulty: t.difficulty || 'intermediate',
|
||
}, { requestKey: null });
|
||
}
|
||
}
|
||
|
||
export async function upsertTopic(topic) {
|
||
try {
|
||
await pb.collection('topics').getOne(topic.id);
|
||
return await pb.collection('topics').update(topic.id, topic);
|
||
} catch {
|
||
return await pb.collection('topics').create({
|
||
id: topic.id,
|
||
learning_relevance: 'standard',
|
||
...topic
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Relations ────────────────────────────────────────────────────────────────
|
||
|
||
export async function getRelations() {
|
||
const records = await pb.collection('relations').getFullList();
|
||
// Always return plain string IDs for source/target.
|
||
// PocketBase can return populated objects when relation fields are expanded;
|
||
// normalising here means callers never need the `r.source?.id || r.source` dance.
|
||
return records.map(r => ({
|
||
...r,
|
||
source: typeof r.source === 'object' && r.source !== null ? r.source.id : r.source,
|
||
target: typeof r.target === 'object' && r.target !== null ? r.target.id : r.target,
|
||
}));
|
||
}
|
||
|
||
export async function saveRelations(relations) {
|
||
const existing = await pb.collection('relations').getFullList({ fields: 'id' });
|
||
for (const r of existing) {
|
||
await pb.collection('relations').delete(r.id, { requestKey: null });
|
||
}
|
||
for (const r of relations) {
|
||
await pb.collection('relations').create(r, { requestKey: null });
|
||
}
|
||
}
|
||
|
||
export async function addRelation(relation) {
|
||
return pb.collection('relations').create(relation);
|
||
}
|
||
|
||
export async function removeRelation(source, target, type) {
|
||
const records = await pb.collection('relations').getFullList({
|
||
filter: `source="${source}" && target="${target}" && type="${type}"`,
|
||
});
|
||
return Promise.all(records.map(r => pb.collection('relations').delete(r.id, { requestKey: null })));
|
||
}
|
||
|
||
// ── Content ──────────────────────────────────────────────────────────────────
|
||
|
||
export async function getContent(topicId) {
|
||
try {
|
||
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
|
||
return r.data;
|
||
} catch { return null; }
|
||
}
|
||
|
||
export function setContent(topicId, data) {
|
||
return pbUpsert('content', `topic_id="${topicId}"`, { data }, { topic_id: topicId, data });
|
||
}
|
||
|
||
export async function deleteContent(topicId) {
|
||
try {
|
||
const r = await pb.collection('content').getFirstListItem(`topic_id="${topicId}"`);
|
||
return pb.collection('content').delete(r.id);
|
||
} catch { /* no record, nothing to do */ }
|
||
}
|
||
|
||
// ── Question Bank (shared, admin-curated, used by on-demand tests) ──────────
|
||
// One PB record per topic, with `questions: json[]`. The legacy aliases
|
||
// (getQuizBank etc.) are kept so existing admin UI code keeps working.
|
||
|
||
export async function getQuestionBank(topicId) {
|
||
try {
|
||
const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
|
||
return Array.isArray(r.questions) ? r.questions : [];
|
||
} catch { return []; }
|
||
}
|
||
|
||
export async function setQuestionBank(topicId, questions) {
|
||
return pbUpsert(
|
||
'question_bank',
|
||
`topic_id="${topicId}"`,
|
||
{ questions },
|
||
{ topic_id: topicId, questions },
|
||
);
|
||
}
|
||
|
||
export async function deleteQuestionFromBank(topicId, questionId) {
|
||
try {
|
||
const r = await pb.collection('question_bank').getFirstListItem(`topic_id="${topicId}"`);
|
||
const next = (r.questions || []).filter(q => q.id !== questionId);
|
||
return pb.collection('question_bank').update(r.id, { questions: next });
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Legacy aliases — the admin TestManager UI and forceGenerateTopicQuestions
|
||
// call these. Routing them at the new collection un-breaks the panel.
|
||
export const getQuizBank = getQuestionBank;
|
||
export const setQuizBank = setQuestionBank;
|
||
|
||
// ── On-demand attempts ──────────────────────────────────────────────────────
|
||
|
||
function isoWeekKey(date = new Date()) {
|
||
// Returns "YYYY-Www" for the ISO week containing `date`. Used for the
|
||
// per-(user, topic) weekly point cap aggregation.
|
||
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
|
||
const day = d.getUTCDay() || 7;
|
||
d.setUTCDate(d.getUTCDate() + 4 - day);
|
||
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||
const weekNo = Math.ceil((((d - yearStart) / 86400000) + 1) / 7);
|
||
return `${d.getUTCFullYear()}-W${String(weekNo).padStart(2, '0')}`;
|
||
}
|
||
|
||
export { isoWeekKey };
|
||
|
||
export async function saveOnDemandAttempt(attempt) {
|
||
return pb.collection('on_demand_attempts').create({
|
||
user_id: attempt.userId,
|
||
topic_id: attempt.topicId,
|
||
topic_label: attempt.topicLabel || '',
|
||
question_ids: attempt.questionIds || [],
|
||
score: attempt.score,
|
||
total: attempt.total,
|
||
percentage: attempt.percentage,
|
||
time_used: attempt.timeUsed,
|
||
unseen_correct: attempt.unseenCorrect,
|
||
points_earned: attempt.pointsEarned,
|
||
capped: !!attempt.capped,
|
||
iso_week: attempt.isoWeek,
|
||
completed_at: attempt.completedAt,
|
||
breakdown: attempt.breakdown || [],
|
||
});
|
||
}
|
||
|
||
export async function getOnDemandPointsThisWeek(userId, topicId) {
|
||
try {
|
||
const week = isoWeekKey();
|
||
const rows = await pb.collection('on_demand_attempts').getFullList({
|
||
filter: `user_id="${userId}" && topic_id="${topicId}" && iso_week="${week}"`,
|
||
});
|
||
return rows.reduce((sum, r) => sum + (r.points_earned || 0), 0);
|
||
} catch { return 0; }
|
||
}
|
||
|
||
export async function getUserSeenQuestionIds(userId, topicId) {
|
||
// Returns the Set of question_bank IDs this user has already been served
|
||
// for `topicId`. Used by the draw routine to prefer unseen questions and
|
||
// by finishOnDemandTest to zero out points for repeats.
|
||
try {
|
||
const rows = await pb.collection('on_demand_attempts').getFullList({
|
||
filter: `user_id="${userId}" && topic_id="${topicId}"`,
|
||
fields: 'question_ids',
|
||
});
|
||
const seen = new Set();
|
||
for (const r of rows) {
|
||
for (const id of (r.question_ids || [])) seen.add(id);
|
||
}
|
||
return seen;
|
||
} catch { return new Set(); }
|
||
}
|
||
|
||
export async function getAllOnDemandAttempts() {
|
||
try {
|
||
return await pb.collection('on_demand_attempts').getFullList({ sort: '-created' });
|
||
} catch { return []; }
|
||
}
|
||
|
||
// ── Sources ──────────────────────────────────────────────────────────────────
|
||
|
||
export async function getSources() {
|
||
return pb.collection('sources').getFullList();
|
||
}
|
||
|
||
export async function addSource(source) {
|
||
return pb.collection('sources').create(source);
|
||
}
|
||
|
||
export async function updateSourceStatus(id, status, error = '') {
|
||
return pb.collection('sources').update(id, { status, error });
|
||
}
|
||
|
||
export async function updateSourceProgress(id, progress) {
|
||
return pb.collection('sources').update(id, { progress });
|
||
}
|
||
|
||
/**
|
||
* Find sources stuck in 'processing' status — likely orphaned by a tab close.
|
||
* Sources older than 5 minutes in 'processing' state are considered stale.
|
||
*/
|
||
export async function getOrphanedSources() {
|
||
try {
|
||
const all = await pb.collection('sources').getFullList({
|
||
filter: 'status="processing"',
|
||
});
|
||
const fiveMinAgo = Date.now() - 5 * 60 * 1000;
|
||
return all.filter(s => new Date(s.updated || s.created).getTime() < fiveMinAgo);
|
||
} catch { return []; }
|
||
}
|
||
|
||
export async function deleteSource(id) {
|
||
return pb.collection('sources').delete(id);
|
||
}
|
||
|
||
// ── Team Members ─────────────────────────────────────────────────────────────
|
||
|
||
export async function getTeamMembers() {
|
||
return pb.collection('team_members').getFullList();
|
||
}
|
||
|
||
export async function addTeamMember(member) {
|
||
return pb.collection('team_members').create(member);
|
||
}
|
||
|
||
export async function updateTeamMember(id, data) {
|
||
return pb.collection('team_members').update(id, data);
|
||
}
|
||
|
||
export async function deleteTeamMember(id) {
|
||
return pb.collection('team_members').delete(id);
|
||
}
|
||
|
||
// ── Quiz Results ──────────────────────────────────────────────────────────────
|
||
// Persists one record per user per week in the `test_results` PocketBase
|
||
// collection (recreated in migration 1780900001_created_test_results.js).
|
||
|
||
export async function saveQuizResult(userId, weekNumber, result) {
|
||
const data = {
|
||
user_id: userId,
|
||
week_number: weekNumber,
|
||
score: result.score,
|
||
total: result.total,
|
||
percentage: result.percentage,
|
||
time_used: result.timeUsed,
|
||
completed_at: result.completedAt,
|
||
breakdown: result.breakdown,
|
||
points_earned: result.score * 2,
|
||
};
|
||
return pbUpsert(
|
||
'test_results',
|
||
`user_id="${userId}" && week_number=${weekNumber}`,
|
||
data,
|
||
data,
|
||
);
|
||
}
|
||
|
||
export async function getQuizResult(userId, weekNumber) {
|
||
try {
|
||
// Use getList instead of getFirstListItem so that "no record yet" returns
|
||
// an empty items array (HTTP 200) rather than throwing a ClientResponseError
|
||
// that the SDK logs to the browser console as a 404.
|
||
const res = await pb.collection('test_results').getList(1, 1, {
|
||
filter: `user_id="${userId}" && week_number=${weekNumber}`,
|
||
skipTotal: true,
|
||
requestKey: null,
|
||
});
|
||
const r = res.items[0];
|
||
if (!r) return null;
|
||
// Map snake_case PB fields back to the camelCase shape Testen.jsx expects.
|
||
return {
|
||
score: r.score,
|
||
total: r.total,
|
||
percentage: r.percentage,
|
||
timeUsed: r.time_used,
|
||
completedAt: r.completed_at,
|
||
breakdown: r.breakdown,
|
||
pointsEarned: r.points_earned,
|
||
};
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ── Quiz Cache (localStorage — ephemeral pre-generation) ─────────────────────
|
||
// Stores a pre-generated quiz object in localStorage so the Testen page can
|
||
// skip the LLM call when the user arrives after completing their learning
|
||
// session. The key includes weekNumber so stale entries are silently ignored
|
||
// when the week rolls over.
|
||
|
||
export function getCachedQuiz(userId, weekNumber) {
|
||
const cached = storage.get(`quiz_cache:${userId}:${weekNumber}`);
|
||
return Promise.resolve(cached || null);
|
||
}
|
||
|
||
export function setCachedQuiz(userId, weekNumber, quiz) {
|
||
storage.set(`quiz_cache:${userId}:${weekNumber}`, quiz);
|
||
return Promise.resolve(quiz);
|
||
}
|
||
|
||
// ── Theme Sessions ──────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Look up the cached theme session for a given (curriculum_version, week_number).
|
||
* Returns null when no cache entry exists yet.
|
||
*/
|
||
export async function getThemeSession(curriculumVersionId, weekNumber) {
|
||
try {
|
||
const record = await pb.collection('theme_sessions').getFirstListItem(
|
||
`curriculum_version = "${curriculumVersionId}" && week_number = ${weekNumber}`
|
||
);
|
||
return record;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Upsert the theme session for a given (curriculum_version, week_number).
|
||
*/
|
||
export async function setThemeSession(curriculumVersionId, weekNumber, theme, content) {
|
||
return pbUpsert(
|
||
'theme_sessions',
|
||
`curriculum_version = "${curriculumVersionId}" && week_number = ${weekNumber}`,
|
||
{ theme, content },
|
||
{ curriculum_version: curriculumVersionId, week_number: weekNumber, theme, content },
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Return every cached theme session, newest first. Used by the admin
|
||
* Content panel to surface what's been generated across all weeks.
|
||
*/
|
||
export async function getAllThemeSessions() {
|
||
try {
|
||
return await pb.collection('theme_sessions').getFullList({ sort: '-updated' });
|
||
} catch { return []; }
|
||
}
|
||
|
||
/**
|
||
* Delete a single theme session record by PocketBase id.
|
||
*/
|
||
export async function deleteThemeSession(id) {
|
||
try { return await pb.collection('theme_sessions').delete(id); }
|
||
catch { return null; }
|
||
}
|
||
|
||
/**
|
||
* Has the user completed the theme session for a given personal week?
|
||
* Returns the completion record, or null.
|
||
*/
|
||
export async function getThemeSessionCompletion(userId, sessionWeek) {
|
||
try {
|
||
return await pb.collection('theme_session_completions').getFirstListItem(
|
||
`team_member_id = "${userId}" && session_week = ${sessionWeek}`
|
||
);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Mark a theme session as completed for the user/week. Idempotent.
|
||
*/
|
||
export async function setThemeSessionCompletion(userId, themeSessionId, sessionWeek) {
|
||
return pbUpsert(
|
||
'theme_session_completions',
|
||
`team_member_id = "${userId}" && session_week = ${sessionWeek}`,
|
||
{ theme_session_id: themeSessionId },
|
||
{ team_member_id: userId, theme_session_id: themeSessionId, session_week: sessionWeek },
|
||
);
|
||
}
|
||
|
||
// ── Leaderboard ───────────────────────────────────────────────────────────────
|
||
|
||
export async function getLeaderboard() {
|
||
const entries = await pb.collection('leaderboard').getFullList();
|
||
return entries.sort((a, b) => (b.points || 0) - (a.points || 0));
|
||
}
|
||
|
||
/**
|
||
* Fetch every test_results record across all users, newest first.
|
||
* Used by the Contributions page to build per-user heatmaps, streaks,
|
||
* perfect-score counts, and the recent-activity feed in a single query.
|
||
*/
|
||
export async function getAllTestResults() {
|
||
try {
|
||
return await pb.collection('test_results').getFullList({ sort: '-created' });
|
||
} catch { return []; }
|
||
}
|
||
|
||
export async function upsertLeaderboardEntry(userId, name, pointsDelta, testsCompletedDelta = 0) {
|
||
try {
|
||
const r = await pb.collection('leaderboard').getFirstListItem(`user_id="${userId}"`);
|
||
return pb.collection('leaderboard').update(r.id, {
|
||
points: (r.points || 0) + pointsDelta,
|
||
tests_completed: (r.tests_completed || 0) + testsCompletedDelta,
|
||
});
|
||
} catch {
|
||
return pb.collection('leaderboard').create({
|
||
user_id: userId,
|
||
name,
|
||
points: pointsDelta,
|
||
tests_completed: testsCompletedDelta,
|
||
learnings_completed: 0,
|
||
});
|
||
}
|
||
}
|
||
|
||
// ── Settings ──────────────────────────────────────────────────────────────────
|
||
|
||
export async function getSetting(key, defaultValue = null) {
|
||
try {
|
||
const r = await pb.collection('settings').getFirstListItem(`key="${key}"`);
|
||
return r.value ?? defaultValue;
|
||
} catch { return defaultValue; }
|
||
}
|
||
|
||
export function setSetting(key, value) {
|
||
return pbUpsert('settings', `key="${key}"`, { value: String(value) }, { key, value: String(value) });
|
||
}
|
||
|
||
// ── Graph Snapshots ───────────────────────────────────────────────────────────
|
||
//
|
||
// Stored in localStorage, not in PocketBase.
|
||
//
|
||
// The settings collection's `value` field is a plain text field designed for
|
||
// small strings (model overrides, flags). A full graph snapshot can easily
|
||
// reach 40–100 KB of JSON, which causes a 400 Bad Request from PocketBase.
|
||
//
|
||
// localStorage is the right choice here: the admin who clicks "Analyze" and
|
||
// the admin who clicks "Restore" are always the same person, on the same
|
||
// machine, in the same browser. Cross-device persistence is not required.
|
||
|
||
const SNAPSHOT_LS_KEY = 'respellion:graph_snapshot';
|
||
|
||
/**
|
||
* Persist the current graph state as a rollback point in localStorage.
|
||
* Called by useGraphData.bulkSave() before overwriting the graph.
|
||
*
|
||
* Silently no-ops if localStorage is unavailable (private browsing quota
|
||
* exceeded, etc.) — the snapshot is best-effort, not critical-path.
|
||
*
|
||
* @param {object[]} topics
|
||
* @param {object[]} relations
|
||
*/
|
||
export function saveGraphSnapshot(topics, relations) {
|
||
try {
|
||
localStorage.setItem(SNAPSHOT_LS_KEY, JSON.stringify({
|
||
ts: Date.now(),
|
||
topicCount: topics.length,
|
||
relationCount: relations.length,
|
||
topics,
|
||
relations,
|
||
}));
|
||
} catch {
|
||
// Storage quota exceeded or unavailable — skip silently.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Read the latest snapshot from localStorage.
|
||
* Returns null when nothing is stored or the value is malformed.
|
||
*
|
||
* @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null}
|
||
*/
|
||
export function getGraphSnapshot() {
|
||
try {
|
||
const raw = localStorage.getItem(SNAPSHOT_LS_KEY);
|
||
if (!raw) return null;
|
||
return JSON.parse(raw);
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Remove the stored snapshot after a successful restore so the admin cannot
|
||
* accidentally restore twice.
|
||
*/
|
||
export function clearGraphSnapshot() {
|
||
try {
|
||
localStorage.removeItem(SNAPSHOT_LS_KEY);
|
||
} catch { /* non-fatal */ }
|
||
}
|
||
|
||
// ── Curriculum Versions (v2) ──────────────────────────────────────────────────
|
||
|
||
export async function getCurriculumVersions(status) {
|
||
try {
|
||
const opts = { sort: '-version_number' };
|
||
if (status) opts.filter = `status="${status}"`;
|
||
return await pb.collection('curriculum_versions').getFullList(opts);
|
||
} catch { return []; }
|
||
}
|
||
|
||
export async function getCurriculumVersion(id) {
|
||
try {
|
||
return await pb.collection('curriculum_versions').getOne(id);
|
||
} catch { return null; }
|
||
}
|
||
|
||
export async function getActiveCurriculumVersion() {
|
||
try {
|
||
return await pb.collection('curriculum_versions').getFirstListItem('status="active"');
|
||
} catch { return null; }
|
||
}
|
||
|
||
export async function getDraftCurriculumVersion() {
|
||
try {
|
||
return await pb.collection('curriculum_versions').getFirstListItem('status="draft"');
|
||
} catch { return null; }
|
||
}
|
||
|
||
export async function createCurriculumVersion(data) {
|
||
return pb.collection('curriculum_versions').create(data);
|
||
}
|
||
|
||
export async function updateCurriculumVersion(id, data) {
|
||
return pb.collection('curriculum_versions').update(id, data);
|
||
}
|
||
|
||
export async function getNextVersionNumber() {
|
||
try {
|
||
const latest = await pb.collection('curriculum_versions').getFirstListItem('', {
|
||
sort: '-version_number',
|
||
fields: 'version_number',
|
||
});
|
||
return (latest?.version_number || 0) + 1;
|
||
} catch { return 1; }
|
||
}
|
||
|
||
// ── Curriculum (legacy, v1 — deprecated) ──────────────────────────────────────
|
||
|
||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||
export async function getCurriculum(year) {
|
||
try {
|
||
return await pb.collection('curriculum').getFullList({
|
||
filter: `year=${year}`,
|
||
sort: 'week_number',
|
||
});
|
||
} catch { return []; }
|
||
}
|
||
|
||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||
export async function getCurriculumWeek(year, weekNumber) {
|
||
try {
|
||
return await pb.collection('curriculum').getFirstListItem(
|
||
`year=${year} && week_number=${weekNumber}`
|
||
);
|
||
} catch { return null; }
|
||
}
|
||
|
||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||
export function setCurriculumWeek(year, weekNumber, data) {
|
||
return pbUpsert('curriculum', `year=${year} && week_number=${weekNumber}`,
|
||
data, { year, week_number: weekNumber, ...data });
|
||
}
|
||
|
||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||
export async function deleteCurriculumWeek(year, weekNumber) {
|
||
try {
|
||
const r = await pb.collection('curriculum').getFirstListItem(
|
||
`year=${year} && week_number=${weekNumber}`
|
||
);
|
||
return pb.collection('curriculum').delete(r.id);
|
||
} catch { /* nothing to delete */ }
|
||
}
|
||
|
||
/** @deprecated Use curriculum_versions (v2) instead. */
|
||
export async function bulkSetCurriculum(year, weeks) {
|
||
// Delete all existing entries for this year first
|
||
const existing = await getCurriculum(year);
|
||
await Promise.all(
|
||
existing.map(r => pb.collection('curriculum').delete(r.id, { requestKey: null }))
|
||
);
|
||
// Create all new entries
|
||
return Promise.all(
|
||
weeks.map(w => pb.collection('curriculum').create({
|
||
year,
|
||
week_number: w.week_number,
|
||
topic_id: w.topic_id || '',
|
||
theme: w.theme || '',
|
||
quarter: w.quarter || Math.ceil(w.week_number / 13),
|
||
is_review_week: w.is_review_week || false,
|
||
sort_order: w.sort_order ?? w.week_number,
|
||
}, { requestKey: null }))
|
||
);
|
||
}
|
||
|
||
// ── Reset for Smoke Testing ──────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Truncate the collections that hold AI-generated and progress data.
|
||
*
|
||
* Order matters: rows that reference `topics.id` (relations, content,
|
||
* quiz_banks) are deleted before topics themselves so we never leave
|
||
* dangling references mid-wipe.
|
||
*
|
||
* `team_members`, `settings`, and `llm_calls` are intentionally preserved.
|
||
*
|
||
* @param {{ includeProgress?: boolean }} [opts]
|
||
* @returns {Promise<Array<{collection: string, deleted: number, error?: string}>>}
|
||
*/
|
||
export async function resetForSmokeTest({
|
||
includeProgress = false,
|
||
} = {}) {
|
||
const collections = [
|
||
'relations',
|
||
'content',
|
||
'curriculum',
|
||
'sources',
|
||
'topics',
|
||
'micro_learnings',
|
||
'micro_learning_completions',
|
||
'theme_session_completions',
|
||
'theme_sessions',
|
||
'test_results',
|
||
];
|
||
if (includeProgress) collections.push('leaderboard');
|
||
|
||
const results = [];
|
||
for (const name of collections) {
|
||
try {
|
||
const rows = await pb.collection(name).getFullList({ fields: 'id', requestKey: null });
|
||
// Sequential deletes — bulk delete-many isn't in the JS SDK and parallel
|
||
// deletes on the same collection sometimes 409 in PocketBase.
|
||
let deleted = 0;
|
||
for (const r of rows) {
|
||
await pb.collection(name).delete(r.id, { requestKey: null });
|
||
deleted++;
|
||
}
|
||
results.push({ collection: name, deleted });
|
||
} catch (err) {
|
||
results.push({ collection: name, deleted: 0, error: err?.message || String(err) });
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
|