import { Router, type Request, type RequestHandler, type Response } from 'express' import { fyOf, validateGstin } from '@sims/domain' import { isManagerial, login, requireAuth, requireOwner } from './auth' import type { DB } from './db' import { createClient, getClient, listClients, setClientOwner, updateClient, type Client, type ClientInput, type ClientPatch, } from './repos-clients' import { createEmployee, deactivateEmployee, getEmployee, listEmployees, reactivateEmployee, setEmployeePassword, updateEmployee, type EmployeeRole, } from './repos-employees' import { assignModule, createModule, getClientModule, getModule, listClientModules, listClientsByModule, listModules, listPrices, setPrice, updateClientModule, updateModule, type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch, type PriceInput, } from './repos-modules' import { cancelDocument, convertDocument, createCreditNote, createDraft, getDocument, issueDocument, listDocumentEvents, listDocuments, markStatus, prepareDraft, supersedeProforma, type Doc, type DocumentFilter, type DraftInput, type DraftLineInput, } from './repos-documents' import { clientLedger, modulePaidView, recordPayment, receiptForPayment, type RecordPaymentInput, } from './repos-payments' import { listPipeline, PIPELINE_FILTERS, type ListPipelineOpts, type PipelineFilter } from './repos-pipeline' import { getShare, listShares, mintShare, revokeShare, type MintShareOpts } from './repos-shares' import { createRecurringPlan, deactivateRecurringPlan, getRecurringPlan, listRecurringPlans, updateRecurringPlan, type CreateRecurringInput, type RecurringPatch, } from './repos-recurring' import { amcPaidStatus, createAmc, deactivateAmc, generateAmcRenewalInvoice, getAmc, listAmc, updateAmc, type AmcPatch, type CreateAmcInput, } from './repos-amc' import { createInteraction, createInteractionType, getInteraction, listInteractions, listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch, } from './repos-interactions' import { dismissReminder, getNumberSetting, getReminder, insertSchedule, listQueue, listSchedules, setSetting, type QueueOpts, type ReminderStatus, } from './repos-reminders' import { sendReminder, reminderContext, type SendReminderDeps } from './send-reminder' import { reminderEmail } from './reminder-templates' import { dashboardView } from './repos-dashboard' import { costRanking, listAwsUsage, previousMonth, upsertAwsUsage, type UpsertAwsUsageInput, } from './repos-aws' import { pullMonthlyCosts } from './aws-costs' import { clientProfitability, duesAging, moduleRevenue, type DateRange } from './repos-reports' import { documentHtml, documentHtmlSample } from './templates' import { renderPdf } from './pdf' import { emailStatus } from './repos-email' import { sendDocumentEmail, sendReminderEmail, type GmailDeps } from './gmail' import { writeAudit } from './audit' const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id export function apiRouter( db: DB, gmailDeps?: Partial, renderPdfImpl: (html: string) => Promise = renderPdf, ): Router { const r = Router() const companySettings = (): Record => { // The full letterhead settings map documentHtml reads — company.* identity + template.* text. const rows = db.prepare( `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, // template settings, sample preview) uses to map request field → setting key. const COMPANY_FIELDS: Record = { name: 'company.name', address: 'company.address', gstin: 'company.gstin', stateCode: 'company.state_code', phone: 'company.phone', email: 'company.email', bank: 'company.bank', } const TEMPLATE_FIELDS: Record = { terms: 'template.terms', declaration: 'template.declaration', jurisdiction: 'template.jurisdiction', footerNote: 'template.footer_note', signatoryLabel: 'template.signatory_label', } const DOC_TYPES = ['QUOTATION', 'PROFORMA', 'INVOICE', 'CREDIT_NOTE', 'RECEIPT'] as const // Env read at request time so gmail-connect can run without a restart. const gmail = (): GmailDeps => ({ f: gmailDeps?.f ?? fetch, clientId: gmailDeps?.clientId ?? process.env['GOOGLE_CLIENT_ID'] ?? '', clientSecret: gmailDeps?.clientSecret ?? process.env['GOOGLE_CLIENT_SECRET'] ?? '', keyHex: gmailDeps?.keyHex ?? process.env['HQ_SECRET_KEY'] ?? '', }) 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 }) }) // ---------- 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 }) }) // Mutations are owner-only; the signed-in user is the audited actor. r.post('/employees', requireAuth, requireOwner, (req, res) => { try { const body = req.body as { email?: unknown; displayName?: unknown; role?: unknown; password?: unknown } const employee = 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 password: typeof body.password === 'string' ? body.password : '', }) res.json({ ok: true, employee }) } catch (err) { 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 id = String(req.params['id'] ?? '') if (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) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } } r.patch('/employees/:id', requireAuth, requireOwner, employeeAction((id, res, req) => { const body = req.body as { displayName?: unknown; role?: unknown } const patch: { displayName?: string; role?: EmployeeRole } = {} if (typeof body.displayName === 'string') patch.displayName = body.displayName if (typeof body.role === 'string') patch.role = body.role as EmployeeRole // repo validates the enum return updateEmployee(db, staffId(res), id, patch) })) r.post('/employees/:id/deactivate', requireAuth, requireOwner, employeeAction((id, res) => 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) => { const { password } = req.body as { password?: unknown } 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 res.json({ ok: true, clients: listClients(db, q) }) }) r.post('/clients', requireAuth, (req, res) => { try { const client = 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) }) } }) 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.patch('/clients/:id', 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 } try { const client = updateClient(db, staffId(res), id, req.body as ClientPatch) res.json({ ok: true, client }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // 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) => { 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) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { const raw = (req.body as { ownerId?: unknown }).ownerId 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) res.json({ ok: true, client }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- pipeline chase-list (D-PIPE; spec §6b/§9) ---------- // 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, } 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.post('/modules', requireAuth, requireOwner, (req, res) => { try { const mod = 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) => { const id = String(req.params['id'] ?? '') if (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) 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 } res.json({ ok: true, prices: listPrices(db, id) }) }) r.post('/modules/:id/prices', requireAuth, requireOwner, (req, res) => { const id = String(req.params['id'] ?? '') if (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) }) } 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 } 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(',')) } 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') }) // 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, }) } 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 } // 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 } // Warn, never truncate: every unreached client is named in the response. res.json({ ok: true, sent, failed, total: targets.length }) })() }) // ---------- 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 } res.json({ ok: true, clientModules: listClientModules(db, id) }) }) r.post('/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 } try { const cm = assignModule(db, staffId(res), { ...(req.body as Omit), clientId: id, }) res.json({ ok: true, clientModule: cm }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) r.patch('/client-modules/:id', requireAuth, (req, res) => { const id = String(req.params['id'] ?? '') if (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) res.json({ ok: true, clientModule: cm }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- documents ---------- r.post('/documents', requireAuth, (req, res) => { try { const document = 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) }) } }) // Live preview: the SAME prepareDraft + documentHtml the PDF uses — persists // 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) => { try { const input = req.body as DraftInput const prepared = prepareDraft(db, input, { permissive: true }) const company = companySettings() const picked = typeof input.clientId === 'string' && input.clientId !== '' ? getClient(db, input.clientId) : null const client: Client = picked ?? { id: '', code: '—', name: '— pick a client —', stateCode: company['company.state_code'] ?? '32', address: '', contacts: [], status: 'lead', notes: '', } const t = prepared.totals const doc: Doc = { id: 'preview', docType: input.docType, docNo: null, fy: fyOf(prepared.docDate), clientId: client.id, docDate: prepared.docDate, dueDate: prepared.dueDate ?? null, status: 'draft', refDocId: null, taxablePaise: t.taxablePaise, cgstPaise: t.cgstPaise, sgstPaise: t.sgstPaise, igstPaise: t.igstPaise, roundOffPaise: t.roundOffPaise, payablePaise: t.payablePaise, payload: prepared.payload, source: 'preview', createdBy: staffId(res), createdAt: '', } res.json({ ok: true, html: documentHtml(doc, client, company), totals: prepared.totals, warnings: prepared.warnings }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // 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 } } 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) }) }) 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'] res.json({ ok: true, documents: 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 id = String(req.params['id'] ?? '') if (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) }) } 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.post('/documents/:id/issue', requireAuth, docAction((id, res) => issueDocument(db, staffId(res), id))) r.post('/documents/:id/status', requireAuth, docAction((id, res, req) => { const { status } = req.body as { status?: string } if (status !== 'sent' && status !== 'accepted' && status !== 'lost') { throw new Error(`Status must be one of: sent, accepted, lost`) } return markStatus(db, staffId(res), id, status) })) r.post('/documents/:id/convert', requireAuth, docAction((id, res, req) => { const { to } = req.body as { to?: string } if (to !== 'PROFORMA' && to !== 'INVOICE') { throw new Error(`Convert target must be PROFORMA or INVOICE`) } return convertDocument(db, staffId(res), id, to) })) r.post('/documents/:id/cancel', requireAuth, docAction((id, res) => cancelDocument(db, staffId(res), id))) // Supersede & recreate (spec §8): proforma-only cancel + fresh linked draft. r.post('/documents/:id/supersede', requireAuth, docAction((id, res) => supersedeProforma(db, staffId(res), id))) // 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) => { const id = String(req.params['id'] ?? '') const src = 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) 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) 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 if (to === undefined || to === '') { res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' }) return } let invoice: Doc try { invoice = db.transaction(() => { const draft = 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 companyName = company['company.name'] ?? '' void (async () => { const issued = (): Doc => getDocument(db, invoice.id)! try { const pdf = await renderPdfImpl(documentHtml(issued(), client, company)) const out = await sendDocumentEmail(db, gmail(), { documentId: invoice.id, to, subject: `INVOICE ${invoice.docNo} — ${companyName}`, bodyText: `Dear ${client.name},\n\nPlease find attached INVOICE ${invoice.docNo}.\n\nRegards,\n${companyName}`, pdf, userId: staffId(res), }) if (!out.ok) { res.json({ ok: true, document: issued(), warning: `Invoice issued, email failed: ${out.error}` }) return } res.json({ ok: true, document: issued() }) } catch (err) { res.json({ ok: true, document: issued(), warning: `Invoice issued, email failed: ${err instanceof Error ? err.message : String(err)}`, }) } })() }) r.post('/documents/:id/credit-note', requireAuth, docAction((id, res, req) => { const { lines } = req.body as { lines?: DraftLineInput[] } return createCreditNote(db, staffId(res), id, lines) })) // ---------- 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) => { const id = String(req.params['id'] ?? '') if (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) }) } 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) => { const id = String(req.params['id'] ?? '') if (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) // 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) }) } 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 } 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) }) } })() }) // ---------- 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.post('/clients/:id/recurring', requireAuth, requireOwner, (req, res) => { const id = String(req.params['id'] ?? '') if (getClient(db, id) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return } try { const plan = createRecurringPlan(db, staffId(res), { ...(req.body as Omit), clientId: id, }) res.json({ ok: true, plan }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) r.patch('/recurring/:id', 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 } try { res.json({ ok: true, plan: 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) }) }) // ---------- 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.post('/clients/:id/amc', requireAuth, requireOwner, (req, res) => { const id = String(req.params['id'] ?? '') if (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) } }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) r.patch('/amc/:id', 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 } try { const amc = updateAmc(db, staffId(res), id, req.body as AmcPatch) res.json({ ok: true, contract: { ...amc, paidStatus: 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/renewal-invoice', 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 } try { res.json({ ok: true, document: 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.post('/interaction-types', requireAuth, requireOwner, (req, res) => { try { const type = 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.post('/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 } try { const interaction = createInteraction(db, staffId(res), { ...(req.body as Omit), clientId: id, }) res.json({ ok: true, interaction }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) r.patch('/interactions/:id', requireAuth, (req, res) => { const id = String(req.params['id'] ?? '') if (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) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) const sendReminderDeps = (): SendReminderDeps => ({ gmail: gmail(), renderPdf, company: companySettings, }) // ---------- reminders (manual queue) ---------- // Queue view is paginated with an honest total (rule 8) and owner-scoped: staff are // 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) => { const id = String(req.params['id'] ?? '') const reminder = 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 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 } res.json({ ok: true, reminder: getReminder(db, id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } })() }) r.post('/reminders/:id/dismiss', 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 } try { res.json({ ok: true, reminder: dismissReminder(db, staffId(res), id) }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- dashboard ---------- 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 }) }) }) // ---------- payments ---------- r.post('/payments', requireAuth, (req, res) => { try { const out = 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 } res.json({ ok: true, ...clientLedger(db, id), modulePaid: modulePaidView(db, id) }) }) r.post('/payments/:id/receipt', requireAuth, (req, res) => { const id = String(req.params['id'] ?? '') try { res.json({ ok: true, document: 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('/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.post('/aws/usage', requireAuth, requireOwner, (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' }) res.json({ ok: true, usage }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) r.post('/aws/pull', requireAuth, requireOwner, (req, res) => { const accessKeyId = process.env['AWS_ACCESS_KEY_ID'] ?? '' const secretAccessKey = process.env['AWS_SECRET_ACCESS_KEY'] ?? '' if (accessKeyId === '' || secretAccessKey === '') { res.status(409).json({ ok: false, error: 'aws-credentials-not-configured' }); return } const today = new Date().toISOString().slice(0, 10) const body = req.body as { month?: string } const month = typeof body.month === 'string' ? body.month : previousMonth(today) void (async () => { try { const out = await pullMonthlyCosts(db, { f: fetch, creds: { accessKeyId, secretAccessKey } }, month) res.json({ ok: true, month, ...out }) } catch (err) { res.status(502).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } })() }) // ---------- reports ---------- const rangeOf = (req: Request): DateRange => { const range: DateRange = {} if (typeof req.query['from'] === 'string') range.from = req.query['from'] 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/module-revenue', requireAuth, (req, res) => { res.json({ ok: true, rows: moduleRevenue(db, rangeOf(req)) }) }) r.get('/reports/profitability', requireAuth, (req, res) => { res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) }) }) // ---------- 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/company', requireAuth, (_req, res) => { res.json({ ok: true, company: companySettings() }) }) r.put('/settings/company', requireAuth, requireOwner, (req, res) => { const body = req.body as Record try { const gstin = body['gstin'] if (typeof gstin === 'string' && gstin !== '') { const v = validateGstin(gstin) if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'})`) } const stateCode = body['stateCode'] if (typeof stateCode === 'string' && !/^\d{2}$/.test(stateCode)) { throw new Error('State code must be two digits') } for (const [field, key] of Object.entries(COMPANY_FIELDS)) { const val = body[field] if (typeof val === 'string') setSetting(db, staffId(res), key, val) } res.json({ ok: true, company: 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) => { const body = req.body as { overdueDays?: unknown; renewalDays?: unknown } try { // First pass: validate all fields, collect tuples const toSet: Array<[string, string]> = [] for (const [field, key] of [['overdueDays', 'reminders.overdue_days'], ['renewalDays', 'reminders.renewal_days']] as const) { const v = body[field] if (v === undefined) continue if (typeof v !== 'number' || !Number.isInteger(v) || v < 1) throw new Error(`${field} must be a positive integer`) toSet.push([key, String(v)]) } // Second pass: write all validated settings for (const [key, value] of toSet) { 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) => { const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string } try { const schedule = 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 } : {}), }) res.json({ ok: true, schedule }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) // ---------- document template (company.* + template.* letterhead data) ---------- r.get('/settings/template', requireAuth, (_req, res) => { res.json({ ok: true, settings: companySettings() }) }) r.put('/settings/template', requireAuth, requireOwner, (req, res) => { const body = req.body as { company?: Record; template?: Record; titles?: Record } try { const company = body.company ?? {} const gstin = company['gstin'] if (typeof gstin === 'string' && gstin !== '') { const v = validateGstin(gstin) if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'})`) } const stateCode = company['state_code'] ?? company['stateCode'] if (typeof stateCode === 'string' && !/^\d{2}$/.test(stateCode)) { throw new Error('State code must be two digits') } for (const [field, key] of Object.entries(COMPANY_FIELDS)) { const val = company[field] if (typeof val === 'string') 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 (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) } } res.json({ ok: true, settings: 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) => { const { dataUri } = req.body as { dataUri?: unknown } try { if (typeof dataUri !== 'string') throw new Error('dataUri (string) is required') if (dataUri !== '') { const m = /^data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(dataUri) if (m === null) throw new Error('Logo must be a base64 PNG/JPEG/GIF/WebP/SVG data URI') 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 res.json({ ok: true, logo: dataUri }) } catch (err) { res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) } }) return r }