#!/usr/bin/env node // Phase 2 demo seed — produces the visual state described in SPEC §Phase 2: // 4 cab members with title/pull_quote/focus_tags/member_number, 1 active // pulse (2 of 4 voted), 4 roadmap items (1 shipping / 1 beta / 2 exploring), // 3 contributions with reactions, 4 dispatches at staggered ages, 1 hero // event + 1 studio hours + 1 working session + 2 past events. // // Destructive in scope: wipes the data tables it owns then re-inserts. // Users (created by seed.js) are kept; new ones are added with INSERT OR // IGNORE. Idempotent: re-running produces the same demo state. import Database from 'better-sqlite3'; import bcrypt from 'bcryptjs'; import { join, dirname } from 'path'; import { fileURLToPath } from 'url'; const __dirname = dirname(fileURLToPath(import.meta.url)); const dbPath = process.env.BIFROST_DB_PATH ?? join(__dirname, '..', 'bifrost.db'); const db = new Database(dbPath); db.pragma('foreign_keys = ON'); // ── Wipe the tables this seed owns ───────────────────────────────── db.exec(` DELETE FROM activity; DELETE FROM attendance; DELETE FROM reactions; DELETE FROM replies; DELETE FROM contributions; DELETE FROM roadmap_attributions; DELETE FROM roadmap_items; DELETE FROM votes; DELETE FROM pulses; DELETE FROM dispatches; DELETE FROM events; `); // ── Locate canonical users from seed.js ──────────────────────────── const allUsers = db.prepare("SELECT id, name, role FROM users WHERE active = 1").all(); const byRole = (r) => allUsers.find(u => u.role === r); const mette = byRole('pilot'); const lars = byRole('cab'); const jon = byRole('fenja'); if (!mette || !lars || !jon) { console.error(' seed.js users not found — run `pnpm db:seed` first.'); process.exit(1); } // ── Add 3 additional CAB members, then populate metadata on all 4 ── const ROUNDS = 10; const hash = bcrypt.hashSync('cab123', ROUNDS); function kebab(s) { return s.toLowerCase() .normalize('NFKD').replace(/[̀-ͯ]/g, '') .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, ''); } const newCabs = [ { name: 'Anna Kjær', email: 'anna@virk3.dk', org: 'Virksomhed 3' }, { name: 'Søren Vedel', email: 'soren@virk4.dk', org: 'Virksomhed 4' }, { name: 'Henriette Rask',email: 'henriette@virk5.dk',org: 'Virksomhed 5' }, ]; const insertUser = db.prepare(` INSERT OR IGNORE INTO users (email, password_hash, name, organisation, role, slug, cab_joined_date) VALUES (?, ?, ?, ?, 'cab', ?, NULL) `); for (const c of newCabs) { insertUser.run(c.email, hash, c.name, c.org, kebab(c.name)); } // Allocate member_numbers in member-since order, tiebreak id asc. // We backdate cab_joined_date first, then let allocateMemberNumber pick it up. // Lars: 0 weeks ago (most senior), then 2 / 4 / 6 weeks for the others. const cabRows = db.prepare("SELECT id, email, name FROM users WHERE role = 'cab' AND active = 1 ORDER BY id").all(); const tenureWeeks = { 'lars@virk2.dk': 24, 'anna@virk3.dk': 6, 'soren@virk4.dk': 4, 'henriette@virk5.dk': 2 }; const setCabMeta = db.prepare(` UPDATE users SET cab_joined_date = date('now', ?), title = ?, pull_quote = ?, focus_tags = ?, last_seen_at = datetime('now', '-2 minutes') WHERE id = ? `); const cabMeta = { 'lars@virk2.dk': { title: 'Senior Adviser, Operational Risk', pull_quote: 'A model is only as auditable as the chain of evidence behind it. That chain is the work.', focus_tags: ['Risk', 'Audit trail', 'GDPR'], }, 'anna@virk3.dk': { title: 'Director of Digital Services', pull_quote: 'Municipalities can\'t outsource sovereignty. We need tools that assume that.', focus_tags: ['Public sector', 'Sovereignty'], }, 'soren@virk4.dk': { title: 'Head of Data Engineering', pull_quote: 'Make it boring to deploy and surprising to query.', focus_tags: ['Infrastructure', 'Telemetry'], }, 'henriette@virk5.dk': { title: 'Lead Counsel, Compliance', pull_quote: 'I\'ve never trusted a system I couldn\'t cross-examine.', focus_tags: ['Legal', 'Policy', 'EU AI Act'], }, }; for (const u of cabRows) { const m = cabMeta[u.email] ?? null; if (!m) continue; const weeks = tenureWeeks[u.email] ?? 0; setCabMeta.run(`-${weeks} weeks`, m.title, m.pull_quote, JSON.stringify(m.focus_tags), u.id); } // Allocate member_numbers using the SQL backfill from migration 0004, // but only for cab rows that still lack one (idempotent). db.exec(` WITH ranked AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY COALESCE(cab_joined_date, created_at) ASC, id ASC) AS rn FROM users WHERE role = 'cab' ) UPDATE users SET member_number = (SELECT rn FROM ranked WHERE ranked.id = users.id) WHERE role = 'cab' AND member_number IS NULL; `); // Mark Mette + Jonathan recently seen too (for the avatar pile on /events) db.prepare("UPDATE users SET last_seen_at = datetime('now', '-2 minutes') WHERE id IN (?, ?)").run(mette.id, jon.id); // Re-fetch cab rows ordered by member_number const cabs = db.prepare("SELECT id, name, email, member_number FROM users WHERE role = 'cab' AND active = 1 ORDER BY member_number ASC").all(); console.log(` cab members: ${cabs.map(c => `${c.name} #${c.member_number}`).join(', ')}`); // ── Helper: SQL datetime string at offset seconds from now ───────── const nowIso = (offsetSeconds = 0) => { const d = new Date(Date.now() + offsetSeconds * 1000); return d.toISOString().replace('T', ' ').slice(0, 19); }; // ── Poll attached to a dispatch (the decision) — open, 2/4 voted ── // Polls are no longer standalone; they attach to a dispatch via pulse_id. // We create the pulse first, capture its id, and stamp it on the dispatch // when we INSERT it further down. const pulseOptions = [ 'Locking down on-prem deployment first', 'Pushing the traceability layer to GA', 'Going wide on document ingestion', 'Building the agentic query loop', ]; const decisionPulseId = db.prepare(` INSERT INTO pulses (question, context, options, opens_at, closes_at, status, created_by) VALUES (?,?,?,?,?,?,?) `).run( 'Which milestone should we anchor Q3 around?', 'Council input on this directly shapes what the team works on in July–September. Read the roadmap before voting.', JSON.stringify(pulseOptions), nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id, ).lastInsertRowid; // 2 votes — Lars and Anna db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') .run(decisionPulseId, cabs[0].id, 1, nowIso(-2 * 3600)); db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)') .run(decisionPulseId, cabs[1].id, 1, nowIso(-30 * 60)); // ── Roadmap: 7 items spanning shipping → considering, admin-ordered ── const roadmap = [ { title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 1, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] }, { title: 'Audit log export', description: 'One-click export of every model call, source, and reviewer action.', status: 'shipping', target: 'Next week', display_order: 2, shipped_at: nowIso(-1 * 24 * 3600), attributed: [] }, { title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'in_beta', target: 'July', display_order: 3, shipped_at: null, attributed: [cabs[1].id] }, { title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 4, shipped_at: null, attributed: [cabs[3].id] }, { title: 'Multi-tenant isolation', description: 'Strict per-organisation data boundaries for shared deployments.', status: 'exploring', target: 'Q4 2026', display_order: 5, shipped_at: null, attributed: [] }, { title: 'Federated learning hooks', description: 'Train shared models across council members without moving data.', status: 'considering', target: '2027', display_order: 6, shipped_at: null, attributed: [] }, { title: 'Open evaluation framework', description: 'A public benchmark suite for sovereign AI deployments.', status: 'considering', target: '2027', display_order: 7, shipped_at: null, attributed: [] }, ]; const insertRoad = db.prepare(` INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at) VALUES (?,?,?,?,?,?) `); const insertAttr = db.prepare('INSERT INTO roadmap_attributions (roadmap_item_id, user_id) VALUES (?,?)'); for (const r of roadmap) { const id = Number(insertRoad.run(r.title, r.description, r.status, r.target, r.display_order, r.shipped_at).lastInsertRowid); for (const uid of r.attributed) insertAttr.run(id, uid); } // ── Contributions: 3 entries; most recent gets 3 reactions ───────── const contribs = [ { user_id: cabs[2].id, type: 'idea', body_md: 'What if we let council members write **inline annotations** on the roadmap items they shaped? A trail of "here\'s what I pushed for."', when: nowIso(-2 * 60 * 60), reactors: [cabs[0].id, cabs[1].id, jon.id] }, { user_id: cabs[1].id, type: 'question', body_md: 'How will the traceability layer handle documents with conflicting metadata across versions?', when: nowIso(-2 * 24 * 60 * 60) }, { user_id: cabs[0].id, type: 'inspiration', body_md: 'A piece on **institutional memory** in regulated industries — it lines up almost exactly with what we\'re trying to build.', when: nowIso(-9 * 24 * 60 * 60) }, ]; const insertContrib = db.prepare(` INSERT INTO contributions (user_id, type, body_md, created_at) VALUES (?,?,?,?) `); const insertReact = db.prepare('INSERT INTO reactions (user_id, contribution_id) VALUES (?,?)'); for (const c of contribs) { const id = Number(insertContrib.run(c.user_id, c.type, c.body_md, c.when).lastInsertRowid); for (const uid of c.reactors ?? []) insertReact.run(uid, id); } // ── Dispatches: 4 published at staggered ages ────────────────────── const dispatchSeed = [ { kind: 'decision', ageDays: 2, title: 'We are deprioritising public-cloud parity for Q3', excerpt: 'After three weeks of pilot feedback, the team is locking the platform to on-prem and Hetzner sovereign cloud for the next quarter.', body: `After three weeks of pilot feedback — the kind of feedback that only happens when people are actually trying to deploy this thing — we are deprioritising public-cloud parity for Q3. The signal was unambiguous. Every council member we spoke to in May has the same constraint: the data cannot leave their network. AWS, Azure, GCP are non-starters not because of price but because of jurisdiction. So for Q3 the platform supports two deployment targets only — on-prem inside the customer's own VPC, and our Hetzner sovereign cloud in Helsinki. Everything else is parked. The agentic query work moves up a quarter to fill the gap. We'll revisit public cloud in Q4 once the on-prem story is boring.`, }, { kind: 'behind_the_scenes', ageDays: 5, title: 'A morning at the council kickoff', excerpt: 'Four members in the room, two on video. The whiteboard ended up with three lists: must-have, would-be-nice, do-not-build.', body: `Four council members in the room, two on video. The kickoff meeting was meant to be 90 minutes. It went four hours. The whiteboard ended up with three lists: must-have, would-be-nice, and — the most interesting one — do-not-build. Henriette pushed back hard on the "agent that emails on your behalf" pattern. "I don't want a system speaking on my legal team's behalf. Ever." That note alone reshaped a whole feature. Photos to come, with permission.`, }, { kind: 'update', ageDays: 9, title: 'Document ingestion is now feature-complete in beta', excerpt: 'PDF, Word, plain text. Chunking, metadata, deduplication, basic OCR. Three pilots have run it against their corpora.', body: `Document ingestion is feature-complete in beta. PDF, Word, plain text. Chunking, metadata extraction, deduplication, and basic OCR for scanned PDFs. Three pilots have now run it against their internal corpora — biggest was 47,000 documents, smallest was 380. Both worked. The 47k run took 8 hours and surfaced some neat edge cases (mostly around tables that span pages). Next week we open it to the full pilot group. We'll need notes.`, }, { kind: 'note', ageDays: 12, title: 'Welcome to the council', excerpt: 'A short note to mark the start. This page will fill up with decisions, half-built ideas, and things we have changed our minds about.', body: `This page will fill up with decisions, half-built ideas, and things we have changed our minds about. It is not a blog. It is the studio talking to the room — short, dated, signed.`, }, ]; const fenjas = db.prepare("SELECT id FROM users WHERE role = 'fenja' AND active = 1 ORDER BY id").all(); const insertDispatch = db.prepare(` INSERT INTO dispatches (title, body, excerpt, kind, author_id, status, published_at, created_at, updated_at, pulse_id) VALUES (?,?,?,?,?,'published',?,?,?,?) `); for (let i = 0; i < dispatchSeed.length; i += 1) { const d = dispatchSeed[i]; const when = nowIso(-d.ageDays * 24 * 60 * 60); const authorId = fenjas[i % fenjas.length].id; // Attach the decision-pulse to the decision dispatch — this is the demo // case for polls-as-articles. Other dispatches stay poll-free. const attachedPulse = d.kind === 'decision' ? decisionPulseId : null; insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when, attachedPulse); } // ── Events: 1 hero dinner, 1 studio hours, 1 working session, 2 past const insertEvent = db.prepare(` INSERT INTO events (slug, title, kind, description, location, starts_at, ends_at, capacity, photo_url, audience, duration_label, action_label, notes_url, created_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?) `); insertEvent.run( 'kickoff-dinner-2026-06', 'Council kickoff dinner', 'dinner', 'A private dinner at the studio. Conversation about what we ship next, no slides.', 'Studio, Refshalevej · Copenhagen', nowIso(38 * 24 * 3600), null, 12, null, 'Members only', null, null, null, jon.id, ); const dinnerSlug = 'kickoff-dinner-2026-06'; insertEvent.run( 'studio-hours-2026-05', 'Studio hours with Jonathan', 'office_hours', '30-minute slots. Open agenda. Drop in when you\'ve got something to talk through.', 'Virtual (link sent after RSVP)', nowIso(14 * 24 * 3600), null, null, null, 'Council members', '30 minutes', null, null, jon.id, ); insertEvent.run( 'working-session-2026-06', 'Working session: traceability UX', 'working_session', 'Three-person session to walk through the new traceability UI before it lands in beta.', 'Studio, Refshalevej · Copenhagen', nowIso(21 * 24 * 3600), null, 6, null, 'Council + Fenja team', '90 minutes', 'RSVP →', null, jon.id, ); insertEvent.run( 'apr-roundtable-2026', 'April roundtable', 'summit', 'A half-day session anchored around the EU AI Act compliance roadmap.', 'Studio, Refshalevej · Copenhagen', nowIso(-21 * 24 * 3600), null, 10, null, 'Members only', null, null, 'https://example.invalid/notes/april', jon.id, ); const aprilSlug = 'apr-roundtable-2026'; insertEvent.run( 'march-launch-dinner', 'Launch dinner', 'dinner', 'The first dinner. Where the council was formally introduced.', 'Aamanns Etablissement · Copenhagen', nowIso(-52 * 24 * 3600), null, 10, null, 'Members only', null, null, null, jon.id, ); const marchSlug = 'march-launch-dinner'; // Past-event RSVPs (drives the "attended_count" on past cards) const insertRsvp = db.prepare(` INSERT INTO attendance (user_id, meeting_slug, kind, status, updated_at) VALUES (?, ?, 'event', 'yes', datetime('now')) `); for (const c of cabs) insertRsvp.run(c.id, aprilSlug); for (const c of cabs.slice(0, 3)) insertRsvp.run(c.id, marchSlug); // Hero dinner: 1 confirmed so far (Lars) — keeps the avatar pile small at first insertRsvp.run(cabs[0].id, dinnerSlug); // ── Activity rows for the (now hidden but still-written) feed ────── const insertActivity = db.prepare(` INSERT INTO activity (actor_id, kind, subject_type, subject_id, created_at) VALUES (?,?,?,?,?) `); insertActivity.run(jon.id, 'pulse_opened', 'pulse', decisionPulseId, nowIso(-3600)); insertActivity.run(cabs[0].id,'voted', 'pulse', decisionPulseId, nowIso(-2 * 3600)); insertActivity.run(cabs[1].id,'voted', 'pulse', decisionPulseId, nowIso(-30 * 60)); insertActivity.run(cabs[0].id,'rsvped', 'event', db.prepare("SELECT id FROM events WHERE slug = ?").get(dinnerSlug).id, nowIso(-8 * 3600)); console.log(' pulse #' + decisionPulseId + ' open, 2 of 4 voted'); console.log(' roadmap: 7 items (2 shipping / 1 in_beta / 2 exploring / 2 considering)'); console.log(' contributions: 3 (most recent has 3 reactions)'); console.log(' dispatches: 4 published (2/5/9/12 days ago)'); console.log(' events: dinner + studio hours + working session, 2 past'); db.close();