feat(hq): company profile settings — owner-gated GET/PUT, audited

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
feat/client-detail-redesign
Thomas Joise 2 weeks ago
parent b7085ce56e
commit ea8c2b23ad

@ -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<GmailDeps>): Router {
res.json({ ok: true, rows: clientProfitability(db, rangeOf(req)) })
})
// ---------- company profile (owner-editable; PDFs read these live) ----------
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',
}
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<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') 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
}

@ -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)
})
})
Loading…
Cancel
Save