project-bifrost-platform/scripts/seed-demo.js
Jonathan Hvid ed2c272d3a chore: Studio hours rename + Phase 2 demo seed
Studio hours rename pass (one commit, grep-driven):
  - /pulse right-card eyebrow: 'Office hours' → 'Studio hours'
  - scripts/seed-demo.js: event title + description match the spec
    ('Studio hours with Jonathan' · '30-minute slots. Open agenda. Drop in
    when you've got something to talk through.')
  - No code-level enum changes — the kind value office_hours is preserved
    for back-compat; display labels switch wherever surfaced (admin form
    select, /pulse eyebrow, /events list and meta)

scripts/seed-demo.js — Phase 2 demo state. Destructive in scope (wipes the
data tables it owns then re-inserts), idempotent on re-run:
  - 4 cab members: Lars (existing) + Anna Kjær / Søren Vedel / Henriette
    Rask. cab_joined_date staggered 24/6/4/2 weeks ago so tenures vary.
    title, pull_quote, focus_tags populated per spec. member_number
    backfilled via the same SQL pattern as migration 0004 (deterministic).
  - 1 active pulse with 2 of 4 council members voted. Vote count on /pulse
    now reads '2 of 4 council members have weighed in.' — the line voted
    test was designed to lock down.
  - 4 roadmap items: Traceability layer (shipping, attributed to Lars),
    Document ingestion (beta, attributed to Anna + Søren), Contextual
    memory (exploring, attributed to Henriette), Agentic query mode
    (exploring, unattributed).
  - 3 contributions, most recent ('inline annotations' idea by Søren) has
    3 reactions — populates the RecentlyFromTheCouncil card.
  - 4 published dispatches at 2/5/9/12 days ago covering all four kinds
    (decision / behind_the_scenes / update / note). Real-ish prose so the
    excerpt cutter has actual sentence boundaries to find.
  - Events: hero dinner 5w out, Studio hours 2w out, a working_session 3w
    out (exercising the new kind), April roundtable 3w ago with a
    notes_url, March launch dinner 7.5w ago without notes (exercises both
    past-card thumb modes). Hero dinner has 1 confirmed RSVP (Lars) to
    drive the avatar pile at small scale.
  - Activity rows for the (now-hidden but still-written) feed so admin's
    Activity tab has something to display.

Smoke (curl as Lars): /pulse renders 'Good afternoon, Lars.' · COUNCIL · 001
· '2 of 4' · Latest from the studio · Recently from the council · Studio
hours. /members shows all four members with pull quotes + focus pills.
/events shows the dinner hero, 'Save your seat →', Studio hours +
working session in 'also coming up', April and March in 'past gatherings'.
/dispatches lists all four; /dispatches/{slug} renders body + adjacent
prev/next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-11 16:16:24 +02:00

342 lines
16 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.

#!/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@kommune.dk', org: 'Kbh Kommune' },
{ name: 'Søren Vedel', email: 'soren@energinet.dk', org: 'Energinet' },
{ name: 'Henriette Rask',email: 'henriette@dnv.dk', org: 'Dansk Nationalbank' },
];
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@rigspolitiet.dk': 24, 'anna@kommune.dk': 6, 'soren@energinet.dk': 4, 'henriette@dnv.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@rigspolitiet.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@kommune.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@energinet.dk': {
title: 'Head of Data Engineering',
pull_quote: 'Make it boring to deploy and surprising to query.',
focus_tags: ['Infrastructure', 'Telemetry'],
},
'henriette@dnv.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);
};
// ── Pulse: open now, closes in 5 days, 2 of 4 voted ────────────────
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 pulseId = 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 JulySeptember. Read the roadmap before voting.',
JSON.stringify(pulseOptions),
nowIso(-3600), nowIso(5 * 24 * 3600), 'open', jon.id,
).lastInsertRowid;
// 2 votes from cabs[0] and cabs[1]
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[0].id, 1, nowIso(-2 * 3600));
db.prepare('INSERT INTO votes (pulse_id, user_id, option_index, voted_at) VALUES (?,?,?,?)')
.run(pulseId, cabs[1].id, 1, nowIso(-30 * 60));
// ── Roadmap: 1 shipping / 1 beta / 2 exploring, attributions ───────
const roadmap = [
{ title: 'Traceability layer', description: 'Every response cites its sources with structured provenance.', status: 'shipping', target: 'Live now', display_order: 10, shipped_at: nowIso(-2 * 24 * 3600), attributed: [cabs[0].id] },
{ title: 'Document ingestion pipeline', description: 'Upload PDF, Word, plain text. Chunked, indexed, retrievable.', status: 'beta', target: null, display_order: 10, shipped_at: null, attributed: [cabs[1].id, cabs[2].id] },
{ title: 'Contextual memory', description: 'The system learns the regulatory and organisational context over time.', status: 'exploring', target: 'Q3 2026', display_order: 10, shipped_at: null, attributed: [cabs[3].id] },
{ title: 'Agentic query mode', description: 'Multi-step retrieval and synthesis with full provenance.', status: 'exploring', target: 'Q4 2026', display_order: 20, 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)
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;
insertDispatch.run(d.title, d.body, d.excerpt, d.kind, authorId, when, when, when);
}
// ── 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', pulseId, nowIso(-3600));
insertActivity.run(cabs[0].id,'voted', 'pulse', pulseId, nowIso(-2 * 3600));
insertActivity.run(cabs[1].id,'voted', 'pulse', pulseId, 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 #' + pulseId + ' open, 2 of 4 voted');
console.log(' roadmap: 1 shipping / 1 beta / 2 exploring');
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();