diff --git a/apps/hq/src/api.ts b/apps/hq/src/api.ts index 64117c1..3a001bd 100644 --- a/apps/hq/src/api.ts +++ b/apps/hq/src/api.ts @@ -1,4 +1,5 @@ import { Router, type Request, type RequestHandler, type Response } from 'express' +import { validateGstin } from '@sims/domain' import { login, requireAuth, requireOwner } from './auth' import type { DB } from './db' import { @@ -32,7 +33,7 @@ import { listInteractionTypes, updateInteraction, type CreateInteractionInput, type InteractionPatch, } from './repos-interactions' import { - dismissReminder, getReminder, listQueue, listReminders, + dismissReminder, getReminder, listQueue, listReminders, setSetting, type ReminderStatus, } from './repos-reminders' import { sendReminder, type SendReminderDeps } from './send-reminder' @@ -529,5 +530,35 @@ export function apiRouter(db: DB, gmailDeps?: Partial): Router { res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) }) }) + // ---------- company profile (owner-editable; PDFs read these live) ---------- + const COMPANY_FIELDS: Record = { + name: 'company.name', address: 'company.address', gstin: 'company.gstin', + stateCode: 'company.state_code', phone: 'company.phone', email: 'company.email', bank: 'company.bank', + } + r.get('/settings/company', requireAuth, (_req, res) => { + res.json({ ok: true, company: companySettings() }) + }) + r.put('/settings/company', requireAuth, requireOwner, (req, res) => { + const body = req.body as Record + try { + const gstin = body['gstin'] + if (typeof gstin === 'string' && gstin !== '') { + const v = validateGstin(gstin) + if (!v.ok) throw new Error(`Invalid GSTIN (${v.reason ?? 'invalid'})`) + } + const stateCode = body['stateCode'] + if (typeof stateCode === 'string' && !/^\d{2}$/.test(stateCode)) { + throw new Error('State code must be two digits') + } + for (const [field, key] of Object.entries(COMPANY_FIELDS)) { + const val = body[field] + if (typeof val === 'string') setSetting(db, staffId(res), key, val) + } + res.json({ ok: true, company: companySettings() }) + } catch (err) { + res.status(400).json({ ok: false, error: err instanceof Error ? err.message : String(err) }) + } + }) + return r } diff --git a/apps/hq/test/settings-company.test.ts b/apps/hq/test/settings-company.test.ts new file mode 100644 index 0000000..d6b6836 --- /dev/null +++ b/apps/hq/test/settings-company.test.ts @@ -0,0 +1,46 @@ +// apps/hq/test/settings-company.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 { 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()); 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('company profile 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 reads and updates company.* and it is audited', async () => { + const token = await login('owner@test.in', 'owner-password') + const before = await call(token, 'GET', '/settings/company') + expect(before.json.company['company.name']).toBe('Tecnostac') + const put = await call(token, 'PUT', '/settings/company', { name: 'Tecnostac Pvt Ltd', phone: '0484-1234567', stateCode: '32' }) + expect(put.status).toBe(200) + expect(put.json.company['company.name']).toBe('Tecnostac Pvt Ltd') + expect(put.json.company['company.phone']).toBe('0484-1234567') + const audits = db.prepare(`SELECT COUNT(*) AS n FROM audit_log WHERE entity='setting' AND entity_id LIKE 'company.%'`).get() as { n: number } + expect(audits.n).toBeGreaterThanOrEqual(3) + }) + 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/company', { name: 'Nope' })).status).toBe(403) + const owner = await login('owner@test.in', 'owner-password') + expect((await call(owner, 'PUT', '/settings/company', { gstin: 'NOTAGSTIN' })).status).toBe(400) + }) +})