/** * Creates all PocketBase collections for the learning platform. * Usage: node scripts/setup-pb-collections.mjs [PB_URL] [ADMIN_EMAIL] [ADMIN_PASSWORD] * Defaults: http://localhost:8090, prompted if not provided */ const PB_URL = process.argv[2] || 'http://localhost:8090'; const ADMIN_EMAIL = process.argv[3]; const ADMIN_PASSWORD = process.argv[4]; if (!ADMIN_EMAIL || !ADMIN_PASSWORD) { console.error('Usage: node scripts/setup-pb-collections.mjs '); process.exit(1); } const OPEN_RULES = { listRule: '', viewRule: '', createRule: '', updateRule: '', deleteRule: '' }; // PocketBase v0.23: created/updated must be explicitly defined as autodate fields const AUTODATE_FIELDS = [ { name: 'created', type: 'autodate', onCreate: true, onUpdate: false }, { name: 'updated', type: 'autodate', onCreate: true, onUpdate: true }, ]; const COLLECTIONS = [ { name: 'topics', type: 'base', ...OPEN_RULES, fields: [ { name: 'id', type: 'text', primaryKey: true, system: true, min: 1, max: 255, pattern: '^[a-zA-Z0-9_-]+$' }, { name: 'label', type: 'text', required: true }, { name: 'type', type: 'text', required: false }, { name: 'description', type: 'text', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'relations', type: 'base', ...OPEN_RULES, fields: [ { name: 'source', type: 'text', required: true }, { name: 'target', type: 'text', required: true }, { name: 'type', type: 'text', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'content', type: 'base', ...OPEN_RULES, fields: [ { name: 'topic_id', type: 'text', required: true }, { name: 'data', type: 'json', required: false }, ...AUTODATE_FIELDS, ], }, { // Question bank: one record per topic with a `questions` JSON array. // Shared across all users; admin-curated; powers on-demand topic tests. name: 'question_bank', type: 'base', ...OPEN_RULES, fields: [ { name: 'topic_id', type: 'text', required: true }, { name: 'questions', type: 'json', required: false }, ...AUTODATE_FIELDS, ], }, { // On-demand attempts log: one record per completed topic test. // Powers the weekly-cap aggregation and the Contributions feed. name: 'on_demand_attempts', type: 'base', ...OPEN_RULES, fields: [ { name: 'user_id', type: 'text', required: true }, { name: 'topic_id', type: 'text', required: true }, { name: 'topic_label', type: 'text', required: false }, { name: 'question_ids', type: 'json', required: false }, { name: 'score', type: 'number', required: false }, { name: 'total', type: 'number', required: false }, { name: 'percentage', type: 'number', required: false }, { name: 'time_used', type: 'number', required: false }, { name: 'unseen_correct', type: 'number', required: false }, { name: 'points_earned', type: 'number', required: false }, { name: 'capped', type: 'bool', required: false }, { name: 'iso_week', type: 'text', required: false }, { name: 'completed_at', type: 'text', required: false }, { name: 'breakdown', type: 'json', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'sources', type: 'base', ...OPEN_RULES, fields: [ { name: 'name', type: 'text', required: true }, { name: 'status', type: 'text', required: false }, { name: 'error', type: 'text', required: false }, ...AUTODATE_FIELDS, ], }, // NOTE: `team_members` is an AUTH collection owned by the migration // pb_migrations/1781000000_team_members_to_auth.js (Azure/Entra ID login). // Migrations run on PocketBase start — before this script — so the entry // below is only a fallback for environments where migrations have not run; // when the auth collection already exists, this create is skipped. { name: 'team_members', type: 'auth', ...OPEN_RULES, passwordAuth: { enabled: false, identityFields: ['email'] }, fields: [ { name: 'name', type: 'text', required: false }, { name: 'role', type: 'text', required: false }, { name: 'curriculum_started_at', type: 'date', required: false }, { name: 'enrollment_status', type: 'text', required: false }, ], }, { name: 'quiz_results', type: 'base', ...OPEN_RULES, fields: [ { name: 'user_id', type: 'text', required: true }, { name: 'week_number', type: 'number', required: true }, { name: 'score', type: 'number', required: false }, { name: 'total', type: 'number', required: false }, { name: 'percentage', type: 'number', required: false }, { name: 'time_used', type: 'number', required: false }, { name: 'completed_at', type: 'text', required: false }, { name: 'breakdown', type: 'json', required: false }, { name: 'points_earned',type: 'number', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'quiz_cache', type: 'base', ...OPEN_RULES, fields: [ { name: 'user_id', type: 'text', required: true }, { name: 'week_number', type: 'number', required: true }, { name: 'questions', type: 'json', required: false }, { name: 'meta', type: 'json', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'learn_progress', type: 'base', ...OPEN_RULES, fields: [ { name: 'user_id', type: 'text', required: true }, { name: 'week_number', type: 'number', required: true }, { name: 'done', type: 'bool', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'leaderboard', type: 'base', ...OPEN_RULES, fields: [ { name: 'user_id', type: 'text', required: true }, { name: 'name', type: 'text', required: false }, { name: 'points', type: 'number', required: false }, { name: 'tests_completed', type: 'number', required: false }, { name: 'learnings_completed', type: 'number', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'curriculum', type: 'base', ...OPEN_RULES, fields: [ { name: 'year', type: 'number', required: true }, { name: 'week_number', type: 'number', required: true }, { name: 'topic_id', type: 'text', required: false }, { name: 'theme', type: 'text', required: false }, { name: 'quarter', type: 'number', required: false }, { name: 'is_review_week', type: 'bool', required: false }, { name: 'sort_order', type: 'number', required: false }, ...AUTODATE_FIELDS, ], }, { name: 'settings', type: 'base', ...OPEN_RULES, fields: [ { name: 'key', type: 'text', required: true }, { name: 'value', type: 'text', required: false }, ...AUTODATE_FIELDS, ], }, ]; async function post(path, body, token) { const res = await fetch(`${PB_URL}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: token } : {}), }, body: JSON.stringify(body), }); const json = await res.json(); if (!res.ok) throw Object.assign(new Error(json.message || 'Request failed'), { status: res.status, data: json }); return json; } async function main() { console.log(`Connecting to PocketBase at ${PB_URL}...`); // Authenticate as superuser (PocketBase v0.23+) const auth = await post('/api/collections/_superusers/auth-with-password', { identity: ADMIN_EMAIL, password: ADMIN_PASSWORD, }); const token = auth.token; console.log('Authenticated as admin.\n'); for (const col of COLLECTIONS) { try { await post('/api/collections', col, token); console.log(` ✓ Created: ${col.name}`); } catch (err) { if (err.status === 400 && err.data?.data?.name?.code === 'validation_not_unique') { console.log(` ~ Skipped: ${col.name} (already exists)`); } else { console.error(` ✗ Failed: ${col.name} — ${err.message}`); } } } console.log('\nDone. All collections are set up with open API rules.'); } main().catch(err => { console.error('\nFatal error:', err.message); process.exit(1); });