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.
311 lines
13 KiB
TypeScript
311 lines
13 KiB
TypeScript
import { Router, type Request, type RequestHandler, type Response } from 'express'
|
|
import { login, requireAuth, requireOwner } from './auth'
|
|
import type { DB } from './db'
|
|
import {
|
|
createClient, getClient, listClients, updateClient,
|
|
type ClientInput, type ClientPatch,
|
|
} from './repos-clients'
|
|
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,
|
|
type DocumentFilter, type DraftInput, type DraftLineInput,
|
|
} from './repos-documents'
|
|
import {
|
|
clientLedger, modulePaidView, recordPayment, type RecordPaymentInput,
|
|
} from './repos-payments'
|
|
import { documentHtml } 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>): Router {
|
|
const r = Router()
|
|
const companySettings = (): Record<string, string> => {
|
|
const rows = db.prepare(
|
|
`SELECT key, value FROM setting WHERE key LIKE 'company.%'`,
|
|
).all() as { key: string; value: string }[]
|
|
return Object.fromEntries(rows.map((row) => [row.key, row.value]))
|
|
}
|
|
// 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 })
|
|
})
|
|
|
|
// ---------- 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) })
|
|
}
|
|
})
|
|
|
|
// ---------- 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) })
|
|
}
|
|
})
|
|
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 })
|
|
})
|
|
|
|
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()
|
|
void (async () => {
|
|
try {
|
|
const pdf = await renderPdf(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', `inline; 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)
|
|
}))
|
|
|
|
// ---------- 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) })
|
|
}
|
|
})()
|
|
})
|
|
|
|
// ---------- 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) })
|
|
})
|
|
|
|
return r
|
|
}
|