501 lines
18 KiB
JavaScript
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;
|
|
}
|
|
|
|
|