You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
190 lines
9.1 KiB
TypeScript
190 lines
9.1 KiB
TypeScript
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'
|
|
import { TICKET_TYPE_FALLBACK } from './repos-tickets'
|
|
|
|
/** 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<string, string> = {
|
|
'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<string, string> = {
|
|
'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<void> {
|
|
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<string, string> = { '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)
|
|
|
|
// Ticket classification list (config over code): the Tickets desk reads `ticket.types`,
|
|
// falling back to TICKET_TYPE_FALLBACK in repos-tickets.ts when the row is missing.
|
|
// Order here is display order; the first entry is the default for a new ticket.
|
|
const seededTicketTypes = (await db.run(
|
|
INSERT_SETTING_SQL, 'ticket.types', JSON.stringify(TICKET_TYPE_FALLBACK))).changes
|
|
if (seededTicketTypes > 0) {
|
|
await writeAudit(db, 'system', 'seed', 'setting', 'ticket.types', undefined,
|
|
{ types: TICKET_TYPE_FALLBACK.map((t) => t.key) })
|
|
}
|
|
|
|
// Onboarding milestone template (D22), composed model: a shared "front" (same for every
|
|
// module — enquiry through quotation-approved) plus a per-module "tail". Config over code
|
|
// — owner-editable; a project can also add its own milestones. Order here is display order.
|
|
const MILESTONE_TEMPLATE_FRONT = JSON.stringify([
|
|
{ key: 'enquiry', label: 'Enquiry received' },
|
|
{ key: 'visit_meeting', label: 'Visit / meeting scheduled' },
|
|
{ key: 'quotation_sent', label: 'Quotation sent' },
|
|
{ key: 'quotation_approved', label: 'Quotation approved' },
|
|
])
|
|
const seededFront = (await db.run(
|
|
INSERT_SETTING_SQL, 'project.milestone_template.front', MILESTONE_TEMPLATE_FRONT)).changes
|
|
if (seededFront > 0) {
|
|
await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template.front', undefined, { set: true })
|
|
}
|
|
|
|
// Per-module onboarding tails (config over code). SMS/RTGS/Mobile App differ; each ends
|
|
// with go-live + payment received so a payment tick maps cleanly on migration.
|
|
const PER_MODULE_TEMPLATES: Record<string, { key: string; label: string }[]> = {
|
|
SMS: [
|
|
{ key: 'document_collection', label: 'Document collection' },
|
|
{ key: 'dlt_registration', label: 'DLT registration' },
|
|
{ key: 'account_creation', label: 'Account creation & registration' },
|
|
{ key: 'installation', label: 'Installation' },
|
|
{ key: 'testing', label: 'Testing' },
|
|
{ key: 'training', label: 'Training' },
|
|
{ key: 'go_live', label: 'Go-live' },
|
|
{ key: 'payment_received', label: 'Payment received' },
|
|
],
|
|
RTGS: [
|
|
{ key: 'document_collection', label: 'Document collection' },
|
|
{ key: 'server_checkup', label: 'Server checkup' },
|
|
{ key: 'setup_configuration', label: 'Setup & configuration' },
|
|
{ key: 'installation', label: 'Installation' },
|
|
{ key: 'testing', label: 'Testing' },
|
|
{ key: 'training', label: 'Training' },
|
|
{ key: 'go_live', label: 'Go-live' },
|
|
{ key: 'payment_received', label: 'Payment received' },
|
|
],
|
|
MOBILEAPP: [
|
|
{ key: 'requirement_setup', label: 'Requirement & setup' },
|
|
{ key: 'configuration', label: 'Configuration' },
|
|
{ key: 'data_import', label: 'Data import' },
|
|
{ key: 'installation', label: 'Installation' },
|
|
{ key: 'testing', label: 'Testing' },
|
|
{ key: 'training', label: 'Training' },
|
|
{ key: 'go_live', label: 'Go-live' },
|
|
{ key: 'payment_received', label: 'Payment received' },
|
|
],
|
|
}
|
|
let seededPerModule = 0
|
|
for (const [code, list] of Object.entries(PER_MODULE_TEMPLATES)) {
|
|
seededPerModule += (await db.run(
|
|
INSERT_SETTING_SQL, `project.milestone_template:${code}`, JSON.stringify(list))).changes
|
|
}
|
|
if (seededPerModule > 0) {
|
|
await writeAudit(db, 'system', 'seed', 'setting', 'project.milestone_template:*', undefined,
|
|
{ modules: Object.keys(PER_MODULE_TEMPLATES) })
|
|
}
|
|
|
|
// 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'],
|
|
})
|
|
}
|
|
}
|