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 */ } } // ── Quiz Banks (DEPRECATED — collection dropped) ──────────────────────────── /** @deprecated quiz_banks collection has been dropped. */ export async function getQuizBank() { return []; } /** @deprecated quiz_banks collection has been dropped. */ export async function setQuizBank() { return null; } /** @deprecated quiz_banks collection has been dropped. */ export async function deleteQuestionFromBank() { return null; } // ── 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); } // ── Learn Progress (DEPRECATED — collection dropped) ──────────────────────── /** @deprecated learn_progress collection has been dropped. */ export async function getLearnDone() { return false; } /** @deprecated learn_progress collection has been dropped. */ export async function setLearnDone() { return null; } // ── 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 ─────────────────────────────────────────────────────────── // // A single "latest" snapshot is persisted in the settings collection before // every bulk graph write. This survives browser/tab close and lets the admin // roll back an AI analysis that produced unwanted results. // // The value is stored as a JSON string because the settings collection only // has a plain text `value` field. Large graphs (150+ topics) can produce // ~100 KB of JSON, which SQLite handles without issue. const SNAPSHOT_KEY = 'graph_snapshot:latest'; /** * Persist the current graph state as a rollback point. * Called by useGraphData.bulkSave() before overwriting the graph. * * @param {object[]} topics — the graph state to preserve * @param {object[]} relations — the graph state to preserve */ export async function saveGraphSnapshot(topics, relations) { const payload = JSON.stringify({ ts: Date.now(), topicCount: topics.length, relationCount: relations.length, topics, relations, }); return setSetting(SNAPSHOT_KEY, payload); } /** * Read the latest snapshot back from PocketBase. * Returns null when no snapshot exists or the stored value is malformed. * * @returns {{ ts: number, topicCount: number, relationCount: number, topics: object[], relations: object[] } | null} */ export async function getGraphSnapshot() { const raw = await getSetting(SNAPSHOT_KEY, null); if (!raw) return null; try { return JSON.parse(raw); } catch { return null; } } /** * Delete the stored snapshot (e.g. after a successful restore so the * admin cannot accidentally restore twice). */ export function clearGraphSnapshot() { return setSetting(SNAPSHOT_KEY, ''); } // ── 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>} */ export async function resetForSmokeTest({ includeProgress = false, } = {}) { const collections = [ 'relations', 'content', 'curriculum', 'sources', 'topics', 'micro_learnings', 'micro_learning_completions', '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; }