import { ConsentInput, DeleteAccountInput, DependentInput, NotificationPrefsInput, ProfileInput, UserRole, } from '@impuestos/contracts'; import { Hono } from 'hono'; import type { z } from 'zod'; import { writeAudit } from '../../modules/audit'; import { buildDataExport, createDependent, deactivateDependent, getNotificationPrefs, getProfile, listDependents, setConsent, softDeleteAccount, updateNotificationPrefs, upsertProfile, } from '../../modules/pii'; import type { AppDeps, AppEnv, SessionUser } from '../context'; import { HttpError } from '../errors'; import { requireUser } from '../middleware'; export function meRoutes(deps: AppDeps): Hono { const routes = new Hono(); const db = deps.handle.db; // Who you are, for a client that needs the role before it renders (the admin console). routes.get('/session', (c) => { const user = requireUser(c); // The session carries whatever string the user row holds. A value outside the enum // would fail the client's schema and take a page down, so it reads as the least // privileged role instead. const role = UserRole.safeParse(user.role); return c.json({ id: user.id, email: user.email, role: role.success ? role.data : 'user' }); }); // 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3). routes.get('/profile', async (c) => { const user = requireUser(c); const profile = await getProfile(db, user.id); if (!profile) throw new HttpError('not_found'); return c.json(profile); }); routes.put('/profile', async (c) => { const user = requireUser(c); const input = parse(ProfileInput, await body(c)); if (input.docType === 'ruc' && !input.ruc && !input.ci) { throw new HttpError('validation_error', { field: 'ruc' }); } if (input.docType === 'ci' && !input.ci && !input.ruc) { throw new HttpError('validation_error', { field: 'ci' }); } const { profile, created } = await upsertProfile(db, user.id, input); await audit(c, deps, user, { action: created ? 'profile.create' : 'profile.update', resource: 'profiles', detail: { docType: profile.docType, taxpayerKind: profile.taxpayerKind }, }); return c.json(profile); }); routes.get('/dependents', async (c) => { const user = requireUser(c); return c.json(await listDependents(db, user.id)); }); routes.post('/dependents', async (c) => { const user = requireUser(c); const dependent = await createDependent(db, user.id, parse(DependentInput, await body(c))); await audit(c, deps, user, { action: 'dependent.create', resource: `dependents/${dependent.id}`, }); return c.json(dependent, 201); }); routes.delete('/dependents/:id', async (c) => { const user = requireUser(c); const id = c.req.param('id'); if (!(await deactivateDependent(db, user.id, id))) throw new HttpError('not_found'); await audit(c, deps, user, { action: 'dependent.delete', resource: `dependents/${id}` }); return c.json({ ok: true } as const); }); /** * Revoking data processing consent is a withdrawal of the basis on which we hold the * data at all, so it freezes the account and drops every session, exactly like a * deletion (CONTRACTS.md section 3). */ routes.post('/consents', async (c) => { const user = requireUser(c); const input = parse(ConsentInput, await body(c)); await setConsent(db, user.id, input.kind, input.granted); await audit(c, deps, user, { action: input.granted ? 'consent.grant' : 'consent.revoke', resource: `consents/${input.kind}`, detail: { kind: input.kind }, }); if (input.kind === 'data_processing' && !input.granted) { await softDeleteAccount(db, user.id, 'consent_revoked'); } return c.json({ ok: true } as const); }); routes.get('/notification-prefs', async (c) => { const user = requireUser(c); return c.json(await getNotificationPrefs(db, user.id)); }); routes.patch('/notification-prefs', async (c) => { const user = requireUser(c); const prefs = await updateNotificationPrefs(db, user.id, parse(NotificationPrefsInput, await body(c))); await audit(c, deps, user, { action: 'notification_prefs.update', resource: 'notification_prefs', }); return c.json(prefs); }); routes.get('/data-export', async (c) => { const user = requireUser(c); const data = await buildDataExport(db, user.id); await audit(c, deps, user, { action: 'data.export', resource: 'data-export' }); const filename = `impuestos-datos-${new Date().toISOString().slice(0, 10)}.json`; c.header('content-disposition', `attachment; filename="${filename}"`); return c.json(data); }); routes.delete('/account', async (c) => { const user = requireUser(c); const input = parse(DeleteAccountInput, await body(c)); // Typing the email is the second confirmation. Compared case insensitively because // the keyboard on a phone will capitalise the first letter. if (input.confirmText.trim().toLowerCase() !== user.email.toLowerCase()) { throw new HttpError('validation_error', { field: 'confirmText' }); } await audit(c, deps, user, { action: 'account.delete', resource: 'user' }); await softDeleteAccount(db, user.id, 'account_deleted'); return c.json({ ok: true } as const); }); return routes; } async function body(c: { req: { json: () => Promise } }): Promise { try { return await c.req.json(); } catch { throw new HttpError('validation_error'); } } function parse(schema: z.ZodType, value: unknown): T { const result = schema.safeParse(value); if (!result.success) { const issue = result.error.issues[0]; throw new HttpError('validation_error', { ...(issue?.path.length ? { field: issue.path.join('.') } : {}), detail: result.error.issues, }); } return result.data; } function audit( c: { req: { header: (name: string) => string | undefined } }, deps: AppDeps, user: SessionUser, entry: { action: Parameters[1]['action']; resource: string; detail?: Record }, ): Promise { return writeAudit(deps.handle.db, { actorUserId: user.id, actorRole: user.role, subjectUserId: user.id, ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null, ...entry, }); }