import { randomBytes } from 'node:crypto' import { uuidv7 } from '@sims/domain' import { createStaff } from './auth' import { writeAudit } from './audit' import type { DB } from './db' import { createModule } from './repos-modules' import { SCHEDULE_DEFAULTS, type ScheduleRuleKind } from './repos-reminders' /** First-boot seed: owner login, company settings placeholders, GST18 tax class. */ // First-boot bootstrap owner only (empty-DB chicken-and-egg: no employees yet → nobody // to log in → can't open the Employees page). Set HQ_OWNER_EMAIL at deploy so the first // employee IS the real owner; all other staff are then created in the owner-only // Employees page. Neutral fallback keeps any company address out of the code. const OWNER_EMAIL = process.env['HQ_OWNER_EMAIL']?.trim() || 'simssoftware13@gmail.com' const SETTING_DEFAULTS: Record = { 'company.name': 'SiMS', 'company.address': '', 'company.gstin': '', 'company.state_code': '32', // Kerala — founder to confirm 'company.phone': '', 'company.email': OWNER_EMAIL, 'company.bank': '', } const INTERACTION_TYPES: [string, string][] = [ ['call', 'Call'], ['site_visit', 'Site visit'], ['training', 'Training'], ['courtesy_meeting', 'Courtesy meeting'], ['committee_meeting', 'Committee meeting'], ['demo', 'Demo'], ['complaint', 'Complaint'], ] const REMINDER_SETTINGS: Record = { 'reminders.overdue_days': '7', 'reminders.renewal_days': '15', } // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). const INSERT_SETTING_SQL = `INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO NOTHING` export async function seedIfEmpty(db: DB): Promise { const staff = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM staff_user`))! if (staff.n === 0) { // 9 random bytes → 12 base64url chars; printed exactly once, never stored in clear. const password = randomBytes(9).toString('base64url') await createStaff(db, { email: OWNER_EMAIL, displayName: 'Owner', role: 'owner', password }) console.log(`SiMS HQ first boot — owner ${OWNER_EMAIL} password: ${password} (change after first login)`) } let seededSettings = 0 for (const [key, value] of Object.entries(SETTING_DEFAULTS)) { seededSettings += (await db.run(INSERT_SETTING_SQL, key, value)).changes } if (seededSettings > 0) { await writeAudit(db, 'system', 'seed', 'setting', 'company.*', undefined, SETTING_DEFAULTS) } const gst = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM tax_class WHERE class_code='GST18'`))! if (gst.n === 0) { await db.run( `INSERT INTO tax_class (class_code, rate_pct_bp, effective_from) VALUES ('GST18', 1800, '2017-07-01')`, ) await writeAudit(db, 'system', 'seed', 'tax_class', 'GST18', undefined, { ratePctBp: 1800, effectiveFrom: '2017-07-01' }) } let seededTypes = 0 for (const [code, label] of INTERACTION_TYPES) { // Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE). seededTypes += (await db.run( `INSERT INTO interaction_type (code, label) VALUES (?, ?) ON CONFLICT (code) DO NOTHING`, code, label, )).changes } if (seededTypes > 0) await writeAudit(db, 'system', 'seed', 'interaction_type', '*', undefined, { count: seededTypes }) let seededReminderSettings = 0 for (const [key, value] of Object.entries(REMINDER_SETTINGS)) { seededReminderSettings += (await db.run(INSERT_SETTING_SQL, key, value)).changes } if (seededReminderSettings > 0) await writeAudit(db, 'system', 'seed', 'setting', 'reminders.*', undefined, REMINDER_SETTINGS) // Billing defaults (D18): payment terms in days — issueDocument stamps // due_date = doc_date + terms on invoices that don't carry one. const BILLING_SETTINGS: Record = { 'billing.payment_terms_days': '15' } let seededBilling = 0 for (const [key, value] of Object.entries(BILLING_SETTINGS)) { seededBilling += (await db.run(INSERT_SETTING_SQL, key, value)).changes } if (seededBilling > 0) await writeAudit(db, 'system', 'seed', 'setting', 'billing.*', undefined, BILLING_SETTINGS) // Onboarding milestone template (D22): the standard checklist every new project // (client_module) starts with. Config over code — owner-editable; a project can also add // its own milestones. Order here is the display order. const MILESTONE_TEMPLATE = JSON.stringify([ { key: 'advance_paid', label: 'Advance payment received' }, { key: 'setup_done', label: 'Setup / configuration done' }, { key: 'installed', label: 'Installation done' }, { key: 'go_live', label: 'Go-live' }, { key: 'balance_paid', label: 'Balance payment received' }, { key: 'amc_start', label: 'AMC / renewal start' }, ]) const seededMilestone = (await db.run(INSERT_SETTING_SQL, 'project.milestone_template', MILESTONE_TEMPLATE)).changes if (seededMilestone > 0) await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template', undefined, { set: true }) // Dated reminder schedules (rule 3 / D-REMIND): one open-ended row per rule kind, // matching the code-constant fallback. A cadence change is a NEW dated row, not an edit. const scheduleKinds: ScheduleRuleKind[] = ['quote_followup', 'invoice_overdue'] for (const ruleKind of scheduleKinds) { const have = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM reminder_schedule WHERE rule_kind=?`, ruleKind))! if (have.n > 0) continue const d = SCHEDULE_DEFAULTS[ruleKind] const id = uuidv7() const effectiveFrom = '2020-01-01' // predates all HQ data — active for every business date await db.transaction(async () => { await db.run( `INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body) VALUES (?, ?, ?, NULL, ?, ?, ?)`, id, ruleKind, effectiveFrom, d.dayOffsets.join(','), d.subject, d.body, ) await writeAudit(db, 'system', 'seed', 'reminder_schedule', id, undefined, { ruleKind, effectiveFrom, dayOffsets: d.dayOffsets.join(','), }) }) } const amc = (await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM module WHERE code='AMC'`))! if (amc.n === 0) { // The AMC/renewal invoice line hangs off a real module so it flows through // createDraft → computeBill unchanged. SAC 998719 (maintenance/repair) — CA to confirm. await createModule(db, 'system', { code: 'AMC', name: 'Annual Maintenance Contract', sac: '998719', allowedKinds: ['yearly', 'one_time'], }) } }