// Loads demo data into an empty database: `npm run db:seed`. Not for production use. import { hashPassword } from '../auth/password.ts' import { loadConfig } from '../config.ts' import { openDatabase } from './client.ts' import { assessRecordability } from '../../shared/osha.ts' import { auditLog, areas, correctiveActions, hoursWorked, incidentCounters, incidents, injuredPersons, investigations, organizations, rootCauses, sites, users } from './schema.ts' const DEMO_PASSWORD = 'safetylog-demo' const config = loadConfig() const { db, close } = await openDatabase(config.databaseUrl) try { if ((await db.select({ id: users.id }).from(users).limit(1)).length > 0) { console.error('Database already has users; seeding is only for empty databases.') process.exitCode = 1 } else { await db.transaction(async (tx) => { const [org] = await tx.insert(organizations).values({ name: 'Northstar Co.' }).returning() const passwordHash = await hashPassword(DEMO_PASSWORD) const people = await tx .insert(users) .values([ { name: 'Jordan Lee', email: 'admin@example.com', role: 'admin' as const }, { name: 'Priya Shah', email: 'manager@example.com', role: 'safety_manager' as const }, { name: 'Sam Rivera', email: 'supervisor@example.com', role: 'supervisor' as const }, { name: 'Maya Chen', email: 'reporter@example.com', role: 'reporter' as const }, ].map((u) => ({ ...u, organizationId: org.id, passwordHash }))) .returning() const person = (email: string) => people.find((p) => p.email === email)!.id const layout: Record = { 'North Campus': ['Building A', 'Warehouse 04', 'Lab 2'], 'South Campus': ['Fabrication 02', 'Yard', 'Office block'], 'West Campus': ['Warehouse 01', 'Loading dock'], } const areaIds: Record = {} for (const [siteName, buildings] of Object.entries(layout)) { const [site] = await tx.insert(sites).values({ organizationId: org.id, name: siteName }).returning() for (const name of buildings) { const [area] = await tx.insert(areas).values({ siteId: site.id, name, kind: 'building' }).returning() areaIds[name] = { siteId: site.id, areaId: area.id } } } const daysAgo = (days: number) => new Date(Date.now() - days * 86_400_000) const demo = [ { title: 'Damaged guardrail on mezzanine', type: 'property_damage', severity: 'low', status: 'closed', building: 'Warehouse 01', locationDetail: 'Mezzanine east', reporter: 'manager@example.com', days: 9 }, { title: 'Hand laceration during material handling', type: 'injury', severity: 'high', status: 'investigation', building: 'Fabrication 02', locationDetail: 'Line 3', reporter: 'supervisor@example.com', days: 7 }, { title: 'Wet floor near employee entrance', type: 'hazard', severity: 'medium', status: 'actions', building: 'Building A', locationDetail: 'Ground floor lobby', reporter: 'reporter@example.com', days: 6 }, { title: 'Forklift reversed into pallet stack', type: 'near_miss', severity: 'high', status: 'triage', building: 'Warehouse 04', locationDetail: 'Loading bay C', reporter: 'admin@example.com', days: 5 }, ] as const const created = await tx .insert(incidents) .values( demo.map((item, index) => ({ organizationId: org.id, number: index + 1, title: item.title, type: item.type, severity: item.severity, status: item.status, occurredAt: daysAgo(item.days), createdAt: new Date(daysAgo(item.days).getTime() + 2 * 3_600_000), ...areaIds[item.building], locationDetail: item.locationDetail, reporterId: person(item.reporter), closedAt: item.status === 'closed' ? daysAgo(item.days - 3) : null, })), ) .returning() await tx.insert(auditLog).values( created.map((incident) => ({ organizationId: org.id, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number: incident.number, title: incident.title }, createdAt: incident.createdAt, })), ) // A year of history so dashboards and the OSHA log have something to show const lastNumber = await seedHistory(tx as unknown as typeof db, org.id, demo.length, areaIds, people.map((p) => p.id)) await tx.insert(incidentCounters).values({ organizationId: org.id, lastNumber }) }) console.log(`Seeded demo data. Sign in as admin@example.com, manager@example.com, supervisor@example.com or reporter@example.com with password "${DEMO_PASSWORD}".`) } } finally { await close() } /** Deterministic pseudo-random numbers, so every seed produces the same demo. */ function random(seed: number) { let state = seed return () => { state = (state * 1_664_525 + 1_013_904_223) % 2 ** 32 return state / 2 ** 32 } } async function seedHistory( database: typeof db, organizationId: string, startNumber: number, areaIds: Record, userIds: string[], ): Promise { const rand = random(42) const pick = (items: readonly T[]) => items[Math.floor(rand() * items.length)] const day = 86_400_000 const now = Date.now() const locations = Object.values(areaIds) // Near misses and hazards outnumber injuries, as in a healthy reporting culture const types = ['near_miss', 'near_miss', 'near_miss', 'hazard', 'hazard', 'hazard', 'injury', 'injury', 'property_damage', 'environmental', 'illness'] as const // Busier around shift changes (06–08, 14–16) and mid-shift const hours = [6, 7, 7, 9, 10, 11, 13, 14, 14, 15, 15, 16, 19, 22, 2] const titles = { near_miss: ['Forklift nearly struck pedestrian', 'Load slipped from pallet jack', 'Unsecured ladder almost fell', 'Dropped tool from mezzanine'], hazard: ['Oil leak under press', 'Blocked emergency exit', 'Damaged guard on conveyor', 'Frayed extension cord', 'Missing machine guard'], injury: ['Hand laceration from sheet metal', 'Back strain lifting boxes', 'Slip on wet floor', 'Pinched finger in die'], illness: ['Dermatitis from cutting fluid'], property_damage: ['Forklift hit racking', 'Damaged dock door'], environmental: ['Hydraulic fluid spill', 'Coolant leak to drain'], } as const const categories = ['people', 'process', 'equipment', 'materials', 'environment', 'management'] as const let number = startNumber for (let i = 0; i < 150; i++) { const type = pick(types) const occurred = new Date(now - Math.floor(rand() * 360 + 3) * day) occurred.setHours(pick(hours), Math.floor(rand() * 60), 0, 0) const created = new Date(occurred.getTime() + (1 + rand() * 6) * 3_600_000) const age = (now - occurred.getTime()) / day const status = age > 60 ? (rand() < 0.85 ? 'closed' : 'actions') : age > 20 ? pick(['investigation', 'actions', 'verification', 'closed'] as const) : pick(['reported', 'triage', 'investigation'] as const) const closedAt = status === 'closed' ? new Date(created.getTime() + (5 + rand() * 35) * day) : null number++ const [incident] = await database .insert(incidents) .values({ organizationId, number, title: pick(titles[type]), type, severity: type === 'injury' ? pick(['medium', 'high', 'high'] as const) : pick(['low', 'medium', 'medium', 'high'] as const), status, occurredAt: occurred, createdAt: created, closedAt: closedAt && closedAt.getTime() < now ? closedAt : null, ...pick(locations), reporterId: pick(userIds), }) .returning() const auditRows: (typeof auditLog.$inferInsert)[] = [ { organizationId, actorId: incident.reporterId, entityType: 'incident', entityId: incident.id, action: 'create', changes: { number, title: incident.title }, createdAt: created }, ] if (status !== 'reported') { // Most reports are triaged within a day; the team got quicker over the year const delayHours = rand() < 0.55 + (1 - age / 365) * 0.35 ? 2 + rand() * 18 : 26 + rand() * 70 auditRows.push({ organizationId, actorId: pick(userIds), entityType: 'incident', entityId: incident.id, action: 'status', changes: { status: ['reported', 'triage'] }, createdAt: new Date(created.getTime() + delayHours * 3_600_000) }) } await database.insert(auditLog).values(auditRows) if (['investigation', 'actions', 'verification', 'closed'].includes(status) && type !== 'near_miss') { const [investigation] = await database.insert(investigations).values({ incidentId: incident.id, method: 'five_whys', startedAt: created }).returning() const causes = await database .insert(rootCauses) .values(Array.from({ length: 1 + Math.floor(rand() * 2) }, () => ({ investigationId: investigation.id, category: pick(categories), description: 'Identified during investigation' }))) .returning() const due = new Date(created.getTime() + 21 * day) const done = status === 'closed' || status === 'verification' || rand() < 0.4 const completed = new Date(created.getTime() + (8 + rand() * 30) * day) await database.insert(correctiveActions).values({ incidentId: incident.id, rootCauseId: causes[0].id, title: 'Fix the cause and brief the team', assigneeId: pick(userIds), dueDate: due.toISOString().slice(0, 10), status: done && completed.getTime() < now ? (status === 'closed' ? 'verified' : 'done') : 'open', completedAt: done && completed.getTime() < now ? completed : null, }) } if ((type === 'injury' || type === 'illness') && rand() < 0.6) { const answers = { employmentType: 'employee' as const, workRelated: true, newCase: true, died: false, daysAwayFromWork: rand() < 0.3, restrictedOrTransfer: rand() < 0.4, medicalTreatment: rand() < 0.7, lossOfConsciousness: false, significantDiagnosis: false, specialCase: 'none' as const, } const verdict = assessRecordability(answers) await database.insert(injuredPersons).values({ incidentId: incident.id, caseNumber: `INC-${String(number).padStart(4, '0')}`, name: pick(['Alex Moreno', 'Jamie Patel', 'Chris Nguyen', 'Taylor Brooks', 'Morgan Diaz']), jobTitle: pick(['Press operator', 'Material handler', 'Maintenance technician', 'Assembler']), natureOfInjury: incident.title, caseType: type === 'illness' ? 'skin_disorder' : 'injury', ...answers, daysAway: answers.daysAwayFromWork ? 1 + Math.floor(rand() * 10) : 0, daysRestricted: answers.restrictedOrTransfer ? 2 + Math.floor(rand() * 14) : 0, recordable: verdict.status === 'recordable', outcome: verdict.outcome, }) } } // Monthly hours worked and headcount for every site, for the last 13 months const siteIds = [...new Set(locations.map((l) => l.siteId))] const today = new Date() for (const siteId of siteIds) { for (let m = 12; m >= 0; m--) { const month = new Date(today.getFullYear(), today.getMonth() - m, 1) const employees = 70 + Math.floor(rand() * 30) await database.insert(hoursWorked).values({ siteId, month: `${month.getFullYear()}-${String(month.getMonth() + 1).padStart(2, '0')}-01`, hours: String(employees * 168), averageEmployees: employees, }) } } return number }