Files
learning-platform/src/lib/db.js

501 lines
18 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 */ }
}
// ── 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<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',
'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;
}