You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
sims-hq/apps/hq/src/api.ts

864 lines
41 KiB
TypeScript

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,
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,
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, getReminder, listQueue, listReminders, setSetting,
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, type GmailDeps } from './gmail'
const staffId = (res: Response): string => (res.locals['staff'] as { id: string }).id
export function apiRouter(
db: DB,
gmailDeps?: Partial<GmailDeps>,
renderPdfImpl: (html: string) => Promise<Buffer> = renderPdf,
): Router {
const r = Router()
const companySettings = (): Record<string, string> => {
// 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<string, string> = {
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<string, string> = {
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<PriceInput, 'moduleId'>), 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) })
}
})
// ---------- 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<AssignModuleInput, 'clientId'>), 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, 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<string, unknown>; template?: Record<string, unknown>
titles?: Record<string, unknown>; logo?: unknown; contentLines?: unknown
}
const merged = companySettings() // company.* + template.*
const applyGroup = (group: Record<string, unknown> | undefined, fields: Record<string, string>): 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)))
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<CreateRecurringInput, 'clientId'>), 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<CreateAmcInput, 'clientId'>), 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<CreateInteractionInput, 'clientId'>), 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) ----------
r.get('/reminders', requireAuth, (req, res) => {
const status = typeof req.query['status'] === 'string' ? req.query['status'] as ReminderStatus : undefined
res.json({ ok: true, reminders: status !== undefined ? listReminders(db, { status }) : listQueue(db) })
})
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) })
})
// ---------- 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) ----------
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<string, unknown>
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) })
}
})
// ---------- 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<string, unknown>; template?: Record<string, unknown>; titles?: Record<string, unknown> }
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
}