project-bifrost-platform/scripts/seed-demo.js
Arlind a9e8a57642 feat(roadmap): feature bullets, live items, larger scroll-locked route
- Add per-item feature bullets (features column, migration 0009, db helpers,
  admin field) rendered as plus-icon lists on desktop + mobile.
- Reseed with the live roadmap items; status labels renamed
  (In dev / Planning) and "— Alpha" suffix dropped from titles.
- Enlarge the route and lock the roadmap page to sideways-only scroll so the
  timeline stays on screen; full-bleed edge-to-edge width; nudge the header
  down toward the page centre.
- Small-caps stage suffix helper (splitStageSuffix) in format.ts.
2026-06-18 16:04:56 +02:00

405 lines
21 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@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' },
{ name: 'Mads Lindberg', email: 'mads@virk6.dk', org: 'Virksomhed 6' },
{ name: 'Camilla Storm', email: 'camilla@virk7.dk', org: 'Virksomhed 7' },
{ name: 'Frederik Lund', email: 'frederik@virk8.dk', org: 'Virksomhed 8' },
];
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': 14,
'soren@virk4.dk': 12,
'henriette@virk5.dk': 10,
'mads@virk6.dk': 8,
'camilla@virk7.dk': 6,
'frederik@virk8.dk': 3,
};
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'],
},
'mads@virk6.dk': {
title: 'Chief Strategy Officer',
pull_quote: 'Healthcare runs on consent — and consent runs on trust.',
focus_tags: ['Healthcare', 'Consent', 'Governance'],
},
'camilla@virk7.dk': {
title: 'Head of Cyber Resilience',
pull_quote: 'Cyber resilience is not a feature — it is the substrate.',
focus_tags: ['Defence', 'Resilience'],
},
'frederik@virk8.dk': {
title: 'Director of Public Innovation',
pull_quote: 'Public innovation succeeds when it is measurably better, not just newer.',
focus_tags: ['Public sector', 'Measurement'],
},
};
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 JulySeptember. 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: mirrors the live bifrost-portal.fenja.ai/roadmap items
// (titles, statuses, target months, order). Descriptions are completed from
// the truncated live text; the `features` plus-icon bullets are drafted from
// each item's description.
const roadmap = [
{ title: 'MCP Usage', status: 'shipping', target: 'July 2026', display_order: 1, shipped_at: nowIso(-3 * 24 * 3600), attributed: [], metadata_text: null,
description: 'Connect Fenja to external MCP servers — databases, internal tools, and other software — so it can read from and act across your systems.',
features: ['Connect external MCP servers', 'Reach databases, internal tools & software', 'Per-connection credentials & scoping'] },
{ title: 'Extended Logging', status: 'shipping', target: 'July 2026', display_order: 2, shipped_at: nowIso(-2 * 24 * 3600), attributed: [], metadata_text: null,
description: 'Granular logging and audit controls, capturing usage down to the individual user, request, and document.',
features: ['Granular usage & audit logging', 'Per-user, per-request, per-document trails', 'Exportable audit records'] },
{ title: 'Wiki 2.0', status: 'in_beta', target: 'July 2026', display_order: 3, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major wiki upgrade: version history with diffs between revisions, admin-locked pages, and a richer page structure.',
features: ['Version history with revision diffs', 'Admin-locked pages', 'Richer page structure'] },
{ title: 'Interview 2.0', status: 'planned', target: 'July 2026', display_order: 4, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major Interviews upgrade: build and manage interview scripts, send invites, and track responses in one place.',
features: ['Build & manage interview scripts', 'Send invites & track responses', 'Centralised results view'] },
{ title: 'Fenja Analyze', status: 'planned', target: 'August 2026', display_order: 5, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Fenja Analyze: ask a question in plain language, run it as a query against your data, and get an answer with sources.',
features: ['Plain-language questions → queries', 'Answers grounded in your data', 'Source citations'] },
{ title: 'Fenja Agentic', status: 'planned', target: 'September 2026', display_order: 6, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Agents: create and orchestrate agents from an admin panel, wiring them to the tools and data they need.',
features: ['Create agents from an admin panel', 'Orchestrate multi-agent workflows', 'Wire agents to tools & data'] },
{ title: 'Fenja Dev', status: 'planned', target: 'September 2026', display_order: 7, shipped_at: null, attributed: [], metadata_text: null,
description: 'The first release of Fenja Dev: a sovereign IDE with terminal and git integration, running entirely inside your environment.',
features: ['Sovereign in-environment IDE', 'Integrated terminal', 'Git integration'] },
{ title: 'HTML Reports', status: 'planned', target: 'September 2026', display_order: 8, shipped_at: null, attributed: [], metadata_text: null,
description: 'Structured reporting for Fenja Analyze: admins define report templates so generated analyses render as clean, shareable HTML.',
features: ['Admin-defined report templates', 'Analyses rendered as clean HTML', 'Shareable, structured output'] },
{ title: 'Self-Service Agents', status: 'planned', target: 'October 2026', display_order: 9, shipped_at: null, attributed: [], metadata_text: null,
description: 'A major expansion of the agent experience, letting users create and run their own agents without admin involvement.',
features: ['User-created agents (no admin needed)', 'Run & manage your own agents', 'Reuse across tasks'] },
{ title: 'Self-service Routines & Skills', status: 'exploring', target: 'October 2026', display_order: 10, shipped_at: null, attributed: [], metadata_text: null,
description: "Personal and domain-level routines and skills: users build reusable, tailored workflows for their own and their team's recurring tasks.",
features: ['Personal & domain-level routines', 'Reusable, tailored workflows', 'Built for recurring team tasks'] },
];
const insertRoad = db.prepare(`
INSERT INTO roadmap_items (title, description, status, target, display_order, shipped_at, metadata_text, features)
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, r.metadata_text, JSON.stringify(r.features ?? [])).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 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.`,
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 ${cabs.length} voted`);
console.log(' roadmap: 10 items (2 shipping / 1 in_beta / 6 planned / 1 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();