Files
impuestospy/apps/api/src/http/routes/me.ts
T
MichilisandClaude Opus 5 4c39926483 phase-6: the staff console, and a log that says who did what
Flow H, three screens behind a role check: find an account, work the
ingestion error queue, read and export the audit log. Superadmins can
change a role, never their own.

The error queue merges ingest errors and dead jobs into one table with a
cursor that pages both sources; only a job can be retried and only an
ingest row resolved, with a note that migration 003 gives it somewhere
to live.

writeAudit no longer defaults a missing subject to the actor, which had
been recording a user search as staff looking themselves up. Omitting
the subject still means acting on yourself; null now means the action
has no subject, which is what a search, a retry and an export are.

Reading the log is not audited. Exporting it is: a copy leaving the
building is a different act from looking.

e2e/global-setup.ts asks for every screen once before the suite starts,
so a dev server's first-request compile is paid before the first test
rather than by it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 21:55:59 +00:00

192 lines
6.3 KiB
TypeScript

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<AppEnv> {
const routes = new Hono<AppEnv>();
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<unknown> } }): Promise<unknown> {
try {
return await c.req.json();
} catch {
throw new HttpError('validation_error');
}
}
function parse<T>(schema: z.ZodType<T>, 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<typeof writeAudit>[1]['action']; resource: string; detail?: Record<string, unknown> },
): Promise<void> {
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,
});
}