Files
learning-platform/src/lib/db.js
RaymondVerhoef 20449f0bc1 fix: gebruik theme_session_completions voor learnDone op dashboard
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>
2026-06-03 12:49:12 +02:00

670 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 40100 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;
}