diff --git a/apps/hq/scripts/demo-seed.ts b/apps/hq/scripts/demo-seed.ts
index bcec437..9998471 100644
--- a/apps/hq/scripts/demo-seed.ts
+++ b/apps/hq/scripts/demo-seed.ts
@@ -13,93 +13,95 @@ import { createDraft, issueDocument, markStatus } from '../src/repos-documents'
import { recordPayment } from '../src/repos-payments'
import { mintShare } from '../src/repos-shares'
-const db = openDb(process.env['HQ_DATA_DIR'])
+void (async () => {
+ const db = openDb(process.env['HQ_DATA_DIR'])
-// Known owner so we can log in for screenshots (before seedIfEmpty, so it skips its own).
-createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
-seedIfEmpty(db)
-const U = 'demo'
+ // Known owner so we can log in for screenshots (before seedIfEmpty, so it skips its own).
+ await createStaff(db, { email: 'owner@tecnostac.com', displayName: 'Thomas (Owner)', role: 'owner', password: 'demo-owner-2026' })
+ await seedIfEmpty(db)
+ const U = 'demo'
-// A simple monogram logo as an SVG data URI (renders in
and puppeteer).
-const LOGO = 'data:image/svg+xml;base64,' + Buffer.from(
- ``,
-).toString('base64')
+ // A simple monogram logo as an SVG data URI (renders in
and puppeteer).
+ const LOGO = 'data:image/svg+xml;base64,' + Buffer.from(
+ ``,
+ ).toString('base64')
-// Company + template letterhead data (what the Document Template page edits).
-const settings: Record = {
- 'company.name': 'Tecnostac Solutions Pvt Ltd',
- 'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030',
- 'company.gstin': '32ABCDE1234F1Z9',
- 'company.state_code': '32',
- 'company.phone': '+91 98470 12345',
- 'company.email': 'accounts@tecnostac.com',
- 'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad',
- 'template.accent': '#1e3a5f',
- 'template.logo': LOGO,
- 'template.terms': '1. Prices are valid for 15 days from the date of this quotation.\n2. 50% advance on confirmation, balance before go-live.\n3. Annual subscription renews on the anniversary of activation.',
- 'template.declaration': 'We declare that this invoice shows the actual price of the services described and that all particulars are true and correct.',
- 'template.jurisdiction': 'Subject to Ernakulam jurisdiction',
- 'template.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345',
- 'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd',
-}
-for (const [k, v] of Object.entries(settings)) setSetting(db, U, k, v)
+ // Company + template letterhead data (what the Document Template page edits).
+ const settings: Record = {
+ 'company.name': 'Tecnostac Solutions Pvt Ltd',
+ 'company.address': '2nd Floor, Cyber Park, Kakkanad, Kochi, Kerala 682030',
+ 'company.gstin': '32ABCDE1234F1Z9',
+ 'company.state_code': '32',
+ 'company.phone': '+91 98470 12345',
+ 'company.email': 'accounts@tecnostac.com',
+ 'company.bank': 'HDFC Bank · A/c 50200012345678 · IFSC HDFC0001234 · Kakkanad',
+ 'template.accent': '#1e3a5f',
+ 'template.logo': LOGO,
+ 'template.terms': '1. Prices are valid for 15 days from the date of this quotation.\n2. 50% advance on confirmation, balance before go-live.\n3. Annual subscription renews on the anniversary of activation.',
+ 'template.declaration': 'We declare that this invoice shows the actual price of the services described and that all particulars are true and correct.',
+ 'template.jurisdiction': 'Subject to Ernakulam jurisdiction',
+ 'template.footer_note': 'Thank you for your business. For support: support@tecnostac.com · +91 98470 12345',
+ 'template.signatory_label': 'For Tecnostac Solutions Pvt Ltd',
+ }
+ for (const [k, v] of Object.entries(settings)) await setSetting(db, U, k, v)
-// Clients — one out-of-state (Karnataka, 29) to show IGST; one lead.
-const malabar = createClient(db, U, { name: 'Malabar Supermarket', stateCode: '32', address: 'MG Road, Kozhikode, Kerala 673001', status: 'active', contacts: [{ name: 'Rajesh Nair', phone: '9847011111', email: 'rajesh@malabarsuper.in' }] }).id
-const lulu = createClient(db, U, { name: 'Lulu Hyper Kochi', stateCode: '32', address: 'Edappally, Kochi, Kerala 682024', status: 'active', contacts: [{ name: 'Fathima K', phone: '9847022222', email: 'it@lulukochi.in' }] }).id
-const blr = createClient(db, U, { name: 'Bengaluru Fresh Mart', stateCode: '29', address: 'Indiranagar, Bengaluru, Karnataka 560038', status: 'active', contacts: [{ name: 'Anil Kumar', phone: '9880033333', email: 'anil@blrfresh.in' }] }).id
-createClient(db, U, { name: 'Green Valley Stores', stateCode: '32', address: 'Thrissur, Kerala', status: 'lead', contacts: [{ name: 'Suresh', phone: '9847044444' }] })
+ // Clients — one out-of-state (Karnataka, 29) to show IGST; one lead.
+ const malabar = (await createClient(db, U, { name: 'Malabar Supermarket', stateCode: '32', address: 'MG Road, Kozhikode, Kerala 673001', status: 'active', contacts: [{ name: 'Rajesh Nair', phone: '9847011111', email: 'rajesh@malabarsuper.in' }] })).id
+ const lulu = (await createClient(db, U, { name: 'Lulu Hyper Kochi', stateCode: '32', address: 'Edappally, Kochi, Kerala 682024', status: 'active', contacts: [{ name: 'Fathima K', phone: '9847022222', email: 'it@lulukochi.in' }] })).id
+ const blr = (await createClient(db, U, { name: 'Bengaluru Fresh Mart', stateCode: '29', address: 'Indiranagar, Bengaluru, Karnataka 560038', status: 'active', contacts: [{ name: 'Anil Kumar', phone: '9880033333', email: 'anil@blrfresh.in' }] })).id
+ await createClient(db, U, { name: 'Green Valley Stores', stateCode: '32', address: 'Thrissur, Kerala', status: 'lead', contacts: [{ name: 'Suresh', phone: '9847044444' }] })
-// Modules + dated prices (GST-exclusive; the engine adds 18%).
-const pos = createModule(db, U, { code: 'POS', name: 'POS Billing', sac: '998314', allowedKinds: ['yearly', 'one_time'], quoteContent: ['Unlimited counters & cashiers', 'Offline billing with auto-sync', 'GST invoices + thermal print', 'Barcode & weighing-scale support'] }).id
-const inv = createModule(db, U, { code: 'INV', name: 'Inventory & GST Filing', sac: '998314', allowedKinds: ['yearly'], quoteContent: ['Stock, purchases, suppliers', 'GSTR-1 / 3B ready exports', 'Expiry & reorder alerts'] }).id
-const cloud = createModule(db, U, { code: 'CLOUD', name: 'Cloud Sync & Backup', sac: '998315', allowedKinds: ['monthly', 'yearly'], multiSubscription: true, quoteContent: ['Real-time multi-store sync', 'Nightly encrypted cloud backup'] }).id
-const sms = createModule(db, U, { code: 'SMS', name: 'SMS Pack', sac: '998414', allowedKinds: ['one_time'], quoteContent: ['Bill alerts & payment reminders to customers', 'Sender ID + DLT registration included'] }).id
+ // Modules + dated prices (GST-exclusive; the engine adds 18%).
+ const pos = (await createModule(db, U, { code: 'POS', name: 'POS Billing', sac: '998314', allowedKinds: ['yearly', 'one_time'], quoteContent: ['Unlimited counters & cashiers', 'Offline billing with auto-sync', 'GST invoices + thermal print', 'Barcode & weighing-scale support'] })).id
+ const inv = (await createModule(db, U, { code: 'INV', name: 'Inventory & GST Filing', sac: '998314', allowedKinds: ['yearly'], quoteContent: ['Stock, purchases, suppliers', 'GSTR-1 / 3B ready exports', 'Expiry & reorder alerts'] })).id
+ const cloud = (await createModule(db, U, { code: 'CLOUD', name: 'Cloud Sync & Backup', sac: '998315', allowedKinds: ['monthly', 'yearly'], multiSubscription: true, quoteContent: ['Real-time multi-store sync', 'Nightly encrypted cloud backup'] })).id
+ const sms = (await createModule(db, U, { code: 'SMS', name: 'SMS Pack', sac: '998414', allowedKinds: ['one_time'], quoteContent: ['Bill alerts & payment reminders to customers', 'Sender ID + DLT registration included'] })).id
-const FROM = '2026-04-01'
-setPrice(db, U, { moduleId: pos, kind: 'yearly', pricePaise: 18_000_00, effectiveFrom: FROM })
-setPrice(db, U, { moduleId: pos, kind: 'one_time', pricePaise: 25_000_00, effectiveFrom: FROM })
-setPrice(db, U, { moduleId: inv, kind: 'yearly', pricePaise: 12_000_00, effectiveFrom: FROM })
-setPrice(db, U, { moduleId: cloud, kind: 'monthly', pricePaise: 1_500_00, effectiveFrom: FROM })
-setPrice(db, U, { moduleId: sms, edition: 'SMS-50K', kind: 'one_time', pricePaise: 4_000_00, effectiveFrom: FROM })
-setPrice(db, U, { moduleId: sms, edition: 'SMS-1L', kind: 'one_time', pricePaise: 7_000_00, effectiveFrom: FROM })
+ const FROM = '2026-04-01'
+ await setPrice(db, U, { moduleId: pos, kind: 'yearly', pricePaise: 18_000_00, effectiveFrom: FROM })
+ await setPrice(db, U, { moduleId: pos, kind: 'one_time', pricePaise: 25_000_00, effectiveFrom: FROM })
+ await setPrice(db, U, { moduleId: inv, kind: 'yearly', pricePaise: 12_000_00, effectiveFrom: FROM })
+ await setPrice(db, U, { moduleId: cloud, kind: 'monthly', pricePaise: 1_500_00, effectiveFrom: FROM })
+ await setPrice(db, U, { moduleId: sms, edition: 'SMS-50K', kind: 'one_time', pricePaise: 4_000_00, effectiveFrom: FROM })
+ await setPrice(db, U, { moduleId: sms, edition: 'SMS-1L', kind: 'one_time', pricePaise: 7_000_00, effectiveFrom: FROM })
-// Client 360 modules
-assignModule(db, U, { clientId: malabar, moduleId: pos, kind: 'yearly' })
-assignModule(db, U, { clientId: malabar, moduleId: inv, kind: 'yearly' })
+ // Client 360 modules
+ await assignModule(db, U, { clientId: malabar, moduleId: pos, kind: 'yearly' })
+ await assignModule(db, U, { clientId: malabar, moduleId: inv, kind: 'yearly' })
-// A quotation for Malabar (POS yearly + Inventory + SMS 1-Lakh pack) → issued + share link.
-const qt = createDraft(db, U, {
- docType: 'QUOTATION', clientId: malabar,
- lines: [
- { moduleId: pos, qty: 1, kind: 'yearly' },
- { moduleId: inv, qty: 1, kind: 'yearly' },
- { moduleId: sms, qty: 1, kind: 'one_time', edition: 'SMS-1L' },
- ],
- terms: 'Prices valid for 15 days. 50% advance on confirmation.',
-})
-const qtIssued = issueDocument(db, U, qt.id)
-markStatus(db, U, qtIssued.id, 'sent')
-mintShare(db, U, qtIssued.id, { expiresDays: 30 })
+ // A quotation for Malabar (POS yearly + Inventory + SMS 1-Lakh pack) → issued + share link.
+ const qt = await createDraft(db, U, {
+ docType: 'QUOTATION', clientId: malabar,
+ lines: [
+ { moduleId: pos, qty: 1, kind: 'yearly' },
+ { moduleId: inv, qty: 1, kind: 'yearly' },
+ { moduleId: sms, qty: 1, kind: 'one_time', edition: 'SMS-1L' },
+ ],
+ terms: 'Prices valid for 15 days. 50% advance on confirmation.',
+ })
+ const qtIssued = await issueDocument(db, U, qt.id)
+ await markStatus(db, U, qtIssued.id, 'sent')
+ await mintShare(db, U, qtIssued.id, { expiresDays: 30 })
-// An invoice for Lulu (POS one-time + AMC) → issued, part-paid (drives the dashboard).
-const amcMod = (db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string }).id
-setPrice(db, U, { moduleId: amcMod, kind: 'yearly', pricePaise: 6_000_00, effectiveFrom: FROM })
-const inv1 = issueDocument(db, U, createDraft(db, U, {
- docType: 'INVOICE', clientId: lulu,
- lines: [{ moduleId: pos, qty: 1, kind: 'one_time' }, { moduleId: amcMod, qty: 1, kind: 'yearly' }],
-}).id)
-recordPayment(db, U, { clientId: lulu, receivedOn: '2026-07-05', mode: 'bank', reference: 'NEFT-88213', amountPaise: 20_000_00 })
+ // An invoice for Lulu (POS one-time + AMC) → issued, part-paid (drives the dashboard).
+ const amcMod = ((await db.get<{ id: string }>(`SELECT id FROM module WHERE code='AMC'`))!).id
+ await setPrice(db, U, { moduleId: amcMod, kind: 'yearly', pricePaise: 6_000_00, effectiveFrom: FROM })
+ const inv1 = await issueDocument(db, U, (await createDraft(db, U, {
+ docType: 'INVOICE', clientId: lulu,
+ lines: [{ moduleId: pos, qty: 1, kind: 'one_time' }, { moduleId: amcMod, qty: 1, kind: 'yearly' }],
+ })).id)
+ await recordPayment(db, U, { clientId: lulu, receivedOn: '2026-07-05', mode: 'bank', reference: 'NEFT-88213', amountPaise: 20_000_00 })
-// An out-of-state quotation (IGST) for Bengaluru.
-issueDocument(db, U, createDraft(db, U, {
- docType: 'QUOTATION', clientId: blr,
- lines: [{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: cloud, qty: 12, kind: 'monthly' }],
-}).id)
+ // An out-of-state quotation (IGST) for Bengaluru.
+ await issueDocument(db, U, (await createDraft(db, U, {
+ docType: 'QUOTATION', clientId: blr,
+ lines: [{ moduleId: pos, qty: 1, kind: 'yearly' }, { moduleId: cloud, qty: 12, kind: 'monthly' }],
+ })).id)
-const shareToken = (db.prepare(`SELECT token FROM document_share LIMIT 1`).get() as { token: string }).token
-console.log('DEMO_READY')
-console.log('login: owner@tecnostac.com / demo-owner-2026')
-console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo)
-console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo)
-console.log('share_token:', shareToken)
+ const shareToken = ((await db.get<{ token: string }>(`SELECT token FROM document_share LIMIT 1`))!).token
+ console.log('DEMO_READY')
+ console.log('login: owner@tecnostac.com / demo-owner-2026')
+ console.log('quotation_id:', qtIssued.id, 'doc_no:', qtIssued.docNo)
+ console.log('invoice_id:', inv1.id, 'doc_no:', inv1.docNo)
+ console.log('share_token:', shareToken)
+})()
diff --git a/apps/hq/scripts/gmail-connect.ts b/apps/hq/scripts/gmail-connect.ts
index f41be55..169190e 100644
--- a/apps/hq/scripts/gmail-connect.ts
+++ b/apps/hq/scripts/gmail-connect.ts
@@ -105,7 +105,7 @@ async function main(): Promise {
const code = await waitForCode()
const { accessToken, refreshToken } = await exchangeCode(code, clientId, clientSecret, redirectUri)
const address = await fetchAddress(accessToken)
- const account = saveAccount(openDb(process.env['HQ_DATA_DIR']), address, encrypt(refreshToken, keyHex))
+ const account = await saveAccount(openDb(process.env['HQ_DATA_DIR']), address, encrypt(refreshToken, keyHex))
console.log(`Connected ${account.address} — refresh token stored encrypted. HQ can now send email.`)
}
diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts
index 4691c6e..5207bd4 100644
--- a/apps/hq/src/api.ts
+++ b/apps/hq/src/api.ts
@@ -67,11 +67,11 @@ export function apiRouter(
renderPdfImpl: (html: string) => Promise = renderPdf,
): Router {
const r = Router()
- const companySettings = (): Record => {
+ const companySettings = async (): Promise> => {
// The full letterhead settings map documentHtml reads — company.* identity + template.* text.
- const rows = db.prepare(
+ const rows = await db.all<{ key: string; value: string }>(
`SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`,
- ).all() as { key: string; value: string }[]
+ )
return Object.fromEntries(rows.map((row) => [row.key, row.value]))
}
// Shared letterhead field maps — the one definition every route (company profile,
@@ -95,26 +95,34 @@ export function apiRouter(
r.get('/health', (_req, res) => {
res.json({ ok: true, service: 'sims-hq', version: '0.1.0' })
})
- r.post('/auth/login', (req, res) => {
- const { email, password } = req.body as { email?: string; password?: string }
- const out = typeof email === 'string' && typeof password === 'string' ? login(db, email, password) : null
- if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return }
- res.json({ ok: true, ...out })
+ r.post('/auth/login', async (req, res) => {
+ try {
+ const { email, password } = req.body as { email?: string; password?: string }
+ const out = typeof email === 'string' && typeof password === 'string' ? await login(db, email, password) : null
+ if (!out) { res.status(401).json({ ok: false, error: 'Invalid email or password' }); return }
+ res.json({ ok: true, ...out })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- employees (staff_user; D16) ----------
// GET is open to any signed-in user — the pipeline/owner pickers need names.
// Console users are a small bounded set: returned whole, `total` echoes the
// full count so the UI can display it (no silent truncation).
- r.get('/employees', requireAuth, (_req, res) => {
- const employees = listEmployees(db)
- res.json({ ok: true, employees, total: employees.length })
+ r.get('/employees', requireAuth, async (_req, res) => {
+ try {
+ const employees = await listEmployees(db)
+ res.json({ ok: true, employees, total: employees.length })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// Mutations are owner-only; the signed-in user is the audited actor.
- r.post('/employees', requireAuth, requireOwner, (req, res) => {
+ r.post('/employees', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { email?: unknown; displayName?: unknown; role?: unknown; password?: unknown }
- const employee = createEmployee(db, staffId(res), {
+ const employee = await createEmployee(db, staffId(res), {
email: typeof body.email === 'string' ? body.email : '',
displayName: typeof body.displayName === 'string' ? body.displayName : '',
role: (typeof body.role === 'string' ? body.role : '') as EmployeeRole, // repo validates the enum
@@ -125,14 +133,14 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- const employeeAction = (fn: (id: string, res: Response, req: Request) => unknown): RequestHandler =>
- (req, res) => {
+ const employeeAction = (fn: (id: string, res: Response, req: Request) => unknown | Promise): RequestHandler =>
+ async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getEmployee(db, id) === null) {
+ if ((await getEmployee(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Employee not found' }); return
}
try {
- res.json({ ok: true, employee: fn(id, res, req) })
+ res.json({ ok: true, employee: await fn(id, res, req) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
@@ -150,27 +158,31 @@ export function apiRouter(
// ---------- self-service profile (D18 WS-E) ----------
// /me is strictly self-scoped: the id comes from the session, never the request,
// and the whitelist below cannot touch role, email or active.
- r.get('/me', requireAuth, (_req, res) => {
- const me = getEmployee(db, staffId(res))
- if (me === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return }
- res.json({ ok: true, employee: me })
+ r.get('/me', requireAuth, async (_req, res) => {
+ try {
+ const me = await getEmployee(db, staffId(res))
+ if (me === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return }
+ res.json({ ok: true, employee: me })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.patch('/me', requireAuth, (req, res) => {
+ r.patch('/me', requireAuth, async (req, res) => {
try {
const body = req.body as { displayName?: unknown; phone?: unknown }
const patch: { displayName?: string; phone?: string } = {}
if (typeof body.displayName === 'string') patch.displayName = body.displayName
if (typeof body.phone === 'string') patch.phone = body.phone
- res.json({ ok: true, employee: updateEmployee(db, staffId(res), staffId(res), patch) })
+ res.json({ ok: true, employee: await updateEmployee(db, staffId(res), staffId(res), patch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/me/password', requireAuth, (req, res) => {
+ r.post('/me/password', requireAuth, async (req, res) => {
try {
const body = req.body as { currentPassword?: unknown; newPassword?: unknown }
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
- changeOwnPassword(
+ await changeOwnPassword(
db, staffId(res),
typeof body.currentPassword === 'string' ? body.currentPassword : '',
typeof body.newPassword === 'string' ? body.newPassword : '',
@@ -185,53 +197,61 @@ export function apiRouter(
deactivateEmployee(db, staffId(res), id)))
r.post('/employees/:id/reactivate', requireAuth, requireOwner, employeeAction((id, res) =>
reactivateEmployee(db, staffId(res), id)))
- r.post('/employees/:id/password', requireAuth, requireOwner, employeeAction((id, res, req) => {
+ r.post('/employees/:id/password', requireAuth, requireOwner, employeeAction(async (id, res, req) => {
const { password } = req.body as { password?: unknown }
- setEmployeePassword(db, staffId(res), id, typeof password === 'string' ? password : '')
+ await setEmployeePassword(db, staffId(res), id, typeof password === 'string' ? password : '')
return getEmployee(db, id)
}))
// ---------- clients ----------
- r.get('/clients', requireAuth, (req, res) => {
- const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined
- const filters = {
- ...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}),
- ...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}),
+ r.get('/clients', requireAuth, async (req, res) => {
+ try {
+ const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined
+ const filters = {
+ ...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}),
+ ...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}),
+ }
+ res.json({ ok: true, clients: await listClients(db, q, filters) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- res.json({ ok: true, clients: listClients(db, q, filters) })
})
- r.post('/clients', requireAuth, (req, res) => {
+ r.post('/clients', requireAuth, async (req, res) => {
try {
- const client = createClient(db, staffId(res), req.body as ClientInput)
+ const client = await createClient(db, staffId(res), req.body as ClientInput)
res.json({ ok: true, client })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// D18 WS-F: decrypt-and-return the support DB password — every reveal audited.
- r.post('/clients/:id/reveal-db-password', requireAuth, (req, res) => {
+ r.post('/clients/:id/reveal-db-password', requireAuth, async (req, res) => {
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) {
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) {
+ if ((await getClient(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
try {
- res.json({ ok: true, password: revealClientDbPassword(db, viewer.id, id, gmail().keyHex) })
+ res.json({ ok: true, password: await revealClientDbPassword(db, viewer.id, id, gmail().keyHex) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.get('/clients/:id', requireAuth, (req, res) => {
- const client = getClient(db, String(req.params['id'] ?? ''))
- if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
- res.json({ ok: true, client })
+ r.get('/clients/:id', requireAuth, async (req, res) => {
+ try {
+ const client = await getClient(db, String(req.params['id'] ?? ''))
+ if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ res.json({ ok: true, client })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.patch('/clients/:id', requireAuth, (req, res) => {
+ r.patch('/clients/:id', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) {
+ if ((await getClient(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
try {
@@ -247,14 +267,14 @@ export function apiRouter(
}
// One transaction: a bad field elsewhere in the patch must not leave a
// half-applied (already audited) password change behind.
- const client = db.transaction(() => {
+ const client = await db.transaction(async () => {
if (typeof dbPassword === 'string') {
- setClientDbPassword(db, staffId(res), id, dbPassword, gmail().keyHex)
+ await setClientDbPassword(db, staffId(res), id, dbPassword, gmail().keyHex)
}
return Object.keys(rest).length > 0
- ? updateClient(db, staffId(res), id, rest)
- : getClient(db, id)!
- })()
+ ? await updateClient(db, staffId(res), id, rest)
+ : (await getClient(db, id))!
+ })
res.json({ ok: true, client })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@@ -263,13 +283,13 @@ export function apiRouter(
// Account-owner routing — owner/manager only (inline check per spec: no requireManager
// middleware). Separate from the staff-open generic PATCH above so staff cannot set or
// widen ownership; the repo audits the write as its own 'set_owner' action.
- r.patch('/clients/:id/owner', requireAuth, (req, res) => {
+ r.patch('/clients/:id/owner', requireAuth, async (req, res) => {
const viewer = res.locals['staff'] as { id: string; role: string }
if (!isManagerial(viewer.role)) {
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) {
+ if ((await getClient(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
try {
@@ -277,7 +297,7 @@ export function apiRouter(
if (raw !== null && typeof raw !== 'string') {
throw new Error('ownerId is required — an employee id, or null to clear')
}
- const client = setClientOwner(db, viewer.id, id, raw === null || raw === '' ? null : raw)
+ const client = await setClientOwner(db, viewer.id, id, raw === null || raw === '' ? null : raw)
res.json({ ok: true, client })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@@ -288,177 +308,205 @@ export function apiRouter(
// Role gating lives inside the query: staff are server-forced to their own rows
// (viewer identity comes from res.locals.staff, never the request), owner/manager
// may narrow via ?owner= or filter=mine. Paginated with an honest total (rule 8).
- r.get('/pipeline', requireAuth, (req, res) => {
- const viewer = res.locals['staff'] as { id: string; role: string }
- const rawFilter = typeof req.query['filter'] === 'string' ? req.query['filter'] : 'all'
- if (!(PIPELINE_FILTERS as string[]).includes(rawFilter)) {
- res.status(400).json({ ok: false, error: `filter must be one of: ${PIPELINE_FILTERS.join(', ')}` })
- return
- }
- const opts: ListPipelineOpts = {
- filter: rawFilter as PipelineFilter, viewerRole: viewer.role, viewerId: viewer.id,
+ r.get('/pipeline', requireAuth, async (req, res) => {
+ try {
+ const viewer = res.locals['staff'] as { id: string; role: string }
+ const rawFilter = typeof req.query['filter'] === 'string' ? req.query['filter'] : 'all'
+ if (!(PIPELINE_FILTERS as string[]).includes(rawFilter)) {
+ res.status(400).json({ ok: false, error: `filter must be one of: ${PIPELINE_FILTERS.join(', ')}` })
+ return
+ }
+ const opts: ListPipelineOpts = {
+ filter: rawFilter as PipelineFilter, viewerRole: viewer.role, viewerId: viewer.id,
+ }
+ if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
+ const page = Number(req.query['page'])
+ if (Number.isInteger(page) && page >= 1) opts.page = page
+ const pageSize = Number(req.query['pageSize'])
+ if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
+ res.json({ ok: true, ...await listPipeline(db, opts) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
- const page = Number(req.query['page'])
- if (Number.isInteger(page) && page >= 1) opts.page = page
- const pageSize = Number(req.query['pageSize'])
- if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
- res.json({ ok: true, ...listPipeline(db, opts) })
})
// ---------- modules ----------
- r.get('/modules', requireAuth, (_req, res) => {
- res.json({ ok: true, modules: listModules(db) })
+ r.get('/modules', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, modules: await listModules(db) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/modules', requireAuth, requireOwner, (req, res) => {
+ r.post('/modules', requireAuth, requireOwner, async (req, res) => {
try {
- const mod = createModule(db, staffId(res), req.body as ModuleInput)
+ const mod = await createModule(db, staffId(res), req.body as ModuleInput)
res.json({ ok: true, module: mod })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.patch('/modules/:id', requireAuth, requireOwner, (req, res) => {
+ r.patch('/modules/:id', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getModule(db, id) === null) {
+ if ((await getModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Module not found' }); return
}
try {
- const mod = updateModule(db, staffId(res), id, req.body as ModulePatch)
+ const mod = await updateModule(db, staffId(res), id, req.body as ModulePatch)
res.json({ ok: true, module: mod })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.get('/modules/:id/prices', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getModule(db, id) === null) {
- res.status(404).json({ ok: false, error: 'Module not found' }); return
+ r.get('/modules/:id/prices', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getModule(db, id)) === null) {
+ res.status(404).json({ ok: false, error: 'Module not found' }); return
+ }
+ res.json({ ok: true, prices: await listPrices(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- res.json({ ok: true, prices: listPrices(db, id) })
})
- r.post('/modules/:id/prices', requireAuth, requireOwner, (req, res) => {
+ r.post('/modules/:id/prices', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getModule(db, id) === null) {
+ if ((await getModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Module not found' }); return
}
try {
- setPrice(db, staffId(res), { ...(req.body as Omit), moduleId: id })
- res.json({ ok: true, prices: listPrices(db, id) })
+ await setPrice(db, staffId(res), { ...(req.body as Omit), moduleId: id })
+ res.json({ ok: true, prices: await listPrices(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- module → client roster (spec §10, D-ROSTER) ----------
- r.get('/modules/:id/clients', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getModule(db, id) === null) {
- res.status(404).json({ ok: false, error: 'Module not found' }); return
+ r.get('/modules/:id/clients', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getModule(db, id)) === null) {
+ res.status(404).json({ ok: false, error: 'Module not found' }); return
+ }
+ const num = (v: unknown): number | undefined =>
+ typeof v === 'string' && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined
+ const page = num(req.query['page'])
+ const pageSize = num(req.query['pageSize'])
+ res.json({
+ ok: true,
+ ...await listClientsByModule(db, id, {
+ ...(page !== undefined ? { page } : {}), ...(pageSize !== undefined ? { pageSize } : {}),
+ }),
+ })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- const num = (v: unknown): number | undefined =>
- typeof v === 'string' && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined
- const page = num(req.query['page'])
- const pageSize = num(req.query['pageSize'])
- res.json({
- ok: true,
- ...listClientsByModule(db, id, {
- ...(page !== undefined ? { page } : {}), ...(pageSize !== undefined ? { pageSize } : {}),
- }),
- })
})
// CSV export streams EVERY row — an export never silently caps (rule 8).
- r.get('/modules/:id/clients.csv', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- const mod = getModule(db, id)
- if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
- const esc = (v: string): string => /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v
- const lines = ['client_code,client_name,status,kind,edition,price_paise,next_renewal']
- for (let page = 1; ; page++) {
- const batch = listClientsByModule(db, id, { page, pageSize: 200 })
- for (const c of batch.clients) {
- lines.push([
- esc(c.clientCode), esc(c.clientName), c.status, c.kind, esc(c.edition),
- c.pricePaise === null ? '' : String(c.pricePaise), c.nextRenewal ?? '',
- ].join(','))
+ r.get('/modules/:id/clients.csv', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ const mod = await getModule(db, id)
+ if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
+ const esc = (v: string): string => /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v
+ const lines = ['client_code,client_name,status,kind,edition,price_paise,next_renewal']
+ for (let page = 1; ; page++) {
+ const batch = await listClientsByModule(db, id, { page, pageSize: 200 })
+ for (const c of batch.clients) {
+ lines.push([
+ esc(c.clientCode), esc(c.clientName), c.status, c.kind, esc(c.edition),
+ c.pricePaise === null ? '' : String(c.pricePaise), c.nextRenewal ?? '',
+ ].join(','))
+ }
+ if (page * batch.pageSize >= batch.total) break
}
- if (page * batch.pageSize >= batch.total) break
+ res.setHeader('Content-Type', 'text/csv; charset=utf-8')
+ res.setHeader('Content-Disposition', `attachment; filename="module-${mod.code}-clients.csv"`)
+ res.send(lines.join('\n') + '\n')
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- res.setHeader('Content-Type', 'text/csv; charset=utf-8')
- res.setHeader('Content-Disposition', `attachment; filename="module-${mod.code}-clients.csv"`)
- res.send(lines.join('\n') + '\n')
})
// Notify every client on the module (owner/manager — bulk client email is privileged, A6).
// Ad-hoc human broadcast (A5): subject/body typed by the sender, not dated config.
- r.post('/modules/:id/notify', requireAuth, (req, res) => {
- const viewer = res.locals['staff'] as { id: string; role: string }
- if (!isManagerial(viewer.role)) {
- res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
- }
- const id = String(req.params['id'] ?? '')
- const mod = getModule(db, id)
- if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
- const body = req.body as { subject?: unknown; body?: unknown }
- const subject = typeof body.subject === 'string' ? body.subject.trim() : ''
- const bodyText = typeof body.body === 'string' ? body.body.trim() : ''
- if (subject === '' || bodyText === '') {
- res.status(400).json({ ok: false, error: 'subject and body are required' }); return
- }
- const status = emailStatus(db)
- if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
- if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
- // Resolve the FULL roster (internally paginated) before sending anything.
- const targets: { clientId: string; clientName: string; email: string | undefined }[] = []
- for (let page = 1; ; page++) {
- const batch = listClientsByModule(db, id, { page, pageSize: 200 })
- for (const c of batch.clients) {
- const client = getClient(db, c.clientId)
- targets.push({
- clientId: c.clientId, clientName: c.clientName,
- email: client?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email,
- })
+ r.post('/modules/:id/notify', requireAuth, async (req, res) => {
+ try {
+ const viewer = res.locals['staff'] as { id: string; role: string }
+ if (!isManagerial(viewer.role)) {
+ res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
}
- if (page * batch.pageSize >= batch.total) break
- }
- void (async () => {
- let sent = 0
- const failed: { client: string; error: string }[] = []
- for (const t of targets) {
- if (t.email === undefined) {
- failed.push({ client: t.clientName, error: 'no contact email' })
- continue
+ const id = String(req.params['id'] ?? '')
+ const mod = await getModule(db, id)
+ if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
+ const body = req.body as { subject?: unknown; body?: unknown }
+ const subject = typeof body.subject === 'string' ? body.subject.trim() : ''
+ const bodyText = typeof body.body === 'string' ? body.body.trim() : ''
+ if (subject === '' || bodyText === '') {
+ res.status(400).json({ ok: false, error: 'subject and body are required' }); return
+ }
+ const status = await emailStatus(db)
+ if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
+ if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
+ // Resolve the FULL roster (internally paginated) before sending anything.
+ const targets: { clientId: string; clientName: string; email: string | undefined }[] = []
+ for (let page = 1; ; page++) {
+ const batch = await listClientsByModule(db, id, { page, pageSize: 200 })
+ for (const c of batch.clients) {
+ const client = await getClient(db, c.clientId)
+ targets.push({
+ clientId: c.clientId, clientName: c.clientName,
+ email: client?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email,
+ })
}
- // sendReminderEmail logs every attempt to email_log; one audit row per
- // recipient records who broadcast what to whom (rule 6).
- const out = await sendReminderEmail(db, gmail(), { to: t.email, subject, bodyText })
- writeAudit(db, viewer.id, 'notify', 'client', t.clientId, undefined, {
- moduleId: id, subject, status: out.ok ? 'sent' : 'failed',
- ...(out.ok ? {} : { error: out.error }),
- })
- if (out.ok) sent += 1
- else failed.push({ client: t.clientName, error: out.error })
- if (!out.ok && out.error === 'gmail-token-dead') break // account died mid-run: stop, report the rest honestly
+ if (page * batch.pageSize >= batch.total) break
}
- // Warn, never truncate: every unreached client is named in the response.
- res.json({ ok: true, sent, failed, total: targets.length })
- })()
+ void (async () => {
+ let sent = 0
+ const failed: { client: string; error: string }[] = []
+ for (const t of targets) {
+ if (t.email === undefined) {
+ failed.push({ client: t.clientName, error: 'no contact email' })
+ continue
+ }
+ // sendReminderEmail logs every attempt to email_log; one audit row per
+ // recipient records who broadcast what to whom (rule 6).
+ const out = await sendReminderEmail(db, gmail(), { to: t.email, subject, bodyText })
+ await writeAudit(db, viewer.id, 'notify', 'client', t.clientId, undefined, {
+ moduleId: id, subject, status: out.ok ? 'sent' : 'failed',
+ ...(out.ok ? {} : { error: out.error }),
+ })
+ if (out.ok) sent += 1
+ else failed.push({ client: t.clientName, error: out.error })
+ if (!out.ok && out.error === 'gmail-token-dead') break // account died mid-run: stop, report the rest honestly
+ }
+ // Warn, never truncate: every unreached client is named in the response.
+ res.json({ ok: true, sent, failed, total: targets.length })
+ })()
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- client modules ----------
- r.get('/clients/:id/modules', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) {
- res.status(404).json({ ok: false, error: 'Client not found' }); return
+ r.get('/clients/:id/modules', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getClient(db, id)) === null) {
+ res.status(404).json({ ok: false, error: 'Client not found' }); return
+ }
+ res.json({ ok: true, clientModules: await listClientModules(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- res.json({ ok: true, clientModules: listClientModules(db, id) })
})
- r.post('/clients/:id/modules', requireAuth, (req, res) => {
+ r.post('/clients/:id/modules', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) {
+ if ((await getClient(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client not found' }); return
}
try {
- const cm = assignModule(db, staffId(res), {
+ const cm = await assignModule(db, staffId(res), {
...(req.body as Omit), clientId: id,
})
res.json({ ok: true, clientModule: cm })
@@ -466,13 +514,13 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.patch('/client-modules/:id', requireAuth, (req, res) => {
+ r.patch('/client-modules/:id', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getClientModule(db, id) === null) {
+ if ((await getClientModule(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Client module not found' }); return
}
try {
- const cm = updateClientModule(db, staffId(res), id, req.body as ClientModulePatch)
+ const cm = await updateClientModule(db, staffId(res), id, req.body as ClientModulePatch)
res.json({ ok: true, clientModule: cm })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@@ -480,9 +528,9 @@ export function apiRouter(
})
// ---------- documents ----------
- r.post('/documents', requireAuth, (req, res) => {
+ r.post('/documents', requireAuth, async (req, res) => {
try {
- const document = createDraft(db, staffId(res), req.body as DraftInput)
+ const document = await createDraft(db, staffId(res), req.body as DraftInput)
res.json({ ok: true, document })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@@ -492,13 +540,13 @@ export function apiRouter(
// nothing, writes no audit (it is a read). Permissive: a price gap computes at
// ₹0 + a warning; no client picked renders a placeholder letterhead at our own
// state. renderPdf(html) rasterizes this exact string for the actual PDF.
- r.post('/documents/preview', requireAuth, (req, res) => {
+ r.post('/documents/preview', requireAuth, async (req, res) => {
try {
const input = req.body as DraftInput
- const prepared = prepareDraft(db, input, { permissive: true })
- const company = companySettings()
+ const prepared = await prepareDraft(db, input, { permissive: true })
+ const company = await companySettings()
const picked = typeof input.clientId === 'string' && input.clientId !== ''
- ? getClient(db, input.clientId) : null
+ ? await getClient(db, input.clientId) : null
const client: Client = picked ?? {
id: '', code: '—', name: '— pick a client —',
stateCode: company['company.state_code'] ?? '32',
@@ -521,89 +569,105 @@ export function apiRouter(
// Sibling preview: sample letterhead / quote-content through the ONE renderer.
// Body uses the same field keys as PUT /settings/template (unsaved edits), merged
// over the saved settings — so the preview shows exactly what Save would print.
- r.post('/previews/sample', requireAuth, (req, res) => {
- const body = req.body as {
- company?: Record; template?: Record
- titles?: Record; logo?: unknown; contentLines?: unknown
- }
- const merged = companySettings() // company.* + template.*
- const applyGroup = (group: Record | undefined, fields: Record): void => {
- if (group === undefined || group === null) return
- for (const [field, key] of Object.entries(fields)) {
- const val = group[field]
- if (typeof val === 'string') merged[key] = val
+ r.post('/previews/sample', requireAuth, async (req, res) => {
+ try {
+ const body = req.body as {
+ company?: Record; template?: Record
+ titles?: Record; logo?: unknown; contentLines?: unknown
}
- }
- applyGroup(body.company, COMPANY_FIELDS)
- applyGroup(body.template, TEMPLATE_FIELDS)
- if (body.titles !== undefined && body.titles !== null) {
- for (const t of DOC_TYPES) {
- const val = body.titles[t]
- if (typeof val === 'string') merged[`template.title_${t}`] = val
+ const merged = await companySettings() // company.* + template.*
+ const applyGroup = (group: Record | undefined, fields: Record): void => {
+ if (group === undefined || group === null) return
+ for (const [field, key] of Object.entries(fields)) {
+ const val = group[field]
+ if (typeof val === 'string') merged[key] = val
+ }
}
+ applyGroup(body.company, COMPANY_FIELDS)
+ applyGroup(body.template, TEMPLATE_FIELDS)
+ if (body.titles !== undefined && body.titles !== null) {
+ for (const t of DOC_TYPES) {
+ const val = body.titles[t]
+ if (typeof val === 'string') merged[`template.title_${t}`] = val
+ }
+ }
+ if (typeof body.logo === 'string') merged['template.logo'] = body.logo
+ const opts = Array.isArray(body.contentLines)
+ ? { contentLines: body.contentLines.filter((x): x is string => typeof x === 'string') }
+ : {}
+ res.json({ ok: true, html: documentHtmlSample(merged, opts) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.get('/documents', requireAuth, async (req, res) => {
+ try {
+ const filter: DocumentFilter = {}
+ if (typeof req.query['type'] === 'string') filter.type = req.query['type']
+ if (typeof req.query['status'] === 'string') filter.status = req.query['status']
+ if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId']
+ const page = Number(req.query['page'])
+ if (Number.isInteger(page) && page >= 1) filter.page = page
+ const pageSize = Number(req.query['pageSize'])
+ if (Number.isInteger(pageSize) && pageSize >= 1) filter.pageSize = pageSize
+ res.json({ ok: true, ...await listDocuments(db, filter) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.get('/documents/:id', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ const document = await getDocument(db, id)
+ if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
+ const emails = await db.all(
+ `SELECT * FROM email_log WHERE document_id=? ORDER BY id`, id,
+ )
+ res.json({ ok: true, document, events: await listDocumentEvents(db, id), emails, shares: await listShares(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- if (typeof body.logo === 'string') merged['template.logo'] = body.logo
- const opts = Array.isArray(body.contentLines)
- ? { contentLines: body.contentLines.filter((x): x is string => typeof x === 'string') }
- : {}
- res.json({ ok: true, html: documentHtmlSample(merged, opts) })
- })
- r.get('/documents', requireAuth, (req, res) => {
- const filter: DocumentFilter = {}
- if (typeof req.query['type'] === 'string') filter.type = req.query['type']
- if (typeof req.query['status'] === 'string') filter.status = req.query['status']
- if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId']
- const page = Number(req.query['page'])
- if (Number.isInteger(page) && page >= 1) filter.page = page
- const pageSize = Number(req.query['pageSize'])
- if (Number.isInteger(pageSize) && pageSize >= 1) filter.pageSize = pageSize
- res.json({ ok: true, ...listDocuments(db, filter) })
- })
- r.get('/documents/:id', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- const document = getDocument(db, id)
- if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
- const emails = db.prepare(
- `SELECT * FROM email_log WHERE document_id=? ORDER BY id`,
- ).all(id)
- res.json({ ok: true, document, events: listDocumentEvents(db, id), emails, shares: listShares(db, id) })
})
- const docAction = (fn: (docId: string, res: Response, req: Request) => unknown): RequestHandler =>
- (req, res) => {
+ const docAction = (fn: (docId: string, res: Response, req: Request) => unknown | Promise): RequestHandler =>
+ async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getDocument(db, id) === null) {
+ if ((await getDocument(db, id)) === null) {
res.status(404).json({ ok: false, error: 'Document not found' }); return
}
try {
- res.json({ ok: true, document: fn(id, res, req) })
+ res.json({ ok: true, document: await fn(id, res, req) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
}
- r.get('/documents/:id/pdf', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- const document = getDocument(db, id)
- if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
- const client = getClient(db, document.clientId)
- if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
- const company = companySettings()
- // ?download=1 forces a Save-As (attachment); the default stays inline so the
- // composer iframe can preview the PDF in place.
- const disposition = req.query['download'] === '1' ? 'attachment' : 'inline'
- void (async () => {
- try {
- const pdf = await renderPdfImpl(documentHtml(document, client, company))
- res.setHeader('Content-Type', 'application/pdf')
- // docNo contains '/' (QT/26-27-0001) — not legal in a filename.
- const filename = (document.docNo ?? 'draft').replaceAll('/', '-')
- res.setHeader('Content-Disposition', `${disposition}; filename="${filename}.pdf"`)
- res.send(pdf)
- } catch (err) {
- res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
- }
- })()
+ r.get('/documents/:id/pdf', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ const document = await getDocument(db, id)
+ if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
+ const client = await getClient(db, document.clientId)
+ if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ const company = await companySettings()
+ // ?download=1 forces a Save-As (attachment); the default stays inline so the
+ // composer iframe can preview the PDF in place.
+ const disposition = req.query['download'] === '1' ? 'attachment' : 'inline'
+ void (async () => {
+ try {
+ const pdf = await renderPdfImpl(documentHtml(document, client, company))
+ res.setHeader('Content-Type', 'application/pdf')
+ // docNo contains '/' (QT/26-27-0001) — not legal in a filename.
+ const filename = (document.docNo ?? 'draft').replaceAll('/', '-')
+ res.setHeader('Content-Disposition', `${disposition}; filename="${filename}.pdf"`)
+ res.send(pdf)
+ } catch (err) {
+ res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })()
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
r.post('/documents/:id/issue', requireAuth, docAction((id, res) =>
@@ -630,17 +694,17 @@ export function apiRouter(
// One-click convert & send (spec §8): every guard runs BEFORE any write; then ONE
// transaction converts + issues; the email goes strictly AFTER commit — a failed
// send never rolls back an issued invoice, it surfaces as a warning instead.
- r.post('/documents/:id/convert-and-send', requireAuth, (req, res) => {
+ r.post('/documents/:id/convert-and-send', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- const src = getDocument(db, id)
+ const src = await getDocument(db, id)
if (src === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
if (src.docType !== 'PROFORMA') {
res.status(400).json({ ok: false, error: 'Only proformas can be converted & sent' }); return
}
- const status = emailStatus(db)
+ const status = await emailStatus(db)
if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
- const client = getClient(db, src.clientId)
+ const client = await getClient(db, src.clientId)
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
const body = req.body as { to?: string }
const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
@@ -650,20 +714,20 @@ export function apiRouter(
}
let invoice: Doc
try {
- invoice = db.transaction(() => {
- const draft = convertDocument(db, staffId(res), id, 'INVOICE') // F3 recompute + F4 dup-guard live here
+ invoice = await db.transaction(async () => {
+ const draft = await convertDocument(db, staffId(res), id, 'INVOICE') // F3 recompute + F4 dup-guard live here
return issueDocument(db, staffId(res), draft.id)
- })()
+ })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
return
}
- const company = companySettings()
+ const company = await companySettings()
const companyName = company['company.name'] ?? ''
void (async () => {
- const issued = (): Doc => getDocument(db, invoice.id)!
+ const issued = async (): Promise => (await getDocument(db, invoice.id))!
try {
- const pdf = await renderPdfImpl(documentHtml(issued(), client, company))
+ const pdf = await renderPdfImpl(documentHtml(await issued(), client, company))
const out = await sendDocumentEmail(db, gmail(), {
documentId: invoice.id, to,
subject: `INVOICE ${invoice.docNo} — ${companyName}`,
@@ -671,13 +735,13 @@ export function apiRouter(
pdf, userId: staffId(res),
})
if (!out.ok) {
- res.json({ ok: true, document: issued(), warning: `Invoice issued, email failed: ${out.error}` })
+ res.json({ ok: true, document: await issued(), warning: `Invoice issued, email failed: ${out.error}` })
return
}
- res.json({ ok: true, document: issued() })
+ res.json({ ok: true, document: await issued() })
} catch (err) {
res.json({
- ok: true, document: issued(),
+ ok: true, document: await issued(),
warning: `Invoice issued, email failed: ${err instanceof Error ? err.message : String(err)}`,
})
}
@@ -691,92 +755,104 @@ export function apiRouter(
// ---------- shareable links ----------
// Owner mints an opaque public token for one document (default +30d expiry). The
// token is returned to the authenticated owner (they build the link) but never logged.
- r.post('/documents/:id/share', requireAuth, (req, res) => {
+ r.post('/documents/:id/share', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getDocument(db, id) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
+ if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
try {
const body = req.body as { expiresDays?: unknown }
const opts: MintShareOpts = {}
if (typeof body.expiresDays === 'number') opts.expiresDays = body.expiresDays
else if (body.expiresDays === null) opts.expiresDays = null
- const share = mintShare(db, staffId(res), id, opts)
- res.json({ ok: true, share, shares: listShares(db, id) })
+ const share = await mintShare(db, staffId(res), id, opts)
+ res.json({ ok: true, share, shares: await listShares(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/documents/:id/share/:shareId/revoke', requireAuth, (req, res) => {
+ r.post('/documents/:id/share/:shareId/revoke', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getDocument(db, id) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
+ if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
const shareId = String(req.params['shareId'] ?? '')
- const existing = getShare(db, shareId)
+ const existing = await getShare(db, shareId)
// Guard the pairing: a share is only revocable through the document it belongs to.
if (existing === null || existing.documentId !== id) {
res.status(404).json({ ok: false, error: 'Share not found' }); return
}
try {
- const share = revokeShare(db, staffId(res), shareId)
- res.json({ ok: true, share, shares: listShares(db, id) })
+ const share = await revokeShare(db, staffId(res), shareId)
+ res.json({ ok: true, share, shares: await listShares(db, id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- email ----------
- r.get('/email/status', requireAuth, (_req, res) => {
- res.json({ ok: true, ...emailStatus(db) })
- })
- r.post('/documents/:id/send', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- const document = getDocument(db, id)
- if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
- // Guard before any work: a dead/missing account never attempts a send.
- const status = emailStatus(db)
- if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
- if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
- if (document.docNo === null) {
- res.status(400).json({ ok: false, error: 'Only issued documents can be emailed' }); return
- }
- const client = getClient(db, document.clientId)
- if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
- const body = req.body as { to?: string; subject?: string; body?: string }
- const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
- if (to === undefined || to === '') {
- res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' })
- return
+ r.get('/email/status', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, ...await emailStatus(db) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- const company = companySettings()
- const companyName = company['company.name'] ?? ''
- const subject = body.subject ?? `${document.docType} ${document.docNo} — ${companyName}`
- const bodyText = body.body ??
- `Dear ${client.name},\n\nPlease find attached ${document.docType} ${document.docNo}.\n\nRegards,\n${companyName}`
- void (async () => {
- try {
- const pdf = await renderPdf(documentHtml(document, client, company))
- const out = await sendDocumentEmail(db, gmail(), {
- documentId: id, to, subject, bodyText, pdf, userId: staffId(res),
- })
- if (!out.ok) {
- res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error })
- return
- }
- res.json({ ok: true, document: getDocument(db, id) })
- } catch (err) {
- res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ })
+ r.post('/documents/:id/send', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ const document = await getDocument(db, id)
+ if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
+ // Guard before any work: a dead/missing account never attempts a send.
+ const status = await emailStatus(db)
+ if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
+ if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
+ if (document.docNo === null) {
+ res.status(400).json({ ok: false, error: 'Only issued documents can be emailed' }); return
}
- })()
+ const client = await getClient(db, document.clientId)
+ if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ const body = req.body as { to?: string; subject?: string; body?: string }
+ const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
+ if (to === undefined || to === '') {
+ res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' })
+ return
+ }
+ const company = await companySettings()
+ const companyName = company['company.name'] ?? ''
+ const subject = body.subject ?? `${document.docType} ${document.docNo} — ${companyName}`
+ const bodyText = body.body ??
+ `Dear ${client.name},\n\nPlease find attached ${document.docType} ${document.docNo}.\n\nRegards,\n${companyName}`
+ void (async () => {
+ try {
+ const pdf = await renderPdf(documentHtml(document, client, company))
+ const out = await sendDocumentEmail(db, gmail(), {
+ documentId: id, to, subject, bodyText, pdf, userId: staffId(res),
+ })
+ if (!out.ok) {
+ res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error })
+ return
+ }
+ res.json({ ok: true, document: await getDocument(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })()
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- recurring plans ----------
- r.get('/recurring', requireAuth, (req, res) => {
- const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined
- res.json({ ok: true, plans: listRecurringPlans(db, clientId) })
+ r.get('/recurring', requireAuth, async (req, res) => {
+ try {
+ const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined
+ res.json({ ok: true, plans: await listRecurringPlans(db, clientId) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => {
+ r.post('/clients/:id/recurring', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
try {
- const plan = createRecurringPlan(db, staffId(res), {
+ const plan = await createRecurringPlan(db, staffId(res), {
...(req.body as Omit), clientId: id,
})
res.json({ ok: true, plan })
@@ -784,86 +860,108 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.patch('/recurring/:id', requireAuth, requireOwner, (req, res) => {
+ r.patch('/recurring/:id', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
+ if ((await getRecurringPlan(db, id)) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
try {
- res.json({ ok: true, plan: updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) })
+ res.json({ ok: true, plan: await updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/recurring/:id/deactivate', requireAuth, requireOwner, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getRecurringPlan(db, id) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
- res.json({ ok: true, plan: deactivateRecurringPlan(db, staffId(res), id) })
+ r.post('/recurring/:id/deactivate', requireAuth, requireOwner, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getRecurringPlan(db, id)) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
+ res.json({ ok: true, plan: await deactivateRecurringPlan(db, staffId(res), id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- amc ----------
- r.get('/clients/:id/amc', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
- const contracts = listAmc(db, id).map((a) => ({ ...a, paidStatus: amcPaidStatus(db, a) }))
- res.json({ ok: true, contracts })
+ r.get('/clients/:id/amc', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ const contracts = await Promise.all(
+ (await listAmc(db, id)).map(async (a) => ({ ...a, paidStatus: await amcPaidStatus(db, a) })),
+ )
+ res.json({ ok: true, contracts })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => {
+ r.post('/clients/:id/amc', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
try {
- const amc = createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id })
- res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
+ const amc = await createAmc(db, staffId(res), { ...(req.body as Omit), clientId: id })
+ res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.patch('/amc/:id', requireAuth, requireOwner, (req, res) => {
+ r.patch('/amc/:id', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
+ if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
try {
- const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch)
- res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
+ const amc = await updateAmc(db, staffId(res), id, req.body as AmcPatch)
+ res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/amc/:id/deactivate', requireAuth, requireOwner, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
- const amc = deactivateAmc(db, staffId(res), id)
- res.json({ ok: true, contract: { ...amc, paidStatus: amcPaidStatus(db, amc) } })
+ r.post('/amc/:id/deactivate', requireAuth, requireOwner, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
+ const amc = await deactivateAmc(db, staffId(res), id)
+ res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, (req, res) => {
+ r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getAmc(db, id) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
+ if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
try {
- res.json({ ok: true, document: generateAmcRenewalInvoice(db, staffId(res), id) })
+ res.json({ ok: true, document: await generateAmcRenewalInvoice(db, staffId(res), id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- interactions ----------
- r.get('/interaction-types', requireAuth, (_req, res) => {
- res.json({ ok: true, types: listInteractionTypes(db) })
+ r.get('/interaction-types', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, types: await listInteractionTypes(db) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/interaction-types', requireAuth, requireOwner, (req, res) => {
+ r.post('/interaction-types', requireAuth, requireOwner, async (req, res) => {
try {
- const type = createInteractionType(db, staffId(res), req.body as { code: string; label: string })
+ const type = await createInteractionType(db, staffId(res), req.body as { code: string; label: string })
res.json({ ok: true, type })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.get('/clients/:id/interactions', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
- res.json({ ok: true, interactions: listInteractions(db, id) })
+ r.get('/clients/:id/interactions', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ res.json({ ok: true, interactions: await listInteractions(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/clients/:id/interactions', requireAuth, (req, res) => {
+ r.post('/clients/:id/interactions', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
try {
- const interaction = createInteraction(db, staffId(res), {
+ const interaction = await createInteraction(db, staffId(res), {
...(req.body as Omit), clientId: id,
})
res.json({ ok: true, interaction })
@@ -871,11 +969,11 @@ export function apiRouter(
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.patch('/interactions/:id', requireAuth, (req, res) => {
+ r.patch('/interactions/:id', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getInteraction(db, id) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return }
+ if ((await getInteraction(db, id)) === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return }
try {
- res.json({ ok: true, interaction: updateInteraction(db, staffId(res), id, req.body as InteractionPatch) })
+ res.json({ ok: true, interaction: await updateInteraction(db, staffId(res), id, req.body as InteractionPatch) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
@@ -890,63 +988,71 @@ export function apiRouter(
// server-forced to their own rows (derived doc_id → document.created_by, spec A3;
// doc-less rows are shared work, visible to all); owner/manager see all and may
// narrow via ?owner=. `?status=` narrows the SAME scoped, paginated view.
- r.get('/reminders', requireAuth, (req, res) => {
- const viewer = res.locals['staff'] as { id: string; role: string }
- const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id }
- if (typeof req.query['status'] === 'string' && req.query['status'] !== '') {
- opts.status = req.query['status'] as ReminderStatus
- }
- if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
- const page = Number(req.query['page'])
- if (Number.isInteger(page) && page >= 1) opts.page = page
- const pageSize = Number(req.query['pageSize'])
- if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
- const q = listQueue(db, opts)
- res.json({ ok: true, reminders: q.rows, total: q.total, page: q.page, pageSize: q.pageSize })
- })
- r.get('/reminders/:id/preview', requireAuth, (req, res) => {
+ r.get('/reminders', requireAuth, async (req, res) => {
+ try {
+ const viewer = res.locals['staff'] as { id: string; role: string }
+ const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id }
+ if (typeof req.query['status'] === 'string' && req.query['status'] !== '') {
+ opts.status = req.query['status'] as ReminderStatus
+ }
+ if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
+ const page = Number(req.query['page'])
+ if (Number.isInteger(page) && page >= 1) opts.page = page
+ const pageSize = Number(req.query['pageSize'])
+ if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
+ const q = await listQueue(db, opts)
+ res.json({ ok: true, reminders: q.rows, total: q.total, page: q.page, pageSize: q.pageSize })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.get('/reminders/:id/preview', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- const reminder = getReminder(db, id)
+ const reminder = await getReminder(db, id)
if (reminder === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
try {
if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`)
}
- const { ctx } = reminderContext(db, reminder, companySettings()['company.name'] ?? '')
+ const { ctx } = await reminderContext(db, reminder, (await companySettings())['company.name'] ?? '')
const mail = reminderEmail(reminder.ruleKind, ctx)
res.json({ ok: true, subject: mail.subject, body: mail.bodyText })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/reminders/:id/send', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
- void (async () => {
- try {
- const out = await sendReminder(db, sendReminderDeps(), id, staffId(res))
- if (!out.ok) {
- res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error })
- return
+ r.post('/reminders/:id/send', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getReminder(db, id)) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
+ void (async () => {
+ try {
+ const out = await sendReminder(db, sendReminderDeps(), id, staffId(res))
+ if (!out.ok) {
+ res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error })
+ return
+ }
+ res.json({ ok: true, reminder: await getReminder(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- res.json({ ok: true, reminder: getReminder(db, id) })
- } catch (err) {
- res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
- }
- })()
+ })()
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/reminders/:id/dismiss', requireAuth, (req, res) => {
+ r.post('/reminders/:id/dismiss', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
- if (getReminder(db, id) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
+ if ((await getReminder(db, id)) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
try {
- res.json({ ok: true, reminder: dismissReminder(db, staffId(res), id) })
+ res.json({ ok: true, reminder: await dismissReminder(db, staffId(res), id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- APEX import (D18 WS-B; owner-only — the cutover is one-shot) ----------
- r.post('/import/stage', requireAuth, requireOwner, (req, res) => {
+ r.post('/import/stage', requireAuth, requireOwner, async (req, res) => {
try {
const body = req.body as { clientsCsv?: unknown; invoicesCsv?: unknown }
const clientsCsv = typeof body.clientsCsv === 'string' && body.clientsCsv !== '' ? body.clientsCsv : undefined
@@ -956,37 +1062,41 @@ export function apiRouter(
return
}
// Staging writes and their audit rows land (or roll back) together.
- const out = db.transaction(() => {
+ const out = await db.transaction(async () => {
const o: { clientsStaged?: number; invoicesStaged?: number } = {}
- if (clientsCsv !== undefined) o.clientsStaged = stageCsv(db, 'clients', clientsCsv).staged
- if (invoicesCsv !== undefined) o.invoicesStaged = stageCsv(db, 'invoices', invoicesCsv).staged
- writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, o)
+ if (clientsCsv !== undefined) o.clientsStaged = (await stageCsv(db, 'clients', clientsCsv)).staged
+ if (invoicesCsv !== undefined) o.invoicesStaged = (await stageCsv(db, 'invoices', invoicesCsv)).staged
+ await writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, o)
return o
- })()
- res.json({ ok: true, ...out, report: verificationReport(db) })
+ })
+ res.json({ ok: true, ...out, report: await verificationReport(db) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.get('/import/status', requireAuth, requireOwner, (_req, res) => {
- // Per-row problem lists (first 50 each, with honest totals — rule 8).
- const problemClients = db.prepare(
- `SELECT row_no, code, name, problems FROM stg_client WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
- ).all()
- const problemInvoices = db.prepare(
- `SELECT row_no, client_code, doc_no, problems FROM stg_invoice WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
- ).all()
- res.json({ ok: true, report: verificationReport(db), problemClients, problemInvoices })
+ r.get('/import/status', requireAuth, requireOwner, async (_req, res) => {
+ try {
+ // Per-row problem lists (first 50 each, with honest totals — rule 8).
+ const problemClients = await db.all(
+ `SELECT row_no, code, name, problems FROM stg_client WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
+ )
+ const problemInvoices = await db.all(
+ `SELECT row_no, client_code, doc_no, problems FROM stg_invoice WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
+ )
+ res.json({ ok: true, report: await verificationReport(db), problemClients, problemInvoices })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/import/commit', requireAuth, requireOwner, (_req, res) => {
+ r.post('/import/commit', requireAuth, requireOwner, async (_req, res) => {
try {
// commitImport is itself transactional; the outer txn folds it into a savepoint
// so the cutover and its 'import_commit' audit row are one atomic unit.
- const result = db.transaction(() => {
- const r = commitImport(db, staffId(res)) // refuses while ANY staged row has problems
- writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, r)
+ const result = await db.transaction(async () => {
+ const r = await commitImport(db, staffId(res)) // refuses while ANY staged row has problems
+ await writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, r)
return r
- })()
+ })
res.json({ ok: true, result })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@@ -996,55 +1106,71 @@ export function apiRouter(
// ---------- dashboard ----------
// ?mine=1 = My Day (D18 WS-E): owner/manager narrow to their own book; staff
// are always scoped server-side regardless of the param.
- r.get('/dashboard', requireAuth, (req, res) => {
- const today = new Date().toISOString().slice(0, 10)
- res.json({
- ok: true,
- view: dashboardView(db, today, res.locals['staff'] as { id: string; role: string },
- { mine: req.query['mine'] === '1' }),
- })
+ r.get('/dashboard', requireAuth, async (req, res) => {
+ try {
+ const today = new Date().toISOString().slice(0, 10)
+ res.json({
+ ok: true,
+ view: await dashboardView(db, today, res.locals['staff'] as { id: string; role: string },
+ { mine: req.query['mine'] === '1' }),
+ })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- payments ----------
- r.post('/payments', requireAuth, (req, res) => {
+ r.post('/payments', requireAuth, async (req, res) => {
try {
- const out = recordPayment(db, staffId(res), req.body as RecordPaymentInput)
+ const out = await recordPayment(db, staffId(res), req.body as RecordPaymentInput)
res.json({ ok: true, ...out })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.get('/clients/:id/ledger', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) {
- res.status(404).json({ ok: false, error: 'Client not found' }); return
+ r.get('/clients/:id/ledger', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getClient(db, id)) === null) {
+ res.status(404).json({ ok: false, error: 'Client not found' }); return
+ }
+ res.json({ ok: true, ...await clientLedger(db, id), modulePaid: await modulePaidView(db, id) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) })
})
- r.post('/payments/:id/receipt', requireAuth, (req, res) => {
+ r.post('/payments/:id/receipt', requireAuth, async (req, res) => {
const id = String(req.params['id'] ?? '')
try {
- res.json({ ok: true, document: receiptForPayment(db, staffId(res), id) })
+ res.json({ ok: true, document: await receiptForPayment(db, staffId(res), id) })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- aws usage & cost ----------
- r.get('/aws/ranking', requireAuth, (req, res) => {
- const today = new Date().toISOString().slice(0, 10)
- const month = typeof req.query['month'] === 'string' ? req.query['month'] : previousMonth(today)
- res.json({ ok: true, month, ...costRanking(db, month) })
+ r.get('/aws/ranking', requireAuth, async (req, res) => {
+ try {
+ const today = new Date().toISOString().slice(0, 10)
+ const month = typeof req.query['month'] === 'string' ? req.query['month'] : previousMonth(today)
+ res.json({ ok: true, month, ...await costRanking(db, month) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.get('/clients/:id/aws-usage', requireAuth, (req, res) => {
- const id = String(req.params['id'] ?? '')
- if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
- res.json({ ok: true, usage: listAwsUsage(db, id, 12) })
+ r.get('/clients/:id/aws-usage', requireAuth, async (req, res) => {
+ try {
+ const id = String(req.params['id'] ?? '')
+ if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
+ res.json({ ok: true, usage: await listAwsUsage(db, id, 12) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.post('/aws/usage', requireAuth, requireOwner, (req, res) => {
+ r.post('/aws/usage', requireAuth, requireOwner, async (req, res) => {
try {
// Manual owner entry — always 'manual' provenance regardless of the body.
- const usage = upsertAwsUsage(db, staffId(res), { ...(req.body as UpsertAwsUsageInput), source: 'manual' })
+ const usage = await upsertAwsUsage(db, staffId(res), { ...(req.body as UpsertAwsUsageInput), source: 'manual' })
res.json({ ok: true, usage })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
@@ -1076,42 +1202,69 @@ export function apiRouter(
if (typeof req.query['to'] === 'string') range.to = req.query['to']
return range
}
- r.get('/reports/dues-aging', requireAuth, (_req, res) => {
- res.json({ ok: true, rows: duesAging(db, new Date().toISOString().slice(0, 10)) })
+ r.get('/reports/dues-aging', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, rows: await duesAging(db, new Date().toISOString().slice(0, 10)) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.get('/reports/module-revenue', requireAuth, (req, res) => {
- res.json({ ok: true, rows: moduleRevenue(db, rangeOf(req)) })
+ r.get('/reports/module-revenue', requireAuth, async (req, res) => {
+ try {
+ res.json({ ok: true, rows: await moduleRevenue(db, rangeOf(req)) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.get('/reports/profitability', requireAuth, (req, res) => {
- res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) })
+ r.get('/reports/profitability', requireAuth, async (req, res) => {
+ try {
+ res.json({ ok: true, rows: await clientProfitability(db, rangeOf(req)) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- company profile (owner-editable; PDFs read these live) ----------
// D18: the composer autofills invoice due dates from this (issue also stamps with it).
- r.get('/settings/billing', requireAuth, (_req, res) => {
- res.json({ ok: true, paymentTermsDays: getNumberSetting(db, 'billing.payment_terms_days', 15) })
+ r.get('/settings/billing', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, paymentTermsDays: await getNumberSetting(db, 'billing.payment_terms_days', 15) })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.get('/settings/company', requireAuth, (_req, res) => {
- res.json({ ok: true, company: companySettings() })
+ r.get('/settings/company', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, company: await companySettings() })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
// ---------- sharing defaults (owner) ----------
- r.get('/settings/sharing', requireAuth, requireOwner, (_req, res) => {
- const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
- .get() as { value: string } | undefined
- const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value)
- res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days })
- })
- r.put('/settings/sharing', requireAuth, requireOwner, (req, res) => {
- const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays
- if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) {
- res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' })
- return
+ r.get('/settings/sharing', requireAuth, requireOwner, async (_req, res) => {
+ try {
+ const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
+ const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value)
+ res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.put('/settings/sharing', requireAuth, requireOwner, async (req, res) => {
+ try {
+ const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays
+ if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) {
+ res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' })
+ return
+ }
+ await setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v))
+ res.json({ ok: true, defaultExpiryDays: v })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
- setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v))
- res.json({ ok: true, defaultExpiryDays: v })
})
- r.put('/settings/company', requireAuth, requireOwner, (req, res) => {
+ r.put('/settings/company', requireAuth, requireOwner, async (req, res) => {
const body = req.body as Record
try {
const gstin = body['gstin']
@@ -1125,25 +1278,29 @@ export function apiRouter(
}
for (const [field, key] of Object.entries(COMPANY_FIELDS)) {
const val = body[field]
- if (typeof val === 'string') setSetting(db, staffId(res), key, val)
+ if (typeof val === 'string') await setSetting(db, staffId(res), key, val)
}
- res.json({ ok: true, company: companySettings() })
+ res.json({ ok: true, company: await companySettings() })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
// ---------- reminder cadences (owner; dated rows are append-only — rule 3) ----------
- r.get('/settings/reminders', requireAuth, requireOwner, (_req, res) => {
- const schedules = listSchedules(db)
- res.json({
- ok: true,
- overdueDays: getNumberSetting(db, 'reminders.overdue_days', 7),
- renewalDays: getNumberSetting(db, 'reminders.renewal_days', 15),
- schedules, total: schedules.length,
- })
- })
- r.put('/settings/reminders', requireAuth, requireOwner, (req, res) => {
+ r.get('/settings/reminders', requireAuth, requireOwner, async (_req, res) => {
+ try {
+ const schedules = await listSchedules(db)
+ res.json({
+ ok: true,
+ overdueDays: await getNumberSetting(db, 'reminders.overdue_days', 7),
+ renewalDays: await getNumberSetting(db, 'reminders.renewal_days', 15),
+ schedules, total: schedules.length,
+ })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
+ })
+ r.put('/settings/reminders', requireAuth, requireOwner, async (req, res) => {
const body = req.body as { overdueDays?: unknown; renewalDays?: unknown }
try {
// First pass: validate all fields, collect tuples
@@ -1157,20 +1314,20 @@ export function apiRouter(
// Second pass: write all validated settings — one transaction, so a failure
// mid-loop cannot persist overdueDays without renewalDays (and each setting
// lands with its audit row atomically).
- db.transaction(() => {
+ await db.transaction(async () => {
for (const [key, value] of toSet) {
- setSetting(db, staffId(res), key, value)
+ await setSetting(db, staffId(res), key, value)
}
- })()
+ })
res.json({ ok: true })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/settings/schedules', requireAuth, requireOwner, (req, res) => {
+ r.post('/settings/schedules', requireAuth, requireOwner, async (req, res) => {
const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string }
try {
- const schedule = insertSchedule(db, staffId(res), {
+ const schedule = await insertSchedule(db, staffId(res), {
ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '',
...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}),
...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}),
@@ -1182,10 +1339,14 @@ export function apiRouter(
})
// ---------- document template (company.* + template.* letterhead data) ----------
- r.get('/settings/template', requireAuth, (_req, res) => {
- res.json({ ok: true, settings: companySettings() })
+ r.get('/settings/template', requireAuth, async (_req, res) => {
+ try {
+ res.json({ ok: true, settings: await companySettings() })
+ } catch (err) {
+ res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
+ }
})
- r.put('/settings/template', requireAuth, requireOwner, (req, res) => {
+ r.put('/settings/template', requireAuth, requireOwner, async (req, res) => {
const body = req.body as { company?: Record; template?: Record; titles?: Record }
try {
const company = body.company ?? {}
@@ -1200,24 +1361,24 @@ export function apiRouter(
}
for (const [field, key] of Object.entries(COMPANY_FIELDS)) {
const val = company[field]
- if (typeof val === 'string') setSetting(db, staffId(res), key, val)
+ if (typeof val === 'string') await setSetting(db, staffId(res), key, val)
}
for (const [field, key] of Object.entries(TEMPLATE_FIELDS)) {
const val = (body.template ?? {})[field]
- if (typeof val === 'string') setSetting(db, staffId(res), key, val)
+ if (typeof val === 'string') await setSetting(db, staffId(res), key, val)
}
if (body.titles !== undefined && body.titles !== null) {
for (const t of DOC_TYPES) {
const val = body.titles[t]
- if (typeof val === 'string') setSetting(db, staffId(res), `template.title_${t}`, val)
+ if (typeof val === 'string') await setSetting(db, staffId(res), `template.title_${t}`, val)
}
}
- res.json({ ok: true, settings: companySettings() })
+ res.json({ ok: true, settings: await companySettings() })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
- r.post('/settings/template/logo', requireAuth, requireOwner, (req, res) => {
+ r.post('/settings/template/logo', requireAuth, requireOwner, async (req, res) => {
const { dataUri } = req.body as { dataUri?: unknown }
try {
if (typeof dataUri !== 'string') throw new Error('dataUri (string) is required')
@@ -1227,7 +1388,7 @@ export function apiRouter(
const bytes = Math.floor((m[2]!.length * 3) / 4) // approximate decoded size
if (bytes > 200 * 1024) throw new Error('Logo too large — keep it under 200 KB')
}
- setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it
+ await setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it
res.json({ ok: true, logo: dataUri })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
diff --git a/apps/hq/src/audit.ts b/apps/hq/src/audit.ts
index ad77a68..5fa534b 100644
--- a/apps/hq/src/audit.ts
+++ b/apps/hq/src/audit.ts
@@ -16,20 +16,19 @@ function nextIdMs(): number {
return lastIdMs
}
-export function writeAudit(
+export async function writeAudit(
db: DB, userId: string, action: string, entity: string, entityId: string,
before?: unknown, after?: unknown,
-): void {
- db.prepare(
+): Promise {
+ await db.run(
`INSERT INTO audit_log (id, at_wall, user_id, action, entity, entity_id, before_json, after_json)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
uuidv7(nextIdMs()), new Date().toISOString(), userId, action, entity, entityId,
before === undefined ? null : JSON.stringify(before),
after === undefined ? null : JSON.stringify(after),
)
}
-export function listAudit(db: DB, limit = 200): AuditRow[] {
- return db.prepare(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`).all(limit) as AuditRow[]
+export async function listAudit(db: DB, limit = 200): Promise {
+ return await db.all(`SELECT * FROM audit_log ORDER BY id DESC LIMIT ?`, limit)
}
diff --git a/apps/hq/src/auth.ts b/apps/hq/src/auth.ts
index 5a7317e..7b2d1e0 100644
--- a/apps/hq/src/auth.ts
+++ b/apps/hq/src/auth.ts
@@ -8,48 +8,52 @@ import type { DB } from './db'
const SESSION_DAYS = 14
-export function createStaff(db: DB, input: {
+export async function createStaff(db: DB, input: {
email: string; displayName: string; role: 'owner' | 'manager' | 'staff'; password: string
-}): { id: string } {
+}): Promise<{ id: string }> {
if (input.password.length < 8) throw new Error('Password must be at least 8 characters')
const id = uuidv7()
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
- db.prepare(
+ await db.run(
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
- ).run(id, input.email.toLowerCase(), input.displayName, input.role, salt, hash)
- writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role })
+ id, input.email.toLowerCase(), input.displayName, input.role, salt, hash,
+ )
+ await writeAudit(db, 'system', 'create', 'staff_user', id, undefined, { email: input.email, role: input.role })
return { id }
}
interface StaffRow { id: string; email: string; display_name: string; role: string; pw_salt: string; pw_hash: string; active: number }
-export function login(db: DB, email: string, password: string):
- { token: string; staff: { id: string; displayName: string; role: string } } | null {
- const row = db.prepare(`SELECT * FROM staff_user WHERE email=? AND active=1`).get(email.toLowerCase()) as StaffRow | undefined
+export async function login(db: DB, email: string, password: string):
+ Promise<{ token: string; staff: { id: string; displayName: string; role: string } } | null> {
+ const row = await db.get(`SELECT * FROM staff_user WHERE email=? AND active=1`, email.toLowerCase())
if (!row || !verifyPin(password, { salt: row.pw_salt, hash: row.pw_hash })) return null
const token = randomBytes(32).toString('hex')
const expires = new Date(Date.now() + SESSION_DAYS * 86_400_000).toISOString()
- db.prepare(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`).run(token, row.id, expires)
- writeAudit(db, row.id, 'login', 'staff_user', row.id)
+ await db.run(`INSERT INTO session (token, staff_id, expires_at) VALUES (?, ?, ?)`, token, row.id, expires)
+ await writeAudit(db, row.id, 'login', 'staff_user', row.id)
return { token, staff: { id: row.id, displayName: row.display_name, role: row.role } }
}
-export function verifySession(db: DB, token: string): { id: string; role: string } | null {
+export async function verifySession(db: DB, token: string): Promise<{ id: string; role: string } | null> {
// u.active=1: a deactivated employee's unexpired token must die immediately.
- const row = db.prepare(
+ const row = await db.get<{ id: string; role: string }>(
`SELECT s.staff_id AS id, u.role FROM session s JOIN staff_user u ON u.id = s.staff_id
WHERE s.token=? AND s.expires_at > ? AND u.active = 1`,
- ).get(token, new Date().toISOString()) as { id: string; role: string } | undefined
+ token, new Date().toISOString(),
+ )
return row ?? null
}
export const requireAuth: RequestHandler = (req, res, next) => {
- const db = req.app.locals['db'] as DB
- const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
- const staff = token ? verifySession(db, token) : null
- if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return }
- res.locals['staff'] = staff
- next()
+ void (async () => {
+ const db = req.app.locals['db'] as DB
+ const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
+ const staff = token ? await verifySession(db, token) : null
+ if (!staff) { res.status(401).json({ ok: false, error: 'Not signed in' }); return }
+ res.locals['staff'] = staff
+ next()
+ })()
}
export const requireOwner: RequestHandler = (_req, res, next) => {
diff --git a/apps/hq/src/aws-costs.ts b/apps/hq/src/aws-costs.ts
index 8d61833..8a6551b 100644
--- a/apps/hq/src/aws-costs.ts
+++ b/apps/hq/src/aws-costs.ts
@@ -106,9 +106,9 @@ export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string)
totalPaise += costPaise
const client = tag === ''
? undefined
- : db.prepare(`SELECT id FROM client WHERE lower(code)=lower(?)`).get(tag) as { id: string } | undefined
+ : await db.get<{ id: string }>(`SELECT id FROM client WHERE lower(code)=lower(?)`, tag)
if (client === undefined) { unknownTags.push({ tag, costPaise }); continue }
- upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' })
+ await upsertAwsUsage(db, 'system', { clientId: client.id, month, costPaise, source: 'auto' })
upserted += 1
}
return { upserted, unknownTags, totalPaise }
@@ -117,8 +117,8 @@ export async function pullMonthlyCosts(db: DB, deps: AwsCostDeps, month: string)
/** Gate the pull to once per calendar month; the 6-hourly tick calls this. */
export async function maybePullAwsCosts(db: DB, deps: AwsCostDeps, today: string): Promise {
const target = previousMonth(today)
- if (getSetting(db, 'aws.last_pull_month') === target) return null
+ if (await getSetting(db, 'aws.last_pull_month') === target) return null
const out = await pullMonthlyCosts(db, deps, target)
- setSetting(db, 'system', 'aws.last_pull_month', target)
+ await setSetting(db, 'system', 'aws.last_pull_month', target)
return out
}
diff --git a/apps/hq/src/bounces.ts b/apps/hq/src/bounces.ts
index eb0a811..2bb8b84 100644
--- a/apps/hq/src/bounces.ts
+++ b/apps/hq/src/bounces.ts
@@ -33,26 +33,26 @@ function extractRecipients(msg: GmailMessageMeta): string[] {
}
/** Flip the most recent matching sent log to bounced + raise an email_bounced reminder. */
-export function markBounce(db: DB, recipient: string, at: string): number {
- const row = db.prepare(
+export async function markBounce(db: DB, recipient: string, at: string): Promise {
+ const row = await db.get<{ id: string; document_id: string | null }>(
`SELECT id, document_id FROM email_log
WHERE lower(to_addr)=lower(?) AND status='sent' AND bounced=0
- ORDER BY id DESC LIMIT 1`,
- ).get(recipient) as { id: string; document_id: string | null } | undefined
+ ORDER BY id DESC LIMIT 1`, recipient,
+ )
if (row === undefined) return 0
- db.prepare(`UPDATE email_log SET bounced=1 WHERE id=?`).run(row.id)
- const clientId = row.document_id !== null ? (getDocument(db, row.document_id)?.clientId ?? '') : ''
- upsertReminder(db, {
+ await db.run(`UPDATE email_log SET bounced=1 WHERE id=?`, row.id)
+ const clientId = row.document_id !== null ? ((await getDocument(db, row.document_id))?.clientId ?? '') : ''
+ await upsertReminder(db, {
ruleKind: 'email_bounced', subjectId: row.id, duePeriod: row.id, clientId,
docId: row.document_id, now: at,
})
- writeAudit(db, 'system', 'update', 'email_log', row.id, { bounced: 0 }, { bounced: 1, recipient })
+ await writeAudit(db, 'system', 'update', 'email_log', row.id, { bounced: 0 }, { bounced: 1, recipient })
return 1
}
export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned: number; bounced: number }> {
const now = deps.now?.() ?? new Date().toISOString()
- const account = getAccount(db)
+ const account = await getAccount(db)
if (account === null || account.status === 'dead') return { scanned: 0, bounced: 0 }
let accessToken: string
try {
@@ -60,10 +60,10 @@ export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned:
decrypt(account.refreshTokenEnc, deps.gmail.keyHex), deps.gmail.clientId, deps.gmail.clientSecret, deps.gmail.f,
)
} catch (err) {
- if (err instanceof TokenDeadError) markAccountDead(db)
+ if (err instanceof TokenDeadError) await markAccountDead(db)
return { scanned: 0, bounced: 0 }
}
- const since = getSetting(db, 'reminders.bounce_last_poll')
+ const since = await getSetting(db, 'reminders.bounce_last_poll')
?? new Date(Date.parse(now) - 2 * 86_400_000).toISOString()
const q = `from:mailer-daemon OR from:postmaster after:${Math.floor(Date.parse(since) / 1000)}`
const headers = { authorization: `Bearer ${accessToken}` }
@@ -78,12 +78,12 @@ export async function pollBounces(db: DB, deps: BounceDeps): Promise<{ scanned:
`${GMAIL_LIST}/${id}?format=metadata&metadataHeaders=Subject&metadataHeaders=X-Failed-Recipients`, { headers },
)
const meta = await msg.json().catch(() => ({})) as GmailMessageMeta
- for (const recipient of extractRecipients(meta)) bounced += markBounce(db, recipient, now)
+ for (const recipient of extractRecipients(meta)) bounced += await markBounce(db, recipient, now)
}
} catch {
// Network hiccup: leave bounce_last_poll unchanged so the next pass re-scans this window.
return { scanned, bounced }
}
- setSetting(db, 'system', 'reminders.bounce_last_poll', now)
+ await setSetting(db, 'system', 'reminders.bounce_last_poll', now)
return { scanned, bounced }
}
diff --git a/apps/hq/src/db-pg.ts b/apps/hq/src/db-pg.ts
new file mode 100644
index 0000000..7703df8
--- /dev/null
+++ b/apps/hq/src/db-pg.ts
@@ -0,0 +1,126 @@
+import { AsyncLocalStorage } from 'node:async_hooks'
+import pg from 'pg'
+import type { DB } from './db'
+import { PG_MIGRATIONS } from './migrations-pg'
+
+/**
+ * Postgres engine behind the async DB interface (D19). Selected by DATABASE_URL.
+ * Repos speak `?` placeholders and portable SQL; this adapter owns the dialect:
+ * `?` → `$n`, transactions pinned to one pooled client via AsyncLocalStorage
+ * (nested calls become savepoints), and bigint/numeric results parsed to JS
+ * numbers so integer-paise math is identical on both engines.
+ */
+
+// int8 (bigint paise, COUNT(*)) and numeric (SUM over bigint) arrive as strings by
+// default; paise totals sit far below 2^53, so Number is lossless here.
+pg.types.setTypeParser(20, Number)
+pg.types.setTypeParser(1700, Number)
+
+/** Rewrite `?` placeholders to `$1..$n`, leaving single-quoted literals untouched. */
+export function toDollarParams(sql: string): string {
+ let out = ''
+ let n = 0
+ let inQuote = false
+ for (let i = 0; i < sql.length; i++) {
+ const ch = sql[i]!
+ if (inQuote) {
+ out += ch
+ if (ch === "'") {
+ if (sql[i + 1] === "'") { out += "'"; i++ } else inQuote = false
+ }
+ continue
+ }
+ if (ch === "'") { inQuote = true; out += ch; continue }
+ out += ch === '?' ? `$${++n}` : ch
+ }
+ return out
+}
+
+interface TxnStore { client: pg.PoolClient; depth: number }
+
+export class PgDb implements DB {
+ readonly engine = 'postgres' as const
+ private als = new AsyncLocalStorage()
+ constructor(readonly pool: pg.Pool) {}
+
+ /** Queries inside a transaction ride its pinned client; otherwise the pool. */
+ private q(): { query: (sql: string, args?: unknown[]) => Promise } {
+ return this.als.getStore()?.client ?? this.pool
+ }
+
+ async all(sql: string, ...args: unknown[]): Promise {
+ return (await this.q().query(toDollarParams(sql), args)).rows as T[]
+ }
+
+ async get(sql: string, ...args: unknown[]): Promise {
+ return ((await this.q().query(toDollarParams(sql), args)).rows[0] ?? undefined) as T | undefined
+ }
+
+ async run(sql: string, ...args: unknown[]): Promise<{ changes: number }> {
+ const r = await this.q().query(toDollarParams(sql), args)
+ return { changes: r.rowCount ?? 0 }
+ }
+
+ async exec(sql: string): Promise {
+ await this.q().query(sql)
+ }
+
+ async transaction(fn: () => Promise | T): Promise {
+ const store = this.als.getStore()
+ if (store !== undefined) {
+ // Nested: savepoint on the already-pinned client.
+ const sp = `sp_d${++store.depth}`
+ await store.client.query(`SAVEPOINT ${sp}`)
+ try {
+ const out = await fn()
+ await store.client.query(`RELEASE SAVEPOINT ${sp}`)
+ return out
+ } catch (err) {
+ await store.client.query(`ROLLBACK TO SAVEPOINT ${sp}`)
+ await store.client.query(`RELEASE SAVEPOINT ${sp}`)
+ throw err
+ } finally {
+ store.depth--
+ }
+ }
+ const client = await this.pool.connect()
+ try {
+ await client.query('BEGIN')
+ const out = await this.als.run({ client, depth: 0 }, () => Promise.resolve(fn()))
+ await client.query('COMMIT')
+ return out
+ } catch (err) {
+ await client.query('ROLLBACK')
+ throw err
+ } finally {
+ client.release()
+ }
+ }
+
+ async close(): Promise {
+ await this.pool.end()
+ }
+}
+
+/** Apply pending numbered migrations, each atomically, recorded in schema_migrations. */
+export async function runPgMigrations(db: PgDb): Promise {
+ await db.exec(`CREATE TABLE IF NOT EXISTS schema_migrations (id text PRIMARY KEY, applied_at text NOT NULL)`)
+ const applied: string[] = []
+ for (const m of PG_MIGRATIONS) {
+ await db.transaction(async () => {
+ const done = await db.get(`SELECT id FROM schema_migrations WHERE id=?`, m.id)
+ if (done !== undefined) return
+ await db.exec(m.sql)
+ await db.run(`INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)`, m.id, new Date().toISOString())
+ applied.push(m.id)
+ })
+ }
+ return applied
+}
+
+/** Open the Postgres engine: pool + migrations. Caller runs seedIfEmpty after. */
+export async function openPgDb(url: string): Promise {
+ const db = new PgDb(new pg.Pool({ connectionString: url, max: 10 }))
+ await runPgMigrations(db)
+ return db
+}
diff --git a/apps/hq/src/db.ts b/apps/hq/src/db.ts
index f4c911e..7ca29c1 100644
--- a/apps/hq/src/db.ts
+++ b/apps/hq/src/db.ts
@@ -2,8 +2,71 @@ import Database from 'better-sqlite3'
import fs from 'node:fs'
import path from 'node:path'
-/** HQ console DB — SQLite behind portable repositories, S3 backup at deploy time. */
-export type DB = Database.Database
+/**
+ * HQ console DB handle (D19): ONE async interface, two engines — better-sqlite3
+ * for dev/tests (this file), Postgres for production (`db-pg.ts`, selected by
+ * `DATABASE_URL`). Repos speak portable SQL with `?` placeholders; each engine
+ * adapter owns its dialect differences. Every data-access call is awaited.
+ */
+export interface DB {
+ /** All rows. */
+ all(sql: string, ...args: unknown[]): Promise
+ /** First row, or undefined. */
+ get(sql: string, ...args: unknown[]): Promise
+ /** INSERT/UPDATE/DELETE — resolves with the affected-row count. */
+ run(sql: string, ...args: unknown[]): Promise<{ changes: number }>
+ /** Multi-statement DDL, no params. */
+ exec(sql: string): Promise
+ /** Atomic unit; nested calls become savepoints. (No trailing `()` — unlike better-sqlite3.) */
+ transaction(fn: () => Promise | T): Promise
+ readonly engine: 'sqlite' | 'postgres'
+ close(): Promise
+}
+
+/** The underlying better-sqlite3 handle — migrations and schema tests only. */
+export type SqliteRaw = Database.Database
+
+/** better-sqlite3 behind the async interface — the dev/test engine. */
+export class SqliteDb implements DB {
+ readonly engine = 'sqlite' as const
+ private stmts = new Map()
+ private txnDepth = 0
+ constructor(readonly raw: SqliteRaw) {}
+ private stmt(sql: string): Database.Statement {
+ let s = this.stmts.get(sql)
+ if (s === undefined) { s = this.raw.prepare(sql); this.stmts.set(sql, s) }
+ return s
+ }
+ async all(sql: string, ...args: unknown[]): Promise {
+ return this.stmt(sql).all(...args) as T[]
+ }
+ async get(sql: string, ...args: unknown[]): Promise {
+ return this.stmt(sql).get(...args) as T | undefined
+ }
+ async run(sql: string, ...args: unknown[]): Promise<{ changes: number }> {
+ return { changes: this.stmt(sql).run(...args).changes }
+ }
+ async exec(sql: string): Promise { this.raw.exec(sql) }
+ async transaction(fn: () => Promise | T): Promise {
+ // better-sqlite3's own transaction() rejects async fns, so BEGIN/SAVEPOINT by
+ // hand with a depth counter. Single connection: interleaved writers would join
+ // the open txn — fine for the dev/test engine (requests are awaited serially).
+ const depth = this.txnDepth++
+ const sp = `sp_d${depth}`
+ this.raw.exec(depth === 0 ? 'BEGIN' : `SAVEPOINT ${sp}`)
+ try {
+ const out = await fn()
+ this.raw.exec(depth === 0 ? 'COMMIT' : `RELEASE ${sp}`)
+ return out
+ } catch (err) {
+ this.raw.exec(depth === 0 ? 'ROLLBACK' : `ROLLBACK TO ${sp}; RELEASE ${sp}`)
+ throw err
+ } finally {
+ this.txnDepth--
+ }
+ }
+ async close(): Promise { this.raw.close() }
+}
const SCHEMA = `
CREATE TABLE IF NOT EXISTS staff_user (
@@ -169,23 +232,26 @@ CREATE TABLE IF NOT EXISTS aws_usage (
);
`
+/** Open the SQLite engine (dev/tests). Deliberately sync — schema + migrate run
+ * on the raw handle before it is wrapped, so test fixtures stay one-liners. */
export function openDb(dataDir?: string): DB {
- let db: DB
+ let raw: SqliteRaw
if (dataDir === ':memory:') {
- db = new Database(':memory:')
+ raw = new Database(':memory:')
} else {
const dir = dataDir ?? path.resolve(process.cwd(), 'data')
fs.mkdirSync(dir, { recursive: true })
- db = new Database(path.join(dir, 'hq.db'))
- db.pragma('journal_mode = WAL')
+ raw = new Database(path.join(dir, 'hq.db'))
+ raw.pragma('journal_mode = WAL')
}
- db.exec(SCHEMA)
- migrate(db)
- return db
+ raw.exec(SCHEMA)
+ migrate(raw)
+ return new SqliteDb(raw)
}
-/** Additive, idempotent column adds for DBs created before a schema change. */
-function migrate(db: DB): void {
+/** Additive, idempotent column adds for DBs created before a schema change.
+ * SQLite-only path on the raw handle; Postgres migrates via numbered SQL files. */
+function migrate(db: SqliteRaw): void {
const moduleCols = db.prepare(`PRAGMA table_info(module)`).all() as { name: string }[]
if (!moduleCols.some((c) => c.name === 'quote_content')) {
db.exec(`ALTER TABLE module ADD COLUMN quote_content TEXT NOT NULL DEFAULT '[]'`)
@@ -236,7 +302,7 @@ function migrate(db: DB): void {
* ALTER TABLE … DROP/ADD CONSTRAINT in the prod migration set.
*/
export function rebuildTable(
- db: DB, table: string, newDdl: string, guardToken: string, columns: string[],
+ db: SqliteRaw, table: string, newDdl: string, guardToken: string, columns: string[],
): void {
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type='table' AND name=?`)
.get(table) as { sql: string } | undefined
@@ -251,7 +317,7 @@ export function rebuildTable(
}
/** Widen staff_user.role CHECK to allow 'manager' on DBs created before the employee slice. */
-export function rebuildStaffUserRoleCheck(db: DB): void {
+export function rebuildStaffUserRoleCheck(db: SqliteRaw): void {
rebuildTable(
db,
'staff_user',
@@ -272,7 +338,7 @@ export function rebuildStaffUserRoleCheck(db: DB): void {
* Guard token: the old list ends "...'email_bounced')" — the new DDL continues
* ",'quote_followup')" instead, so the fragment vanishes once rebuilt (idempotent).
*/
-export function rebuildReminderRuleKindCheck(db: DB): void {
+export function rebuildReminderRuleKindCheck(db: SqliteRaw): void {
rebuildTable(
db,
'reminder',
diff --git a/apps/hq/src/gmail.ts b/apps/hq/src/gmail.ts
index 4a53ffc..a8ac8a4 100644
--- a/apps/hq/src/gmail.ts
+++ b/apps/hq/src/gmail.ts
@@ -94,15 +94,15 @@ export type SendResult = { ok: true } | { ok: false; error: string }
export async function sendDocumentEmail(
db: DB, deps: GmailDeps, args: SendDocumentInput,
): Promise {
- const doc = getDocument(db, args.documentId)
+ const doc = await getDocument(db, args.documentId)
if (doc === null) throw new Error('Document not found')
// Only issued documents leave the building — a draft has no legal number.
if (doc.docNo === null) throw new Error('Only issued documents can be emailed')
- const account = getAccount(db)
+ const account = await getAccount(db)
if (account === null) throw new Error('Gmail is not connected — run gmail-connect on the server')
- const fail = (error: string): SendResult => {
- logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error })
+ const fail = async (error: string): Promise => {
+ await logEmail(db, { documentId: args.documentId, to: args.to, subject: args.subject, status: 'failed', error })
return { ok: false, error }
}
if (account.status === 'dead') return fail('gmail-token-dead')
@@ -113,7 +113,7 @@ export async function sendDocumentEmail(
accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f)
} catch (err) {
if (err instanceof TokenDeadError) {
- markAccountDead(db)
+ await markAccountDead(db)
return fail('gmail-token-dead')
}
return fail(err instanceof Error ? err.message : String(err))
@@ -140,18 +140,18 @@ export async function sendDocumentEmail(
return fail(err instanceof Error ? err.message : String(err))
}
- logEmail(db, {
+ await logEmail(db, {
documentId: args.documentId, to: args.to, subject: args.subject, status: 'sent',
...(gmailMessageId !== undefined ? { gmailMessageId } : {}),
})
if (doc.status === 'draft') {
// markStatus appends the 'sent' document_event and the audit row.
- markStatus(db, args.userId, args.documentId, 'sent')
+ await markStatus(db, args.userId, args.documentId, 'sent')
} else {
// Re-send of an already-sent/accepted doc: trace the event, status unchanged.
- db.prepare(
+ await db.run(
`INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`,
- ).run(uuidv7(), args.documentId, new Date().toISOString(), 'sent',
+ uuidv7(), args.documentId, new Date().toISOString(), 'sent',
JSON.stringify({ to: args.to, resend: true }))
}
return { ok: true }
@@ -171,14 +171,14 @@ export interface ReminderMailInput {
export async function sendReminderEmail(
db: DB, deps: GmailDeps, args: ReminderMailInput,
): Promise {
- const logFail = (error: string): SendResult => {
- logEmail(db, {
+ const logFail = async (error: string): Promise => {
+ await logEmail(db, {
...(args.documentId !== undefined ? { documentId: args.documentId } : {}),
to: args.to, subject: args.subject, status: 'failed', error,
})
return { ok: false, error }
}
- const account = getAccount(db)
+ const account = await getAccount(db)
if (account === null) return logFail('gmail-not-connected')
if (account.status === 'dead') return logFail('gmail-token-dead')
@@ -187,7 +187,7 @@ export async function sendReminderEmail(
const refreshToken = decrypt(account.refreshTokenEnc, deps.keyHex)
accessToken = await getAccessToken(refreshToken, deps.clientId, deps.clientSecret, deps.f)
} catch (err) {
- if (err instanceof TokenDeadError) { markAccountDead(db); return logFail('gmail-token-dead') }
+ if (err instanceof TokenDeadError) { await markAccountDead(db); return logFail('gmail-token-dead') }
return logFail(err instanceof Error ? err.message : String(err))
}
@@ -211,7 +211,7 @@ export async function sendReminderEmail(
return logFail(err instanceof Error ? err.message : String(err))
}
- logEmail(db, {
+ await logEmail(db, {
...(args.documentId !== undefined ? { documentId: args.documentId } : {}),
to: args.to, subject: args.subject, status: 'sent',
...(gmailMessageId !== undefined ? { gmailMessageId } : {}),
diff --git a/apps/hq/src/import-apex.ts b/apps/hq/src/import-apex.ts
index 74e731f..5f300b1 100644
--- a/apps/hq/src/import-apex.ts
+++ b/apps/hq/src/import-apex.ts
@@ -44,18 +44,18 @@ function parseCsv(text: string): Record[] {
const CLIENT_STATUSES = new Set(['lead', 'active', 'dormant', 'lost'])
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
-function stageClients(db: DB, records: Record[]): number {
- db.prepare(`DELETE FROM stg_client`).run()
+async function stageClients(db: DB, records: Record[]): Promise {
+ await db.run(`DELETE FROM stg_client`)
const existing = new Set(
- (db.prepare(`SELECT code FROM client`).all() as { code: string }[]).map((r) => r.code),
+ (await db.all<{ code: string }>(`SELECT code FROM client`)).map((r) => r.code),
)
const seen = new Set()
- const insert = db.prepare(
+ const INSERT_SQL =
`INSERT INTO stg_client (row_no, code, name, gstin, state_code, address, phone, email, status,
anydesk, os, district, sector, problems)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- )
- records.forEach((r, i) => {
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ for (let i = 0; i < records.length; i++) {
+ const r = records[i]!
const problems: string[] = []
const code = r['code'] ?? ''
if (code === '') problems.push('missing code')
@@ -66,14 +66,15 @@ function stageClients(db: DB, records: Record[]): number {
if (gstin !== '' && !validateGstin(gstin).ok) problems.push(`bad GSTIN: ${gstin}`)
const status = r['status'] ?? ''
if (status !== '' && !CLIENT_STATUSES.has(status)) problems.push(`bad status: ${status}`)
- insert.run(
+ await db.run(
+ INSERT_SQL,
i + 1, code, r['name'] ?? '', gstin, r['state_code'] ?? '',
r['address'] ?? '', r['phone'] ?? '', r['email'] ?? '', status,
// WS-F support-access fields — optional headers; absent columns stage as ''.
r['anydesk'] ?? '', r['os'] ?? '', r['district'] ?? '', r['sector'] ?? '',
JSON.stringify(problems),
)
- })
+ }
return records.length
}
@@ -83,18 +84,18 @@ function paiseOrNull(raw: string): number | null {
return Number.isFinite(n) ? fromRupees(n) : null
}
-function stageInvoices(db: DB, records: Record[]): number {
- db.prepare(`DELETE FROM stg_invoice`).run()
+async function stageInvoices(db: DB, records: Record[]): Promise {
+ await db.run(`DELETE FROM stg_invoice`)
const existing = new Set(
- (db.prepare(`SELECT doc_no FROM document WHERE doc_no IS NOT NULL`).all() as { doc_no: string }[])
+ (await db.all<{ doc_no: string }>(`SELECT doc_no FROM document WHERE doc_no IS NOT NULL`))
.map((r) => r.doc_no),
)
const seen = new Set()
- const insert = db.prepare(
+ const INSERT_SQL =
`INSERT INTO stg_invoice (row_no, client_code, doc_no, doc_date, taxable_paise, tax_paise, total_paise, paid, problems)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- )
- records.forEach((r, i) => {
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
+ for (let i = 0; i < records.length; i++) {
+ const r = records[i]!
const problems: string[] = []
if ((r['client_code'] ?? '') === '') problems.push('missing client_code')
const docNo = r['doc_no'] ?? ''
@@ -109,24 +110,25 @@ function stageInvoices(db: DB, records: Record[]): number {
if (taxable === null) problems.push(`bad taxable: ${r['taxable'] ?? ''}`)
if (tax === null) problems.push(`bad tax: ${r['tax'] ?? ''}`)
if (total === null) problems.push(`bad total: ${r['total'] ?? ''}`)
- insert.run(
+ await db.run(
+ INSERT_SQL,
i + 1, r['client_code'] ?? '', docNo, docDate,
taxable, tax, total, r['paid'] === '1' ? 1 : 0,
JSON.stringify(problems),
)
- })
+ }
return records.length
}
-export function stageCsv(db: DB, kind: 'clients' | 'invoices', csvText: string): { staged: number } {
+export async function stageCsv(db: DB, kind: 'clients' | 'invoices', csvText: string): Promise<{ staged: number }> {
const records = parseCsv(csvText)
// One transaction: the DELETE + re-insert of the staging area and its audit row
// land (or roll back) together — same-txn audit rule.
- return db.transaction(() => {
- const staged = kind === 'clients' ? stageClients(db, records) : stageInvoices(db, records)
- writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged })
+ return db.transaction(async () => {
+ const staged = kind === 'clients' ? await stageClients(db, records) : await stageInvoices(db, records)
+ await writeAudit(db, 'system', 'stage', `stg_${kind}`, kind, undefined, { staged })
return { staged }
- })()
+ })
}
// ---------- verification report ----------
@@ -137,24 +139,24 @@ export interface VerificationReport {
samples: { firstClients: string[]; lastInvoices: string[] }
}
-export function verificationReport(db: DB): VerificationReport {
- const c = db.prepare(
+export async function verificationReport(db: DB): Promise {
+ const c = (await db.get<{ staged: number; problems: number }>(
`SELECT COUNT(*) AS staged,
COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems
FROM stg_client`,
- ).get() as { staged: number; problems: number }
- const i = db.prepare(
+ ))!
+ const i = (await db.get<{ staged: number; problems: number; totalPaise: number }>(
`SELECT COUNT(*) AS staged,
COALESCE(SUM(CASE WHEN problems <> '[]' THEN 1 ELSE 0 END), 0) AS problems,
COALESCE(SUM(total_paise), 0) AS totalPaise
FROM stg_invoice`,
- ).get() as { staged: number; problems: number; totalPaise: number }
- const firstClients = (db.prepare(
+ ))!
+ const firstClients = (await db.all<{ code: string; name: string }>(
`SELECT code, name FROM stg_client ORDER BY row_no LIMIT 5`,
- ).all() as { code: string; name: string }[]).map((r) => `${r.code} ${r.name}`.trim())
- const lastInvoices = (db.prepare(
+ )).map((r) => `${r.code} ${r.name}`.trim())
+ const lastInvoices = (await db.all<{ doc_no: string }>(
`SELECT doc_no FROM stg_invoice ORDER BY row_no DESC LIMIT 5`,
- ).all() as { doc_no: string }[]).map((r) => r.doc_no)
+ )).map((r) => r.doc_no)
return {
clients: { staged: c.staged, problems: c.problems },
invoices: { staged: i.staged, problems: i.problems, totalPaise: i.totalPaise },
@@ -181,32 +183,30 @@ export interface CommitResult {
seeded: { fy: string; lastSeq: number } | null
}
-function companyStateCode(db: DB): string {
- const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`)
- .get() as { value: string } | undefined
+async function companyStateCode(db: DB): Promise {
+ const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='company.state_code'`)
// Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly.
if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`)
return row.value
}
-export function commitImport(db: DB, userId: string): CommitResult {
- return db.transaction((): CommitResult => {
- const bad = (db.prepare(
+export async function commitImport(db: DB, userId: string): Promise {
+ return db.transaction(async (): Promise => {
+ const bad = (await db.get<{ n: number }>(
`SELECT (SELECT COUNT(*) FROM stg_client WHERE problems <> '[]')
+ (SELECT COUNT(*) FROM stg_invoice WHERE problems <> '[]') AS n`,
- ).get() as { n: number }).n
+ ))!.n
if (bad > 0) throw new Error(`${bad} staged row(s) have problems — fix the CSV and re-stage before commit`)
- const ourState = companyStateCode(db)
+ const ourState = await companyStateCode(db)
const now = new Date().toISOString()
// Clients — source 'apex' marks the cutover rows.
- const stgClients = db.prepare(`SELECT * FROM stg_client ORDER BY row_no`).all() as StgClientRow[]
- const insertClient = db.prepare(
+ const stgClients = await db.all(`SELECT * FROM stg_client ORDER BY row_no`)
+ const INSERT_CLIENT_SQL =
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, source, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`,
- )
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'apex', ?)`
const orNull = (v: string): string | null => (v !== '' ? v : null)
for (const c of stgClients) {
const id = uuidv7()
@@ -217,28 +217,29 @@ export function commitImport(db: DB, userId: string): CommitResult {
...(c.email !== '' ? { email: c.email } : {}),
}]
: []
- insertClient.run(
+ await db.run(
+ INSERT_CLIENT_SQL,
id, c.code, c.name, c.gstin !== '' ? c.gstin : null,
c.state_code !== '' ? c.state_code : ourState, c.address,
JSON.stringify(contacts), c.status !== '' ? c.status : 'active',
// WS-F: the old book's support-access data rides the cutover, not re-keying.
orNull(c.anydesk), orNull(c.os), orNull(c.district), orNull(c.sector), now,
)
- writeAudit(db, userId, 'import', 'client', id, undefined, { code: c.code, name: c.name, source: 'apex' })
+ await writeAudit(db, userId, 'import', 'client', id, undefined, { code: c.code, name: c.name, source: 'apex' })
}
// Invoices — issued, history-only documents (empty payload lines) that keep
// their legacy APEX numbers; the series is seeded past them below.
- const stgInvoices = db.prepare(`SELECT * FROM stg_invoice ORDER BY row_no`).all() as StgInvoiceRow[]
- const insertDoc = db.prepare(
+ const stgInvoices = await db.all(`SELECT * FROM stg_invoice ORDER BY row_no`)
+ const INSERT_DOC_SQL =
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, status, ref_doc_id,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, source, created_by, created_at)
- VALUES (?, 'INVOICE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`,
- )
+ VALUES (?, 'INVOICE', ?, ?, ?, ?, ?, NULL, ?, ?, ?, ?, ?, ?, ?, 'apex', ?, ?)`
for (const inv of stgInvoices) {
- const client = db.prepare(`SELECT id, state_code FROM client WHERE code=?`)
- .get(inv.client_code) as { id: string; state_code: string } | undefined
+ const client = await db.get<{ id: string; state_code: string }>(
+ `SELECT id, state_code FROM client WHERE code=?`, inv.client_code,
+ )
if (client === undefined) {
throw new Error(`Invoice ${inv.doc_no}: client code ${inv.client_code} not found — stage clients.csv first`)
}
@@ -254,12 +255,13 @@ export function commitImport(db: DB, userId: string): CommitResult {
}
const id = uuidv7()
const status = inv.paid === 1 ? 'paid' : 'sent'
- insertDoc.run(
+ await db.run(
+ INSERT_DOC_SQL,
id, inv.doc_no, fyOf(inv.doc_date), client.id, inv.doc_date, status,
inv.taxable_paise, cgst, sgst, igst, roundOff, inv.total_paise,
JSON.stringify({ lines: [], totals }), userId, now,
)
- writeAudit(db, userId, 'import', 'document', id, undefined,
+ await writeAudit(db, userId, 'import', 'document', id, undefined,
{ docNo: inv.doc_no, status, payablePaise: inv.total_paise, source: 'apex' })
}
@@ -275,40 +277,42 @@ export function commitImport(db: DB, userId: string): CommitResult {
.map((t) => Number(t))
if (tails.length > 0) {
const lastSeq = Math.max(...tails)
- seedSeries(db, 'INVOICE', currentFy, lastSeq)
+ await seedSeries(db, 'INVOICE', currentFy, lastSeq)
seeded = { fy: currentFy, lastSeq }
- writeAudit(db, userId, 'import', 'doc_series', `INVOICE/${currentFy}`, undefined, { lastSeq })
+ await writeAudit(db, userId, 'import', 'doc_series', `INVOICE/${currentFy}`, undefined, { lastSeq })
}
// Commit consumes the staging area.
- db.prepare(`DELETE FROM stg_client`).run()
- db.prepare(`DELETE FROM stg_invoice`).run()
+ await db.run(`DELETE FROM stg_client`)
+ await db.run(`DELETE FROM stg_invoice`)
return { clients: stgClients.length, invoices: stgInvoices.length, seeded }
- })()
+ })
}
// ---------- CLI: npm run import -- --dir [--commit] ----------
if (process.argv[1] !== undefined && /import-apex\.(ts|cjs|js)$/.test(process.argv[1])) {
- const args = process.argv.slice(2)
- const dirIdx = args.indexOf('--dir')
- const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined
- if (dir === undefined) {
- console.error('Usage: npm run import -- --dir [--commit]')
- process.exit(1)
- }
- const db = openDb(process.env['HQ_DATA_DIR'])
- seedIfEmpty(db)
- const clients = stageCsv(db, 'clients', fs.readFileSync(path.join(dir, 'clients.csv'), 'utf8'))
- const invoices = stageCsv(db, 'invoices', fs.readFileSync(path.join(dir, 'invoices.csv'), 'utf8'))
- console.log(`Staged ${clients.staged} clients, ${invoices.staged} invoices from ${dir}`)
- console.log(JSON.stringify(verificationReport(db), null, 2))
- if (args.includes('--commit')) {
- const out = commitImport(db, 'system')
- console.log(`Committed: ${out.clients} clients, ${out.invoices} invoices; series seeded:`,
- out.seeded ?? 'no current-FY invoices')
- } else {
- console.log('Dry run — verify the report above, then re-run with --commit to apply.')
- }
+ void (async () => {
+ const args = process.argv.slice(2)
+ const dirIdx = args.indexOf('--dir')
+ const dir = dirIdx >= 0 ? args[dirIdx + 1] : undefined
+ if (dir === undefined) {
+ console.error('Usage: npm run import -- --dir [--commit]')
+ process.exit(1)
+ }
+ const db = openDb(process.env['HQ_DATA_DIR'])
+ await seedIfEmpty(db)
+ const clients = await stageCsv(db, 'clients', fs.readFileSync(path.join(dir, 'clients.csv'), 'utf8'))
+ const invoices = await stageCsv(db, 'invoices', fs.readFileSync(path.join(dir, 'invoices.csv'), 'utf8'))
+ console.log(`Staged ${clients.staged} clients, ${invoices.staged} invoices from ${dir}`)
+ console.log(JSON.stringify(await verificationReport(db), null, 2))
+ if (args.includes('--commit')) {
+ const out = await commitImport(db, 'system')
+ console.log(`Committed: ${out.clients} clients, ${out.invoices} invoices; series seeded:`,
+ out.seeded ?? 'no current-FY invoices')
+ } else {
+ console.log('Dry run — verify the report above, then re-run with --commit to apply.')
+ }
+ })()
}
diff --git a/apps/hq/src/migrations-pg.ts b/apps/hq/src/migrations-pg.ts
new file mode 100644
index 0000000..eeb070b
--- /dev/null
+++ b/apps/hq/src/migrations-pg.ts
@@ -0,0 +1,186 @@
+/**
+ * Postgres migration set (D19). Numbered, applied in order by db-pg.ts's runner,
+ * each inside its own transaction, recorded in schema_migrations. Inline strings
+ * (not .sql files) so esbuild bundles them into dist/server.cjs with no file I/O.
+ *
+ * Translation choices vs the SQLite SCHEMA (deliberate, recorded in the D19 spec):
+ * - every *_paise / money column is BIGINT (int4 overflows at ₹2.15 crore in paise);
+ * - 0/1 flags stay INTEGER — repos say `active=1` and that SQL must be identical on
+ * both engines (boolean would break it);
+ * - JSON payloads stay TEXT — node-pg auto-parses json/jsonb columns into objects,
+ * which would break the repos' JSON.parse at the edge. A jsonb ALTER is a later,
+ * pg-only optimisation if we ever index into payloads;
+ * - dates stay TEXT ISO (house convention, index-friendly, no timezone surprises);
+ * - the reminder.rule_kind CHECK is born in its final widened form (SQLite reaches
+ * it via the guarded table rebuild; Postgres starts correct).
+ */
+
+export interface PgMigration { id: string; sql: string }
+
+export const PG_MIGRATIONS: PgMigration[] = [
+ {
+ id: '001-init',
+ sql: `
+CREATE TABLE IF NOT EXISTS staff_user (
+ id text PRIMARY KEY, email text NOT NULL UNIQUE, display_name text NOT NULL,
+ role text NOT NULL CHECK (role IN ('owner','manager','staff')),
+ phone text, title text,
+ pw_salt text NOT NULL, pw_hash text NOT NULL, active integer NOT NULL DEFAULT 1
+);
+CREATE TABLE IF NOT EXISTS session (
+ token text PRIMARY KEY, staff_id text NOT NULL, expires_at text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS client (
+ id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
+ gstin text, state_code text NOT NULL DEFAULT '32', address text NOT NULL DEFAULT '',
+ contacts text NOT NULL DEFAULT '[]',
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('lead','active','dormant','lost')),
+ owner_id text,
+ anydesk text, os text, district text, sector text, db_password_enc text,
+ notes text NOT NULL DEFAULT '', source text NOT NULL DEFAULT 'hq', created_at text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS module (
+ id text PRIMARY KEY, code text NOT NULL UNIQUE, name text NOT NULL,
+ sac text NOT NULL DEFAULT '998313',
+ allowed_kinds text NOT NULL DEFAULT '["one_time","monthly","yearly","usage"]',
+ multi_subscription integer NOT NULL DEFAULT 0, active integer NOT NULL DEFAULT 1,
+ quote_content text NOT NULL DEFAULT '[]'
+);
+CREATE TABLE IF NOT EXISTS module_price_book (
+ id text PRIMARY KEY, module_id text NOT NULL, edition text NOT NULL DEFAULT 'standard',
+ kind text NOT NULL, price_paise bigint NOT NULL, effective_from text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS client_module (
+ id text PRIMARY KEY, client_id text NOT NULL, module_id text NOT NULL,
+ status text NOT NULL DEFAULT 'quoted' CHECK (status IN
+ ('quoted','ordered','installing','installed','trained','live','expired','cancelled')),
+ kind text NOT NULL, edition text NOT NULL DEFAULT 'standard',
+ installed_on text, completed_on text, trained_on text,
+ next_renewal text, active integer NOT NULL DEFAULT 1
+);
+CREATE TABLE IF NOT EXISTS tax_class (
+ class_code text NOT NULL, rate_pct_bp integer NOT NULL,
+ cess_pct_bp integer NOT NULL DEFAULT 0, effective_from text NOT NULL, effective_to text
+);
+CREATE TABLE IF NOT EXISTS doc_series (
+ doc_type text NOT NULL, fy text NOT NULL, prefix text NOT NULL, next_seq integer NOT NULL,
+ PRIMARY KEY (doc_type, fy)
+);
+CREATE TABLE IF NOT EXISTS document (
+ id text PRIMARY KEY, doc_type text NOT NULL CHECK (doc_type IN
+ ('QUOTATION','PROFORMA','INVOICE','RECEIPT','CREDIT_NOTE')),
+ doc_no text, fy text NOT NULL, client_id text NOT NULL, doc_date text NOT NULL,
+ due_date text,
+ status text NOT NULL DEFAULT 'draft' CHECK (status IN
+ ('draft','sent','accepted','invoiced','part_paid','paid','lost','cancelled')),
+ ref_doc_id text,
+ taxable_paise bigint NOT NULL, cgst_paise bigint NOT NULL, sgst_paise bigint NOT NULL,
+ igst_paise bigint NOT NULL, round_off_paise bigint NOT NULL, payable_paise bigint NOT NULL,
+ payload text NOT NULL,
+ source text NOT NULL DEFAULT 'hq',
+ created_by text NOT NULL, created_at text NOT NULL,
+ UNIQUE (doc_no)
+);
+CREATE TABLE IF NOT EXISTS document_event (
+ id text PRIMARY KEY, document_id text NOT NULL, at_wall text NOT NULL,
+ kind text NOT NULL, meta text NOT NULL DEFAULT '{}'
+);
+CREATE TABLE IF NOT EXISTS document_share (
+ id text PRIMARY KEY, document_id text NOT NULL, token text NOT NULL UNIQUE,
+ created_by text NOT NULL, created_at text NOT NULL,
+ expires_at text,
+ revoked integer NOT NULL DEFAULT 0
+);
+CREATE TABLE IF NOT EXISTS payment (
+ id text PRIMARY KEY, client_id text NOT NULL, received_on text NOT NULL,
+ mode text NOT NULL CHECK (mode IN ('bank','upi','cheque','cash','other')),
+ reference text NOT NULL DEFAULT '', amount_paise bigint NOT NULL,
+ tds_paise bigint NOT NULL DEFAULT 0, created_by text NOT NULL, created_at text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS payment_allocation (
+ id text PRIMARY KEY, payment_id text NOT NULL, document_id text NOT NULL,
+ amount_paise bigint NOT NULL
+);
+CREATE TABLE IF NOT EXISTS email_account (
+ id text PRIMARY KEY, address text NOT NULL, refresh_token_enc text NOT NULL,
+ status text NOT NULL DEFAULT 'active' CHECK (status IN ('active','dead')), updated_at text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS email_log (
+ id text PRIMARY KEY, document_id text, to_addr text NOT NULL, subject text NOT NULL,
+ status text NOT NULL CHECK (status IN ('sent','failed')), gmail_message_id text,
+ error text, at_wall text NOT NULL,
+ bounced integer NOT NULL DEFAULT 0
+);
+CREATE TABLE IF NOT EXISTS setting (
+ key text PRIMARY KEY, value text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS audit_log (
+ id text PRIMARY KEY, at_wall text NOT NULL, user_id text NOT NULL, action text NOT NULL,
+ entity text NOT NULL, entity_id text NOT NULL, before_json text, after_json text
+);
+CREATE TABLE IF NOT EXISTS stg_client (
+ row_no integer PRIMARY KEY, code text, name text, gstin text, state_code text,
+ address text, phone text, email text, status text,
+ anydesk text, os text, district text, sector text,
+ problems text NOT NULL DEFAULT '[]'
+);
+CREATE TABLE IF NOT EXISTS stg_invoice (
+ row_no integer PRIMARY KEY, client_code text, doc_no text, doc_date text,
+ taxable_paise bigint, tax_paise bigint, total_paise bigint, paid integer,
+ problems text NOT NULL DEFAULT '[]'
+);
+CREATE TABLE IF NOT EXISTS recurring_plan (
+ id text PRIMARY KEY, client_id text NOT NULL,
+ client_module_id text,
+ cadence text NOT NULL CHECK (cadence IN ('monthly','yearly')),
+ amount_paise bigint,
+ next_run text NOT NULL,
+ policy text NOT NULL DEFAULT 'manual' CHECK (policy IN ('auto','manual')),
+ active integer NOT NULL DEFAULT 1
+);
+CREATE TABLE IF NOT EXISTS amc_contract (
+ id text PRIMARY KEY, client_id text NOT NULL, coverage text NOT NULL DEFAULT '',
+ period_from text NOT NULL, period_to text NOT NULL, amount_paise bigint NOT NULL,
+ renewal_reminder_days integer NOT NULL DEFAULT 30,
+ legacy_paid integer,
+ invoice_doc_id text, active integer NOT NULL DEFAULT 1
+);
+CREATE TABLE IF NOT EXISTS interaction_type (
+ code text PRIMARY KEY, label text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS interaction (
+ id text PRIMARY KEY, client_id text NOT NULL, type_code text NOT NULL,
+ on_date text NOT NULL, staff_id text NOT NULL, notes text NOT NULL DEFAULT '',
+ outcome text CHECK (outcome IN ('positive','neutral','negative')),
+ follow_up_on text, created_at text NOT NULL
+);
+CREATE TABLE IF NOT EXISTS reminder (
+ id text PRIMARY KEY,
+ rule_kind text NOT NULL CHECK (rule_kind IN
+ ('invoice_overdue','renewal_due','amc_expiring','follow_up','recurring_generated','email_bounced','quote_followup')),
+ subject_id text NOT NULL, due_period text NOT NULL, client_id text NOT NULL,
+ doc_id text,
+ status text NOT NULL DEFAULT 'queued' CHECK (status IN ('queued','sent','failed','dismissed')),
+ policy_applied text NOT NULL DEFAULT 'manual' CHECK (policy_applied IN ('auto','manual')),
+ error text, created_at text NOT NULL, sent_at text,
+ UNIQUE (rule_kind, subject_id, due_period)
+);
+CREATE TABLE IF NOT EXISTS reminder_schedule (
+ id text PRIMARY KEY, rule_kind text NOT NULL,
+ effective_from text NOT NULL, effective_to text,
+ day_offsets text NOT NULL,
+ subject text, body text
+);
+CREATE TABLE IF NOT EXISTS aws_usage (
+ id text PRIMARY KEY, client_id text NOT NULL,
+ month text NOT NULL,
+ storage_gb double precision NOT NULL DEFAULT 0,
+ transfer_gb double precision NOT NULL DEFAULT 0,
+ cost_paise bigint NOT NULL DEFAULT 0,
+ source text NOT NULL DEFAULT 'auto' CHECK (source IN ('auto','manual')),
+ updated_at text NOT NULL,
+ UNIQUE (client_id, month)
+);
+`,
+ },
+]
diff --git a/apps/hq/src/repos-amc.ts b/apps/hq/src/repos-amc.ts
index cd44953..d54d0da 100644
--- a/apps/hq/src/repos-amc.ts
+++ b/apps/hq/src/repos-amc.ts
@@ -30,13 +30,13 @@ function toAmc(r: AmcRow): AmcContract {
}
}
-export function getAmc(db: DB, id: string): AmcContract | null {
- const row = db.prepare(`SELECT * FROM amc_contract WHERE id=?`).get(id) as AmcRow | undefined
+export async function getAmc(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM amc_contract WHERE id=?`, id)
return row === undefined ? null : toAmc(row)
}
-export function listAmc(db: DB, clientId: string): AmcContract[] {
- const rows = db.prepare(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`).all(clientId) as AmcRow[]
+export async function listAmc(db: DB, clientId: string): Promise {
+ const rows = await db.all(`SELECT * FROM amc_contract WHERE client_id=? ORDER BY id DESC`, clientId)
return rows.map(toAmc)
}
@@ -45,22 +45,21 @@ export interface CreateAmcInput {
amountPaise: number; renewalReminderDays?: number; legacyPaid?: boolean
}
-export function createAmc(db: DB, userId: string, input: CreateAmcInput): AmcContract {
- if (getClient(db, input.clientId) === null) throw new Error('Client not found')
+export async function createAmc(db: DB, userId: string, input: CreateAmcInput): Promise {
+ if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
if (!Number.isInteger(input.amountPaise) || input.amountPaise < 0) {
throw new Error('amountPaise must be a non-negative integer (paise)')
}
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO amc_contract (id, client_id, coverage, period_from, period_to, amount_paise,
renewal_reminder_days, legacy_paid, invoice_doc_id, active)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, NULL, 1)`,
- ).run(
id, input.clientId, input.coverage ?? '', input.periodFrom, input.periodTo, input.amountPaise,
input.renewalReminderDays ?? 30, input.legacyPaid === undefined ? null : input.legacyPaid ? 1 : 0,
)
- const amc = getAmc(db, id)!
- writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
+ const amc = (await getAmc(db, id))!
+ await writeAudit(db, userId, 'create', 'amc_contract', id, undefined, amc)
return amc
}
@@ -69,8 +68,8 @@ export interface AmcPatch {
renewalReminderDays?: number; legacyPaid?: boolean | null; active?: boolean
}
-export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): AmcContract {
- const before = getAmc(db, id)
+export async function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch): Promise {
+ const before = await getAmc(db, id)
if (before === null) throw new Error('AMC contract not found')
const sets: string[] = []
const args: unknown[] = []
@@ -83,63 +82,65 @@ export function updateAmc(db: DB, userId: string, id: string, patch: AmcPatch):
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
- db.prepare(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ await db.run(`UPDATE amc_contract SET ${sets.join(', ')} WHERE id=?`, ...args)
}
- const after = getAmc(db, id)!
- writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
+ const after = (await getAmc(db, id))!
+ await writeAudit(db, userId, 'update', 'amc_contract', id, before, after)
return after
}
-export function deactivateAmc(db: DB, userId: string, id: string): AmcContract {
+export async function deactivateAmc(db: DB, userId: string, id: string): Promise {
return updateAmc(db, userId, id, { active: false })
}
/** Renewal invoice for an AMC: one line on the seeded AMC module, priced at the
* contract amount, issued and linked back via invoice_doc_id. */
-export function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Doc {
- const amc = getAmc(db, amcId)
+export async function generateAmcRenewalInvoice(db: DB, userId: string, amcId: string): Promise {
+ const amc = await getAmc(db, amcId)
if (amc === null) throw new Error('AMC contract not found')
if (amc.invoiceDocId !== null) {
- const existing = getDocument(db, amc.invoiceDocId)
- if (existing !== null && existing.status !== 'cancelled' && outstandingOf(db, existing.id) > 0) {
+ const existing = await getDocument(db, amc.invoiceDocId)
+ if (existing !== null && existing.status !== 'cancelled' && (await outstandingOf(db, existing.id)) > 0) {
throw new Error(`AMC already has an unpaid renewal invoice ${existing.docNo}`)
}
}
- const amcModule = db.prepare(`SELECT id FROM module WHERE code='AMC'`).get() as { id: string } | undefined
+ const amcModule = await db.get<{ id: string }>(`SELECT id FROM module WHERE code='AMC'`)
if (amcModule === undefined) throw new Error('AMC module missing — run the seed')
- return db.transaction(() => {
- const draft = createDraft(db, userId, {
+ return db.transaction(async () => {
+ const draft = await createDraft(db, userId, {
docType: 'INVOICE', clientId: amc.clientId,
lines: [{
moduleId: amcModule.id, description: amc.coverage !== '' ? amc.coverage : 'Annual Maintenance',
qty: 1, kind: 'yearly', unitPricePaise: amc.amountPaise,
}],
})
- const inv = issueDocument(db, userId, draft.id)
- db.prepare(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`).run(inv.id, amcId)
- writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
+ const inv = await issueDocument(db, userId, draft.id)
+ await db.run(`UPDATE amc_contract SET invoice_doc_id=? WHERE id=?`, inv.id, amcId)
+ await writeAudit(db, userId, 'update', 'amc_contract', amcId, { invoiceDocId: amc.invoiceDocId }, { invoiceDocId: inv.id })
return inv
- })()
+ })
}
/** Local outstanding read (payable − allocations − non-cancelled credit notes) —
* mirrors repos-payments.outstandingOf. Task 7 exports outstandingPaise; this can
* switch to it then, but does not depend on Task 7 to compile. */
-function outstandingOf(db: DB, docId: string): number {
- const doc = getDocument(db, docId)
+async function outstandingOf(db: DB, docId: string): Promise {
+ const doc = await getDocument(db, docId)
if (doc === null) return 0
- const alloc = (db.prepare(
+ const alloc = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
- ).get(docId) as { total: number }).total
- const credited = (db.prepare(
+ docId,
+ ))!.total
+ const credited = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
- ).get(docId) as { total: number }).total
+ docId,
+ ))!.total
return doc.payablePaise - alloc - credited
}
-export function amcPaidStatus(db: DB, amc: AmcContract): 'paid' | 'unpaid' | 'unbilled' {
+export async function amcPaidStatus(db: DB, amc: AmcContract): Promise<'paid' | 'unpaid' | 'unbilled'> {
if (amc.legacyPaid === true) return 'paid'
if (amc.invoiceDocId === null) return 'unbilled'
- return outstandingOf(db, amc.invoiceDocId) <= 0 ? 'paid' : 'unpaid'
+ return (await outstandingOf(db, amc.invoiceDocId)) <= 0 ? 'paid' : 'unpaid'
}
diff --git a/apps/hq/src/repos-aws.ts b/apps/hq/src/repos-aws.ts
index 34521f4..6cc98aa 100644
--- a/apps/hq/src/repos-aws.ts
+++ b/apps/hq/src/repos-aws.ts
@@ -27,15 +27,16 @@ function toUsage(r: AwsUsageRow): AwsUsage {
}
}
-export function getAwsUsage(db: DB, clientId: string, month: string): AwsUsage | null {
- const row = db.prepare(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`).get(clientId, month) as AwsUsageRow | undefined
+export async function getAwsUsage(db: DB, clientId: string, month: string): Promise {
+ const row = await db.get(`SELECT * FROM aws_usage WHERE client_id=? AND month=?`, clientId, month)
return row === undefined ? null : toUsage(row)
}
-export function listAwsUsage(db: DB, clientId: string, limit = 12): AwsUsage[] {
- const rows = db.prepare(
+export async function listAwsUsage(db: DB, clientId: string, limit = 12): Promise {
+ const rows = await db.all(
`SELECT * FROM aws_usage WHERE client_id=? ORDER BY month DESC LIMIT ?`,
- ).all(clientId, limit) as AwsUsageRow[]
+ clientId, limit,
+ )
return rows.map(toUsage)
}
@@ -44,8 +45,8 @@ export interface UpsertAwsUsageInput {
costPaise?: number; source?: 'auto' | 'manual'
}
-export function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): AwsUsage {
- if (getClient(db, input.clientId) === null) throw new Error('Client not found')
+export async function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInput): Promise {
+ if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
if (!/^\d{4}-\d{2}$/.test(input.month)) throw new Error("month must be 'YYYY-MM'")
if (input.costPaise !== undefined && (!Number.isInteger(input.costPaise) || input.costPaise < 0)) {
throw new Error('costPaise must be a non-negative integer (paise)')
@@ -53,34 +54,36 @@ export function upsertAwsUsage(db: DB, userId: string, input: UpsertAwsUsageInpu
for (const [k, v] of [['storageGb', input.storageGb], ['transferGb', input.transferGb]] as const) {
if (v !== undefined && (!Number.isFinite(v) || v < 0)) throw new Error(`${k} must be a non-negative number`)
}
- const before = getAwsUsage(db, input.clientId, input.month)
+ const before = await getAwsUsage(db, input.clientId, input.month)
const id = before?.id ?? uuidv7()
const storageGb = input.storageGb ?? before?.storageGb ?? 0
const transferGb = input.transferGb ?? before?.transferGb ?? 0
const costPaise = input.costPaise ?? before?.costPaise ?? 0
const source = input.source ?? before?.source ?? 'manual'
- db.prepare(
+ await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO aws_usage (id, client_id, month, storage_gb, transfer_gb, cost_paise, source, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT (client_id, month) DO UPDATE SET
storage_gb=excluded.storage_gb, transfer_gb=excluded.transfer_gb,
cost_paise=excluded.cost_paise, source=excluded.source, updated_at=excluded.updated_at`,
- ).run(id, input.clientId, input.month, storageGb, transferGb, costPaise, source, new Date().toISOString())
- const after = getAwsUsage(db, input.clientId, input.month)!
- writeAudit(db, userId, before === null ? 'create' : 'update', 'aws_usage', after.id, before ?? undefined, after)
+ id, input.clientId, input.month, storageGb, transferGb, costPaise, source, new Date().toISOString(),
+ )
+ const after = (await getAwsUsage(db, input.clientId, input.month))!
+ await writeAudit(db, userId, before === null ? 'create' : 'update', 'aws_usage', after.id, before ?? undefined, after)
return after
}
export interface CostRankRow { clientId: string; clientName: string; costPaise: number; sharePctBp: number }
/** Cross-client ranking for one month: cost desc, share of total in basis points. */
-export function costRanking(db: DB, month: string): { total: number; rows: CostRankRow[] } {
- const rows = db.prepare(
+export async function costRanking(db: DB, month: string): Promise<{ total: number; rows: CostRankRow[] }> {
+ const rows = await db.all<{ client_id: string; client_name: string; cost_paise: number }>(
`SELECT a.client_id, c.name AS client_name, a.cost_paise
FROM aws_usage a JOIN client c ON c.id = a.client_id
WHERE a.month=? ORDER BY a.cost_paise DESC, c.name`,
- ).all(month) as { client_id: string; client_name: string; cost_paise: number }[]
+ month,
+ )
const total = rows.reduce((s, r) => s + r.cost_paise, 0)
return {
total,
@@ -92,12 +95,12 @@ export function costRanking(db: DB, month: string): { total: number; rows: CostR
}
/** Σ cost over months in [from-month, to-month]; unbounded when a bound is omitted. */
-export function awsCostForClient(db: DB, clientId: string, from?: string, to?: string): number {
+export async function awsCostForClient(db: DB, clientId: string, from?: string, to?: string): Promise {
let sql = `SELECT COALESCE(SUM(cost_paise), 0) AS total FROM aws_usage WHERE client_id=?`
const args: unknown[] = [clientId]
if (from !== undefined) { sql += ` AND month >= ?`; args.push(from.slice(0, 7)) }
if (to !== undefined) { sql += ` AND month <= ?`; args.push(to.slice(0, 7)) }
- return (db.prepare(sql).get(...args) as { total: number }).total
+ return (await db.get<{ total: number }>(sql, ...args))!.total
}
/** '2026-07-10' → '2026-06' (the previous complete month, which is what has settled). */
diff --git a/apps/hq/src/repos-clients.ts b/apps/hq/src/repos-clients.ts
index a6f88b9..a6d29f8 100644
--- a/apps/hq/src/repos-clients.ts
+++ b/apps/hq/src/repos-clients.ts
@@ -49,9 +49,9 @@ function assertGstin(gstin: string): void {
if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'}): ${gstin}`)
}
-export function listClients(
+export async function listClients(
db: DB, q?: string, filters: { district?: string; sector?: string } = {},
-): Client[] {
+): Promise {
let where = ' WHERE 1=1'
const args: unknown[] = []
if (q !== undefined && q !== '') {
@@ -67,12 +67,12 @@ export function listClients(
if (filters.sector !== undefined && filters.sector !== '') {
where += ` AND sector LIKE ?`; args.push(`%${filters.sector}%`)
}
- const rows = db.prepare(`SELECT * FROM client${where} ORDER BY name`).all(...args) as ClientRow[]
+ const rows = await db.all(`SELECT * FROM client${where} ORDER BY name`, ...args)
return rows.map(toClient)
}
-export function getClient(db: DB, id: string): Client | null {
- const row = db.prepare(`SELECT * FROM client WHERE id=?`).get(id) as ClientRow | undefined
+export async function getClient(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM client WHERE id=?`, id)
return row === undefined ? null : toClient(row)
}
@@ -82,24 +82,23 @@ export interface ClientInput {
anydesk?: string; os?: string; district?: string; sector?: string
}
-export function createClient(db: DB, userId: string, input: ClientInput): Client {
+export async function createClient(db: DB, userId: string, input: ClientInput): Promise {
if (input.gstin !== undefined) assertGstin(input.gstin)
const id = uuidv7()
- const n = (db.prepare(`SELECT COUNT(*) AS n FROM client`).get() as { n: number }).n
+ const n = ((await db.get<{ n: number }>(`SELECT COUNT(*) AS n FROM client`))!).n
const code = input.code ?? `CL${String(n + 1).padStart(4, '0')}`
- db.prepare(
+ await db.run(
`INSERT INTO client (id, code, name, gstin, state_code, address, contacts, status,
anydesk, os, district, sector, notes, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
id, code, input.name, input.gstin ?? null, input.stateCode,
input.address ?? '', JSON.stringify(input.contacts ?? []),
input.status ?? 'active',
input.anydesk ?? null, input.os ?? null, input.district ?? null, input.sector ?? null,
input.notes ?? '', new Date().toISOString(),
)
- const client = getClient(db, id)!
- writeAudit(db, userId, 'create', 'client', id, undefined, client)
+ const client = (await getClient(db, id))!
+ await writeAudit(db, userId, 'create', 'client', id, undefined, client)
return client
}
@@ -110,8 +109,8 @@ export interface ClientPatch {
anydesk?: string; os?: string; district?: string; sector?: string
}
-export function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Client {
- const before = getClient(db, id)
+export async function updateClient(db: DB, userId: string, id: string, patch: ClientPatch): Promise {
+ const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (patch.gstin !== undefined && patch.gstin !== null) assertGstin(patch.gstin)
const sets: string[] = []
@@ -132,10 +131,10 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
optText('sector', patch.sector)
if (sets.length > 0) {
args.push(id)
- db.prepare(`UPDATE client SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ await db.run(`UPDATE client SET ${sets.join(', ')} WHERE id=?`, ...args)
}
- const after = getClient(db, id)!
- writeAudit(db, userId, 'update', 'client', id, before, after)
+ const after = (await getClient(db, id))!
+ await writeAudit(db, userId, 'update', 'client', id, before, after)
return after
}
@@ -145,31 +144,31 @@ export function updateClient(db: DB, userId: string, id: string, patch: ClientPa
* owner/manager-gated, audited action, and the value never appears in audit rows.
* An empty keyHex fails loudly — never store a support credential unencrypted.
*/
-export function setClientDbPassword(
+export async function setClientDbPassword(
db: DB, userId: string, id: string, plain: string, keyHex: string,
-): Client {
- return db.transaction(() => {
- const before = getClient(db, id)
+): Promise {
+ return db.transaction(async () => {
+ const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (plain !== '' && keyHex === '') {
throw new Error(`HQ_SECRET_KEY is not configured — refusing to store a DB password unencrypted`)
}
const enc = plain === '' ? null : encrypt(plain, keyHex)
- db.prepare(`UPDATE client SET db_password_enc=? WHERE id=?`).run(enc, id)
- writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null })
- return getClient(db, id)!
- })()
+ await db.run(`UPDATE client SET db_password_enc=? WHERE id=?`, enc, id)
+ await writeAudit(db, userId, 'set_db_password', 'client', id, undefined, { set: enc !== null })
+ return (await getClient(db, id))!
+ })
}
/** Decrypt-and-return the stored DB password — every reveal writes an audit row. */
-export function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): string {
- const row = db.prepare(`SELECT db_password_enc FROM client WHERE id=?`)
- .get(id) as { db_password_enc: string | null } | undefined
+export async function revealClientDbPassword(db: DB, userId: string, id: string, keyHex: string): Promise {
+ const row = await db.get<{ db_password_enc: string | null }>(
+ `SELECT db_password_enc FROM client WHERE id=?`, id)
if (row === undefined) throw new Error('Client not found')
if (row.db_password_enc === null) throw new Error('No DB password stored for this client')
if (keyHex === '') throw new Error(`HQ_SECRET_KEY is not configured — cannot decrypt`)
const plain = decrypt(row.db_password_enc, keyHex)
- writeAudit(db, userId, 'reveal_db_password', 'client', id)
+ await writeAudit(db, userId, 'reveal_db_password', 'client', id)
return plain
}
@@ -178,19 +177,18 @@ export function revealClientDbPassword(db: DB, userId: string, id: string, keyHe
* updateClient/ClientPatch: the generic PATCH /clients/:id is open to staff, while
* ownership routing is owner/manager-gated and audited as its own action.
*/
-export function setClientOwner(db: DB, userId: string, id: string, ownerId: string | null): Client {
- return db.transaction(() => {
- const before = getClient(db, id)
+export async function setClientOwner(db: DB, userId: string, id: string, ownerId: string | null): Promise {
+ return db.transaction(async () => {
+ const before = await getClient(db, id)
if (before === null) throw new Error('Client not found')
if (ownerId !== null) {
- const emp = db.prepare(`SELECT active FROM staff_user WHERE id=?`)
- .get(ownerId) as { active: number } | undefined
+ const emp = await db.get<{ active: number }>(`SELECT active FROM staff_user WHERE id=?`, ownerId)
if (emp === undefined) throw new Error('Employee not found')
if (emp.active !== 1) throw new Error('Cannot assign an inactive employee as account owner')
}
- db.prepare(`UPDATE client SET owner_id=? WHERE id=?`).run(ownerId, id)
- const after = getClient(db, id)!
- writeAudit(db, userId, 'set_owner', 'client', id, before, after)
+ await db.run(`UPDATE client SET owner_id=? WHERE id=?`, ownerId, id)
+ const after = (await getClient(db, id))!
+ await writeAudit(db, userId, 'set_owner', 'client', id, before, after)
return after
- })()
+ })
}
diff --git a/apps/hq/src/repos-dashboard.ts b/apps/hq/src/repos-dashboard.ts
index 3b090c8..1004e34 100644
--- a/apps/hq/src/repos-dashboard.ts
+++ b/apps/hq/src/repos-dashboard.ts
@@ -29,9 +29,9 @@ function endOfMonth(today: string): string {
return new Date(Date.UTC(y!, m!, 0)).toISOString().slice(0, 10) // day 0 of next month = last day of this
}
-export function dashboardView(
+export async function dashboardView(
db: DB, today: string, viewer?: { id: string; role: string }, opts: { mine?: boolean } = {},
-): DashboardView {
+): Promise {
// My Day (D18 WS-E): staff are ALWAYS scoped to their own book (ownerScope forces
// it); owner/manager opt in via mine=true. "Mine" = clients I own, documents and
// payments I created, my follow-ups.
@@ -43,17 +43,18 @@ export function dashboardView(
// Overdue anchors on the stamped due date when present (D18 WS-A) — an unpaid
// invoice whose due date is still in the future is NOT overdue.
- const overdueRows = db.prepare(
+ const overdueRows = await db.all<{ id: string; doc_no: string | null; client_id: string; anchor: string; client_name: string }>(
`SELECT d.id, d.doc_no, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id
WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL
AND d.status NOT IN ('paid','cancelled','lost') AND COALESCE(d.due_date, d.doc_date) <= ?${mineDoc}
ORDER BY anchor`,
- ).all(...(meId !== undefined ? [today, meId, meId] : [today])) as { id: string; doc_no: string | null; client_id: string; anchor: string; client_name: string }[]
+ ...(meId !== undefined ? [today, meId, meId] : [today]),
+ )
const overdue: DashboardView['overdue'] = []
let overduePaise = 0
for (const r of overdueRows) {
- const outstanding = outstandingPaise(db, r.id)
+ const outstanding = await outstandingPaise(db, r.id)
if (outstanding <= 0) continue
overduePaise += outstanding
overdue.push({
@@ -63,37 +64,40 @@ export function dashboardView(
}
const weekEnd = addDaysIso(today, 7)
- const dueThisWeek = db.prepare(
+ const dueThisWeek = (await db.all(
`SELECT rp.id AS plan_id, rp.client_id, rp.next_run, rp.amount_paise, c.name AS client_name
FROM recurring_plan rp JOIN client c ON c.id = rp.client_id
WHERE rp.active=1 AND rp.next_run >= ? AND rp.next_run <= ?${mineClient} ORDER BY rp.next_run`,
- ).all(...(meId !== undefined ? [today, weekEnd, meId] : [today, weekEnd])).map((r) => {
+ ...(meId !== undefined ? [today, weekEnd, meId] : [today, weekEnd]),
+ )).map((r) => {
const row = r as { plan_id: string; client_id: string; next_run: string; amount_paise: number | null; client_name: string }
return { planId: row.plan_id, clientId: row.client_id, clientName: row.client_name, nextRun: row.next_run, amountPaise: row.amount_paise }
})
- const renewalsThisMonth = db.prepare(
+ const renewalsThisMonth = (await db.all(
`SELECT cm.id AS cm_id, cm.client_id, cm.next_renewal, c.name AS client_name
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.active=1 AND cm.next_renewal >= ? AND cm.next_renewal <= ?${mineClient} ORDER BY cm.next_renewal`,
- ).all(...(meId !== undefined ? [today, endOfMonth(today), meId] : [today, endOfMonth(today)])).map((r) => {
+ ...(meId !== undefined ? [today, endOfMonth(today), meId] : [today, endOfMonth(today)]),
+ )).map((r) => {
const row = r as { cm_id: string; client_id: string; next_renewal: string; client_name: string }
return { clientModuleId: row.cm_id, clientId: row.client_id, clientName: row.client_name, nextRenewal: row.next_renewal }
})
- const followUpsToday = listOpenFollowUps(db, today)
+ const followUpsToday = (await listOpenFollowUps(db, today))
.filter((f) => meId === undefined || f.staffId === meId) // my follow-ups only under My Day
.map((f) => ({
id: f.id, clientId: f.clientId, clientName: f.clientName, onDate: f.onDate,
followUpOn: f.followUpOn!, notes: f.notes,
}))
- const recentPayments = db.prepare(
+ const recentPayments = (await db.all(
`SELECT p.id, p.client_id, p.received_on, p.amount_paise, p.mode, c.name AS client_name
FROM payment p JOIN client c ON c.id = p.client_id
WHERE 1=1${meId !== undefined ? ' AND (p.created_by=? OR c.owner_id=?)' : ''}
ORDER BY p.id DESC LIMIT 10`,
- ).all(...(meId !== undefined ? [meId, meId] : [])).map((r) => {
+ ...(meId !== undefined ? [meId, meId] : []),
+ )).map((r) => {
const row = r as { id: string; client_id: string; received_on: string; amount_paise: number; mode: string; client_name: string }
return { id: row.id, clientId: row.client_id, clientName: row.client_name, receivedOn: row.received_on, amountPaise: row.amount_paise, mode: row.mode }
})
@@ -101,9 +105,9 @@ export function dashboardView(
// Same owner scope as GET /reminders: staff see only their own queue (spec §6e);
// a managerial My Day narrows the queue to their own rows via ownerId.
const queuePage = viewer !== undefined
- ? listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) })
- : listQueue(db)
- const counts = queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined)
+ ? await listQueue(db, { viewerId: viewer.id, viewerRole: viewer.role, ...(meId !== undefined ? { ownerId: meId } : {}) })
+ : await listQueue(db)
+ const counts = await queueCounts(db, viewer !== undefined ? (meId ?? ownerScope(viewer)) : undefined)
return {
today, overdue, dueThisWeek, renewalsThisMonth, followUpsToday, recentPayments,
diff --git a/apps/hq/src/repos-documents.ts b/apps/hq/src/repos-documents.ts
index 0bc0d6d..22e4692 100644
--- a/apps/hq/src/repos-documents.ts
+++ b/apps/hq/src/repos-documents.ts
@@ -63,8 +63,8 @@ function toDoc(r: DocRow): Doc {
}
}
-export function getDocument(db: DB, id: string): Doc | null {
- const row = db.prepare(`SELECT * FROM document WHERE id=?`).get(id) as DocRow | undefined
+export async function getDocument(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM document WHERE id=?`, id)
return row === undefined ? null : toDoc(row)
}
@@ -82,7 +82,7 @@ export type DocListRow = Doc & { clientName: string }
export interface DocumentsPage { documents: DocListRow[]; total: number; page: number; pageSize: number }
/** Filtered document list, newest first, paginated with a true total (rule 8 — no silent caps). */
-export function listDocuments(db: DB, filter: DocumentFilter = {}): DocumentsPage {
+export async function listDocuments(db: DB, filter: DocumentFilter = {}): Promise {
const page = Math.max(1, filter.page ?? 1)
const pageSize = Math.min(200, Math.max(1, filter.pageSize ?? 50))
let where = ` WHERE 1=1`
@@ -90,14 +90,14 @@ export function listDocuments(db: DB, filter: DocumentFilter = {}): DocumentsPag
if (filter.type !== undefined) { where += ` AND d.doc_type=?`; args.push(filter.type) }
if (filter.status !== undefined) { where += ` AND d.status=?`; args.push(filter.status) }
if (filter.clientId !== undefined) { where += ` AND d.client_id=?`; args.push(filter.clientId) }
- const total = (db.prepare(
- `SELECT COUNT(*) AS n FROM document d${where}`,
- ).get(...args) as { n: number }).n
- const rows = db.prepare(
+ const total = (await db.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM document d${where}`, ...args,
+ ))!.n
+ const rows = await db.all(
`SELECT d.*, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id${where}
ORDER BY d.id DESC LIMIT ? OFFSET ?`, // uuidv7 ids sort by creation time
- ).all(...args, pageSize, (page - 1) * pageSize) as (DocRow & { client_name: string })[]
+ ...args, pageSize, (page - 1) * pageSize)
return {
documents: rows.map((r) => ({ ...toDoc(r), clientName: r.client_name })),
total, page, pageSize,
@@ -112,16 +112,15 @@ export interface DocumentEvent {
interface EventRow { id: string; document_id: string; at_wall: string; kind: string; meta: string }
-function addEvent(db: DB, documentId: string, kind: string, meta: Record = {}): void {
- db.prepare(
+async function addEvent(db: DB, documentId: string, kind: string, meta: Record = {}): Promise {
+ await db.run(
`INSERT INTO document_event (id, document_id, at_wall, kind, meta) VALUES (?, ?, ?, ?, ?)`,
- ).run(uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta))
+ uuidv7(), documentId, new Date().toISOString(), kind, JSON.stringify(meta))
}
-export function listDocumentEvents(db: DB, documentId: string): DocumentEvent[] {
- const rows = db.prepare(
- `SELECT * FROM document_event WHERE document_id=? ORDER BY id`,
- ).all(documentId) as EventRow[]
+export async function listDocumentEvents(db: DB, documentId: string): Promise {
+ const rows = await db.all(
+ `SELECT * FROM document_event WHERE document_id=? ORDER BY id`, documentId)
return rows.map((r) => ({
id: r.id, documentId: r.document_id, atWall: r.at_wall, kind: r.kind,
meta: JSON.parse(r.meta) as Record,
@@ -154,19 +153,19 @@ const addDays = (dateIso: string, days: number): string => {
return new Date(Date.UTC(y!, m! - 1, d! + days)).toISOString().slice(0, 10)
}
-function supplyStateCode(db: DB): string {
- const row = db.prepare(`SELECT value FROM setting WHERE key='company.state_code'`)
- .get() as { value: string } | undefined
+async function supplyStateCode(db: DB): Promise {
+ const row = await db.get<{ value: string }>(
+ `SELECT value FROM setting WHERE key='company.state_code'`)
// Silent defaults on place-of-supply are how wrong GST reaches the portal — fail loudly.
if (row === undefined) throw new Error(`Setting 'company.state_code' is not configured`)
return row.value
}
-function taxRates(db: DB): TaxClassRow[] {
- const rows = db.prepare(`SELECT * FROM tax_class`).all() as {
+async function taxRates(db: DB): Promise {
+ const rows = await db.all<{
class_code: string; rate_pct_bp: number; cess_pct_bp: number
effective_from: string; effective_to: string | null
- }[]
+ }>(`SELECT * FROM tax_class`)
return rows.map((r) => ({
classCode: r.class_code, ratePctBp: r.rate_pct_bp, cessPctBp: r.cess_pct_bp,
effectiveFrom: r.effective_from,
@@ -174,12 +173,14 @@ function taxRates(db: DB): TaxClassRow[] {
}))
}
-function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): LineInput[] {
- return inputs.map((line) => {
- const mod = getModule(db, line.moduleId)
+async function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?: string[]): Promise {
+ const out: LineInput[] = []
+ for (const line of inputs) {
+ const mod = await getModule(db, line.moduleId)
if (mod === null) throw new Error(`Module not found: ${line.moduleId}`)
const edition = line.edition ?? 'standard'
- let unitPricePaise = line.unitPricePaise ?? priceOn(db, line.moduleId, line.kind, edition, onDate)
+ let unitPricePaise = line.unitPricePaise
+ ?? await priceOn(db, line.moduleId, line.kind, edition, onDate)
if (unitPricePaise === null) {
// Strict (no warnings sink): refuse — same message save has always thrown.
if (warnings === undefined) {
@@ -190,7 +191,7 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?:
unitPricePaise = 0
}
const packSuffix = edition !== 'standard' ? ` — ${edition}` : '' // pack name prints on the line
- return {
+ out.push({
itemId: mod.id,
name: mod.name
+ (line.description !== undefined && line.description !== '' ? ` — ${line.description}` : '')
@@ -201,30 +202,29 @@ function buildLines(db: DB, inputs: DraftLineInput[], onDate: string, warnings?:
unitPricePaise,
priceIncludesTax: false, // HQ B2B convention: prices are GST-exclusive
taxClassCode: TAX_CLASS,
- }
- })
+ })
+ }
+ return out
}
/** Insert a draft row + created event + audit. Callers wrap in a transaction. */
-function insertDocRow(db: DB, userId: string, a: {
+async function insertDocRow(db: DB, userId: string, a: {
docType: DocType; clientId: string; refDocId: string | null; docDate: string
totals: BillTotals; payload: DocPayload; dueDate?: string | null
-}): Doc {
+}): Promise {
const id = uuidv7()
const t = a.totals
- db.prepare(
+ await db.run(
`INSERT INTO document (id, doc_type, doc_no, fy, client_id, doc_date, due_date, status, ref_doc_id,
taxable_paise, cgst_paise, sgst_paise, igst_paise, round_off_paise, payable_paise,
payload, created_by, created_at)
VALUES (?, ?, NULL, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
id, a.docType, fyOf(a.docDate), a.clientId, a.docDate, a.dueDate ?? null, a.refDocId,
t.taxablePaise, t.cgstPaise, t.sgstPaise, t.igstPaise, t.roundOffPaise, t.payablePaise,
- JSON.stringify(a.payload), userId, new Date().toISOString(),
- )
- addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
- const doc = getDocument(db, id)!
- writeAudit(db, userId, 'create', 'document', id, undefined, {
+ JSON.stringify(a.payload), userId, new Date().toISOString())
+ await addEvent(db, id, 'created', a.refDocId !== null ? { refDocId: a.refDocId } : {})
+ const doc = (await getDocument(db, id))!
+ await writeAudit(db, userId, 'create', 'document', id, undefined, {
docType: doc.docType, clientId: doc.clientId,
payablePaise: doc.payablePaise, refDocId: doc.refDocId,
})
@@ -252,7 +252,7 @@ export interface PreparedDraft {
* price gap (₹0 + warning) and a not-yet-picked client (intra-state default);
* strict (the default, used by save) throws exactly as before.
*/
-export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): PreparedDraft {
+export async function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boolean } = {}): Promise {
const permissive = opts.permissive === true
if (!['QUOTATION', 'PROFORMA', 'INVOICE'].includes(input.docType)) {
throw new Error(`Cannot draft doc type: ${input.docType}`)
@@ -262,7 +262,7 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo
if (input.docType !== 'INVOICE') throw new Error('Due date applies to INVOICE drafts only')
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.dueDate)) throw new Error('dueDate must be YYYY-MM-DD')
}
- const client = getClient(db, input.clientId)
+ const client = await getClient(db, input.clientId)
if (client === null && !permissive) throw new Error('Client not found')
const today = todayIso()
const lineInputs = Array.isArray(input.lines) ? input.lines : []
@@ -276,16 +276,18 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo
totals: zero, payload: { lines: [], totals: zero, lineContents: [] }, warnings,
}
}
- const supply = supplyStateCode(db)
- const computed = computeBill(buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
+ const supply = await supplyStateCode(db)
+ const computed = computeBill(await buildLines(db, lineInputs, today, permissive ? warnings : undefined), {
businessDate: today,
supplyStateCode: supply,
placeOfSupplyStateCode: client?.stateCode ?? supply, // no client yet ⇒ intra-state default
roundToRupee: true,
- }, taxRates(db))
+ }, await taxRates(db))
// Parallel to lines: caller override, else the module's quoteContent (buildLines proved each module exists).
- const lineContents = lineInputs.map((line) =>
- line.contentLines ?? getModule(db, line.moduleId)!.quoteContent)
+ const lineContents: string[][] = []
+ for (const line of lineInputs) {
+ lineContents.push(line.contentLines ?? (await getModule(db, line.moduleId))!.quoteContent)
+ }
const payload: DocPayload = {
lines: computed.lines, totals: computed.totals, lineContents,
...(input.terms !== undefined ? { terms: input.terms } : {}),
@@ -297,38 +299,38 @@ export function prepareDraft(db: DB, input: DraftInput, opts: { permissive?: boo
}
}
-export function createDraft(db: DB, userId: string, input: DraftInput): Doc {
- const prepared = prepareDraft(db, input) // strict — save refuses a bad document
+export async function createDraft(db: DB, userId: string, input: DraftInput): Promise {
+ const prepared = await prepareDraft(db, input) // strict — save refuses a bad document
return db.transaction(() => insertDocRow(db, userId, {
docType: prepared.docType, clientId: prepared.clientId, refDocId: null,
docDate: prepared.docDate, totals: prepared.totals, payload: prepared.payload,
dueDate: prepared.dueDate ?? null,
- }))()
+ }))
}
// ---------- lifecycle ----------
/** Issue = number assignment only; status stays draft until sent/marked. */
-export function issueDocument(db: DB, userId: string, docId: string): Doc {
- return db.transaction(() => {
- const before = getDocument(db, docId)
+export async function issueDocument(db: DB, userId: string, docId: string): Promise {
+ return db.transaction(async () => {
+ const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found')
if (before.docNo !== null) throw new Error(`Document already issued as ${before.docNo}`)
if (before.status === 'cancelled') throw new Error('Cannot issue a cancelled document')
- const docNo = nextDocNo(db, before.docType, before.fy)
- db.prepare(`UPDATE document SET doc_no=? WHERE id=?`).run(docNo, docId)
+ const docNo = await nextDocNo(db, before.docType, before.fy)
+ await db.run(`UPDATE document SET doc_no=? WHERE id=?`, docNo, docId)
// D18: an invoice that reaches issue without a due date gets doc_date + terms
// stamped NOW — the last moment it is still a draft (issued paper never changes).
let dueDate = before.dueDate
if (before.docType === 'INVOICE' && dueDate === null) {
- const terms = getNumberSetting(db, 'billing.payment_terms_days', 15)
+ const terms = await getNumberSetting(db, 'billing.payment_terms_days', 15)
dueDate = addDays(before.docDate, terms)
- db.prepare(`UPDATE document SET due_date=? WHERE id=?`).run(dueDate, docId)
+ await db.run(`UPDATE document SET due_date=? WHERE id=?`, dueDate, docId)
}
- addEvent(db, docId, 'issued', { docNo })
- writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) })
- return getDocument(db, docId)!
- })()
+ await addEvent(db, docId, 'issued', { docNo })
+ await writeAudit(db, userId, 'issue', 'document', docId, { docNo: null }, { docNo, ...(dueDate !== before.dueDate ? { dueDate } : {}) })
+ return (await getDocument(db, docId))!
+ })
}
const LEGAL_MARKS: Partial> = {
@@ -336,23 +338,23 @@ const LEGAL_MARKS: Partial> = {
sent: ['accepted', 'lost'],
}
-export function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Doc {
- return db.transaction(() => {
- const before = getDocument(db, docId)
+export async function markStatus(db: DB, userId: string, docId: string, status: 'sent' | 'accepted' | 'lost'): Promise {
+ return db.transaction(async () => {
+ const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found')
if (!(LEGAL_MARKS[before.status] ?? []).includes(status)) {
throw new Error(`Illegal status transition: ${before.status} → ${status}`)
}
- db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId)
- addEvent(db, docId, status)
- writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
+ await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
+ await addEvent(db, docId, status)
+ await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status })
if (status === 'accepted' || status === 'lost') {
// STOP cleanup (spec §7): a decided quote stops chasing — dismiss its open
// follow-up nudges in this same transaction, one audited row each.
- dismissQuoteFollowups(db, userId, docId)
+ await dismissQuoteFollowups(db, userId, docId)
}
- return getDocument(db, docId)!
- })()
+ return (await getDocument(db, docId))!
+ })
}
const CHAIN: Record = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 }
@@ -362,9 +364,9 @@ const CHAIN: Record = { QUOTATION: 0, PROFORMA: 1, INVOICE: 2 }
* QT→PI copies totals verbatim (both non-legal); →INVOICE recomputes through
* computeBill on the invoice's own doc_date (spec §8 F3, hard rule 2).
*/
-export function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Doc {
- return db.transaction(() => {
- const src = getDocument(db, docId)
+export async function convertDocument(db: DB, userId: string, docId: string, to: 'PROFORMA' | 'INVOICE'): Promise {
+ return db.transaction(async () => {
+ const src = await getDocument(db, docId)
if (src === null) throw new Error('Document not found')
const from = CHAIN[src.docType]
const target = CHAIN[to]
@@ -375,9 +377,9 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
// Rule-4 guard (spec §8 F4): one sale, one forward document. A live (non-cancelled)
// child means this document was already converted — a second convert would mint a
// duplicate for the same sale. Cancelling the child re-opens the path.
- const child = db.prepare(
+ const child = await db.get<{ doc_type: string }>(
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
- ).get(src.id) as { doc_type: string } | undefined
+ src.id)
if (child !== undefined) {
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
}
@@ -388,7 +390,7 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
// Rule-2 fix (spec §8 F3): an invoice is legal paper — recompute the carried
// lines on the invoice's date (mirrors createCreditNote), so a dated tax_class
// change between quote/proforma and invoice lands at the rate that is law today.
- const client = getClient(db, src.clientId)
+ const client = await getClient(db, src.clientId)
if (client === null) throw new Error('Client not found')
const computed = computeBill(
src.payload.lines.map((l): LineInput => ({
@@ -397,52 +399,51 @@ export function convertDocument(db: DB, userId: string, docId: string, to: 'PROF
})),
{
businessDate: docDate,
- supplyStateCode: supplyStateCode(db),
+ supplyStateCode: await supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode,
roundToRupee: true,
- }, taxRates(db))
+ }, await taxRates(db))
totals = computed.totals
payload = { ...src.payload, lines: computed.lines, totals: computed.totals }
}
- const doc = insertDocRow(db, userId, {
+ const doc = await insertDocRow(db, userId, {
docType: to, clientId: src.clientId, refDocId: src.id,
docDate, totals, payload,
})
- addEvent(db, src.id, 'converted', { to, newDocId: doc.id })
+ await addEvent(db, src.id, 'converted', { to, newDocId: doc.id })
// ANY conversion moves the source forward — out of the 'sent' set the follow-up
// scan and the pipeline read, so a converted quote is never chased again (spec §7).
- db.prepare(`UPDATE document SET status='invoiced' WHERE id=?`).run(src.id)
- writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
+ await db.run(`UPDATE document SET status='invoiced' WHERE id=?`, src.id)
+ await writeAudit(db, userId, 'update', 'document', src.id, { status: src.status }, { status: 'invoiced' })
if (src.docType === 'QUOTATION') {
// STOP cleanup (spec §7): a converted quote has moved forward — dismiss its
// open follow-up nudges in this same transaction, one audited row each.
- dismissQuoteFollowups(db, userId, src.id)
+ await dismissQuoteFollowups(db, userId, src.id)
}
return doc
- })()
+ })
}
/** Only issued, unpaid documents; the consumed number is never reused. */
-export function cancelDocument(db: DB, userId: string, docId: string): Doc {
- return db.transaction(() => {
- const before = getDocument(db, docId)
+export async function cancelDocument(db: DB, userId: string, docId: string): Promise {
+ return db.transaction(async () => {
+ const before = await getDocument(db, docId)
if (before === null) throw new Error('Document not found')
if (before.docNo === null) throw new Error('Only issued documents can be cancelled')
if (before.status === 'cancelled') throw new Error('Document is already cancelled')
- const alloc = db.prepare(
- `SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`,
- ).get(docId) as { n: number }
+ const alloc = (await db.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM payment_allocation WHERE document_id=?`, docId))!
if (alloc.n > 0) throw new Error('Cannot cancel a document with payments allocated to it')
- db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId)
- addEvent(db, docId, 'cancelled')
- writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
+ await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
+ await addEvent(db, docId, 'cancelled')
+ await writeAudit(db, userId, 'update', 'document', docId, { status: before.status }, { status: 'cancelled' })
if (before.docType === 'QUOTATION') {
// STOP cleanup (spec §7): cancelled paper is dead — its open follow-up nudges
// must not chase the client. Same transaction, one audited row each.
- dismissQuoteFollowups(db, userId, docId)
+ await dismissQuoteFollowups(db, userId, docId)
}
- return getDocument(db, docId)!
- })()
+ return (await getDocument(db, docId))!
+ })
}
/**
@@ -452,9 +453,9 @@ export function cancelDocument(db: DB, userId: string, docId: string): Doc {
* an INVOICE is immutable legal paper (cancel if unpaid, else credit note), and
* a QUOTATION re-drafts through the normal composer.
*/
-export function supersedeProforma(db: DB, userId: string, docId: string): Doc {
- return db.transaction(() => {
- const src = getDocument(db, docId)
+export async function supersedeProforma(db: DB, userId: string, docId: string): Promise {
+ return db.transaction(async () => {
+ const src = await getDocument(db, docId)
if (src === null) throw new Error('Document not found')
if (src.docType === 'INVOICE') {
throw new Error('Invoices are immutable — cancel if unpaid, or raise a credit note')
@@ -462,64 +463,64 @@ export function supersedeProforma(db: DB, userId: string, docId: string): Doc {
if (src.docType !== 'PROFORMA') throw new Error('Only proformas can be superseded')
if (src.status === 'cancelled') throw new Error('Document is already cancelled')
// A live forward child means the sale already moved on — superseding would fork it.
- const child = db.prepare(
+ const child = await db.get<{ doc_type: string }>(
`SELECT doc_type FROM document WHERE ref_doc_id=? AND status!='cancelled' LIMIT 1`,
- ).get(src.id) as { doc_type: string } | undefined
+ src.id)
if (child !== undefined) {
throw new Error(`Already converted: a live ${child.doc_type} exists for this document`)
}
if (src.docNo !== null) {
- cancelDocument(db, userId, docId) // issued: number stays consumed (rule 4)
+ await cancelDocument(db, userId, docId) // issued: number stays consumed (rule 4)
} else {
// Unissued draft: no number to preserve; retire it the same audited way.
- db.prepare(`UPDATE document SET status='cancelled' WHERE id=?`).run(docId)
- addEvent(db, docId, 'cancelled')
- writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' })
+ await db.run(`UPDATE document SET status='cancelled' WHERE id=?`, docId)
+ await addEvent(db, docId, 'cancelled')
+ await writeAudit(db, userId, 'update', 'document', docId, { status: src.status }, { status: 'cancelled' })
}
- const doc = insertDocRow(db, userId, {
+ const doc = await insertDocRow(db, userId, {
docType: 'PROFORMA', clientId: src.clientId, refDocId: src.id,
docDate: todayIso(), totals: src.payload.totals, payload: src.payload,
})
- addEvent(db, src.id, 'superseded', { newDocId: doc.id })
+ await addEvent(db, src.id, 'superseded', { newDocId: doc.id })
return doc
- })()
+ })
}
/**
* Credit note against an issued invoice; defaults to full value. Amounts are
* positive here — CN semantics are "negative" only in settlement math (Task 9).
*/
-export function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Doc {
- const inv = getDocument(db, invoiceId)
+export async function createCreditNote(db: DB, userId: string, invoiceId: string, lines?: DraftLineInput[]): Promise {
+ const inv = await getDocument(db, invoiceId)
if (inv === null) throw new Error('Invoice not found')
if (inv.docType !== 'INVOICE') throw new Error('Credit notes can only be raised against invoices')
if (inv.docNo === null) throw new Error('Invoice must be issued before raising a credit note')
if (inv.status === 'cancelled') throw new Error('Cannot credit a cancelled invoice')
- const client = getClient(db, inv.clientId)
+ const client = await getClient(db, inv.clientId)
if (client === null) throw new Error('Client not found')
// Recompute rather than copy: rates resolve on the invoice's date, so a CN
// against an old bill uses the rate that was law on that day.
const lineInputs = lines !== undefined
- ? buildLines(db, lines, inv.docDate)
+ ? await buildLines(db, lines, inv.docDate)
: inv.payload.lines.map((l): LineInput => ({
itemId: l.itemId, name: l.name, hsn: l.hsn, qty: l.qty, unitCode: l.unitCode,
unitPricePaise: l.unitPricePaise, priceIncludesTax: false, taxClassCode: TAX_CLASS,
}))
const computed = computeBill(lineInputs, {
businessDate: inv.docDate,
- supplyStateCode: supplyStateCode(db),
+ supplyStateCode: await supplyStateCode(db),
placeOfSupplyStateCode: client.stateCode,
roundToRupee: true,
- }, taxRates(db))
- return db.transaction(() => {
- const doc = insertDocRow(db, userId, {
+ }, await taxRates(db))
+ return db.transaction(async () => {
+ const doc = await insertDocRow(db, userId, {
docType: 'CREDIT_NOTE', clientId: inv.clientId, refDocId: inv.id,
docDate: todayIso(), totals: computed.totals,
payload: { lines: computed.lines, totals: computed.totals },
})
- addEvent(db, inv.id, 'credit_note', { creditNoteId: doc.id })
+ await addEvent(db, inv.id, 'credit_note', { creditNoteId: doc.id })
return doc
- })()
+ })
}
export interface GenerateReceiptInput {
@@ -533,8 +534,8 @@ export interface GenerateReceiptInput {
* acknowledge money, they do not levy tax: zero GST, no line items, payable =
* amount received. Issued (numbered) immediately and never edited thereafter.
*/
-export function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Doc {
- const client = getClient(db, input.clientId)
+export async function generateReceipt(db: DB, userId: string, input: GenerateReceiptInput): Promise {
+ const client = await getClient(db, input.clientId)
if (client === null) throw new Error('Client not found')
const totals: BillTotals = {
grossPaise: input.amountPaise, discountPaise: 0, taxablePaise: 0,
@@ -549,11 +550,11 @@ export function generateReceipt(db: DB, userId: string, input: GenerateReceiptIn
allocations: input.allocations,
},
}
- return db.transaction(() => {
- const draft = insertDocRow(db, userId, {
+ return db.transaction(async () => {
+ const draft = await insertDocRow(db, userId, {
docType: 'RECEIPT', clientId: input.clientId, refDocId: null,
docDate: todayIso(), totals, payload,
})
return issueDocument(db, userId, draft.id) // assigns RCT/26-27-000N
- })()
+ })
}
diff --git a/apps/hq/src/repos-email.ts b/apps/hq/src/repos-email.ts
index 2941e83..db4eb3c 100644
--- a/apps/hq/src/repos-email.ts
+++ b/apps/hq/src/repos-email.ts
@@ -25,33 +25,33 @@ function toAccount(r: AccountRow): EmailAccount {
}
}
-export function getAccount(db: DB): EmailAccount | null {
- const row = db.prepare(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`)
- .get() as AccountRow | undefined
+export async function getAccount(db: DB): Promise {
+ const row = await db.get(`SELECT * FROM email_account ORDER BY id DESC LIMIT 1`)
return row === undefined ? null : toAccount(row)
}
-export function saveAccount(db: DB, address: string, refreshTokenEnc: string): EmailAccount {
- return db.transaction(() => {
- db.prepare(`DELETE FROM email_account`).run() // single sending identity
+export async function saveAccount(db: DB, address: string, refreshTokenEnc: string): Promise {
+ return db.transaction(async () => {
+ await db.run(`DELETE FROM email_account`) // single sending identity
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO email_account (id, address, refresh_token_enc, status, updated_at)
VALUES (?, ?, ?, 'active', ?)`,
- ).run(id, address, refreshTokenEnc, new Date().toISOString())
+ id, address, refreshTokenEnc, new Date().toISOString(),
+ )
// Audit carries the address only — never the (even encrypted) token.
- writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' })
- return getAccount(db)!
- })()
+ await writeAudit(db, 'system', 'create', 'email_account', id, undefined, { address, status: 'active' })
+ return (await getAccount(db))!
+ })
}
/** Refresh token revoked/expired (invalid_grant) — flips the dashboard banner. */
-export function markAccountDead(db: DB): void {
- const account = getAccount(db)
+export async function markAccountDead(db: DB): Promise {
+ const account = await getAccount(db)
if (account === null || account.status === 'dead') return
- db.prepare(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`)
- .run(new Date().toISOString(), account.id)
- writeAudit(db, 'system', 'update', 'email_account', account.id,
+ await db.run(`UPDATE email_account SET status='dead', updated_at=? WHERE id=?`,
+ new Date().toISOString(), account.id)
+ await writeAudit(db, 'system', 'update', 'email_account', account.id,
{ status: account.status }, { status: 'dead' })
}
@@ -61,11 +61,10 @@ export interface EmailLogInput {
}
/** Append-only send trace — the email_log table is itself the record. */
-export function logEmail(db: DB, e: EmailLogInput): void {
- db.prepare(
+export async function logEmail(db: DB, e: EmailLogInput): Promise {
+ await db.run(
`INSERT INTO email_log (id, document_id, to_addr, subject, status, gmail_message_id, error, at_wall)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
uuidv7(), e.documentId ?? null, e.to, e.subject, e.status,
e.gmailMessageId ?? null, e.error ?? null, new Date().toISOString(),
)
@@ -73,8 +72,8 @@ export function logEmail(db: DB, e: EmailLogInput): void {
export interface EmailStatus { connected: boolean; address?: string; dead: boolean }
-export function emailStatus(db: DB): EmailStatus {
- const account = getAccount(db)
+export async function emailStatus(db: DB): Promise {
+ const account = await getAccount(db)
if (account === null) return { connected: false, dead: false }
return { connected: true, address: account.address, dead: account.status === 'dead' }
}
diff --git a/apps/hq/src/repos-employees.ts b/apps/hq/src/repos-employees.ts
index ec71020..ca90d18 100644
--- a/apps/hq/src/repos-employees.ts
+++ b/apps/hq/src/repos-employees.ts
@@ -38,29 +38,30 @@ function assertRole(role: string): asserts role is EmployeeRole {
}
/** Console users are a small bounded set (not a growable list) — returned whole, with a count for the UI. */
-export function listEmployees(db: DB): Employee[] {
- const rows = db.prepare(
+export async function listEmployees(db: DB): Promise {
+ const rows = await db.all(
`SELECT ${COLS} FROM staff_user ORDER BY display_name`,
- ).all() as EmployeeRow[]
+ )
return rows.map(toEmployee)
}
-export function getEmployee(db: DB, id: string): Employee | null {
- const row = db.prepare(`SELECT ${COLS} FROM staff_user WHERE id=?`).get(id) as EmployeeRow | undefined
+export async function getEmployee(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT ${COLS} FROM staff_user WHERE id=?`, id)
return row ? toEmployee(row) : null
}
/** True when at least one ACTIVE owner other than `excludeId` exists. */
-function otherActiveOwnerExists(db: DB, excludeId: string): boolean {
- const row = db.prepare(
+async function otherActiveOwnerExists(db: DB, excludeId: string): Promise {
+ const row = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM staff_user WHERE role='owner' AND active=1 AND id != ?`,
- ).get(excludeId) as { n: number }
+ excludeId,
+ ))!
return row.n > 0
}
-export function createEmployee(db: DB, userId: string, input: {
+export async function createEmployee(db: DB, userId: string, input: {
email: string; displayName: string; role: EmployeeRole; password: string
-}): Employee {
+}): Promise {
assertRole(input.role)
if (input.password.length < 8) throw new Error('Password must be at least 8 characters')
const email = input.email.trim().toLowerCase()
@@ -68,30 +69,31 @@ export function createEmployee(db: DB, userId: string, input: {
if (input.displayName.trim() === '') throw new Error('Name is required')
const id = uuidv7()
const { salt, hash } = hashPin(input.password) // generic scrypt; PIN digit policy not applied
- return db.transaction(() => {
+ return db.transaction(async () => {
// Pre-check the UNIQUE(email) so the caller gets a friendly, engine-neutral
// message instead of raw driver text (SQLite and Postgres word it differently).
- if (db.prepare(`SELECT 1 FROM staff_user WHERE email=?`).get(email) !== undefined) {
+ if (await db.get(`SELECT 1 FROM staff_user WHERE email=?`, email) !== undefined) {
throw new Error('Email already in use')
}
- db.prepare(
+ await db.run(
`INSERT INTO staff_user (id, email, display_name, role, pw_salt, pw_hash) VALUES (?, ?, ?, ?, ?, ?)`,
- ).run(id, email, input.displayName, input.role, salt, hash)
- writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role })
- return getEmployee(db, id)!
- })()
+ id, email, input.displayName, input.role, salt, hash,
+ )
+ await writeAudit(db, userId, 'create', 'staff_user', id, undefined, { email, role: input.role })
+ return (await getEmployee(db, id))!
+ })
}
-export function updateEmployee(db: DB, userId: string, id: string, patch: {
+export async function updateEmployee(db: DB, userId: string, id: string, patch: {
displayName?: string; role?: EmployeeRole; phone?: string; title?: string
-}): Employee {
- return db.transaction(() => {
- const before = getEmployee(db, id)
+}): Promise {
+ return db.transaction(async () => {
+ const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
if (patch.role !== undefined) {
assertRole(patch.role)
// Demoting the last active owner would lock everyone out of owner-gated routes.
- if (before.role === 'owner' && before.active && patch.role !== 'owner' && !otherActiveOwnerExists(db, id)) {
+ if (before.role === 'owner' && before.active && patch.role !== 'owner' && !(await otherActiveOwnerExists(db, id))) {
throw new Error('Cannot demote the last active owner')
}
}
@@ -109,11 +111,11 @@ export function updateEmployee(db: DB, userId: string, id: string, patch: {
// no-op audit row and tell the caller a change landed when nothing did.
if (sets.length === 0) throw new Error('Nothing to update')
args.push(id)
- db.prepare(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`).run(...args)
- const after = getEmployee(db, id)!
- writeAudit(db, userId, 'update', 'staff_user', id, before, after)
+ await db.run(`UPDATE staff_user SET ${sets.join(', ')} WHERE id=?`, ...args)
+ const after = (await getEmployee(db, id))!
+ await writeAudit(db, userId, 'update', 'staff_user', id, before, after)
return after
- })()
+ })
}
/**
@@ -122,65 +124,65 @@ export function updateEmployee(db: DB, userId: string, id: string, patch: {
* change survives (currentToken), everything else dies with the old credential.
* Audited as change_own_password; hashes never leave this module or reach the audit.
*/
-export function changeOwnPassword(
+export async function changeOwnPassword(
db: DB, userId: string, currentPassword: string, newPassword: string, currentToken: string,
-): void {
+): Promise {
if (newPassword.length < 8) throw new Error('Password must be at least 8 characters')
- const row = db.prepare(`SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`)
- .get(userId) as { pw_salt: string; pw_hash: string } | undefined
+ const row = await db.get<{ pw_salt: string; pw_hash: string }>(
+ `SELECT pw_salt, pw_hash FROM staff_user WHERE id=? AND active=1`, userId)
if (row === undefined) throw new Error('Employee not found')
if (!verifyPin(currentPassword, { salt: row.pw_salt, hash: row.pw_hash })) {
throw new Error('Current password is incorrect')
}
const { salt, hash } = hashPin(newPassword)
- db.transaction(() => {
- db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, userId)
- db.prepare(`DELETE FROM session WHERE staff_id=? AND token != ?`).run(userId, currentToken)
- writeAudit(db, userId, 'change_own_password', 'staff_user', userId)
- })()
+ await db.transaction(async () => {
+ await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, userId)
+ await db.run(`DELETE FROM session WHERE staff_id=? AND token != ?`, userId, currentToken)
+ await writeAudit(db, userId, 'change_own_password', 'staff_user', userId)
+ })
}
/** Reset an employee's password. Audits the action; never logs the hash.
* Existing sessions are purged in the same transaction — a reset is the natural
* "lock out the old credential" action, so a stolen token must not outlive it. */
-export function setEmployeePassword(db: DB, userId: string, id: string, password: string): void {
+export async function setEmployeePassword(db: DB, userId: string, id: string, password: string): Promise {
if (password.length < 8) throw new Error('Password must be at least 8 characters')
const { salt, hash } = hashPin(password)
- db.transaction(() => {
- const before = getEmployee(db, id)
+ await db.transaction(async () => {
+ const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
- db.prepare(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`).run(salt, hash, id)
- db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id)
- writeAudit(db, userId, 'reset_password', 'staff_user', id)
- })()
+ await db.run(`UPDATE staff_user SET pw_salt=?, pw_hash=? WHERE id=?`, salt, hash, id)
+ await db.run(`DELETE FROM session WHERE staff_id=?`, id)
+ await writeAudit(db, userId, 'reset_password', 'staff_user', id)
+ })
}
/** Deactivation takes effect immediately: sessions are purged in the same transaction. */
-export function deactivateEmployee(db: DB, userId: string, id: string): Employee {
+export async function deactivateEmployee(db: DB, userId: string, id: string): Promise {
if (id === userId) throw new Error('Cannot deactivate yourself')
- return db.transaction(() => {
- const before = getEmployee(db, id)
+ return db.transaction(async () => {
+ const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
if (!before.active) return before // already inactive — idempotent
- if (before.role === 'owner' && !otherActiveOwnerExists(db, id)) {
+ if (before.role === 'owner' && !(await otherActiveOwnerExists(db, id))) {
throw new Error('Cannot deactivate the last active owner')
}
- db.prepare(`UPDATE staff_user SET active=0 WHERE id=?`).run(id)
- db.prepare(`DELETE FROM session WHERE staff_id=?`).run(id)
- const after = getEmployee(db, id)!
- writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
+ await db.run(`UPDATE staff_user SET active=0 WHERE id=?`, id)
+ await db.run(`DELETE FROM session WHERE staff_id=?`, id)
+ const after = (await getEmployee(db, id))!
+ await writeAudit(db, userId, 'deactivate', 'staff_user', id, before, after)
return after
- })()
+ })
}
-export function reactivateEmployee(db: DB, userId: string, id: string): Employee {
- return db.transaction(() => {
- const before = getEmployee(db, id)
+export async function reactivateEmployee(db: DB, userId: string, id: string): Promise {
+ return db.transaction(async () => {
+ const before = await getEmployee(db, id)
if (!before) throw new Error('Employee not found')
if (before.active) return before // already active — idempotent
- db.prepare(`UPDATE staff_user SET active=1 WHERE id=?`).run(id)
- const after = getEmployee(db, id)!
- writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
+ await db.run(`UPDATE staff_user SET active=1 WHERE id=?`, id)
+ const after = (await getEmployee(db, id))!
+ await writeAudit(db, userId, 'reactivate', 'staff_user', id, before, after)
return after
- })()
+ })
}
diff --git a/apps/hq/src/repos-interactions.ts b/apps/hq/src/repos-interactions.ts
index db643f7..1ee5983 100644
--- a/apps/hq/src/repos-interactions.ts
+++ b/apps/hq/src/repos-interactions.ts
@@ -7,14 +7,14 @@ import { getClient } from './repos-clients'
export interface InteractionType { code: string; label: string }
-export function listInteractionTypes(db: DB): InteractionType[] {
- return db.prepare(`SELECT code, label FROM interaction_type ORDER BY label`).all() as InteractionType[]
+export async function listInteractionTypes(db: DB): Promise {
+ return db.all(`SELECT code, label FROM interaction_type ORDER BY label`)
}
-export function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): InteractionType {
+export async function createInteractionType(db: DB, userId: string, input: { code: string; label: string }): Promise {
if (input.code.trim() === '' || input.label.trim() === '') throw new Error('code and label are required')
- db.prepare(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`).run(input.code.trim(), input.label.trim())
- writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
+ await db.run(`INSERT INTO interaction_type (code, label) VALUES (?, ?)`, input.code.trim(), input.label.trim())
+ await writeAudit(db, userId, 'create', 'interaction_type', input.code, undefined, input)
return { code: input.code.trim(), label: input.label.trim() }
}
@@ -37,15 +37,16 @@ function toInteraction(r: InteractionRow): Interaction {
}
}
-export function getInteraction(db: DB, id: string): Interaction | null {
- const row = db.prepare(`SELECT * FROM interaction WHERE id=?`).get(id) as InteractionRow | undefined
+export async function getInteraction(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM interaction WHERE id=?`, id)
return row === undefined ? null : toInteraction(row)
}
-export function listInteractions(db: DB, clientId: string): Interaction[] {
- const rows = db.prepare(
+export async function listInteractions(db: DB, clientId: string): Promise {
+ const rows = await db.all(
`SELECT * FROM interaction WHERE client_id=? ORDER BY on_date DESC, id DESC`,
- ).all(clientId) as InteractionRow[]
+ clientId,
+ )
return rows.map(toInteraction)
}
@@ -56,28 +57,27 @@ export interface CreateInteractionInput {
const OUTCOMES: Outcome[] = ['positive', 'neutral', 'negative']
-export function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Interaction {
- if (getClient(db, input.clientId) === null) throw new Error('Client not found')
- const type = db.prepare(`SELECT code FROM interaction_type WHERE code=?`).get(input.typeCode)
+export async function createInteraction(db: DB, userId: string, input: CreateInteractionInput): Promise {
+ if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
+ const type = await db.get(`SELECT code FROM interaction_type WHERE code=?`, input.typeCode)
if (type === undefined) throw new Error(`Unknown interaction type: ${input.typeCode}`)
if (input.outcome !== undefined && !OUTCOMES.includes(input.outcome)) throw new Error(`Unknown outcome: ${input.outcome}`)
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO interaction (id, client_id, type_code, on_date, staff_id, notes, outcome, follow_up_on, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
id, input.clientId, input.typeCode, input.onDate, userId, input.notes ?? '',
input.outcome ?? null, input.followUpOn ?? null, new Date().toISOString(),
)
- const interaction = getInteraction(db, id)!
- writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
+ const interaction = (await getInteraction(db, id))!
+ await writeAudit(db, userId, 'create', 'interaction', id, undefined, interaction)
return interaction
}
export interface InteractionPatch { notes?: string; outcome?: Outcome | null; followUpOn?: string | null }
-export function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Interaction {
- const before = getInteraction(db, id)
+export async function updateInteraction(db: DB, userId: string, id: string, patch: InteractionPatch): Promise {
+ const before = await getInteraction(db, id)
if (before === null) throw new Error('Interaction not found')
if (patch.outcome !== undefined && patch.outcome !== null && !OUTCOMES.includes(patch.outcome)) {
throw new Error(`Unknown outcome: ${patch.outcome}`)
@@ -89,19 +89,20 @@ export function updateInteraction(db: DB, userId: string, id: string, patch: Int
if (patch.followUpOn !== undefined) { sets.push('follow_up_on=?'); args.push(patch.followUpOn) }
if (sets.length > 0) {
args.push(id)
- db.prepare(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ await db.run(`UPDATE interaction SET ${sets.join(', ')} WHERE id=?`, ...args)
}
- const after = getInteraction(db, id)!
- writeAudit(db, userId, 'update', 'interaction', id, before, after)
+ const after = (await getInteraction(db, id))!
+ await writeAudit(db, userId, 'update', 'interaction', id, before, after)
return after
}
/** Interactions with a follow-up date on or before the given date — the follow-ups queue. */
-export function listOpenFollowUps(db: DB, onOrBefore: string): (Interaction & { clientName: string })[] {
- const rows = db.prepare(
+export async function listOpenFollowUps(db: DB, onOrBefore: string): Promise<(Interaction & { clientName: string })[]> {
+ const rows = await db.all(
`SELECT i.*, c.name AS client_name FROM interaction i JOIN client c ON c.id = i.client_id
WHERE i.follow_up_on IS NOT NULL AND i.follow_up_on <= ?
ORDER BY i.follow_up_on DESC, i.id DESC`,
- ).all(onOrBefore) as (InteractionRow & { client_name: string })[]
+ onOrBefore,
+ )
return rows.map((r) => ({ ...toInteraction(r), clientName: r.client_name }))
}
diff --git a/apps/hq/src/repos-modules.ts b/apps/hq/src/repos-modules.ts
index adcebbf..6207b88 100644
--- a/apps/hq/src/repos-modules.ts
+++ b/apps/hq/src/repos-modules.ts
@@ -35,32 +35,31 @@ export interface ModuleInput {
allowedKinds?: Kind[]; multiSubscription?: boolean; quoteContent?: string[]
}
-export function getModule(db: DB, id: string): Module | null {
- const row = db.prepare(`SELECT * FROM module WHERE id=?`).get(id) as ModuleRow | undefined
+export async function getModule(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM module WHERE id=?`, id)
return row === undefined ? null : toModule(row)
}
-export function listModules(db: DB): Module[] {
- const rows = db.prepare(`SELECT * FROM module ORDER BY code`).all() as ModuleRow[]
+export async function listModules(db: DB): Promise {
+ const rows = await db.all(`SELECT * FROM module ORDER BY code`)
return rows.map(toModule)
}
-export function createModule(db: DB, userId: string, input: ModuleInput): Module {
+export async function createModule(db: DB, userId: string, input: ModuleInput): Promise {
const kinds = input.allowedKinds ?? ALL_KINDS
for (const k of kinds) {
if (!ALL_KINDS.includes(k)) throw new Error(`Unknown kind: ${k}`)
}
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO module (id, code, name, sac, allowed_kinds, multi_subscription, quote_content)
VALUES (?, ?, ?, ?, ?, ?, ?)`,
- ).run(
id, input.code, input.name, input.sac ?? '998313',
JSON.stringify(kinds), input.multiSubscription === true ? 1 : 0,
JSON.stringify(input.quoteContent ?? []),
)
- const mod = getModule(db, id)!
- writeAudit(db, userId, 'create', 'module', id, undefined, mod)
+ const mod = (await getModule(db, id))!
+ await writeAudit(db, userId, 'create', 'module', id, undefined, mod)
return mod
}
@@ -69,8 +68,8 @@ export interface ModulePatch {
multiSubscription?: boolean; active?: boolean; quoteContent?: string[]
}
-export function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Module {
- const before = getModule(db, id)
+export async function updateModule(db: DB, userId: string, id: string, patch: ModulePatch): Promise {
+ const before = await getModule(db, id)
if (before === null) throw new Error('Module not found')
if (patch.allowedKinds !== undefined) {
for (const k of patch.allowedKinds) {
@@ -87,10 +86,10 @@ export function updateModule(db: DB, userId: string, id: string, patch: ModulePa
if (patch.quoteContent !== undefined) { sets.push('quote_content=?'); args.push(JSON.stringify(patch.quoteContent)) }
if (sets.length > 0) {
args.push(id)
- db.prepare(`UPDATE module SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ await db.run(`UPDATE module SET ${sets.join(', ')} WHERE id=?`, ...args)
}
- const after = getModule(db, id)!
- writeAudit(db, userId, 'update', 'module', id, before, after)
+ const after = (await getModule(db, id))!
+ await writeAudit(db, userId, 'update', 'module', id, before, after)
return after
}
@@ -115,38 +114,41 @@ export interface PriceInput {
moduleId: string; edition?: string; kind: Kind; pricePaise: number; effectiveFrom: string
}
-export function setPrice(db: DB, userId: string, input: PriceInput): void {
+export async function setPrice(db: DB, userId: string, input: PriceInput): Promise {
if (!ALL_KINDS.includes(input.kind)) throw new Error(`Unknown kind: ${input.kind}`)
if (!Number.isInteger(input.pricePaise) || input.pricePaise < 0) {
throw new Error('pricePaise must be a non-negative integer (paise)')
}
- if (getModule(db, input.moduleId) === null) throw new Error('Module not found')
+ if ((await getModule(db, input.moduleId)) === null) throw new Error('Module not found')
const id = uuidv7()
const edition = input.edition ?? 'standard'
- db.prepare(
+ await db.run(
`INSERT INTO module_price_book (id, module_id, edition, kind, price_paise, effective_from)
VALUES (?, ?, ?, ?, ?, ?)`,
- ).run(id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom)
- writeAudit(db, userId, 'create', 'module_price_book', id, undefined, {
+ id, input.moduleId, edition, input.kind, input.pricePaise, input.effectiveFrom,
+ )
+ await writeAudit(db, userId, 'create', 'module_price_book', id, undefined, {
moduleId: input.moduleId, edition, kind: input.kind,
pricePaise: input.pricePaise, effectiveFrom: input.effectiveFrom,
})
}
-export function listPrices(db: DB, moduleId: string): ModulePrice[] {
- const rows = db.prepare(
+export async function listPrices(db: DB, moduleId: string): Promise {
+ const rows = await db.all(
`SELECT * FROM module_price_book WHERE module_id=? ORDER BY edition, kind, effective_from`,
- ).all(moduleId) as PriceRow[]
+ moduleId,
+ )
return rows.map(toPrice)
}
/** Latest effective_from <= onDate wins — the D5 dated-rows philosophy. */
-export function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): number | null {
- const row = db.prepare(
+export async function priceOn(db: DB, moduleId: string, kind: Kind, edition: string, onDate: string): Promise {
+ const row = await db.get<{ price_paise: number }>(
`SELECT price_paise FROM module_price_book
WHERE module_id=? AND kind=? AND edition=? AND effective_from <= ?
ORDER BY effective_from DESC LIMIT 1`,
- ).get(moduleId, kind, edition, onDate) as { price_paise: number } | undefined
+ moduleId, kind, edition, onDate,
+ )
return row === undefined ? null : row.price_paise
}
@@ -183,15 +185,15 @@ function toClientModule(r: ClientModuleRow): ClientModule {
}
}
-export function getClientModule(db: DB, id: string): ClientModule | null {
- const row = db.prepare(`SELECT * FROM client_module WHERE id=?`).get(id) as ClientModuleRow | undefined
+export async function getClientModule(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM client_module WHERE id=?`, id)
return row === undefined ? null : toClientModule(row)
}
-export function listClientModules(db: DB, clientId: string): ClientModule[] {
- const rows = db.prepare(
- `SELECT * FROM client_module WHERE client_id=? ORDER BY id`,
- ).all(clientId) as ClientModuleRow[]
+export async function listClientModules(db: DB, clientId: string): Promise {
+ const rows = await db.all(
+ `SELECT * FROM client_module WHERE client_id=? ORDER BY id`, clientId,
+ )
return rows.map(toClientModule)
}
@@ -220,40 +222,46 @@ export interface ModuleClientsPage {
* excluded (stated here, not silently dropped). Paginated with a true total
* (rule 8); price per row via the dated price book on `onDate`.
*/
-export function listClientsByModule(
+export async function listClientsByModule(
db: DB, moduleId: string,
opts: { page?: number; pageSize?: number; onDate?: string } = {},
-): ModuleClientsPage {
+): Promise {
const page = Math.max(1, opts.page ?? 1)
const pageSize = Math.min(200, Math.max(1, opts.pageSize ?? 50))
const onDate = opts.onDate ?? new Date().toISOString().slice(0, 10)
- const total = (db.prepare(
- `SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`,
- ).get(moduleId) as { n: number }).n
- const rows = db.prepare(
+ const total = ((await db.get<{ n: number }>(
+ `SELECT COUNT(*) AS n FROM client_module WHERE module_id=? AND active=1`, moduleId,
+ ))!).n
+ const rows = await db.all<{
+ cm_id: string; client_id: string; client_name: string; client_code: string
+ status: string; kind: string; edition: string; next_renewal: string | null
+ }>(
`SELECT cm.id AS cm_id, cm.client_id, c.name AS client_name, c.code AS client_code,
cm.status, cm.kind, cm.edition, cm.next_renewal
FROM client_module cm JOIN client c ON c.id = cm.client_id
WHERE cm.module_id=? AND cm.active=1
ORDER BY c.name, cm.id
LIMIT ? OFFSET ?`,
- ).all(moduleId, pageSize, (page - 1) * pageSize) as {
- cm_id: string; client_id: string; client_name: string; client_code: string
- status: string; kind: string; edition: string; next_renewal: string | null
- }[]
- const clients = rows.map((r): ModuleClientRow => ({
- cmId: r.cm_id,
- clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
- status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
- nextRenewal: r.next_renewal,
- pricePaise: priceOn(db, moduleId, r.kind as Kind, r.edition, onDate),
- }))
+ moduleId, pageSize, (page - 1) * pageSize,
+ )
+ const clients: ModuleClientRow[] = []
+ for (const r of rows) {
+ clients.push({
+ cmId: r.cm_id,
+ clientId: r.client_id, clientName: r.client_name, clientCode: r.client_code,
+ status: r.status as ClientModuleStatus, kind: r.kind as Kind, edition: r.edition,
+ nextRenewal: r.next_renewal,
+ pricePaise: await priceOn(db, moduleId, r.kind as Kind, r.edition, onDate),
+ })
+ }
// Revenue total spans ALL active links, not the visible page (the footer figure).
- const allLinks = db.prepare(
- `SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`,
- ).all(moduleId) as { kind: string; edition: string }[]
- const totalPricePaise = allLinks.reduce(
- (sum, l) => sum + (priceOn(db, moduleId, l.kind as Kind, l.edition, onDate) ?? 0), 0)
+ const allLinks = await db.all<{ kind: string; edition: string }>(
+ `SELECT kind, edition FROM client_module WHERE module_id=? AND active=1`, moduleId,
+ )
+ let totalPricePaise = 0
+ for (const l of allLinks) {
+ totalPricePaise += (await priceOn(db, moduleId, l.kind as Kind, l.edition, onDate)) ?? 0
+ }
return { clients, total, page, pageSize, totalPricePaise }
}
@@ -262,8 +270,8 @@ export interface AssignModuleInput {
edition?: string; status?: ClientModuleStatus
}
-export function assignModule(db: DB, userId: string, input: AssignModuleInput): ClientModule {
- const mod = getModule(db, input.moduleId)
+export async function assignModule(db: DB, userId: string, input: AssignModuleInput): Promise {
+ const mod = await getModule(db, input.moduleId)
if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(input.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${input.kind}' (allowed: ${mod.allowedKinds.join(', ')})`)
@@ -271,20 +279,22 @@ export function assignModule(db: DB, userId: string, input: AssignModuleInput):
const status = input.status ?? 'quoted'
if (!ALL_STATUSES.includes(status)) throw new Error(`Unknown status: ${status}`)
if (!mod.multiSubscription) {
- const existing = db.prepare(
+ const existing = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM client_module WHERE client_id=? AND module_id=? AND active=1`,
- ).get(input.clientId, input.moduleId) as { n: number }
+ input.clientId, input.moduleId,
+ ))!
if (existing.n > 0) {
throw new Error(`Client already has an active subscription for module ${mod.code}`)
}
}
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO client_module (id, client_id, module_id, status, kind, edition)
VALUES (?, ?, ?, ?, ?, ?)`,
- ).run(id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard')
- const cm = getClientModule(db, id)!
- writeAudit(db, userId, 'create', 'client_module', id, undefined, cm)
+ id, input.clientId, input.moduleId, status, input.kind, input.edition ?? 'standard',
+ )
+ const cm = (await getClientModule(db, id))!
+ await writeAudit(db, userId, 'create', 'client_module', id, undefined, cm)
return cm
}
@@ -295,14 +305,14 @@ export interface ClientModulePatch {
kind?: Kind; edition?: string
}
-export function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): ClientModule {
- const before = getClientModule(db, id)
+export async function updateClientModule(db: DB, userId: string, id: string, patch: ClientModulePatch): Promise {
+ const before = await getClientModule(db, id)
if (before === null) throw new Error('Client module not found')
if (patch.status !== undefined && !ALL_STATUSES.includes(patch.status)) {
throw new Error(`Unknown status: ${patch.status}`)
}
if (patch.kind !== undefined) {
- const mod = getModule(db, before.moduleId)
+ const mod = await getModule(db, before.moduleId)
if (mod === null) throw new Error('Module not found')
if (!mod.allowedKinds.includes(patch.kind)) {
throw new Error(`Module ${mod.code} does not allow kind '${patch.kind}'`)
@@ -323,9 +333,9 @@ export function updateClientModule(db: DB, userId: string, id: string, patch: Cl
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
- db.prepare(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ await db.run(`UPDATE client_module SET ${sets.join(', ')} WHERE id=?`, ...args)
}
- const after = getClientModule(db, id)!
- writeAudit(db, userId, 'update', 'client_module', id, before, after)
+ const after = (await getClientModule(db, id))!
+ await writeAudit(db, userId, 'update', 'client_module', id, before, after)
return after
}
diff --git a/apps/hq/src/repos-payments.ts b/apps/hq/src/repos-payments.ts
index 4f88252..52e916f 100644
--- a/apps/hq/src/repos-payments.ts
+++ b/apps/hq/src/repos-payments.ts
@@ -39,67 +39,69 @@ function toPayment(r: PaymentRow): Payment {
}
}
-export function getPayment(db: DB, id: string): Payment | null {
- const row = db.prepare(`SELECT * FROM payment WHERE id=?`).get(id) as PaymentRow | undefined
+export async function getPayment(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM payment WHERE id=?`, id)
return row === undefined ? null : toPayment(row)
}
-export function listPayments(db: DB, clientId: string): Payment[] {
- const rows = db.prepare(
+export async function listPayments(db: DB, clientId: string): Promise {
+ const rows = await db.all(
`SELECT * FROM payment WHERE client_id=? ORDER BY id`, // uuidv7 ids sort by creation time
- ).all(clientId) as PaymentRow[]
+ clientId)
return rows.map(toPayment)
}
// ---------- settlement math ----------
-function allocatedTo(db: DB, documentId: string): number {
- const row = db.prepare(
+async function allocatedTo(db: DB, documentId: string): Promise {
+ const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(amount_paise), 0) AS total FROM payment_allocation WHERE document_id=?`,
- ).get(documentId) as { total: number }
+ documentId))!
return row.total
}
/** Credit notes are "negative" only here, in settlement math (Task 8 stores them positive). */
-function creditedTo(db: DB, invoiceId: string): number {
- const row = db.prepare(
+async function creditedTo(db: DB, invoiceId: string): Promise {
+ const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(payable_paise), 0) AS total FROM document
WHERE doc_type='CREDIT_NOTE' AND ref_doc_id=? AND status != 'cancelled'`,
- ).get(invoiceId) as { total: number }
+ invoiceId))!
return row.total
}
-function outstandingOf(db: DB, doc: Doc): number {
- return doc.payablePaise - allocatedTo(db, doc.id) - creditedTo(db, doc.id)
+async function outstandingOf(db: DB, doc: Doc): Promise {
+ return doc.payablePaise - await allocatedTo(db, doc.id) - await creditedTo(db, doc.id)
}
/** Exported outstanding read for the scheduler and dashboard. */
-export function outstandingPaise(db: DB, docId: string): number {
- const doc = getDocument(db, docId)
+export async function outstandingPaise(db: DB, docId: string): Promise {
+ const doc = await getDocument(db, docId)
return doc === null ? 0 : outstandingOf(db, doc)
}
/** Status flips: any allocation > 0 → part_paid; outstanding ≤ 0 → paid. */
-function refreshSettlementStatus(db: DB, userId: string, docId: string): void {
- const doc = getDocument(db, docId)
+async function refreshSettlementStatus(db: DB, userId: string, docId: string): Promise {
+ const doc = await getDocument(db, docId)
if (doc === null || doc.status === 'cancelled') return
- const allocated = allocatedTo(db, docId)
- const outstanding = doc.payablePaise - allocated - creditedTo(db, docId)
+ const allocated = await allocatedTo(db, docId)
+ const outstanding = doc.payablePaise - allocated - await creditedTo(db, docId)
const status = outstanding <= 0 ? 'paid' : allocated > 0 ? 'part_paid' : doc.status
if (status === doc.status) return
- db.prepare(`UPDATE document SET status=? WHERE id=?`).run(status, docId)
- writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status })
+ await db.run(`UPDATE document SET status=? WHERE id=?`, status, docId)
+ await writeAudit(db, userId, 'update', 'document', docId, { status: doc.status }, { status })
}
/** Issued, unsettled invoices for the client, oldest first. */
-function unsettledInvoices(db: DB, clientId: string): Doc[] {
- const ids = db.prepare(
+async function unsettledInvoices(db: DB, clientId: string): Promise {
+ const ids = await db.all<{ id: string }>(
`SELECT id FROM document
WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('cancelled', 'paid')
ORDER BY doc_date, doc_no`,
- ).all(clientId) as { id: string }[]
- return ids.map((r) => getDocument(db, r.id)!)
+ clientId)
+ const docs: Doc[] = []
+ for (const r of ids) docs.push((await getDocument(db, r.id))!)
+ return docs
}
// ---------- recording ----------
@@ -118,8 +120,8 @@ export interface RecordPaymentResult {
receipt?: Doc
}
-export function recordPayment(db: DB, userId: string, input: RecordPaymentInput): RecordPaymentResult {
- if (getClient(db, input.clientId) === null) throw new Error('Client not found')
+export async function recordPayment(db: DB, userId: string, input: RecordPaymentInput): Promise {
+ if (await getClient(db, input.clientId) === null) throw new Error('Client not found')
if (!ALL_MODES.includes(input.mode)) throw new Error(`Unknown payment mode: ${input.mode}`)
if (!Number.isInteger(input.amountPaise) || input.amountPaise <= 0) {
throw new Error('amountPaise must be a positive integer (paise)')
@@ -128,17 +130,15 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
if (!Number.isInteger(tdsPaise) || tdsPaise < 0) {
throw new Error('tdsPaise must be a non-negative integer (paise)')
}
- return db.transaction(() => {
+ return db.transaction(async () => {
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO payment (id, client_id, received_on, mode, reference, amount_paise, tds_paise,
created_by, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
- ).run(
id, input.clientId, input.receivedOn, input.mode, input.reference ?? '',
- input.amountPaise, tdsPaise, userId, new Date().toISOString(),
- )
- writeAudit(db, userId, 'create', 'payment', id, undefined, {
+ input.amountPaise, tdsPaise, userId, new Date().toISOString())
+ await writeAudit(db, userId, 'create', 'payment', id, undefined, {
clientId: input.clientId, receivedOn: input.receivedOn, mode: input.mode,
amountPaise: input.amountPaise, tdsPaise,
})
@@ -146,16 +146,16 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
// One pool: TDS certificates settle invoices just like money received.
let pool = input.amountPaise + tdsPaise
const allocated: { documentId: string; amountPaise: number }[] = []
- const insertAllocation = (documentId: string, amountPaise: number): void => {
+ const insertAllocation = async (documentId: string, amountPaise: number): Promise => {
const allocId = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO payment_allocation (id, payment_id, document_id, amount_paise) VALUES (?, ?, ?, ?)`,
- ).run(allocId, id, documentId, amountPaise)
- writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, {
+ allocId, id, documentId, amountPaise)
+ await writeAudit(db, userId, 'create', 'payment_allocation', allocId, undefined, {
paymentId: id, documentId, amountPaise,
})
allocated.push({ documentId, amountPaise })
- refreshSettlementStatus(db, userId, documentId)
+ await refreshSettlementStatus(db, userId, documentId)
}
if (input.allocations !== undefined) {
@@ -165,13 +165,13 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
if (!Number.isInteger(a.amountPaise) || a.amountPaise <= 0) {
throw new Error('Allocation amountPaise must be a positive integer (paise)')
}
- const doc = getDocument(db, a.documentId)
+ const doc = await getDocument(db, a.documentId)
if (doc === null) throw new Error(`Document not found: ${a.documentId}`)
if (doc.docType !== 'INVOICE') throw new Error(`Can only allocate payments to invoices: ${a.documentId}`)
if (doc.docNo === null) throw new Error(`Invoice is not issued: ${a.documentId}`)
if (doc.status === 'cancelled') throw new Error(`Invoice is cancelled: ${doc.docNo}`)
if (doc.clientId !== input.clientId) throw new Error(`Invoice ${doc.docNo} belongs to another client`)
- const outstanding = outstandingOf(db, doc)
+ const outstanding = await outstandingOf(db, doc)
if (a.amountPaise > outstanding) {
throw new Error(`Allocation ${a.amountPaise} exceeds outstanding ${outstanding} on ${doc.docNo}`)
}
@@ -179,33 +179,33 @@ export function recordPayment(db: DB, userId: string, input: RecordPaymentInput)
throw new Error(`Allocations exceed the payment's settling power (amount + TDS)`)
}
pool -= a.amountPaise
- insertAllocation(doc.id, a.amountPaise)
+ await insertAllocation(doc.id, a.amountPaise)
}
} else {
// Default: oldest-invoice-first, each up to its outstanding; leftover
// stays unallocated (an advance).
- for (const doc of unsettledInvoices(db, input.clientId)) {
+ for (const doc of await unsettledInvoices(db, input.clientId)) {
if (pool <= 0) break
- const take = Math.min(pool, outstandingOf(db, doc))
+ const take = Math.min(pool, await outstandingOf(db, doc))
if (take <= 0) continue
pool -= take
- insertAllocation(doc.id, take)
+ await insertAllocation(doc.id, take)
}
}
- const receipt = input.issueReceipt === true ? receiptForPayment(db, userId, id) : undefined
- return { payment: getPayment(db, id)!, allocated, ...(receipt !== undefined ? { receipt } : {}) }
- })()
+ const receipt = input.issueReceipt === true ? await receiptForPayment(db, userId, id) : undefined
+ return { payment: (await getPayment(db, id))!, allocated, ...(receipt !== undefined ? { receipt } : {}) }
+ })
}
/** Build a RECEIPT for an already-recorded payment from its stored allocations. */
-export function receiptForPayment(db: DB, userId: string, paymentId: string): Doc {
- const p = getPayment(db, paymentId)
+export async function receiptForPayment(db: DB, userId: string, paymentId: string): Promise {
+ const p = await getPayment(db, paymentId)
if (p === null) throw new Error('Payment not found')
- const allocs = db.prepare(
+ const allocs = await db.all<{ amount_paise: number; doc_no: string | null }>(
`SELECT a.amount_paise, d.doc_no FROM payment_allocation a
JOIN document d ON d.id = a.document_id WHERE a.payment_id=?`,
- ).all(paymentId) as { amount_paise: number; doc_no: string | null }[]
+ paymentId)
return generateReceipt(db, userId, {
clientId: p.clientId, paymentId: p.id, receivedOn: p.receivedOn, mode: p.mode,
reference: p.reference, amountPaise: p.amountPaise, tdsPaise: p.tdsPaise,
@@ -217,19 +217,19 @@ export function receiptForPayment(db: DB, userId: string, paymentId: string): Do
export interface ClientLedger { documents: Doc[]; payments: Payment[]; advancePaise: number }
-export function clientLedger(db: DB, clientId: string): ClientLedger {
- const payments = listPayments(db, clientId)
+export async function clientLedger(db: DB, clientId: string): Promise {
+ const payments = await listPayments(db, clientId)
const settling = payments.reduce((sum, p) => sum + p.amountPaise + p.tdsPaise, 0)
- const row = db.prepare(
+ const row = (await db.get<{ total: number }>(
`SELECT COALESCE(SUM(a.amount_paise), 0) AS total
FROM payment_allocation a JOIN payment p ON p.id = a.payment_id
WHERE p.client_id=?`,
- ).get(clientId) as { total: number }
+ clientId))!
// The ledger must show EVERY document for the client (rule 8): walk the
// paginated repo in max-size batches instead of trusting one page.
const documents: Doc[] = []
for (let page = 1; ; page++) {
- const batch = listDocuments(db, { clientId, page, pageSize: 200 })
+ const batch = await listDocuments(db, { clientId, page, pageSize: 200 })
documents.push(...batch.documents)
if (page * batch.pageSize >= batch.total) break
}
@@ -266,17 +266,17 @@ export interface ModulePaidRow { moduleId: string; billedPaise: number; settledP
* across the invoice's lines by lineTotalPaise (largest-remainder, so the
* split sums exactly).
*/
-export function modulePaidView(db: DB, clientId: string): ModulePaidRow[] {
- const invoices = db.prepare(
+export async function modulePaidView(db: DB, clientId: string): Promise {
+ const invoices = await db.all<{ id: string }>(
`SELECT id FROM document
WHERE client_id=? AND doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'
ORDER BY doc_date, doc_no`,
- ).all(clientId) as { id: string }[]
+ clientId)
const acc = new Map()
for (const { id } of invoices) {
- const inv = getDocument(db, id)!
+ const inv = (await getDocument(db, id))!
const settledTotal = Math.min(
- inv.payablePaise, allocatedTo(db, inv.id) + creditedTo(db, inv.id),
+ inv.payablePaise, await allocatedTo(db, inv.id) + await creditedTo(db, inv.id),
)
const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise))
inv.payload.lines.forEach((line, i) => {
diff --git a/apps/hq/src/repos-pipeline.ts b/apps/hq/src/repos-pipeline.ts
index f251288..b873e26 100644
--- a/apps/hq/src/repos-pipeline.ts
+++ b/apps/hq/src/repos-pipeline.ts
@@ -76,18 +76,18 @@ function ageDaysOf(firstSent: string | null, today: string): number | null {
return Number.isFinite(ms) ? Math.max(0, Math.floor(ms / 86_400_000)) : null
}
-export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
+export async function listPipeline(db: DB, opts: ListPipelineOpts): Promise {
const filter = opts.filter ?? 'all'
const today = opts.today ?? new Date().toISOString().slice(0, 10)
const page = Math.max(opts.page ?? 1, 1)
const pageSize = Math.min(Math.max(opts.pageSize ?? DEFAULT_PAGE_SIZE, 1), MAX_PAGE_SIZE)
- const dayOffsets = resolveSchedule(db, 'quote_followup', today).dayOffsets
+ const dayOffsets = (await resolveSchedule(db, 'quote_followup', today)).dayOffsets
const lastOffset = dayOffsets[dayOffsets.length - 1]!
// Latest non-cancelled QUOTATION per client (uuidv7 ids sort by creation time, so
// MAX(id) is the newest — same trick as listDocuments). A cancelled quote is dead
// paper: it neither represents the client nor blocks the bare-lead Enquiry row.
- const quotes = db.prepare(
+ const quotes = await db.all(
`SELECT d.id AS doc_id, d.doc_no, d.status AS doc_status, d.payable_paise, d.created_by,
(SELECT MIN(e.at_wall) FROM document_event e
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent,
@@ -97,12 +97,12 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
AND d.id = (SELECT MAX(d2.id) FROM document d2
WHERE d2.client_id = d.client_id AND d2.doc_type = 'QUOTATION'
AND d2.status != 'cancelled')`,
- ).all() as QuoteRow[]
+ )
// Bare leads: client.status='lead' with no live quotation → derived stage Enquiry.
// Quote-less LOST clients ride the same arm as stage Lost (spec §9: Lost = latest
// quotation lost OR client.status='lost') so the Lost filter can review them.
- const leads = db.prepare(
+ const leads = await db.all(
`SELECT c.id AS client_id, c.code AS client_code, c.name AS client_name,
c.status AS client_status, c.owner_id
FROM client c
@@ -110,10 +110,10 @@ export function listPipeline(db: DB, opts: ListPipelineOpts): PipelinePage {
AND NOT EXISTS (SELECT 1 FROM document d
WHERE d.client_id = c.id AND d.doc_type = 'QUOTATION'
AND d.status != 'cancelled')`,
- ).all() as LeadRow[]
+ )
const staffNames = new Map(
- (db.prepare(`SELECT id, display_name FROM staff_user`).all() as { id: string; display_name: string }[])
+ (await db.all<{ id: string; display_name: string }>(`SELECT id, display_name FROM staff_user`))
.map((s) => [s.id, s.display_name]),
)
const nameOf = (id: string | null): string | null =>
diff --git a/apps/hq/src/repos-recurring.ts b/apps/hq/src/repos-recurring.ts
index dfecaa2..76290d5 100644
--- a/apps/hq/src/repos-recurring.ts
+++ b/apps/hq/src/repos-recurring.ts
@@ -27,15 +27,15 @@ function toPlan(r: RecurringRow): RecurringPlan {
}
}
-export function getRecurringPlan(db: DB, id: string): RecurringPlan | null {
- const row = db.prepare(`SELECT * FROM recurring_plan WHERE id=?`).get(id) as RecurringRow | undefined
+export async function getRecurringPlan(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM recurring_plan WHERE id=?`, id)
return row === undefined ? null : toPlan(row)
}
-export function listRecurringPlans(db: DB, clientId?: string): RecurringPlan[] {
+export async function listRecurringPlans(db: DB, clientId?: string): Promise {
const rows = clientId === undefined
- ? db.prepare(`SELECT * FROM recurring_plan ORDER BY id DESC`).all() as RecurringRow[]
- : db.prepare(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`).all(clientId) as RecurringRow[]
+ ? await db.all(`SELECT * FROM recurring_plan ORDER BY id DESC`)
+ : await db.all(`SELECT * FROM recurring_plan WHERE client_id=? ORDER BY id DESC`, clientId)
return rows.map(toPlan)
}
@@ -44,9 +44,9 @@ export interface CreateRecurringInput {
amountPaise?: number; nextRun: string; policy?: 'auto' | 'manual'
}
-export function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): RecurringPlan {
- if (getClient(db, input.clientId) === null) throw new Error('Client not found')
- const cm = getClientModule(db, input.clientModuleId)
+export async function createRecurringPlan(db: DB, userId: string, input: CreateRecurringInput): Promise {
+ if ((await getClient(db, input.clientId)) === null) throw new Error('Client not found')
+ const cm = await getClientModule(db, input.clientModuleId)
if (cm === null) throw new Error('Client module not found')
if (cm.clientId !== input.clientId) throw new Error('Client module belongs to another client')
if (input.amountPaise !== undefined && (!Number.isInteger(input.amountPaise) || input.amountPaise < 0)) {
@@ -55,20 +55,19 @@ export function createRecurringPlan(db: DB, userId: string, input: CreateRecurri
// A plan must be priceable at generation time: explicit amount, or a resolvable module price.
if (input.amountPaise === undefined) {
const kind = CADENCE_KIND[input.cadence]
- if (priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun) === null) {
+ if ((await priceOn(db, cm.moduleId, kind, cm.edition, input.nextRun)) === null) {
throw new Error(`No ${kind} price for the module on ${input.nextRun}; set an amount or add a price`)
}
}
const id = uuidv7()
- db.prepare(
+ await db.run(
`INSERT INTO recurring_plan (id, client_id, client_module_id, cadence, amount_paise, next_run, policy, active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
- ).run(
id, input.clientId, input.clientModuleId, input.cadence,
input.amountPaise ?? null, input.nextRun, input.policy ?? 'manual',
)
- const plan = getRecurringPlan(db, id)!
- writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
+ const plan = (await getRecurringPlan(db, id))!
+ await writeAudit(db, userId, 'create', 'recurring_plan', id, undefined, plan)
return plan
}
@@ -77,8 +76,8 @@ export interface RecurringPatch {
policy?: 'auto' | 'manual'; active?: boolean
}
-export function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): RecurringPlan {
- const before = getRecurringPlan(db, id)
+export async function updateRecurringPlan(db: DB, userId: string, id: string, patch: RecurringPatch): Promise {
+ const before = await getRecurringPlan(db, id)
if (before === null) throw new Error('Recurring plan not found')
const sets: string[] = []
const args: unknown[] = []
@@ -89,13 +88,13 @@ export function updateRecurringPlan(db: DB, userId: string, id: string, patch: R
if (patch.active !== undefined) { sets.push('active=?'); args.push(patch.active ? 1 : 0) }
if (sets.length > 0) {
args.push(id)
- db.prepare(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`).run(...args)
+ await db.run(`UPDATE recurring_plan SET ${sets.join(', ')} WHERE id=?`, ...args)
}
- const after = getRecurringPlan(db, id)!
- writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
+ const after = (await getRecurringPlan(db, id))!
+ await writeAudit(db, userId, 'update', 'recurring_plan', id, before, after)
return after
}
-export function deactivateRecurringPlan(db: DB, userId: string, id: string): RecurringPlan {
+export async function deactivateRecurringPlan(db: DB, userId: string, id: string): Promise {
return updateRecurringPlan(db, userId, id, { active: false })
}
diff --git a/apps/hq/src/repos-reminders.ts b/apps/hq/src/repos-reminders.ts
index 59ab714..6c5aee5 100644
--- a/apps/hq/src/repos-reminders.ts
+++ b/apps/hq/src/repos-reminders.ts
@@ -8,7 +8,7 @@ import {
/** Reminder rows + settings helpers (D12 plain-repo pattern). The UNIQUE key
* (rule_kind, subject_id, due_period) is the whole idempotency story — every
- * create goes through INSERT OR IGNORE, so catch-up after downtime is safe. */
+ * create goes through INSERT … ON CONFLICT DO NOTHING, so catch-up after downtime is safe. */
export type ReminderStatus = 'queued' | 'sent' | 'failed' | 'dismissed'
@@ -33,8 +33,8 @@ function toReminder(r: ReminderRow): Reminder {
}
}
-export function getReminder(db: DB, id: string): Reminder | null {
- const row = db.prepare(`SELECT * FROM reminder WHERE id=?`).get(id) as ReminderRow | undefined
+export async function getReminder(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM reminder WHERE id=?`, id)
return row === undefined ? null : toReminder(row)
}
@@ -45,40 +45,41 @@ export interface UpsertReminderInput {
/** Create the reminder for a (rule, subject, period) exactly once. Returns the
* existing row's id with created:false when the key is already present. */
-export function upsertReminder(db: DB, input: UpsertReminderInput): { id: string; created: boolean } {
+export async function upsertReminder(db: DB, input: UpsertReminderInput): Promise<{ id: string; created: boolean }> {
const id = uuidv7()
const now = input.now ?? new Date().toISOString()
- const res = db.prepare(
- // Portability quirk: INSERT OR IGNORE is SQLite/Postgres dialect (standard SQL has MERGE).
- `INSERT OR IGNORE INTO reminder
+ const res = await db.run(
+ // Portability quirk: ON CONFLICT DO NOTHING is SQLite/Postgres dialect (standard SQL has MERGE).
+ `INSERT INTO reminder
(id, rule_kind, subject_id, due_period, client_id, doc_id, status, policy_applied, error, created_at, sent_at)
- VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)`,
- ).run(
+ VALUES (?, ?, ?, ?, ?, ?, 'queued', ?, NULL, ?, NULL)
+ ON CONFLICT (rule_kind, subject_id, due_period) DO NOTHING`,
id, input.ruleKind, input.subjectId, input.duePeriod, input.clientId,
input.docId ?? null, input.policyApplied ?? 'manual', now,
)
if (res.changes === 1) {
- writeAudit(db, 'system', 'create', 'reminder', id, undefined, {
+ await writeAudit(db, 'system', 'create', 'reminder', id, undefined, {
ruleKind: input.ruleKind, subjectId: input.subjectId, duePeriod: input.duePeriod,
})
return { id, created: true }
}
- const existing = db.prepare(
+ const existing = (await db.get<{ id: string }>(
`SELECT id FROM reminder WHERE rule_kind=? AND subject_id=? AND due_period=?`,
- ).get(input.ruleKind, input.subjectId, input.duePeriod) as { id: string }
+ input.ruleKind, input.subjectId, input.duePeriod,
+ ))!
return { id: existing.id, created: false }
}
export interface ReminderFilter { status?: ReminderStatus; ruleKind?: ReminderRuleKind; clientId?: string }
-export function listReminders(db: DB, filter: ReminderFilter = {}): Reminder[] {
+export async function listReminders(db: DB, filter: ReminderFilter = {}): Promise {
let sql = `SELECT * FROM reminder WHERE 1=1`
const args: unknown[] = []
if (filter.status !== undefined) { sql += ` AND status=?`; args.push(filter.status) }
if (filter.ruleKind !== undefined) { sql += ` AND rule_kind=?`; args.push(filter.ruleKind) }
if (filter.clientId !== undefined) { sql += ` AND client_id=?`; args.push(filter.clientId) }
sql += ` ORDER BY id DESC`
- return (db.prepare(sql).all(...args) as ReminderRow[]).map(toReminder)
+ return (await db.all(sql, ...args)).map(toReminder)
}
/** Human labels for queue rows — exhaustive so a new rule kind cannot ship unlabelled. */
@@ -117,7 +118,7 @@ interface QueueRow extends ReminderRow { client_name: string | null; doc_no: str
/** The manual queue: everything awaiting a human — queued items and failed sends.
* Paginated with an honest total (rule 8); owner scope derives via
* doc_id → document.created_by (spec A3 — no owner column on reminder). */
-export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
+export async function listQueue(db: DB, opts: QueueOpts = {}): Promise {
const page = Math.max(opts.page ?? 1, 1)
const pageSize = Math.min(Math.max(opts.pageSize ?? QUEUE_PAGE_SIZE, 1), QUEUE_MAX_PAGE_SIZE)
const scoped = opts.viewerRole !== undefined && opts.viewerId !== undefined
@@ -130,10 +131,11 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
// (renewal_due, amc_expiring, follow_up, email_bounced) have no derived owner —
// they are shared work and stay visible to every viewer, staff included.
if (scoped !== undefined) { where += ` AND (d.created_by = ? OR r.doc_id IS NULL)`; args.push(scoped) }
- const total = (db.prepare(
+ const total = (await db.get<{ n: number }>(
`SELECT COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id WHERE ${where}`,
- ).get(...args) as { n: number }).n
- const rows = db.prepare(
+ ...args,
+ ))!.n
+ const rows = await db.all(
`SELECT r.*, c.name AS client_name, d.doc_no, d.created_by AS owner_id
FROM reminder r
LEFT JOIN client c ON c.id = r.client_id
@@ -141,7 +143,8 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
WHERE ${where}
ORDER BY r.id DESC
LIMIT ? OFFSET ?`,
- ).all(...args, pageSize, (page - 1) * pageSize) as QueueRow[]
+ ...args, pageSize, (page - 1) * pageSize,
+ )
return {
rows: rows.map((r) => ({
...toReminder(r),
@@ -157,35 +160,37 @@ export function listQueue(db: DB, opts: QueueOpts = {}): QueuePage {
}
/** Queue totals (queued/failed) under the same owner scope listQueue applies. */
-export function queueCounts(db: DB, ownerId?: string): { queued: number; failed: number } {
- const rows = db.prepare(
+export async function queueCounts(db: DB, ownerId?: string): Promise<{ queued: number; failed: number }> {
+ const rows = await db.all<{ status: string; n: number }>(
`SELECT r.status, COUNT(*) AS n FROM reminder r LEFT JOIN document d ON d.id = r.doc_id
WHERE r.status IN ('queued','failed')${ownerId !== undefined ? ` AND (d.created_by = ? OR r.doc_id IS NULL)` : ''}
GROUP BY r.status`,
- ).all(...(ownerId !== undefined ? [ownerId] : [])) as { status: string; n: number }[]
+ ...(ownerId !== undefined ? [ownerId] : []),
+ )
const by = new Map(rows.map((r) => [r.status, r.n]))
return { queued: by.get('queued') ?? 0, failed: by.get('failed') ?? 0 }
}
-export function setReminderStatus(
+export async function setReminderStatus(
db: DB, userId: string, id: string, status: ReminderStatus,
opts: { error?: string | null; sentAt?: string | null } = {},
-): Reminder {
- const before = getReminder(db, id)
+): Promise {
+ const before = await getReminder(db, id)
if (before === null) throw new Error('Reminder not found')
- db.prepare(`UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`).run(
+ await db.run(
+ `UPDATE reminder SET status=?, error=?, sent_at=? WHERE id=?`,
status,
opts.error !== undefined ? opts.error : before.error,
opts.sentAt !== undefined ? opts.sentAt : before.sentAt,
id,
)
- const after = getReminder(db, id)!
- writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error })
+ const after = (await getReminder(db, id))!
+ await writeAudit(db, userId, 'update', 'reminder', id, { status: before.status }, { status: after.status, error: after.error })
return after
}
-export function dismissReminder(db: DB, userId: string, id: string): Reminder {
- const before = getReminder(db, id)
+export async function dismissReminder(db: DB, userId: string, id: string): Promise {
+ const before = await getReminder(db, id)
if (before === null) throw new Error('Reminder not found')
if (before.status === 'sent') throw new Error('Cannot dismiss a reminder that was already sent')
return setReminderStatus(db, userId, id, 'dismissed')
@@ -197,12 +202,13 @@ export function dismissReminder(db: DB, userId: string, id: string): Reminder {
* setReminderStatus per row so each dismissal is audited (F14), never a bulk UPDATE.
* Callers (markStatus / convertDocument) run this inside their own transaction.
*/
-export function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): number {
- const rows = db.prepare(
+export async function dismissQuoteFollowups(db: DB, userId: string, quoteId: string): Promise {
+ const rows = await db.all<{ id: string }>(
`SELECT id FROM reminder
WHERE rule_kind='quote_followup' AND subject_id=? AND status IN ('queued','failed')`,
- ).all(quoteId) as { id: string }[]
- for (const r of rows) setReminderStatus(db, userId, r.id, 'dismissed')
+ quoteId,
+ )
+ for (const r of rows) await setReminderStatus(db, userId, r.id, 'dismissed')
return rows.length
}
@@ -261,13 +267,14 @@ export function parseDayOffsetsStrict(csv: string): number[] {
* where effective_from <= today AND (effective_to IS NULL OR effective_to > today),
* latest effective_from winning. No usable row → the code-constant default. A row
* that sets cadence but leaves subject/body NULL inherits the default message text. */
-export function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: string): ResolvedSchedule {
+export async function resolveSchedule(db: DB, ruleKind: ScheduleRuleKind, today: string): Promise {
const fallback = SCHEDULE_DEFAULTS[ruleKind]
- const row = db.prepare(
+ const row = await db.get<{ day_offsets: string; subject: string | null; body: string | null }>(
`SELECT day_offsets, subject, body FROM reminder_schedule
WHERE rule_kind=? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)
ORDER BY effective_from DESC, id DESC LIMIT 1`, // id DESC breaks effective_from ties deterministically (UUIDv7 is time-ordered → latest insert wins on any engine)
- ).get(ruleKind, today, today) as { day_offsets: string; subject: string | null; body: string | null } | undefined
+ ruleKind, today, today,
+ )
if (row !== undefined) {
const dayOffsets = parseDayOffsets(row.day_offsets)
if (dayOffsets.length > 0) {
@@ -290,11 +297,11 @@ export interface ScheduleRow {
const SCHEDULE_KINDS = Object.keys(SCHEDULE_DEFAULTS)
/** All dated schedule rows, newest first within each rule kind (bounded set). */
-export function listSchedules(db: DB): ScheduleRow[] {
- const rows = db.prepare(
+export async function listSchedules(db: DB): Promise {
+ const rows = await db.all<{ id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }>(
`SELECT id, rule_kind, effective_from, effective_to, day_offsets, subject, body
FROM reminder_schedule ORDER BY rule_kind, effective_from DESC`,
- ).all() as { id: string; rule_kind: string; effective_from: string; effective_to: string | null; day_offsets: string; subject: string | null; body: string | null }[]
+ )
return rows.map((r) => ({
id: r.id, ruleKind: r.rule_kind, effectiveFrom: r.effective_from, effectiveTo: r.effective_to,
dayOffsets: r.day_offsets, subject: r.subject, body: r.body,
@@ -302,44 +309,46 @@ export function listSchedules(db: DB): ScheduleRow[] {
}
/** Append a NEW dated cadence row (rule 3: config change = new row, never an edit). */
-export function insertSchedule(db: DB, userId: string, input: {
+export async function insertSchedule(db: DB, userId: string, input: {
ruleKind: string; effectiveFrom: string; dayOffsets: string; subject?: string; body?: string
-}): ScheduleRow {
+}): Promise {
if (!SCHEDULE_KINDS.includes(input.ruleKind)) throw new Error(`Unknown rule kind: ${input.ruleKind}`)
if (!/^\d{4}-\d{2}-\d{2}$/.test(input.effectiveFrom)) throw new Error('effectiveFrom must be YYYY-MM-DD')
const offsets = parseDayOffsetsStrict(input.dayOffsets)
const id = uuidv7()
const csv = offsets.join(',')
- db.transaction(() => {
- db.prepare(
+ await db.transaction(async () => {
+ await db.run(
`INSERT INTO reminder_schedule (id, rule_kind, effective_from, effective_to, day_offsets, subject, body)
VALUES (?, ?, ?, NULL, ?, ?, ?)`,
- ).run(id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null)
- writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
+ id, input.ruleKind, input.effectiveFrom, csv, input.subject ?? null, input.body ?? null,
+ )
+ await writeAudit(db, userId, 'create', 'reminder_schedule', id, undefined, {
ruleKind: input.ruleKind, effectiveFrom: input.effectiveFrom, dayOffsets: csv,
})
- })()
- return listSchedules(db).find((r) => r.id === id)!
+ })
+ return (await listSchedules(db)).find((r) => r.id === id)!
}
// ---------- settings helpers (shared by scheduler + bounce poller) ----------
-export function getSetting(db: DB, key: string): string | null {
- const row = db.prepare(`SELECT value FROM setting WHERE key=?`).get(key) as { value: string } | undefined
+export async function getSetting(db: DB, key: string): Promise {
+ const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key=?`, key)
return row === undefined ? null : row.value
}
-export function setSetting(db: DB, userId: string, key: string, value: string): void {
- const before = getSetting(db, key)
- db.prepare(
+export async function setSetting(db: DB, userId: string, key: string, value: string): Promise {
+ const before = await getSetting(db, key)
+ await db.run(
// Portability quirk: ON CONFLICT upsert is SQLite/Postgres dialect (standard SQL has MERGE).
`INSERT INTO setting (key, value) VALUES (?, ?) ON CONFLICT (key) DO UPDATE SET value = excluded.value`,
- ).run(key, value)
- writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value })
+ key, value,
+ )
+ await writeAudit(db, userId, before === null ? 'create' : 'update', 'setting', key, before === null ? undefined : { value: before }, { value })
}
-export function getNumberSetting(db: DB, key: string, fallback: number): number {
- const raw = getSetting(db, key)
+export async function getNumberSetting(db: DB, key: string, fallback: number): Promise {
+ const raw = await getSetting(db, key)
if (raw === null) return fallback
const n = Number(raw)
return Number.isFinite(n) ? n : fallback
diff --git a/apps/hq/src/repos-reports.ts b/apps/hq/src/repos-reports.ts
index e513a6d..d387c50 100644
--- a/apps/hq/src/repos-reports.ts
+++ b/apps/hq/src/repos-reports.ts
@@ -17,19 +17,19 @@ function daysBetween(fromIso: string, toIso: string): number {
}
/** Issued, non-cancelled invoices, optionally within a doc_date range. */
-function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): string[] {
+async function invoiceIds(db: DB, clientId: string | undefined, range?: DateRange): Promise {
let sql = `SELECT id FROM document WHERE doc_type='INVOICE' AND doc_no IS NOT NULL AND status != 'cancelled'`
const args: unknown[] = []
if (clientId !== undefined) { sql += ` AND client_id=?`; args.push(clientId) }
if (range?.from !== undefined) { sql += ` AND doc_date >= ?`; args.push(range.from) }
if (range?.to !== undefined) { sql += ` AND doc_date <= ?`; args.push(range.to) }
sql += ` ORDER BY doc_date, doc_no`
- return (db.prepare(sql).all(...args) as { id: string }[]).map((r) => r.id)
+ return (await db.all<{ id: string }>(sql, ...args)).map((r) => r.id)
}
/** Settled amount on an invoice, clamped to [0, payable]. */
-function settledOf(db: DB, docId: string, payablePaise: number): number {
- return Math.max(0, Math.min(payablePaise, payablePaise - outstandingPaise(db, docId)))
+async function settledOf(db: DB, docId: string, payablePaise: number): Promise {
+ return Math.max(0, Math.min(payablePaise, payablePaise - await outstandingPaise(db, docId)))
}
export interface DuesAgingRow {
@@ -37,16 +37,16 @@ export interface DuesAgingRow {
b0_30: number; b31_60: number; b61_90: number; b90p: number; totalPaise: number
}
-export function duesAging(db: DB, today: string): DuesAgingRow[] {
+export async function duesAging(db: DB, today: string): Promise {
// Buckets age from the stamped due date when present (D18 WS-A), doc date otherwise.
- const invoices = db.prepare(
+ const invoices = await db.all<{ id: string; client_id: string; anchor: string; client_name: string }>(
`SELECT d.id, d.client_id, COALESCE(d.due_date, d.doc_date) AS anchor, c.name AS client_name
FROM document d JOIN client c ON c.id = d.client_id
WHERE d.doc_type='INVOICE' AND d.doc_no IS NOT NULL AND d.status NOT IN ('paid','cancelled','lost')`,
- ).all() as { id: string; client_id: string; anchor: string; client_name: string }[]
+ )
const acc = new Map()
for (const inv of invoices) {
- const out = outstandingPaise(db, inv.id)
+ const out = await outstandingPaise(db, inv.id)
if (out <= 0) continue
const row = acc.get(inv.client_id)
?? { clientId: inv.client_id, clientName: inv.client_name, b0_30: 0, b31_60: 0, b61_90: 0, b90p: 0, totalPaise: 0 }
@@ -65,11 +65,11 @@ export interface ModuleRevenueRow {
moduleId: string; moduleCode: string; moduleName: string; billedPaise: number; settledPaise: number
}
-export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] {
+export async function moduleRevenue(db: DB, range?: DateRange): Promise {
const acc = new Map()
- for (const id of invoiceIds(db, undefined, range)) {
- const inv = getDocument(db, id)!
- const settledTotal = settledOf(db, inv.id, inv.payablePaise)
+ for (const id of await invoiceIds(db, undefined, range)) {
+ const inv = (await getDocument(db, id))!
+ const settledTotal = await settledOf(db, inv.id, inv.payablePaise)
const shares = splitProRata(settledTotal, inv.payload.lines.map((l) => l.lineTotalPaise))
inv.payload.lines.forEach((line, i) => {
const e = acc.get(line.itemId) ?? { billed: 0, settled: 0 }
@@ -78,13 +78,15 @@ export function moduleRevenue(db: DB, range?: DateRange): ModuleRevenueRow[] {
acc.set(line.itemId, e)
})
}
- return [...acc.entries()].map(([moduleId, v]) => {
- const mod = getModule(db, moduleId)
- return {
+ const out: ModuleRevenueRow[] = []
+ for (const [moduleId, v] of acc.entries()) {
+ const mod = await getModule(db, moduleId)
+ out.push({
moduleId, moduleCode: mod?.code ?? moduleId, moduleName: mod?.name ?? moduleId,
billedPaise: v.billed, settledPaise: v.settled,
- }
- }).sort((a, b) => b.billedPaise - a.billedPaise)
+ })
+ }
+ return out.sort((a, b) => b.billedPaise - a.billedPaise)
}
export interface ProfitabilityRow {
@@ -92,18 +94,18 @@ export interface ProfitabilityRow {
billedPaise: number; settledPaise: number; awsCostPaise: number; marginPaise: number
}
-export function clientProfitability(db: DB, range?: DateRange): ProfitabilityRow[] {
- const clients = db.prepare(`SELECT id, name FROM client ORDER BY name`).all() as { id: string; name: string }[]
+export async function clientProfitability(db: DB, range?: DateRange): Promise {
+ const clients = await db.all<{ id: string; name: string }>(`SELECT id, name FROM client ORDER BY name`)
const out: ProfitabilityRow[] = []
for (const c of clients) {
let billed = 0
let settled = 0
- for (const id of invoiceIds(db, c.id, range)) {
- const inv = getDocument(db, id)!
+ for (const id of await invoiceIds(db, c.id, range)) {
+ const inv = (await getDocument(db, id))!
billed += inv.payablePaise
- settled += settledOf(db, inv.id, inv.payablePaise)
+ settled += await settledOf(db, inv.id, inv.payablePaise)
}
- const awsCostPaise = awsCostForClient(db, c.id, range?.from, range?.to)
+ const awsCostPaise = await awsCostForClient(db, c.id, range?.from, range?.to)
if (billed === 0 && settled === 0 && awsCostPaise === 0) continue // omit inactive clients
out.push({ clientId: c.id, clientName: c.name, billedPaise: billed, settledPaise: settled, awsCostPaise, marginPaise: settled - awsCostPaise })
}
diff --git a/apps/hq/src/repos-shares.ts b/apps/hq/src/repos-shares.ts
index 7fb28de..8c130c0 100644
--- a/apps/hq/src/repos-shares.ts
+++ b/apps/hq/src/repos-shares.ts
@@ -39,15 +39,15 @@ function toShare(r: ShareRow): Share {
}
}
-export function getShare(db: DB, id: string): Share | null {
- const row = db.prepare(`SELECT * FROM document_share WHERE id=?`).get(id) as ShareRow | undefined
+export async function getShare(db: DB, id: string): Promise {
+ const row = await db.get(`SELECT * FROM document_share WHERE id=?`, id)
return row === undefined ? null : toShare(row)
}
-export function listShares(db: DB, documentId: string): Share[] {
- const rows = db.prepare(
+export async function listShares(db: DB, documentId: string): Promise {
+ const rows = await db.all(
`SELECT * FROM document_share WHERE document_id=? ORDER BY id DESC`, // uuidv7 ids: newest first
- ).all(documentId) as ShareRow[]
+ documentId)
return rows.map(toShare)
}
@@ -56,13 +56,13 @@ export function listShares(db: DB, documentId: string): Share[] {
* Read-only — safe for preview/context paths that must write nothing; the send
* path uses it to reuse an existing link instead of minting a duplicate.
*/
-export function resolveLiveShare(db: DB, documentId: string): Share | null {
- const row = db.prepare(
+export async function resolveLiveShare(db: DB, documentId: string): Promise {
+ const row = await db.get(
// ISO-8601 UTC strings compare lexicographically === chronologically.
`SELECT * FROM document_share
WHERE document_id=? AND revoked=0 AND (expires_at IS NULL OR expires_at > ?)
ORDER BY id DESC LIMIT 1`, // uuidv7 ids: newest first
- ).get(documentId, new Date().toISOString()) as ShareRow | undefined
+ documentId, new Date().toISOString())
return row === undefined ? null : toShare(row)
}
@@ -72,32 +72,32 @@ export interface MintShareOpts {
}
/** Owner-set default link lifetime; 'never' → null, absent/garbage → 30. */
-function defaultExpiryDays(db: DB): number | null {
- const row = db.prepare(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
- .get() as { value: string } | undefined
+async function defaultExpiryDays(db: DB): Promise {
+ const row = await db.get<{ value: string }>(
+ `SELECT value FROM setting WHERE key='share.default_expiry_days'`)
if (row === undefined) return 30
if (row.value === 'never') return null
const n = Number(row.value)
return Number.isInteger(n) && n > 0 ? n : 30
}
-export function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Share {
- if (getDocument(db, documentId) === null) throw new Error('Document not found')
+export async function mintShare(db: DB, userId: string, documentId: string, opts: MintShareOpts = {}): Promise {
+ if (await getDocument(db, documentId) === null) throw new Error('Document not found')
const id = uuidv7()
const token = randomBytes(32).toString('hex') // 256-bit, unguessable; 64 hex chars
const now = new Date()
const createdAt = now.toISOString()
- const expiresDays = opts.expiresDays === undefined ? defaultExpiryDays(db) : opts.expiresDays
+ const expiresDays = opts.expiresDays === undefined ? await defaultExpiryDays(db) : opts.expiresDays
const expiresAt = expiresDays === null
? null
: new Date(now.getTime() + expiresDays * 86_400_000).toISOString()
- db.prepare(
+ await db.run(
`INSERT INTO document_share (id, document_id, token, created_by, created_at, expires_at, revoked)
VALUES (?, ?, ?, ?, ?, ?, 0)`,
- ).run(id, documentId, token, userId, createdAt, expiresAt)
+ id, documentId, token, userId, createdAt, expiresAt)
// Audit the mint — but NEVER the token itself (a live share secret).
- writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt })
- return getShare(db, id)!
+ await writeAudit(db, userId, 'create', 'document_share', id, undefined, { documentId, expiresAt })
+ return (await getShare(db, id))!
}
/**
@@ -105,8 +105,8 @@ export function mintShare(db: DB, userId: string, documentId: string, opts: Mint
* revoked, or expired. This is the ONLY gate the public read route trusts — it
* returns nothing but the single document the live token points at.
*/
-export function validateShare(db: DB, token: string): Doc | null {
- const row = db.prepare(`SELECT * FROM document_share WHERE token=?`).get(token) as ShareRow | undefined
+export async function validateShare(db: DB, token: string): Promise {
+ const row = await db.get(`SELECT * FROM document_share WHERE token=?`, token)
if (row === undefined) return null // unknown
if (row.revoked !== 0) return null // revoked
// ISO-8601 UTC strings compare lexicographically === chronologically.
@@ -114,12 +114,12 @@ export function validateShare(db: DB, token: string): Doc | null {
return getDocument(db, row.document_id)
}
-export function revokeShare(db: DB, userId: string, shareId: string): Share {
- const before = getShare(db, shareId)
+export async function revokeShare(db: DB, userId: string, shareId: string): Promise {
+ const before = await getShare(db, shareId)
if (before === null) throw new Error('Share not found')
- db.prepare(`UPDATE document_share SET revoked=1 WHERE id=?`).run(shareId)
- const after = getShare(db, shareId)!
- writeAudit(db, userId, 'revoke', 'document_share', shareId,
+ await db.run(`UPDATE document_share SET revoked=1 WHERE id=?`, shareId)
+ const after = (await getShare(db, shareId))!
+ await writeAudit(db, userId, 'revoke', 'document_share', shareId,
{ revoked: before.revoked }, { revoked: after.revoked })
return after
}
diff --git a/apps/hq/src/scheduler.ts b/apps/hq/src/scheduler.ts
index 43f6c95..47ef519 100644
--- a/apps/hq/src/scheduler.ts
+++ b/apps/hq/src/scheduler.ts
@@ -54,11 +54,11 @@ function bump(created: Record, kind: string): void {
type ClaimResult = 'done' | { created: boolean; auto: boolean; reminderId: string }
/** One period for one plan, atomically. Callers wrap in db.transaction. */
-function claimAndGenerate(db: DB, planId: string, today: string, now: string): ClaimResult {
- const plan = getRecurringPlan(db, planId)
+async function claimAndGenerate(db: DB, planId: string, today: string, now: string): Promise {
+ const plan = await getRecurringPlan(db, planId)
if (plan === null || !plan.active || plan.nextRun > today) return 'done'
const duePeriod = plan.nextRun
- const up = upsertReminder(db, {
+ const up = await upsertReminder(db, {
ruleKind: 'recurring_generated', subjectId: plan.id, duePeriod,
clientId: plan.clientId, policyApplied: plan.policy, now,
})
@@ -66,38 +66,38 @@ function claimAndGenerate(db: DB, planId: string, today: string, now: string): C
if (!up.created) {
// Defensive: the period's reminder already exists but next_run wasn't advanced.
// Only reachable if generation were ever non-transactional; advance, never regenerate.
- db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId)
+ await db.run(`UPDATE recurring_plan SET next_run=? WHERE id=?`, nextRun, planId)
return { created: false, auto: false, reminderId: up.id }
}
if (plan.clientModuleId === null) throw new Error(`recurring_plan ${plan.id}: client_module required to generate`)
- const cm = getClientModule(db, plan.clientModuleId)
+ const cm = await getClientModule(db, plan.clientModuleId)
if (cm === null) throw new Error(`recurring_plan ${plan.id}: client_module not found`)
const kind = CADENCE_KIND[plan.cadence]
- const draft = createDraft(db, 'system', {
+ const draft = await createDraft(db, 'system', {
docType: 'INVOICE', clientId: plan.clientId,
lines: [{
moduleId: cm.moduleId, qty: 1, kind, edition: cm.edition,
...(plan.amountPaise !== null ? { unitPricePaise: plan.amountPaise } : {}),
}],
})
- const inv = issueDocument(db, 'system', draft.id)
- db.prepare(`UPDATE reminder SET doc_id=? WHERE id=?`).run(inv.id, up.id)
- db.prepare(`UPDATE recurring_plan SET next_run=? WHERE id=?`).run(nextRun, planId)
- writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id })
+ const inv = await issueDocument(db, 'system', draft.id)
+ await db.run(`UPDATE reminder SET doc_id=? WHERE id=?`, inv.id, up.id)
+ await db.run(`UPDATE recurring_plan SET next_run=? WHERE id=?`, nextRun, planId)
+ await writeAudit(db, 'system', 'generate', 'recurring_plan', plan.id, { nextRun: plan.nextRun }, { nextRun, invoiceId: inv.id })
return { created: true, auto: plan.policy === 'auto', reminderId: up.id }
}
/** Generate every due period for every active plan; collect auto reminders to send. */
-function generateRecurring(db: DB, today: string, now: string, created: Record): string[] {
- const plans = db.prepare(
- `SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`,
- ).all(today) as { id: string }[]
+async function generateRecurring(db: DB, today: string, now: string, created: Record): Promise {
+ const plans = await db.all<{ id: string }>(
+ `SELECT id FROM recurring_plan WHERE active=1 AND next_run <= ?`, today,
+ )
const autoQueue: string[] = []
for (const { id } of plans) {
let guard = 0
for (;;) {
if (guard++ > 240) break // safety cap: ~20 years of monthly against a corrupt next_run
- const claim = db.transaction(() => claimAndGenerate(db, id, today, now))()
+ const claim = await db.transaction(() => claimAndGenerate(db, id, today, now))
if (claim === 'done') break
if (claim.created) {
bump(created, 'recurring_generated')
@@ -118,57 +118,58 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// Catch-up guard (F7): only the HIGHEST crossed milestone enqueues per scan, so a
// scan gap (or the old monthly→dN cutover) nudges once, never the whole ladder;
// under daily scans each milestone still fires exactly once as it is crossed.
- const invOffsets = resolveSchedule(db, 'invoice_overdue', today).dayOffsets // ascending
+ const invOffsets = (await resolveSchedule(db, 'invoice_overdue', today)).dayOffsets // ascending
const overdueCutoff = addDaysIso(today, -invOffsets[0]!)
- const overdue = db.prepare(
+ const overdue = await db.all<{ id: string; client_id: string; doc_date: string; due_date: string | null }>(
`SELECT id, client_id, doc_date, due_date FROM document
WHERE doc_type='INVOICE' AND doc_no IS NOT NULL
AND status NOT IN ('paid','cancelled','lost')
- AND COALESCE(due_date, doc_date) <= ?`,
- ).all(overdueCutoff) as { id: string; client_id: string; doc_date: string; due_date: string | null }[]
+ AND COALESCE(due_date, doc_date) <= ?`, overdueCutoff,
+ )
for (const inv of overdue) {
- if (outstandingPaise(db, inv.id) <= 0) continue
+ if (await outstandingPaise(db, inv.id) <= 0) continue
const age = daysBetweenIso(inv.due_date ?? inv.doc_date, today)
const crossed = invOffsets.filter((d) => age >= d)
if (crossed.length === 0) continue
const highest = crossed[crossed.length - 1]!
- if (upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: `d${highest}`, clientId: inv.client_id, docId: inv.id, now }).created) {
+ if ((await upsertReminder(db, { ruleKind: 'invoice_overdue', subjectId: inv.id, duePeriod: `d${highest}`, clientId: inv.client_id, docId: inv.id, now })).created) {
bump(created, 'invoice_overdue')
}
}
// --- renewal_due ---
- const renewalDays = getNumberSetting(db, 'reminders.renewal_days', 15)
+ const renewalDays = await getNumberSetting(db, 'reminders.renewal_days', 15)
const renewalHorizon = addDaysIso(today, renewalDays)
- const renewals = db.prepare(
+ const renewals = await db.all<{ id: string; client_id: string; next_renewal: string }>(
`SELECT id, client_id, next_renewal FROM client_module
WHERE active=1 AND next_renewal IS NOT NULL AND next_renewal >= ? AND next_renewal <= ?`,
- ).all(today, renewalHorizon) as { id: string; client_id: string; next_renewal: string }[]
+ today, renewalHorizon,
+ )
for (const cm of renewals) {
- if (upsertReminder(db, { ruleKind: 'renewal_due', subjectId: cm.id, duePeriod: cm.next_renewal, clientId: cm.client_id, now }).created) {
+ if ((await upsertReminder(db, { ruleKind: 'renewal_due', subjectId: cm.id, duePeriod: cm.next_renewal, clientId: cm.client_id, now })).created) {
bump(created, 'renewal_due')
}
}
// --- amc_expiring (per-contract window) ---
- const amcs = db.prepare(
+ const amcs = await db.all<{ id: string; client_id: string; period_to: string; renewal_reminder_days: number }>(
`SELECT id, client_id, period_to, renewal_reminder_days FROM amc_contract WHERE active=1`,
- ).all() as { id: string; client_id: string; period_to: string; renewal_reminder_days: number }[]
+ )
for (const a of amcs) {
const horizon = addDaysIso(today, a.renewal_reminder_days)
if (a.period_to < today || a.period_to > horizon) continue
- if (upsertReminder(db, { ruleKind: 'amc_expiring', subjectId: a.id, duePeriod: a.period_to, clientId: a.client_id, now }).created) {
+ if ((await upsertReminder(db, { ruleKind: 'amc_expiring', subjectId: a.id, duePeriod: a.period_to, clientId: a.client_id, now })).created) {
bump(created, 'amc_expiring')
}
}
// --- follow_up (internal) ---
- const followUps = db.prepare(
+ const followUps = await db.all<{ id: string; client_id: string; follow_up_on: string }>(
`SELECT id, client_id, follow_up_on FROM interaction
- WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`,
- ).all(today) as { id: string; client_id: string; follow_up_on: string }[]
+ WHERE follow_up_on IS NOT NULL AND follow_up_on <= ?`, today,
+ )
for (const f of followUps) {
- if (upsertReminder(db, { ruleKind: 'follow_up', subjectId: f.id, duePeriod: f.follow_up_on, clientId: f.client_id, now }).created) {
+ if ((await upsertReminder(db, { ruleKind: 'follow_up', subjectId: f.id, duePeriod: f.follow_up_on, clientId: f.client_id, now })).created) {
bump(created, 'follow_up')
}
}
@@ -176,13 +177,13 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// --- quote_followup (escalating chase on sent quotations — spec §7) ---
// Age anchors on the FIRST 'sent' event (F15). The dated schedule (rule 3) yields
// the day offsets; send policy is a flat operational setting, default manual.
- const followupSchedule = resolveSchedule(db, 'quote_followup', today)
+ const followupSchedule = await resolveSchedule(db, 'quote_followup', today)
const followupPolicy: 'auto' | 'manual' =
- getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual'
+ await getSetting(db, 'quote.followup.policy') === 'auto' ? 'auto' : 'manual'
// A lost client is dead pipeline — never chase (their queued rows can be dismissed
// by hand). NOT EXISTS covers quotes converted before status-flip-on-convert landed:
// a live forward child means the sale moved on, whatever the quote row still says.
- const sentQuotes = db.prepare(
+ const sentQuotes = await db.all<{ id: string; client_id: string; first_sent: string | null }>(
`SELECT d.id, d.client_id,
(SELECT MIN(e.at_wall) FROM document_event e
WHERE e.document_id = d.id AND e.kind = 'sent') AS first_sent
@@ -191,7 +192,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
AND c.status != 'lost'
AND NOT EXISTS (SELECT 1 FROM document ch
WHERE ch.ref_doc_id = d.id AND ch.status != 'cancelled')`,
- ).all() as { id: string; client_id: string; first_sent: string | null }[]
+ )
const followupAuto: string[] = []
for (const q of sentQuotes) {
if (q.first_sent === null) continue
@@ -203,7 +204,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
// Milestones consumed on their own day stay consumed (threshold-day bucket in the
// unique key), so daily operation still fires each interval exactly once.
const highest = crossed[crossed.length - 1]!
- const up = upsertReminder(db, {
+ const up = await upsertReminder(db, {
ruleKind: 'quote_followup', subjectId: q.id, duePeriod: `d${highest}`,
clientId: q.client_id, docId: q.id, policyApplied: followupPolicy, now,
})
@@ -214,7 +215,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
}
// --- recurring_generated (transactional generation, then async auto-send) ---
- const autoQueue = [...followupAuto, ...generateRecurring(db, today, now, created)]
+ const autoQueue = [...followupAuto, ...await generateRecurring(db, today, now, created)]
let autoSent = 0
let autoFailed = 0
for (const reminderId of autoQueue) {
@@ -227,7 +228,7 @@ export async function runDailyScan(db: DB, deps: ScanDeps, today: string): Promi
} catch (err) {
// A hard guard (no recipient email, share.base_url unset) must not kill the
// drain: park THIS reminder as failed — loudly, with the reason — and keep going.
- setReminderStatus(db, 'system', reminderId, 'failed', {
+ await setReminderStatus(db, 'system', reminderId, 'failed', {
error: err instanceof Error ? err.message : String(err),
})
autoFailed += 1
diff --git a/apps/hq/src/seed.ts b/apps/hq/src/seed.ts
index 77c1462..99e51f6 100644
--- a/apps/hq/src/seed.ts
+++ b/apps/hq/src/seed.ts
@@ -31,77 +31,83 @@ const REMINDER_SETTINGS: Record = {
'reminders.renewal_days': '15',
}
-export function seedIfEmpty(db: DB): void {
- const staff = db.prepare(`SELECT COUNT(*) AS n FROM staff_user`).get() as { n: number }
+// 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