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