From 1ee5f1e8f266971bb127827679b5b233f2a32d51 Mon Sep 17 00:00:00 2001 From: eric Date: Sun, 5 Apr 2026 19:11:25 -0400 Subject: [PATCH] feat: add deterministic demo seed script for live presentations Adds seed-demo.js with fixed UUIDs so provenance URLs are predictable and QR codes can be pre-printed. Includes 6 workers, 3 shifts (1 open for today), 2 lots (full + partial custody chain), and a --reset flag. Closes #117 Co-Authored-By: Claude Opus 4.6 (1M context) --- backend/package.json | 1 + backend/src/db/seed-demo.js | 295 ++++++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+) create mode 100644 backend/src/db/seed-demo.js diff --git a/backend/package.json b/backend/package.json index 3f6dc59..1eac70a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -7,6 +7,7 @@ "start": "node src/server.js", "dev": "node --watch src/server.js", "seed": "node src/db/seed.js", + "seed:demo": "node src/db/seed-demo.js", "backup": "node src/scripts/backup.js", "test": "node --test test/api.test.js" }, diff --git a/backend/src/db/seed-demo.js b/backend/src/db/seed-demo.js new file mode 100644 index 0000000..3ff86c1 --- /dev/null +++ b/backend/src/db/seed-demo.js @@ -0,0 +1,295 @@ +'use strict'; + +require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '..', '.env') }); + +const { getDb, closeDb } = require('./index'); + +/** + * Seed deterministic demo data for live presentations. + * + * Unlike seed.js (random UUIDs), this uses fixed IDs so provenance URLs + * are predictable and QR codes can be pre-printed. + * + * Safe to re-run: checks for existing demo farm before inserting. + * + * Usage: + * node src/db/seed-demo.js # seed demo data + * node src/db/seed-demo.js --reset # drop + re-seed + */ +function seedDemo() { + const db = getDb(); + + const reset = process.argv.includes('--reset'); + if (reset) { + console.log('Resetting database for fresh demo seed...'); + db.exec('DELETE FROM payments'); + db.exec('DELETE FROM transfers'); + db.exec('DELETE FROM lots'); + db.exec('DELETE FROM checkins'); + db.exec('DELETE FROM shifts'); + db.exec('DELETE FROM workers'); + db.exec('DELETE FROM farms'); + } + + const existing = db.prepare('SELECT id FROM farms WHERE id = ?').get(IDS.farm); + if (existing) { + console.log('Demo data already seeded. Use --reset to re-seed.'); + closeDb(); + return; + } + + console.log('Seeding demo data...'); + + const now = new Date(); + const today = now.toISOString().slice(0, 10); + const yesterday = new Date(now - 86400000); + const threeDaysAgo = new Date(now - 3 * 86400000); + const fmt = (d) => (d instanceof Date ? d : new Date(d)).toISOString().replace('T', ' ').slice(0, 19); + + // ── Farm ────────────────────────────────────────────────────────── + db.prepare(` + INSERT INTO farms (id, name, location, altitude_m, owner_name, created_at) + VALUES (?, ?, ?, ?, ?, ?) + `).run(IDS.farm, 'Finca El Salvador', 'Santa Ana, El Salvador', 1400, 'Carlos Mendoza', fmt(threeDaysAgo)); + + // ── Foreman ─────────────────────────────────────────────────────── + db.prepare(` + INSERT INTO workers (id, farm_id, name, phone, role, liquid_address, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(IDS.foreman, IDS.farm, 'Miguel Angel Torres', '+503-7234-5678', + 'foreman', 'tex1qforeman0000000000000000000000000000000', fmt(threeDaysAgo)); + + // ── Workers ─────────────────────────────────────────────────────── + const workers = [ + { id: IDS.workers[0], name: 'Ana Garcia', phone: '+503-7111-0001' }, + { id: IDS.workers[1], name: 'Roberto Hernandez', phone: '+503-7111-0002' }, + { id: IDS.workers[2], name: 'Maria Lopez', phone: '+503-7111-0003' }, + { id: IDS.workers[3], name: 'Jose Martinez', phone: '+503-7111-0004' }, + { id: IDS.workers[4], name: 'Carmen Rodriguez', phone: '+503-7111-0005' }, + { id: IDS.workers[5], name: 'Luis Ramirez', phone: '+503-7111-0006' }, + ]; + + const insertWorker = db.prepare(` + INSERT INTO workers (id, farm_id, name, phone, role, liquid_address, created_at) + VALUES (?, ?, ?, ?, 'worker', ?, ?) + `); + workers.forEach((w, i) => { + insertWorker.run(w.id, IDS.farm, w.name, w.phone, + 'liq1qworker00000000000000000000000000000' + (i + 1), fmt(threeDaysAgo)); + }); + + // ── Shift 1: 3 days ago, closed ────────────────────────────────── + insertShift(db, { + id: IDS.shifts[0], farmId: IDS.farm, foremanId: IDS.foreman, + date: new Date(now - 3 * 86400000), status: 'closed', + workerIds: IDS.workers.slice(0, 5), fmt, + }); + + // ── Shift 2: yesterday, closed ─────────────────────────────────── + insertShift(db, { + id: IDS.shifts[1], farmId: IDS.farm, foremanId: IDS.foreman, + date: yesterday, status: 'closed', + workerIds: IDS.workers.slice(0, 4), fmt, + }); + + // ── Shift 3: today, open (for live demo check-ins) ────────────── + insertShift(db, { + id: IDS.shifts[2], farmId: IDS.farm, foremanId: IDS.foreman, + date: now, status: 'open', + workerIds: IDS.workers.slice(0, 2), fmt, // only 2 checked in so far + }); + + // ── Lot 1: full custody chain (linked to shift 1) ──────────────── + const lot1Asset = 'liq_' + IDS.lots[0].replace(/-/g, '').slice(0, 32); + db.prepare(` + INSERT INTO lots (id, shift_id, farm_id, weight_kg, grade, gps_lat, gps_lng, asset_id, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(IDS.lots[0], IDS.shifts[0], IDS.farm, 380.5, 'A', 13.9942, -89.5469, lot1Asset, + 'Primera cosecha de la temporada. Cerezas rojas maduras.', + fmt(new Date(threeDaysAgo.getTime() + 9 * 3600000))); + + insertCustodyChain(db, IDS.lots[0], threeDaysAgo, fmt); + + // ── Lot 2: partial chain (linked to shift 2, only to dry mill) ─── + const lot2Asset = 'liq_' + IDS.lots[1].replace(/-/g, '').slice(0, 32); + db.prepare(` + INSERT INTO lots (id, shift_id, farm_id, weight_kg, grade, gps_lat, gps_lng, asset_id, notes, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(IDS.lots[1], IDS.shifts[1], IDS.farm, 245.0, 'B', 13.9950, -89.5475, lot2Asset, + 'Segunda cosecha. Mix of red and yellow cherries.', + fmt(new Date(yesterday.getTime() + 9 * 3600000))); + + insertPartialCustodyChain(db, IDS.lots[1], yesterday, fmt); + + // ── Payments: shift 1 (paid), shift 2 (pending) ────────────────── + const PAY_RATE = 5000; + const insertPayment = db.prepare(` + INSERT INTO payments (id, worker_id, shift_id, amount_sats, lightning_invoice, payment_hash, status, paid_at, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + // Shift 1 — all 5 paid + IDS.workers.slice(0, 5).forEach((wid, i) => { + insertPayment.run( + IDS.payments.shift1[i], wid, IDS.shifts[0], PAY_RATE, + 'lnbc50u1demo_invoice_' + wid.slice(0, 8), + 'payment_hash_' + wid.slice(0, 8), + 'paid', + fmt(new Date(threeDaysAgo.getTime() + 10 * 3600000 + i * 60000)), + fmt(threeDaysAgo)); + }); + + // Shift 2 — 4 pending + IDS.workers.slice(0, 4).forEach((wid, i) => { + insertPayment.run( + IDS.payments.shift2[i], wid, IDS.shifts[1], PAY_RATE, + null, null, 'pending', null, fmt(yesterday)); + }); + + closeDb(); + + console.log(''); + console.log('Demo seed complete!'); + console.log(''); + console.log(' Farm: Finca El Salvador'); + console.log(' Foreman: Miguel Angel Torres'); + console.log(' Workers: 6'); + console.log(' Shifts: 2 closed + 1 open (today)'); + console.log(' Lots: 2 (1 full chain, 1 partial)'); + console.log(''); + console.log(' Lot 1 (full chain): ' + IDS.lots[0]); + console.log(' Lot 2 (partial): ' + IDS.lots[1]); + console.log(' Today\'s open shift: ' + IDS.shifts[2]); + console.log(''); + console.log(' Provenance URLs:'); + console.log(' http://localhost:3000/provenance/' + IDS.lots[0]); + console.log(' http://localhost:3000/provenance/' + IDS.lots[1]); + console.log(''); +} + +// ── Deterministic IDs ───────────────────────────────────────────────── +// Fixed UUIDs so provenance URLs and QR codes are stable across re-seeds. +const IDS = { + farm: 'f0000000-0000-4000-a000-000000000001', + foreman: 'w0000000-0000-4000-a000-000000000010', + workers: [ + 'w0000000-0000-4000-a000-000000000001', + 'w0000000-0000-4000-a000-000000000002', + 'w0000000-0000-4000-a000-000000000003', + 'w0000000-0000-4000-a000-000000000004', + 'w0000000-0000-4000-a000-000000000005', + 'w0000000-0000-4000-a000-000000000006', + ], + shifts: [ + 's0000000-0000-4000-a000-000000000001', + 's0000000-0000-4000-a000-000000000002', + 's0000000-0000-4000-a000-000000000003', + ], + lots: [ + 'a0000000-cafe-4000-a000-000000000001', + 'a0000000-cafe-4000-a000-000000000002', + ], + checkins: { + shift1: ['c1000000-0000-4000-a000-00000000000' + 1, 'c1000000-0000-4000-a000-00000000000' + 2, 'c1000000-0000-4000-a000-00000000000' + 3, 'c1000000-0000-4000-a000-00000000000' + 4, 'c1000000-0000-4000-a000-00000000000' + 5], + shift2: ['c2000000-0000-4000-a000-00000000000' + 1, 'c2000000-0000-4000-a000-00000000000' + 2, 'c2000000-0000-4000-a000-00000000000' + 3, 'c2000000-0000-4000-a000-00000000000' + 4], + shift3: ['c3000000-0000-4000-a000-00000000000' + 1, 'c3000000-0000-4000-a000-00000000000' + 2], + }, + transfers: [ + 't0000000-0000-4000-a000-000000000001', + 't0000000-0000-4000-a000-000000000002', + 't0000000-0000-4000-a000-000000000003', + 't0000000-0000-4000-a000-000000000004', + 't0000000-0000-4000-a000-000000000005', + 't0000000-0000-4000-a000-000000000006', + 't0000000-0000-4000-a000-000000000007', + 't0000000-0000-4000-a000-000000000008', + ], + payments: { + shift1: ['p1000000-0000-4000-a000-00000000000' + 1, 'p1000000-0000-4000-a000-00000000000' + 2, 'p1000000-0000-4000-a000-00000000000' + 3, 'p1000000-0000-4000-a000-00000000000' + 4, 'p1000000-0000-4000-a000-00000000000' + 5], + shift2: ['p2000000-0000-4000-a000-00000000000' + 1, 'p2000000-0000-4000-a000-00000000000' + 2, 'p2000000-0000-4000-a000-00000000000' + 3, 'p2000000-0000-4000-a000-00000000000' + 4], + }, +}; + +// ── Helpers ─────────────────────────────────────────────────────────── + +function insertShift(db, { id, farmId, foremanId, date, status, workerIds, fmt }) { + const d = date instanceof Date ? date : new Date(date); + const qrData = JSON.stringify({ shiftId: id, farmId, foremanId, expiresAt: fmt(d) }); + const closedAt = status === 'closed' ? fmt(new Date(d.getTime() + 8 * 3600000)) : null; + + db.prepare(` + INSERT INTO shifts (id, farm_id, foreman_id, date, status, qr_data, liquid_tx, created_at, closed_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, farmId, foremanId, d.toISOString().slice(0, 10), status, qrData, + status === 'closed' ? 'liq_asset_shift_' + id.slice(0, 8) : null, + fmt(d), closedAt); + + // Check-ins + const shiftKey = id === IDS.shifts[0] ? 'shift1' : id === IDS.shifts[1] ? 'shift2' : 'shift3'; + const insertCheckin = db.prepare(` + INSERT INTO checkins (id, shift_id, worker_id, checked_in_at, signature) + VALUES (?, ?, ?, ?, ?) + `); + workerIds.forEach((wid, i) => { + insertCheckin.run( + IDS.checkins[shiftKey][i], id, wid, + fmt(new Date(d.getTime() + (i + 1) * 5 * 60000)), + 'sig_demo_' + wid.slice(0, 8)); + }); +} + +function insertCustodyChain(db, lotId, baseDate, fmt) { + const ins = db.prepare(` + INSERT INTO transfers (id, lot_id, from_entity, to_entity, entity_type, metadata, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + ins.run(IDS.transfers[0], lotId, + 'Finca El Salvador', 'Finca El Salvador', 'farm', + JSON.stringify({ action: 'harvest', weight_kg: 380.5, grade: 'A', workers: ['Ana Garcia', 'Roberto Hernandez', 'Maria Lopez', 'Jose Martinez', 'Carmen Rodriguez'], notes: 'Hand-picked, red cherries only' }), + fmt(new Date(baseDate.getTime() + 9 * 3600000))); + + ins.run(IDS.transfers[1], lotId, + 'Finca El Salvador', 'Beneficio Humedo Santa Ana', 'wet_mill', + JSON.stringify({ action: 'wet_processing', received_weight_kg: 380.5, processing_method: 'washed', fermentation_hours: 36, notes: 'Clean fermentation, no off-flavors' }), + fmt(new Date(baseDate.getTime() + 24 * 3600000))); + + ins.run(IDS.transfers[2], lotId, + 'Beneficio Humedo Santa Ana', 'Beneficio Seco Las Flores', 'dry_mill', + JSON.stringify({ action: 'dry_processing', received_weight_kg: 76.1, drying_method: 'raised_beds', drying_days: 14, final_moisture_pct: 11.5, defect_count: 3, screen_size: '17/18' }), + fmt(new Date(baseDate.getTime() + 20 * 86400000))); + + ins.run(IDS.transfers[3], lotId, + 'Beneficio Seco Las Flores', 'Caravela Coffee El Salvador', 'exporter', + JSON.stringify({ action: 'export_preparation', received_weight_kg: 75.0, export_weight_kg: 69.0, ico_certificate: 'ICO-SLV-2026-CAFE01', destination: 'Portland, OR, USA' }), + fmt(new Date(baseDate.getTime() + 25 * 86400000))); + + ins.run(IDS.transfers[4], lotId, + 'Caravela Coffee El Salvador', 'Heart Coffee Roasters', 'roaster', + JSON.stringify({ action: 'roasting', received_weight_kg: 69.0, roasted_weight_kg: 58.6, roast_profile: 'medium', cupping_score: 86.5, tasting_notes: 'Chocolate, citrus, brown sugar' }), + fmt(new Date(baseDate.getTime() + 40 * 86400000))); +} + +function insertPartialCustodyChain(db, lotId, baseDate, fmt) { + const ins = db.prepare(` + INSERT INTO transfers (id, lot_id, from_entity, to_entity, entity_type, metadata, timestamp) + VALUES (?, ?, ?, ?, ?, ?, ?) + `); + + ins.run(IDS.transfers[5], lotId, + 'Finca El Salvador', 'Finca El Salvador', 'farm', + JSON.stringify({ action: 'harvest', weight_kg: 245.0, grade: 'B', workers: ['Ana Garcia', 'Roberto Hernandez', 'Maria Lopez', 'Jose Martinez'], notes: 'Mix of red and yellow cherries' }), + fmt(new Date(baseDate.getTime() + 9 * 3600000))); + + ins.run(IDS.transfers[6], lotId, + 'Finca El Salvador', 'Beneficio Humedo Santa Ana', 'wet_mill', + JSON.stringify({ action: 'wet_processing', received_weight_kg: 245.0, processing_method: 'honey', fermentation_hours: 24, notes: 'Honey process — partial mucilage removed' }), + fmt(new Date(baseDate.getTime() + 24 * 3600000))); + + ins.run(IDS.transfers[7], lotId, + 'Beneficio Humedo Santa Ana', 'Beneficio Seco Las Flores', 'dry_mill', + JSON.stringify({ action: 'dry_processing', received_weight_kg: 53.9, drying_method: 'patio', drying_days: 18, final_moisture_pct: 11.8, defect_count: 7, screen_size: '15/16' }), + fmt(new Date(baseDate.getTime() + 20 * 86400000))); +} + +seedDemo();