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