feat(hq): GET/PUT /settings/template + logo upload (owner-gated, audited)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 1 week ago
parent c2f94df06d
commit 48731fdb2b

@ -646,5 +646,58 @@ export function apiRouter(db: DB, gmailDeps?: Partial<GmailDeps>): Router {
} }
}) })
// ---------- document template (company.* + template.* letterhead data) ----------
r.get('/settings/template', requireAuth, (_req, res) => {
res.json({ ok: true, settings: companySettings() })
})
r.put('/settings/template', requireAuth, requireOwner, (req, res) => {
const body = req.body as { company?: Record<string, unknown>; template?: Record<string, unknown>; titles?: Record<string, unknown> }
try {
const company = body.company ?? {}
const gstin = company['gstin']
if (typeof gstin === 'string' && gstin !== '') {
const v = validateGstin(gstin)
if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'})`)
}
const stateCode = company['state_code'] ?? company['stateCode']
if (typeof stateCode === 'string' && !/^\d{2}$/.test(stateCode)) {
throw new Error('State code must be two digits')
}
for (const [field, key] of Object.entries(COMPANY_FIELDS)) {
const val = company[field]
if (typeof val === 'string') setSetting(db, staffId(res), key, val)
}
for (const [field, key] of Object.entries(TEMPLATE_FIELDS)) {
const val = (body.template ?? {})[field]
if (typeof val === 'string') setSetting(db, staffId(res), key, val)
}
if (body.titles !== undefined && body.titles !== null) {
for (const t of DOC_TYPES) {
const val = body.titles[t]
if (typeof val === 'string') setSetting(db, staffId(res), `template.title_${t}`, val)
}
}
res.json({ ok: true, settings: companySettings() })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
r.post('/settings/template/logo', requireAuth, requireOwner, (req, res) => {
const { dataUri } = req.body as { dataUri?: unknown }
try {
if (typeof dataUri !== 'string') throw new Error('dataUri (string) is required')
if (dataUri !== '') {
const m = /^data:image\/(png|jpe?g|gif|webp|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(dataUri)
if (m === null) throw new Error('Logo must be a base64 PNG/JPEG/GIF/WebP/SVG data URI')
const bytes = Math.floor((m[2]!.length * 3) / 4) // approximate decoded size
if (bytes > 200 * 1024) throw new Error('Logo too large — keep it under 200 KB')
}
setSetting(db, staffId(res), 'template.logo', dataUri) // '' clears it
res.json({ ok: true, logo: dataUri })
} catch (err) {
res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) })
}
})
return r return r
} }

@ -0,0 +1,66 @@
// apps/hq/test/settings-template.test.ts
import { describe, it, expect, afterAll } from 'vitest'
import express from 'express'
import { openDb } from '../src/db'
import { seedIfEmpty } from '../src/seed'
import { createStaff } from '../src/auth'
import { getSetting } from '../src/repos-reminders'
import { apiRouter } from '../src/api'
function appWith() {
const db = openDb(':memory:'); seedIfEmpty(db)
createStaff(db, { email: 'owner@test.in', displayName: 'Owner', role: 'owner', password: 'owner-password' })
createStaff(db, { email: 'staff@test.in', displayName: 'Staff', role: 'staff', password: 'staff-password' })
const app = express(); app.use(express.json({ limit: '2mb' })); app.locals['db'] = db; app.use('/api', apiRouter(db))
const server = app.listen(0)
const base = `http://localhost:${(server.address() as { port: number }).port}/api`
return { db, server, base }
}
describe('template settings', () => {
const { db, server, base } = appWith()
afterAll(() => server.close())
const login = async (email: string, password: string) =>
(await (await fetch(`${base}/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ email, password }) })).json() as any).token
const call = async (token: string, method: string, path: string, body?: unknown) => {
const res = await fetch(base + path, { method, headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, ...(body ? { body: JSON.stringify(body) } : {}) })
return { status: res.status, json: await res.json() as any }
}
it('owner GET returns company.* + template.*; PUT writes template fields + a title, each audited', async () => {
const token = await login('owner@test.in', 'owner-password')
const before = await call(token, 'GET', '/settings/template')
expect(before.json.settings['company.name']).toBe('Tecnostac')
const put = await call(token, 'PUT', '/settings/template', {
template: { footerNote: 'Thank you!', declaration: 'We declare…', signatoryLabel: 'Proprietor' },
titles: { INVOICE: 'GST INVOICE' },
})
expect(put.status).toBe(200)
expect(getSetting(db, 'template.footer_note')).toBe('Thank you!')
expect(getSetting(db, 'template.signatory_label')).toBe('Proprietor')
expect(getSetting(db, 'template.title_INVOICE')).toBe('GST INVOICE')
const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'template.%'`).get() as { n: number }
expect(audits.n).toBeGreaterThanOrEqual(4)
})
it('rejects a staff PUT (owner only) and an invalid GSTIN', async () => {
const staff = await login('staff@test.in', 'staff-password')
expect((await call(staff, 'PUT', '/settings/template', { template: { terms: 'x' } })).status).toBe(403)
const owner = await login('owner@test.in', 'owner-password')
expect((await call(owner, 'PUT', '/settings/template', { company: { gstin: 'NOTAGSTIN' } })).status).toBe(400)
})
it('logo: accepts a small image data URI (owner, audited), rejects non-image, oversize, and staff', async () => {
const owner = await login('owner@test.in', 'owner-password')
const tiny = 'data:image/png;base64,' + 'A'.repeat(200)
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(200)
expect(getSetting(db, 'template.logo')).toBe(tiny)
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: 'data:text/html;base64,AAAA' })).status).toBe(400)
const huge = 'data:image/png;base64,' + 'A'.repeat(400_000) // ~300 KB decoded > 200 KB cap
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: huge })).status).toBe(400)
const staff = await login('staff@test.in', 'staff-password')
expect((await call(staff, 'POST', '/settings/template/logo', { dataUri: tiny })).status).toBe(403)
// clearing is allowed
expect((await call(owner, 'POST', '/settings/template/logo', { dataUri: '' })).status).toBe(200)
expect(getSetting(db, 'template.logo')).toBe('')
})
})
Loading…
Cancel
Save