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.
2011 lines
99 KiB
TypeScript
2011 lines
99 KiB
TypeScript
import { Router, type Request, type RequestHandler, type Response } from 'express'
|
|
import { fyOf, validateGstin } from '@sims/domain'
|
|
import { isManagerial, login, requireAuth, requireManagerial, requireOwner } from './auth'
|
|
import type { DB } from './db'
|
|
import {
|
|
bulkUpdateClients, createClient, getClient, listClients, revealClientDbPassword, setClientDbPassword,
|
|
setClientOwner, updateClient,
|
|
type Client, type ClientInput, type ClientPatch,
|
|
} from './repos-clients'
|
|
import {
|
|
changeOwnPassword, createEmployee, deactivateEmployee, getEmployee, listEmployees,
|
|
reactivateEmployee, setEmployeePassword, updateEmployee, type EmployeeRole,
|
|
} from './repos-employees'
|
|
import {
|
|
assignModule, createModule, getClientModule, getModule, listClientModules,
|
|
listClientsByModule, moduleDirectory, listPortals, listModules, listPrices, revealModulePassword, revealModuleSecret,
|
|
setModuleFieldValues, setModulePassword, setModuleSecret,
|
|
setPrice, updateClientModule, updateModule,
|
|
type AssignModuleInput, type ClientModulePatch, type ModuleInput, type ModulePatch,
|
|
type PriceInput,
|
|
} from './repos-modules'
|
|
import { createBranch, listBranches, updateBranch } from './repos-branches'
|
|
import {
|
|
createTicket, getTicket, listTickets, ticketCounts, ticketKinds, ticketTypes, overdueCount, updateTicket,
|
|
TICKET_STATUSES, type CreateTicketInput, type TicketPatch, type TicketStatus,
|
|
} from './repos-tickets'
|
|
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, getSetting, 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, gstSummary, moduleRevenue, type DateRange } from './repos-reports'
|
|
import { listUsageRateCards, setUsageRateCard } from './repos-rate-card'
|
|
import { renewalsDue, generateModuleRenewalQuote, generateBulkSmsPurchaseQuote } from './repos-renewals'
|
|
import { misCockpit } from './repos-mis'
|
|
import { smsBalances, refreshSmsBalance, refreshAllSmsBalances, DEFAULT_SMS_BALANCE_API } from './repos-sms'
|
|
import { globalSearch } from './repos-search'
|
|
import { recordLoginEvent, listLoginEvents } from './repos-login-events'
|
|
import { clearLegacyCiphertext } from './migrate-plaintext'
|
|
import { assertSafeGatewayUrl } from './sms-gateway'
|
|
import { makeRateLimiter } from './rate-limit'
|
|
import {
|
|
addProjectMilestone, bulkSetMilestone, ensureProjectMilestones, getFrontTemplate, getModuleTail,
|
|
listProjectMilestones, milestoneBoard, projectsPendingMilestone, setFrontTemplate, setMilestone,
|
|
setModuleTail, stalledProjects,
|
|
} from './repos-milestones'
|
|
import { commitImport, stageCsv, verificationReport } from './import-apex'
|
|
import { documentHtml, documentHtmlSample } from './templates'
|
|
import { renderPdf } from './pdf'
|
|
import { emailStatus } from './repos-email'
|
|
import { sendDocumentEmail, sendReminderEmail, type GmailDeps } from './gmail'
|
|
import { writeAudit, listAuditPage, auditFacets, type AuditFilter } from './audit'
|
|
|
|
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 = async (): Promise<Record<string, string>> => {
|
|
// The full letterhead settings map documentHtml reads — company.* identity + template.* text.
|
|
const rows = await db.all<{ key: string; value: string }>(
|
|
`SELECT key, value FROM setting WHERE key LIKE 'company.%' OR key LIKE 'template.%'`,
|
|
)
|
|
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' })
|
|
})
|
|
// D24 (red-team): brute-force defense. Two fixed-window limiters — per client IP and
|
|
// per target email — layered over the persistent per-account lockout in login(). The
|
|
// per-email bucket trips even when an attacker rotates source IPs; the per-IP bucket
|
|
// stops one host hammering many accounts. login() itself is constant-time.
|
|
const loginIpLimit = makeRateLimiter(20, 60_000) // 20 attempts / IP / minute
|
|
const loginEmailLimit = makeRateLimiter(8, 15 * 60_000) // 8 attempts / email / 15 min
|
|
// D24 (red-team): cap credential reveals per user so one insider account cannot silently
|
|
// bulk-decrypt every client's banking DB password / portal secret. Every reveal is also
|
|
// audited; this bounds the burst. Generous for legitimate support use.
|
|
const revealLimit = makeRateLimiter(20, 60_000) // 20 reveals / user / minute
|
|
const revealGuard = (res: Response): boolean => {
|
|
const ok = revealLimit((res.locals['staff'] as { id: string }).id)
|
|
if (!ok) res.status(429).json({ ok: false, error: 'Too many reveals in a short time. Please slow down.' })
|
|
return ok
|
|
}
|
|
r.post('/auth/login', async (req, res) => {
|
|
try {
|
|
const { email, password } = req.body as { email?: string; password?: string }
|
|
const ip = req.ip ?? 'unknown'
|
|
if (!loginIpLimit(ip)) {
|
|
res.status(429).json({ ok: false, error: 'Too many attempts. Please wait a minute and try again.' }); return
|
|
}
|
|
if (typeof email !== 'string' || typeof password !== 'string') {
|
|
res.status(401).json({ ok: false, error: 'Invalid email or password' }); return
|
|
}
|
|
if (!loginEmailLimit(email.toLowerCase())) {
|
|
res.status(429).json({ ok: false, error: 'Too many attempts for this account. Please try again later.' }); return
|
|
}
|
|
const ua = typeof req.headers['user-agent'] === 'string' ? req.headers['user-agent'] : ''
|
|
const out = await login(db, email, password)
|
|
if (!out.ok) {
|
|
await recordLoginEvent(db, {
|
|
staffId: null, usernameTried: email, ip, userAgent: ua,
|
|
outcome: out.reason === 'locked' ? 'locked' : 'failed',
|
|
})
|
|
const msg = out.reason === 'locked'
|
|
? 'This account is temporarily locked after too many failed attempts. Try again in 15 minutes.'
|
|
: 'Invalid email or password'
|
|
res.status(401).json({ ok: false, error: msg }); return
|
|
}
|
|
await recordLoginEvent(db, { staffId: out.staff.id, usernameTried: email, ip, userAgent: ua, outcome: 'success' })
|
|
res.json({ ok: true, token: out.token, staff: out.staff, mustChangePassword: out.staff.mustChangePassword })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- login history (D36, owner): who logged in / failed, from where ----------
|
|
r.get('/login-events', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const q = req.query
|
|
const opts: { outcome?: string; staffId?: string; page?: number; pageSize?: number } = {}
|
|
if (typeof q['outcome'] === 'string' && q['outcome'] !== '') opts.outcome = q['outcome']
|
|
if (typeof q['staffId'] === 'string' && q['staffId'] !== '') opts.staffId = q['staffId']
|
|
const page = Number(q['page']); if (Number.isInteger(page) && page >= 1) opts.page = page
|
|
const pageSize = Number(q['pageSize']); if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
|
|
res.json({ ok: true, ...await listLoginEvents(db, opts) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- employees (staff_user; D16) ----------
|
|
// GET is open to any signed-in user — the pipeline/owner pickers need names.
|
|
// Console users are a small bounded set: returned whole, `total` echoes the
|
|
// full count so the UI can display it (no silent truncation).
|
|
r.get('/employees', requireAuth, async (_req, res) => {
|
|
try {
|
|
const employees = await listEmployees(db)
|
|
res.json({ ok: true, employees, total: employees.length })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// Mutations are owner-only; the signed-in user is the audited actor.
|
|
r.post('/employees', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const body = req.body as { username?: unknown; email?: unknown; displayName?: unknown; role?: unknown; password?: unknown }
|
|
const employee = await createEmployee(db, staffId(res), {
|
|
...(typeof body.username === 'string' && body.username !== '' ? { username: body.username } : {}),
|
|
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 | Promise<unknown>): RequestHandler =>
|
|
async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getEmployee(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Employee not found' }); return
|
|
}
|
|
try {
|
|
res.json({ ok: true, employee: await fn(id, res, req) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
}
|
|
r.patch('/employees/:id', requireAuth, requireOwner, employeeAction((id, res, req) => {
|
|
const body = req.body as { displayName?: unknown; role?: unknown; phone?: unknown; title?: unknown }
|
|
const patch: { displayName?: string; role?: EmployeeRole; phone?: string; title?: string } = {}
|
|
if (typeof body.displayName === 'string') patch.displayName = body.displayName
|
|
if (typeof body.role === 'string') patch.role = body.role as EmployeeRole // repo validates the enum
|
|
if (typeof body.phone === 'string') patch.phone = body.phone
|
|
if (typeof body.title === 'string') patch.title = body.title
|
|
return updateEmployee(db, staffId(res), id, patch)
|
|
}))
|
|
|
|
// ---------- self-service profile (D18 WS-E) ----------
|
|
// /me is strictly self-scoped: the id comes from the session, never the request,
|
|
// and the whitelist below cannot touch role, email or active.
|
|
r.get('/me', requireAuth, async (_req, res) => {
|
|
try {
|
|
const me = await getEmployee(db, staffId(res))
|
|
if (me === null) { res.status(404).json({ ok: false, error: 'Employee not found' }); return }
|
|
res.json({ ok: true, employee: me })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.patch('/me', requireAuth, async (req, res) => {
|
|
try {
|
|
const body = req.body as { displayName?: unknown; phone?: unknown }
|
|
const patch: { displayName?: string; phone?: string } = {}
|
|
if (typeof body.displayName === 'string') patch.displayName = body.displayName
|
|
if (typeof body.phone === 'string') patch.phone = body.phone
|
|
res.json({ ok: true, employee: await updateEmployee(db, staffId(res), staffId(res), patch) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/me/password', requireAuth, async (req, res) => {
|
|
try {
|
|
const body = req.body as { currentPassword?: unknown; newPassword?: unknown }
|
|
const token = (req.headers.authorization ?? '').replace(/^Bearer /, '')
|
|
await changeOwnPassword(
|
|
db, staffId(res),
|
|
typeof body.currentPassword === 'string' ? body.currentPassword : '',
|
|
typeof body.newPassword === 'string' ? body.newPassword : '',
|
|
token,
|
|
)
|
|
res.json({ ok: true })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
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(async (id, res, req) => {
|
|
const { password } = req.body as { password?: unknown }
|
|
await setEmployeePassword(db, staffId(res), id, typeof password === 'string' ? password : '')
|
|
return getEmployee(db, id)
|
|
}))
|
|
|
|
// ---------- clients ----------
|
|
r.get('/clients', requireAuth, async (req, res) => {
|
|
try {
|
|
const q = typeof req.query['q'] === 'string' ? req.query['q'] : undefined
|
|
const filters = {
|
|
...(typeof req.query['district'] === 'string' ? { district: req.query['district'] } : {}),
|
|
...(typeof req.query['sector'] === 'string' ? { sector: req.query['sector'] } : {}),
|
|
...(typeof req.query['clientType'] === 'string' ? { clientType: req.query['clientType'] } : {}),
|
|
...(typeof req.query['category'] === 'string' ? { category: req.query['category'] } : {}),
|
|
}
|
|
res.json({ ok: true, clients: await listClients(db, q, filters) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/clients', requireAuth, async (req, res) => {
|
|
try {
|
|
const client = await createClient(db, staffId(res), req.body as ClientInput)
|
|
res.json({ ok: true, client })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// D18 WS-F: decrypt-and-return the support DB password — every reveal audited.
|
|
r.post('/clients/:id/reveal-db-password', requireAuth, async (req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) {
|
|
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
|
|
}
|
|
if (!revealGuard(res)) return
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client not found' }); return
|
|
}
|
|
try {
|
|
res.json({ ok: true, password: await revealClientDbPassword(db, viewer.id, id, gmail().keyHex) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/clients/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
const client = await getClient(db, String(req.params['id'] ?? ''))
|
|
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
res.json({ ok: true, client })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.patch('/clients/:id', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client not found' }); return
|
|
}
|
|
try {
|
|
// D18 WS-F: the DB password rides the same PATCH but is its own gated,
|
|
// audited write — owner/manager only, encrypted at rest, never in ClientPatch.
|
|
const { dbPassword, ...rest } = req.body as ClientPatch & { dbPassword?: unknown }
|
|
if (dbPassword !== undefined) {
|
|
if (typeof dbPassword !== 'string') throw new Error('dbPassword must be a string')
|
|
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
|
|
}
|
|
}
|
|
// One transaction: a bad field elsewhere in the patch must not leave a
|
|
// half-applied (already audited) password change behind.
|
|
const client = await db.transaction(async () => {
|
|
if (typeof dbPassword === 'string') {
|
|
await setClientDbPassword(db, staffId(res), id, dbPassword, gmail().keyHex)
|
|
}
|
|
return Object.keys(rest).length > 0
|
|
? await updateClient(db, staffId(res), id, rest)
|
|
: (await getClient(db, id))!
|
|
})
|
|
res.json({ ok: true, client })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// 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, async (req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) {
|
|
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
|
|
}
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await 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 = await setClientOwner(db, viewer.id, id, raw === null || raw === '' ? null : raw)
|
|
res.json({ ok: true, client })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- 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, async (req, res) => {
|
|
try {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
const rawFilter = typeof req.query['filter'] === 'string' ? req.query['filter'] : 'all'
|
|
if (!(PIPELINE_FILTERS as string[]).includes(rawFilter)) {
|
|
res.status(400).json({ ok: false, error: `filter must be one of: ${PIPELINE_FILTERS.join(', ')}` })
|
|
return
|
|
}
|
|
const opts: ListPipelineOpts = {
|
|
filter: rawFilter as PipelineFilter, viewerRole: viewer.role, viewerId: viewer.id,
|
|
}
|
|
if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
|
|
const page = Number(req.query['page'])
|
|
if (Number.isInteger(page) && page >= 1) opts.page = page
|
|
const pageSize = Number(req.query['pageSize'])
|
|
if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
|
|
res.json({ ok: true, ...await listPipeline(db, opts) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- modules ----------
|
|
r.get('/modules', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, modules: await listModules(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/modules', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const mod = await createModule(db, staffId(res), req.body as ModuleInput)
|
|
res.json({ ok: true, module: mod })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.patch('/modules/:id', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Module not found' }); return
|
|
}
|
|
try {
|
|
const mod = await updateModule(db, staffId(res), id, req.body as ModulePatch)
|
|
res.json({ ok: true, module: mod })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/modules/:id/prices', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Module not found' }); return
|
|
}
|
|
res.json({ ok: true, prices: await listPrices(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// D23 quantity-tiered rate card (SMS packs and future usage-billed modules).
|
|
r.get('/modules/:id/rate-card', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Module not found' }); return
|
|
}
|
|
res.json({ ok: true, rateCards: await listUsageRateCards(db, id) })
|
|
})
|
|
r.post('/modules/:id/rate-card', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Module not found' }); return
|
|
}
|
|
try {
|
|
const body = req.body as { effectiveFrom?: unknown; bands?: unknown }
|
|
if (typeof body.effectiveFrom !== 'string' || !Array.isArray(body.bands)) {
|
|
throw new Error('Provide effectiveFrom (YYYY-MM-DD) and bands [{ minQty, ratePaise }]')
|
|
}
|
|
await setUsageRateCard(db, staffId(res), id, body.effectiveFrom, body.bands as { minQty: number; ratePaise: number }[])
|
|
res.json({ ok: true, rateCards: await listUsageRateCards(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/modules/:id/prices', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Module not found' }); return
|
|
}
|
|
try {
|
|
await setPrice(db, staffId(res), { ...(req.body as Omit<PriceInput, 'moduleId'>), moduleId: id })
|
|
res.json({ ok: true, prices: await listPrices(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- onboarding step templates (owner editor): shared front + per-module tail ----------
|
|
r.get('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const code = String(req.params['code'] ?? '')
|
|
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
|
|
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
|
|
res.json({ ok: true, steps: await getModuleTail(db, code) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/modules/:code/onboarding-template', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const code = String(req.params['code'] ?? '')
|
|
const mod = await db.get<{ id: string }>(`SELECT id FROM module WHERE code=?`, code)
|
|
if (mod === undefined) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
|
|
const body = req.body as { steps?: unknown }
|
|
res.json({ ok: true, steps: await setModuleTail(db, staffId(res), code, body.steps) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- module → client roster (spec §10, D-ROSTER) ----------
|
|
// Module directory (D28): every client on a module with its service fields, for review.
|
|
r.get('/modules/:id/directory', requireAuth, async (req, res) => {
|
|
try {
|
|
const dir = await moduleDirectory(db, String(req.params['id'] ?? ''))
|
|
if (dir === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
|
|
res.json({ ok: true, ...dir })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/modules/:id/clients', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Module not found' }); return
|
|
}
|
|
const num = (v: unknown): number | undefined =>
|
|
typeof v === 'string' && v !== '' && Number.isFinite(Number(v)) ? Number(v) : undefined
|
|
const page = num(req.query['page'])
|
|
const pageSize = num(req.query['pageSize'])
|
|
res.json({
|
|
ok: true,
|
|
...await listClientsByModule(db, id, {
|
|
...(page !== undefined ? { page } : {}), ...(pageSize !== undefined ? { pageSize } : {}),
|
|
}),
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// CSV export streams EVERY row — an export never silently caps (rule 8).
|
|
r.get('/modules/:id/clients.csv', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
const mod = await getModule(db, id)
|
|
if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
|
|
const esc = (v: string): string => /[",\n]/.test(v) ? `"${v.replaceAll('"', '""')}"` : v
|
|
const lines = ['client_code,client_name,status,kind,edition,price_paise,next_renewal']
|
|
for (let page = 1; ; page++) {
|
|
const batch = await listClientsByModule(db, id, { page, pageSize: 200 })
|
|
for (const c of batch.clients) {
|
|
lines.push([
|
|
esc(c.clientCode), esc(c.clientName), c.status, c.kind, esc(c.edition),
|
|
c.pricePaise === null ? '' : String(c.pricePaise), c.nextRenewal ?? '',
|
|
].join(','))
|
|
}
|
|
if (page * batch.pageSize >= batch.total) break
|
|
}
|
|
res.setHeader('Content-Type', 'text/csv; charset=utf-8')
|
|
res.setHeader('Content-Disposition', `attachment; filename="module-${mod.code}-clients.csv"`)
|
|
res.send(lines.join('\n') + '\n')
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// 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, async (req, res) => {
|
|
try {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) {
|
|
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
|
|
}
|
|
const id = String(req.params['id'] ?? '')
|
|
const mod = await getModule(db, id)
|
|
if (mod === null) { res.status(404).json({ ok: false, error: 'Module not found' }); return }
|
|
const body = req.body as { subject?: unknown; body?: unknown }
|
|
const subject = typeof body.subject === 'string' ? body.subject.trim() : ''
|
|
const bodyText = typeof body.body === 'string' ? body.body.trim() : ''
|
|
if (subject === '' || bodyText === '') {
|
|
res.status(400).json({ ok: false, error: 'subject and body are required' }); return
|
|
}
|
|
const status = await emailStatus(db)
|
|
if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
|
|
if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
|
|
// Resolve the FULL roster (internally paginated) before sending anything.
|
|
const targets: { clientId: string; clientName: string; email: string | undefined }[] = []
|
|
for (let page = 1; ; page++) {
|
|
const batch = await listClientsByModule(db, id, { page, pageSize: 200 })
|
|
for (const c of batch.clients) {
|
|
const client = await getClient(db, c.clientId)
|
|
targets.push({
|
|
clientId: c.clientId, clientName: c.clientName,
|
|
email: client?.contacts.find((ct) => ct.email !== undefined && ct.email !== '')?.email,
|
|
})
|
|
}
|
|
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 })
|
|
await writeAudit(db, viewer.id, 'notify', 'client', t.clientId, undefined, {
|
|
moduleId: id, subject, status: out.ok ? 'sent' : 'failed',
|
|
...(out.ok ? {} : { error: out.error }),
|
|
})
|
|
if (out.ok) sent += 1
|
|
else failed.push({ client: t.clientName, error: out.error })
|
|
if (!out.ok && out.error === 'gmail-token-dead') break // account died mid-run: stop, report the rest honestly
|
|
}
|
|
// Warn, never truncate: every unreached client is named in the response.
|
|
res.json({ ok: true, sent, failed, total: targets.length })
|
|
})().catch((err: unknown) => {
|
|
// D24: a throw inside the broadcast loop must answer the request, not hang it.
|
|
if (!res.headersSent) {
|
|
res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- client modules ----------
|
|
r.get('/clients/:id/modules', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client not found' }); return
|
|
}
|
|
res.json({ ok: true, clientModules: await listClientModules(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/clients/:id/modules', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client not found' }); return
|
|
}
|
|
try {
|
|
const cm = await 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, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClientModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client module not found' }); return
|
|
}
|
|
try {
|
|
// D20: the portal password rides the same PATCH but is its own gated, audited
|
|
// write — owner/manager only, encrypted at rest, never in ClientModulePatch.
|
|
const { password, ...rest } = req.body as ClientModulePatch & { password?: unknown }
|
|
if (password !== undefined) {
|
|
if (typeof password !== 'string') throw new Error('password must be a string')
|
|
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
|
|
}
|
|
}
|
|
// One transaction: a bad field elsewhere must not leave a half-applied password.
|
|
const cm = await db.transaction(async () => {
|
|
if (typeof password === 'string') {
|
|
await setModulePassword(db, staffId(res), id, password, gmail().keyHex)
|
|
}
|
|
return Object.keys(rest).length > 0
|
|
? await updateClientModule(db, staffId(res), id, rest)
|
|
: (await getClientModule(db, id))!
|
|
})
|
|
res.json({ ok: true, clientModule: cm })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/client-modules/:id/reveal-password', requireAuth, async (req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) {
|
|
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
|
|
}
|
|
if (!revealGuard(res)) return
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClientModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client module not found' }); return
|
|
}
|
|
res.json({ ok: true, password: await revealModulePassword(db, viewer.id, id, gmail().keyHex) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// D21: module-defined NON-secret field values — staff-editable like other client_module data.
|
|
r.put('/client-modules/:id/fields', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClientModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client module not found' }); return
|
|
}
|
|
try {
|
|
const values = (req.body as { values?: Record<string, string> }).values ?? {}
|
|
res.json({ ok: true, clientModule: await setModuleFieldValues(db, staffId(res), id, values) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// D21: set/clear one secret field — managerial, encrypted, audited (plaintext never logged).
|
|
r.post('/client-modules/:id/secrets', requireAuth, async (req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) {
|
|
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
|
|
}
|
|
try {
|
|
const { key, value } = req.body as { key?: unknown; value?: unknown }
|
|
if (typeof key !== 'string' || typeof value !== 'string') throw new Error('key and value must be strings')
|
|
res.json({ ok: true, clientModule: await setModuleSecret(db, viewer.id, String(req.params['id'] ?? ''), key, value, gmail().keyHex) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/client-modules/:id/reveal-secret', requireAuth, async (req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) {
|
|
res.status(403).json({ ok: false, error: 'Owner or manager only' }); return
|
|
}
|
|
if (!revealGuard(res)) return
|
|
try {
|
|
const key = (req.body as { key?: unknown }).key
|
|
if (typeof key !== 'string') throw new Error('key must be a string')
|
|
res.json({ ok: true, value: await revealModuleSecret(db, viewer.id, String(req.params['id'] ?? ''), key, gmail().keyHex) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- onboarding milestones (D22) ----------
|
|
r.get('/client-modules/:id/milestones', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClientModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client module not found' }); return
|
|
}
|
|
try {
|
|
await ensureProjectMilestones(db, id) // lazily seed for projects created before D22 (imports)
|
|
res.json({ ok: true, milestones: await listProjectMilestones(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/client-modules/:id/milestones', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClientModule(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client module not found' }); return
|
|
}
|
|
try {
|
|
const body = req.body as {
|
|
key?: unknown; done?: unknown; doneOn?: unknown; label?: unknown
|
|
paymentId?: unknown; documentId?: unknown
|
|
}
|
|
if (typeof body.label === 'string') {
|
|
// Add a project-specific milestone.
|
|
res.json({ ok: true, milestones: await addProjectMilestone(db, staffId(res), id, body.label) })
|
|
return
|
|
}
|
|
if (typeof body.key !== 'string' || typeof body.done !== 'boolean') {
|
|
throw new Error('Provide { key, done } to tick a milestone, or { label } to add one')
|
|
}
|
|
const milestones = await setMilestone(db, staffId(res), id, body.key, body.done,
|
|
typeof body.doneOn === 'string' ? body.doneOn : undefined,
|
|
typeof body.paymentId === 'string' ? body.paymentId : undefined,
|
|
typeof body.documentId === 'string' ? body.documentId : undefined)
|
|
res.json({ ok: true, milestones })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// The cross-project board + drill-down (the "who's pending go-live" view).
|
|
r.get('/projects/board', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, board: await milestoneBoard(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/projects/pending/:key', requireAuth, async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, projects: await projectsPendingMilestone(db, String(req.params['key'] ?? '')) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// Parked onboarding: active projects whose next step hasn't moved in a while (dashboard/MIS tile).
|
|
// FIX B (pre-ship audit): a caller-supplied `days` used to flow straight into `new Date(...)`
|
|
// math with no bound and no try/catch — an out-of-range/NaN value threw a RangeError that,
|
|
// as an unhandled rejection, crashed the whole process. `days` is now validated (finite
|
|
// integer, sane bounds) and the handler is wrapped like its siblings.
|
|
r.get('/projects/stalled', requireAuth, async (req, res) => {
|
|
try {
|
|
const q = req.query['days']
|
|
let days: number
|
|
if (typeof q === 'string' && q !== '') {
|
|
const parsed = Number(q)
|
|
if (!Number.isInteger(parsed) || parsed < 0 || parsed > 3650) {
|
|
res.status(400).json({ ok: false, error: 'days must be an integer between 0 and 3650' })
|
|
return
|
|
}
|
|
days = parsed
|
|
} else {
|
|
days = await getNumberSetting(db, 'project.stall_days', 30)
|
|
}
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
// FIX 4 (no silent caps): unknownAgeExcluded surfaces the count of pending projects the
|
|
// query could not prove old (no ticks and no client_module.created_at) instead of
|
|
// silently dropping them off the tile with no trace.
|
|
const { rows, unknownAgeExcluded } = await stalledProjects(db, days, today)
|
|
res.json({ ok: true, projects: rows, unknownAgeExcluded })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// The shared onboarding "front" — same lead-in steps for every module's checklist (owner editor).
|
|
r.get('/onboarding-template/front', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, steps: await getFrontTemplate(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/onboarding-template/front', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const body = req.body as { steps?: unknown }
|
|
res.json({ ok: true, steps: await setFrontTemplate(db, staffId(res), body.steps) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- client branches (D20) ----------
|
|
r.get('/clients/:id/branches', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client not found' }); return
|
|
}
|
|
res.json({ ok: true, branches: await listBranches(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/clients/:id/branches', requireAuth, async (req, res) => {
|
|
try {
|
|
const body = req.body as { name?: unknown; code?: unknown }
|
|
const branch = await createBranch(db, staffId(res), {
|
|
clientId: String(req.params['id'] ?? ''),
|
|
name: typeof body.name === 'string' ? body.name : '',
|
|
...(typeof body.code === 'string' ? { code: body.code } : {}),
|
|
})
|
|
res.json({ ok: true, branch })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.patch('/branches/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
const body = req.body as { name?: unknown; code?: unknown; active?: unknown }
|
|
const branch = await updateBranch(db, staffId(res), String(req.params['id'] ?? ''), {
|
|
...(typeof body.name === 'string' ? { name: body.name } : {}),
|
|
...(typeof body.code === 'string' ? { code: body.code } : {}),
|
|
...(typeof body.active === 'boolean' ? { active: body.active } : {}),
|
|
})
|
|
res.json({ ok: true, branch })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- ticket desk (D20 — team-visible workbench) ----------
|
|
// The classification list (config over code: the `ticket.types` setting, code fallback).
|
|
r.get('/tickets/types', requireAuth, async (_req, res) => {
|
|
res.json({ ok: true, types: await ticketTypes(db) })
|
|
})
|
|
r.get('/tickets', requireAuth, async (req, res) => {
|
|
try {
|
|
const q = req.query
|
|
const status = typeof q['status'] === 'string' && (TICKET_STATUSES as readonly string[]).includes(q['status'])
|
|
? q['status'] as TicketStatus : undefined
|
|
// SLA: a still-open ticket older than ticket.sla_days is "overdue" (dated setting, config over code).
|
|
const slaDays = await getNumberSetting(db, 'ticket.sla_days', 7)
|
|
const cutoff = new Date(Date.now() - slaDays * 86_400_000).toISOString().slice(0, 10)
|
|
const out = await listTickets(db, {
|
|
...(status !== undefined ? { status } : {}),
|
|
...(typeof q['clientId'] === 'string' && q['clientId'] !== '' ? { clientId: q['clientId'] } : {}),
|
|
...(q['mine'] === '1'
|
|
? { assignedTo: staffId(res) }
|
|
: typeof q['assignedTo'] === 'string' && q['assignedTo'] !== '' ? { assignedTo: q['assignedTo'] } : {}),
|
|
...(typeof q['module'] === 'string' && q['module'] !== '' ? { moduleCode: q['module'] } : {}),
|
|
...(typeof q['type'] === 'string' && q['type'] !== '' ? { type: q['type'] } : {}),
|
|
...(q['overdue'] === '1' ? { overdueBefore: cutoff } : {}),
|
|
...(typeof q['q'] === 'string' && q['q'] !== '' ? { q: q['q'] } : {}),
|
|
page: Number(q['page'] ?? 1), pageSize: Number(q['pageSize'] ?? 50),
|
|
})
|
|
res.json({
|
|
ok: true, ...out, counts: await ticketCounts(db), kinds: await ticketKinds(db),
|
|
slaDays, overdueCount: await overdueCount(db, cutoff),
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/tickets', requireAuth, async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, ticket: await createTicket(db, staffId(res), req.body as CreateTicketInput) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.patch('/tickets/:id', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getTicket(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Ticket not found' }); return
|
|
}
|
|
try {
|
|
res.json({ ok: true, ticket: await updateTicket(db, staffId(res), id, req.body as TicketPatch) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- documents ----------
|
|
r.post('/documents', requireAuth, async (req, res) => {
|
|
try {
|
|
const document = await createDraft(db, staffId(res), req.body as DraftInput)
|
|
res.json({ ok: true, document })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// 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, async (req, res) => {
|
|
try {
|
|
const input = req.body as DraftInput
|
|
const prepared = await prepareDraft(db, input, { permissive: true })
|
|
const company = await companySettings()
|
|
const picked = typeof input.clientId === 'string' && input.clientId !== ''
|
|
? await 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: '', clientType: 'PACS', category: 'Other', hasDbPassword: false,
|
|
}
|
|
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, async (req, res) => {
|
|
try {
|
|
const body = req.body as {
|
|
company?: Record<string, unknown>; template?: Record<string, unknown>
|
|
titles?: Record<string, unknown>; logo?: unknown; contentLines?: unknown
|
|
}
|
|
const merged = await 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) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/documents', requireAuth, async (req, res) => {
|
|
try {
|
|
const filter: DocumentFilter = {}
|
|
if (typeof req.query['type'] === 'string') filter.type = req.query['type']
|
|
if (typeof req.query['status'] === 'string') filter.status = req.query['status']
|
|
if (typeof req.query['clientId'] === 'string') filter.clientId = req.query['clientId']
|
|
if (typeof req.query['q'] === 'string' && req.query['q'] !== '') filter.q = req.query['q']
|
|
const page = Number(req.query['page'])
|
|
if (Number.isInteger(page) && page >= 1) filter.page = page
|
|
const pageSize = Number(req.query['pageSize'])
|
|
if (Number.isInteger(pageSize) && pageSize >= 1) filter.pageSize = pageSize
|
|
res.json({ ok: true, ...await listDocuments(db, filter) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/documents/:id', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
const document = await getDocument(db, id)
|
|
if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
|
|
const emails = await db.all(
|
|
`SELECT * FROM email_log WHERE document_id=? ORDER BY id`, id,
|
|
)
|
|
res.json({ ok: true, document, events: await listDocumentEvents(db, id), emails, shares: await listShares(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
const docAction = (fn: (docId: string, res: Response, req: Request) => unknown | Promise<unknown>): RequestHandler =>
|
|
async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getDocument(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Document not found' }); return
|
|
}
|
|
try {
|
|
res.json({ ok: true, document: await fn(id, res, req) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
}
|
|
|
|
r.get('/documents/:id/pdf', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
const document = await getDocument(db, id)
|
|
if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
|
|
const client = await getClient(db, document.clientId)
|
|
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
const company = await companySettings()
|
|
// ?download=1 forces a Save-As (attachment); the default stays inline so the
|
|
// composer iframe can preview the PDF in place.
|
|
const disposition = req.query['download'] === '1' ? 'attachment' : 'inline'
|
|
void (async () => {
|
|
try {
|
|
const pdf = await renderPdfImpl(documentHtml(document, client, company))
|
|
res.setHeader('Content-Type', 'application/pdf')
|
|
// docNo contains '/' (QT/26-27-0001) — not legal in a filename.
|
|
const filename = (document.docNo ?? 'draft').replaceAll('/', '-')
|
|
res.setHeader('Content-Disposition', `${disposition}; filename="${filename}.pdf"`)
|
|
res.send(pdf)
|
|
} catch (err) {
|
|
res.status(500).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})()
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
r.post('/documents/:id/issue', requireAuth, docAction((id, res) =>
|
|
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)
|
|
}))
|
|
// Cancel is financially destructive (consumes a document number) — managerial only (D24).
|
|
r.post('/documents/:id/cancel', requireAuth, requireManagerial, docAction((id, res, req) =>
|
|
cancelDocument(db, staffId(res), id, String((req.body as { reason?: unknown }).reason ?? ''))))
|
|
// 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, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
const src = await getDocument(db, id)
|
|
if (src === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
|
|
if (src.docType !== 'PROFORMA') {
|
|
res.status(400).json({ ok: false, error: 'Only proformas can be converted & sent' }); return
|
|
}
|
|
const status = await emailStatus(db)
|
|
if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
|
|
if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
|
|
const client = await getClient(db, src.clientId)
|
|
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
const body = req.body as { to?: string }
|
|
const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
|
|
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 = await db.transaction(async () => {
|
|
const draft = await convertDocument(db, staffId(res), id, 'INVOICE') // F3 recompute + F4 dup-guard live here
|
|
return issueDocument(db, staffId(res), draft.id)
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
return
|
|
}
|
|
const company = await companySettings()
|
|
const companyName = company['company.name'] ?? ''
|
|
void (async () => {
|
|
const issued = async (): Promise<Doc> => (await getDocument(db, invoice.id))!
|
|
try {
|
|
const pdf = await renderPdfImpl(documentHtml(await 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: await issued(), warning: `Invoice issued, email failed: ${out.error}` })
|
|
return
|
|
}
|
|
res.json({ ok: true, document: await issued() })
|
|
} catch (err) {
|
|
res.json({
|
|
ok: true, document: await issued(),
|
|
warning: `Invoice issued, email failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
})
|
|
}
|
|
})()
|
|
})
|
|
// A credit note reverses money on an issued invoice — managerial only (D24).
|
|
r.post('/documents/:id/credit-note', requireAuth, requireManagerial, docAction((id, res, req) => {
|
|
const { lines } = req.body as { lines?: DraftLineInput[] }
|
|
return createCreditNote(db, staffId(res), id, lines)
|
|
}))
|
|
|
|
// ---------- shareable links ----------
|
|
// Owner/manager mints an opaque public token for one document (default +30d expiry).
|
|
// Minting an UNAUTHENTICATED public link to a client's document is privileged (D24) —
|
|
// gated managerial so any staff account cannot exfiltrate any invoice as a public PDF.
|
|
// The token is returned to the caller (they build the link) but never logged.
|
|
r.post('/documents/:id/share', requireAuth, requireManagerial, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
|
|
try {
|
|
const body = req.body as { expiresDays?: unknown }
|
|
const opts: MintShareOpts = {}
|
|
if (typeof body.expiresDays === 'number') opts.expiresDays = body.expiresDays
|
|
else if (body.expiresDays === null) opts.expiresDays = null
|
|
const share = await mintShare(db, staffId(res), id, opts)
|
|
res.json({ ok: true, share, shares: await listShares(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/documents/:id/share/:shareId/revoke', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getDocument(db, id)) === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
|
|
const shareId = String(req.params['shareId'] ?? '')
|
|
const existing = await getShare(db, shareId)
|
|
// Guard the pairing: a share is only revocable through the document it belongs to.
|
|
if (existing === null || existing.documentId !== id) {
|
|
res.status(404).json({ ok: false, error: 'Share not found' }); return
|
|
}
|
|
try {
|
|
const share = await revokeShare(db, staffId(res), shareId)
|
|
res.json({ ok: true, share, shares: await listShares(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- email ----------
|
|
r.get('/email/status', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, ...await emailStatus(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/documents/:id/send', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
const document = await getDocument(db, id)
|
|
if (document === null) { res.status(404).json({ ok: false, error: 'Document not found' }); return }
|
|
// Guard before any work: a dead/missing account never attempts a send.
|
|
const status = await emailStatus(db)
|
|
if (!status.connected) { res.status(409).json({ ok: false, error: 'gmail-not-connected' }); return }
|
|
if (status.dead) { res.status(409).json({ ok: false, error: 'gmail-token-dead' }); return }
|
|
if (document.docNo === null) {
|
|
res.status(400).json({ ok: false, error: 'Only issued documents can be emailed' }); return
|
|
}
|
|
const client = await getClient(db, document.clientId)
|
|
if (client === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
const body = req.body as { to?: string; subject?: string; body?: string }
|
|
const to = body.to ?? client.contacts.find((c) => c.email !== undefined && c.email !== '')?.email
|
|
if (to === undefined || to === '') {
|
|
res.status(400).json({ ok: false, error: 'No recipient: pass "to" or add a contact email to the client' })
|
|
return
|
|
}
|
|
const company = await companySettings()
|
|
const companyName = company['company.name'] ?? ''
|
|
const subject = body.subject ?? `${document.docType} ${document.docNo} — ${companyName}`
|
|
const bodyText = body.body ??
|
|
`Dear ${client.name},\n\nPlease find attached ${document.docType} ${document.docNo}.\n\nRegards,\n${companyName}`
|
|
void (async () => {
|
|
try {
|
|
const pdf = await renderPdf(documentHtml(document, client, company))
|
|
const out = await sendDocumentEmail(db, gmail(), {
|
|
documentId: id, to, subject, bodyText, pdf, userId: staffId(res),
|
|
})
|
|
if (!out.ok) {
|
|
res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error })
|
|
return
|
|
}
|
|
res.json({ ok: true, document: await getDocument(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})()
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- recurring plans ----------
|
|
r.get('/recurring', requireAuth, async (req, res) => {
|
|
try {
|
|
const clientId = typeof req.query['clientId'] === 'string' ? req.query['clientId'] : undefined
|
|
res.json({ ok: true, plans: await listRecurringPlans(db, clientId) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/clients/:id/recurring', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
try {
|
|
const plan = await 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, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getRecurringPlan(db, id)) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
|
|
try {
|
|
res.json({ ok: true, plan: await updateRecurringPlan(db, staffId(res), id, req.body as RecurringPatch) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/recurring/:id/deactivate', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getRecurringPlan(db, id)) === null) { res.status(404).json({ ok: false, error: 'Recurring plan not found' }); return }
|
|
res.json({ ok: true, plan: await deactivateRecurringPlan(db, staffId(res), id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- amc ----------
|
|
r.get('/clients/:id/amc', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
const contracts = await Promise.all(
|
|
(await listAmc(db, id)).map(async (a) => ({ ...a, paidStatus: await amcPaidStatus(db, a) })),
|
|
)
|
|
res.json({ ok: true, contracts })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/clients/:id/amc', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
try {
|
|
const amc = await createAmc(db, staffId(res), { ...(req.body as Omit<CreateAmcInput, 'clientId'>), clientId: id })
|
|
res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.patch('/amc/:id', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
|
|
try {
|
|
const amc = await updateAmc(db, staffId(res), id, req.body as AmcPatch)
|
|
res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/amc/:id/deactivate', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
|
|
const amc = await deactivateAmc(db, staffId(res), id)
|
|
res.json({ ok: true, contract: { ...amc, paidStatus: await amcPaidStatus(db, amc) } })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/amc/:id/renewal-invoice', requireAuth, requireOwner, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getAmc(db, id)) === null) { res.status(404).json({ ok: false, error: 'AMC contract not found' }); return }
|
|
try {
|
|
res.json({ ok: true, document: await generateAmcRenewalInvoice(db, staffId(res), id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- interactions ----------
|
|
r.get('/interaction-types', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, types: await listInteractionTypes(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/interaction-types', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const type = await createInteractionType(db, staffId(res), req.body as { code: string; label: string })
|
|
res.json({ ok: true, type })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/clients/:id/interactions', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
res.json({ ok: true, interactions: await listInteractions(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/clients/:id/interactions', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
try {
|
|
const interaction = await 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, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
const existing = await getInteraction(db, id)
|
|
if (existing === null) { res.status(404).json({ ok: false, error: 'Interaction not found' }); return }
|
|
// D24: only the author or a managerial role may edit a logged interaction —
|
|
// staff cannot overwrite another employee's call/visit record.
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role) && existing.staffId !== viewer.id) {
|
|
res.status(403).json({ ok: false, error: 'You can only edit interactions you logged' }); return
|
|
}
|
|
try {
|
|
res.json({ ok: true, interaction: await updateInteraction(db, staffId(res), id, req.body as InteractionPatch) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
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, async (req, res) => {
|
|
try {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
const opts: QueueOpts = { viewerRole: viewer.role, viewerId: viewer.id }
|
|
if (typeof req.query['status'] === 'string' && req.query['status'] !== '') {
|
|
opts.status = req.query['status'] as ReminderStatus
|
|
}
|
|
if (typeof req.query['owner'] === 'string' && req.query['owner'] !== '') opts.ownerId = req.query['owner']
|
|
const page = Number(req.query['page'])
|
|
if (Number.isInteger(page) && page >= 1) opts.page = page
|
|
const pageSize = Number(req.query['pageSize'])
|
|
if (Number.isInteger(pageSize) && pageSize >= 1) opts.pageSize = pageSize
|
|
const q = await listQueue(db, opts)
|
|
res.json({ ok: true, reminders: q.rows, total: q.total, page: q.page, pageSize: q.pageSize })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/reminders/:id/preview', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
const reminder = await getReminder(db, id)
|
|
if (reminder === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
|
|
try {
|
|
if (reminder.ruleKind === 'follow_up' || reminder.ruleKind === 'email_bounced') {
|
|
throw new Error(`A ${reminder.ruleKind} reminder is an internal item, not a sendable email`)
|
|
}
|
|
const { ctx } = await reminderContext(db, reminder, (await companySettings())['company.name'] ?? '')
|
|
const mail = reminderEmail(reminder.ruleKind, ctx)
|
|
res.json({ ok: true, subject: mail.subject, body: mail.bodyText })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/reminders/:id/send', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getReminder(db, id)) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
|
|
void (async () => {
|
|
try {
|
|
const out = await sendReminder(db, sendReminderDeps(), id, staffId(res))
|
|
if (!out.ok) {
|
|
res.status(out.error === 'gmail-token-dead' ? 409 : 502).json({ ok: false, error: out.error })
|
|
return
|
|
}
|
|
res.json({ ok: true, reminder: await getReminder(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})()
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/reminders/:id/dismiss', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getReminder(db, id)) === null) { res.status(404).json({ ok: false, error: 'Reminder not found' }); return }
|
|
try {
|
|
res.json({ ok: true, reminder: await dismissReminder(db, staffId(res), id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- APEX import (D18 WS-B; owner-only — the cutover is one-shot) ----------
|
|
r.post('/import/stage', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const body = req.body as { clientsCsv?: unknown; invoicesCsv?: unknown }
|
|
const clientsCsv = typeof body.clientsCsv === 'string' && body.clientsCsv !== '' ? body.clientsCsv : undefined
|
|
const invoicesCsv = typeof body.invoicesCsv === 'string' && body.invoicesCsv !== '' ? body.invoicesCsv : undefined
|
|
if (clientsCsv === undefined && invoicesCsv === undefined) {
|
|
res.status(400).json({ ok: false, error: 'Provide clientsCsv and/or invoicesCsv as CSV text' })
|
|
return
|
|
}
|
|
// Staging writes and their audit rows land (or roll back) together.
|
|
const out = await db.transaction(async () => {
|
|
const o: { clientsStaged?: number; invoicesStaged?: number } = {}
|
|
if (clientsCsv !== undefined) o.clientsStaged = (await stageCsv(db, 'clients', clientsCsv)).staged
|
|
if (invoicesCsv !== undefined) o.invoicesStaged = (await stageCsv(db, 'invoices', invoicesCsv)).staged
|
|
await writeAudit(db, staffId(res), 'import_stage', 'import', 'apex', undefined, o)
|
|
return o
|
|
})
|
|
res.json({ ok: true, ...out, report: await verificationReport(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/import/status', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
// Per-row problem lists (first 50 each, with honest totals — rule 8).
|
|
const problemClients = await db.all(
|
|
`SELECT row_no, code, name, problems FROM stg_client WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
|
|
)
|
|
const problemInvoices = await db.all(
|
|
`SELECT row_no, client_code, doc_no, problems FROM stg_invoice WHERE problems <> '[]' ORDER BY row_no LIMIT 50`,
|
|
)
|
|
res.json({ ok: true, report: await verificationReport(db), problemClients, problemInvoices })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/import/commit', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
// commitImport is itself transactional; the outer txn folds it into a savepoint
|
|
// so the cutover and its 'import_commit' audit row are one atomic unit.
|
|
const result = await db.transaction(async () => {
|
|
const r = await commitImport(db, staffId(res)) // refuses while ANY staged row has problems
|
|
await writeAudit(db, staffId(res), 'import_commit', 'import', 'apex', undefined, r)
|
|
return r
|
|
})
|
|
res.json({ ok: true, result })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- dashboard ----------
|
|
// ?mine=1 = My Day (D18 WS-E): owner/manager narrow to their own book; staff
|
|
// are always scoped server-side regardless of the param.
|
|
r.get('/dashboard', requireAuth, async (req, res) => {
|
|
try {
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
res.json({
|
|
ok: true,
|
|
view: await dashboardView(db, today, res.locals['staff'] as { id: string; role: string },
|
|
{ mine: req.query['mine'] === '1' }),
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- payments ----------
|
|
r.post('/payments', requireAuth, async (req, res) => {
|
|
try {
|
|
const out = await recordPayment(db, staffId(res), req.body as RecordPaymentInput)
|
|
res.json({ ok: true, ...out })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/clients/:id/ledger', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) {
|
|
res.status(404).json({ ok: false, error: 'Client not found' }); return
|
|
}
|
|
res.json({ ok: true, ...await clientLedger(db, id), modulePaid: await modulePaidView(db, id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/payments/:id/receipt', requireAuth, async (req, res) => {
|
|
const id = String(req.params['id'] ?? '')
|
|
try {
|
|
res.json({ ok: true, document: await receiptForPayment(db, staffId(res), id) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- aws usage & cost ----------
|
|
r.get('/aws/ranking', requireAuth, async (req, res) => {
|
|
try {
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const month = typeof req.query['month'] === 'string' ? req.query['month'] : previousMonth(today)
|
|
res.json({ ok: true, month, ...await costRanking(db, month) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/clients/:id/aws-usage', requireAuth, async (req, res) => {
|
|
try {
|
|
const id = String(req.params['id'] ?? '')
|
|
if ((await getClient(db, id)) === null) { res.status(404).json({ ok: false, error: 'Client not found' }); return }
|
|
res.json({ ok: true, usage: await listAwsUsage(db, id, 12) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/aws/usage', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
// Manual owner entry — always 'manual' provenance regardless of the body.
|
|
const usage = await upsertAwsUsage(db, staffId(res), { ...(req.body as UpsertAwsUsageInput), source: 'manual' })
|
|
res.json({ ok: true, usage })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
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, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, rows: await duesAging(db, new Date().toISOString().slice(0, 10)) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/reports/module-revenue', requireAuth, async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, rows: await moduleRevenue(db, rangeOf(req)) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/reports/profitability', requireAuth, async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, rows: await clientProfitability(db, rangeOf(req)) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// D26: GST filing summary (per month; invoices add, credit notes subtract).
|
|
r.get('/reports/gst-summary', requireAuth, async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, ...await gstSummary(db, rangeOf(req)) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- D32 keyless cleanup: null out unreadable legacy-encrypted credentials (owner) ----------
|
|
// HQ_SECRET_KEY was removed; pre-D31 AES values can no longer be read, so clear them and
|
|
// re-populate the affected logins in plaintext (APEX re-import or re-entry).
|
|
r.post('/admin/clear-legacy-credentials', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, result: await clearLegacyCiphertext(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- global quick-search (D28): clients + documents + tickets ----------
|
|
r.get('/search', requireAuth, async (req, res) => {
|
|
try {
|
|
const q = typeof req.query['q'] === 'string' ? req.query['q'] : ''
|
|
res.json({ ok: true, ...await globalSearch(db, q) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- portals quick-list (D28): all stored logins across modules ----------
|
|
r.get('/reports/portals', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, portals: await listPortals(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- SMS credit review (D28) ----------
|
|
r.get('/reports/sms-balances', requireAuth, async (_req, res) => {
|
|
try {
|
|
// Managerial viewers get the gateway passwords (D32 plaintext) for display + download.
|
|
const viewer = res.locals['staff'] as { role: string }
|
|
res.json({ ok: true, ...await smsBalances(db, isManagerial(viewer.role)) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// Poll every SMS account's live balance from the gateway (decrypts credentials → managerial).
|
|
r.post('/reports/sms-balances/refresh', requireAuth, async (_req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return }
|
|
try {
|
|
res.json({ ok: true, summary: await refreshAllSmsBalances(db, viewer.id, gmail().keyHex) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// Poll one client's SMS balance (Client 360 / per-row refresh).
|
|
r.post('/client-modules/:id/refresh-sms-balance', requireAuth, async (req, res) => {
|
|
const viewer = res.locals['staff'] as { id: string; role: string }
|
|
if (!isManagerial(viewer.role)) { res.status(403).json({ ok: false, error: 'Owner or manager only' }); return }
|
|
try {
|
|
res.json({ ok: true, result: await refreshSmsBalance(db, viewer.id, String(req.params['id'] ?? ''), gmail().keyHex) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- audit log viewer (D27, owner-only) ----------
|
|
r.get('/audit', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const q = req.query
|
|
const f: AuditFilter = {}
|
|
for (const k of ['entity', 'action', 'userId', 'entityId', 'from', 'to'] as const) {
|
|
if (typeof q[k] === 'string' && q[k] !== '') f[k] = q[k] as string
|
|
}
|
|
const page = Number(q['page']); if (Number.isInteger(page) && page >= 1) f.page = page
|
|
const pageSize = Number(q['pageSize']); if (Number.isInteger(pageSize) && pageSize >= 1) f.pageSize = pageSize
|
|
res.json({ ok: true, ...await listAuditPage(db, f), facets: await auditFacets(db) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- owner MIS cockpit (D27) ----------
|
|
r.get('/reports/mis', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, mis: await misCockpit(db, new Date().toISOString().slice(0, 10)) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- renewals command center (D27) ----------
|
|
r.get('/renewals', requireAuth, async (req, res) => {
|
|
try {
|
|
const today = new Date().toISOString().slice(0, 10)
|
|
const from = typeof req.query['from'] === 'string' ? req.query['from'] : today
|
|
// Default window: next 90 days from today.
|
|
const to = typeof req.query['to'] === 'string' ? req.query['to']
|
|
: new Date(Date.now() + 90 * 86_400_000).toISOString().slice(0, 10)
|
|
res.json({ ok: true, rows: await renewalsDue(db, from, to, today) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/client-modules/:id/renewal-quote', requireAuth, async (req, res) => {
|
|
try {
|
|
res.json({ ok: true, document: await generateModuleRenewalQuote(db, staffId(res), String(req.params['id'] ?? '')) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// Ad-hoc bulk SMS credit purchase (top-up) — distinct from a subscription renewal.
|
|
r.post('/client-modules/:id/bulk-sms-quote', requireAuth, async (req, res) => {
|
|
try {
|
|
const qty = Number((req.body as { smsQty?: unknown }).smsQty)
|
|
const doc = await generateBulkSmsPurchaseQuote(db, staffId(res), String(req.params['id'] ?? ''), qty)
|
|
res.json({ ok: true, document: doc })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- bulk operations (D26 — cleanup at scale, owner-only) ----------
|
|
r.post('/clients/bulk', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const body = req.body as { ids?: unknown; patch?: unknown }
|
|
if (!Array.isArray(body.ids) || typeof body.patch !== 'object' || body.patch === null) {
|
|
throw new Error('Provide ids [] and a patch {}')
|
|
}
|
|
const out = await bulkUpdateClients(db, staffId(res), body.ids as string[], body.patch as ClientPatch)
|
|
res.json({ ok: true, ...out })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/projects/bulk-milestone', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const body = req.body as { clientModuleIds?: unknown; key?: unknown; done?: unknown; doneOn?: unknown }
|
|
if (!Array.isArray(body.clientModuleIds) || typeof body.key !== 'string' || typeof body.done !== 'boolean') {
|
|
throw new Error('Provide clientModuleIds [], key, done')
|
|
}
|
|
const out = await bulkSetMilestone(db, staffId(res), body.clientModuleIds as string[], body.key, body.done,
|
|
typeof body.doneOn === 'string' ? body.doneOn : undefined)
|
|
res.json({ ok: true, ...out })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- company profile (owner-editable; PDFs read these live) ----------
|
|
// D18: the composer autofills invoice due dates from this (issue also stamps with it).
|
|
r.get('/settings/billing', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, paymentTermsDays: await getNumberSetting(db, 'billing.payment_terms_days', 15) })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.get('/settings/company', requireAuth, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, company: await companySettings() })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- sharing defaults (owner) ----------
|
|
r.get('/settings/sharing', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
const row = await db.get<{ value: string }>(`SELECT value FROM setting WHERE key='share.default_expiry_days'`)
|
|
const days = row === undefined ? 30 : row.value === 'never' ? null : Number(row.value)
|
|
res.json({ ok: true, defaultExpiryDays: days !== null && (!Number.isInteger(days) || days < 1) ? 30 : days })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/settings/sharing', requireAuth, requireOwner, async (req, res) => {
|
|
try {
|
|
const v = (req.body as { defaultExpiryDays?: unknown }).defaultExpiryDays
|
|
if (v !== null && (typeof v !== 'number' || !Number.isInteger(v) || v < 1)) {
|
|
res.status(400).json({ ok: false, error: 'defaultExpiryDays must be a positive integer or null (never)' })
|
|
return
|
|
}
|
|
await setSetting(db, staffId(res), 'share.default_expiry_days', v === null ? 'never' : String(v))
|
|
res.json({ ok: true, defaultExpiryDays: v })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
// ---------- operations knobs (owner): SMS gateway + thresholds + ticket SLA (D28) ----------
|
|
r.get('/settings/operations', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
res.json({
|
|
ok: true,
|
|
smsBalanceApiUrl: (await getSetting(db, 'sms.balance_api_url')) ?? DEFAULT_SMS_BALANCE_API,
|
|
smsLowThreshold: await getNumberSetting(db, 'sms.low_balance_threshold', 10000),
|
|
ticketSlaDays: await getNumberSetting(db, 'ticket.sla_days', 7),
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/settings/operations', requireAuth, requireOwner, async (req, res) => {
|
|
const b = req.body as { smsBalanceApiUrl?: unknown; smsLowThreshold?: unknown; ticketSlaDays?: unknown }
|
|
try {
|
|
if (b.smsBalanceApiUrl !== undefined) {
|
|
const url = String(b.smsBalanceApiUrl).trim()
|
|
if (url !== '') assertSafeGatewayUrl(url) // reject internal/loopback targets before storing
|
|
await setSetting(db, staffId(res), 'sms.balance_api_url', url)
|
|
}
|
|
if (b.smsLowThreshold !== undefined) {
|
|
const n = Number(b.smsLowThreshold)
|
|
if (!Number.isInteger(n) || n < 0) throw new Error('Low-balance threshold must be a whole number ≥ 0')
|
|
await setSetting(db, staffId(res), 'sms.low_balance_threshold', String(n))
|
|
}
|
|
if (b.ticketSlaDays !== undefined) {
|
|
const n = Number(b.ticketSlaDays)
|
|
if (!Number.isInteger(n) || n < 1) throw new Error('Ticket SLA must be a whole number of days ≥ 1')
|
|
await setSetting(db, staffId(res), 'ticket.sla_days', String(n))
|
|
}
|
|
res.json({
|
|
ok: true,
|
|
smsBalanceApiUrl: (await getSetting(db, 'sms.balance_api_url')) ?? DEFAULT_SMS_BALANCE_API,
|
|
smsLowThreshold: await getNumberSetting(db, 'sms.low_balance_threshold', 10000),
|
|
ticketSlaDays: await getNumberSetting(db, 'ticket.sla_days', 7),
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/settings/company', requireAuth, requireOwner, async (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') await setSetting(db, staffId(res), key, val)
|
|
}
|
|
res.json({ ok: true, company: await companySettings() })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
// ---------- reminder cadences (owner; dated rows are append-only — rule 3) ----------
|
|
r.get('/settings/reminders', requireAuth, requireOwner, async (_req, res) => {
|
|
try {
|
|
const schedules = await listSchedules(db)
|
|
res.json({
|
|
ok: true,
|
|
overdueDays: await getNumberSetting(db, 'reminders.overdue_days', 7),
|
|
renewalDays: await getNumberSetting(db, 'reminders.renewal_days', 15),
|
|
schedules, total: schedules.length,
|
|
})
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/settings/reminders', requireAuth, requireOwner, async (req, res) => {
|
|
const body = req.body as { overdueDays?: unknown; renewalDays?: unknown }
|
|
try {
|
|
// First pass: validate all fields, collect tuples
|
|
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 — one transaction, so a failure
|
|
// mid-loop cannot persist overdueDays without renewalDays (and each setting
|
|
// lands with its audit row atomically).
|
|
await db.transaction(async () => {
|
|
for (const [key, value] of toSet) {
|
|
await setSetting(db, staffId(res), key, value)
|
|
}
|
|
})
|
|
res.json({ ok: true })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/settings/schedules', requireAuth, requireOwner, async (req, res) => {
|
|
const body = req.body as { ruleKind?: string; effectiveFrom?: string; dayOffsets?: string; subject?: string; body?: string }
|
|
try {
|
|
const schedule = await insertSchedule(db, staffId(res), {
|
|
ruleKind: body.ruleKind ?? '', effectiveFrom: body.effectiveFrom ?? '', dayOffsets: body.dayOffsets ?? '',
|
|
...(body.subject !== undefined && body.subject !== '' ? { subject: body.subject } : {}),
|
|
...(body.body !== undefined && body.body !== '' ? { body: body.body } : {}),
|
|
})
|
|
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, async (_req, res) => {
|
|
try {
|
|
res.json({ ok: true, settings: await companySettings() })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.put('/settings/template', requireAuth, requireOwner, async (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') await setSetting(db, staffId(res), key, val)
|
|
}
|
|
for (const [field, key] of Object.entries(TEMPLATE_FIELDS)) {
|
|
const val = (body.template ?? {})[field]
|
|
if (typeof val === 'string') await setSetting(db, staffId(res), key, val)
|
|
}
|
|
if (body.titles !== undefined && body.titles !== null) {
|
|
for (const t of DOC_TYPES) {
|
|
const val = body.titles[t]
|
|
if (typeof val === 'string') await setSetting(db, staffId(res), `template.title_${t}`, val)
|
|
}
|
|
}
|
|
res.json({ ok: true, settings: await companySettings() })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
r.post('/settings/template/logo', requireAuth, requireOwner, async (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')
|
|
}
|
|
await setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it
|
|
res.json({ ok: true, logo: dataUri })
|
|
} catch (err) {
|
|
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
|
|
}
|
|
})
|
|
|
|
return r
|
|
}
|