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>
192 lines
6.3 KiB
TypeScript
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,
|
|
});
|
|
}
|