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>
This commit is contained in:
Michilis
2026-09-04 21:55:59 +00:00
co-authored by Claude Opus 5
parent 6620650e9e
commit 4c39926483
39 changed files with 2767 additions and 11 deletions
+53
View File
@@ -1,5 +1,11 @@
import type { z } from 'zod';
import {
AdminAuditListDto,
type AdminAuditQuery,
AdminErrorListDto,
type AdminErrorListQuery,
AdminUserOverviewDto,
AdminUserSearchDto,
type ClassificationPatchInput,
type ConsentInput,
DataExportDto,
@@ -24,6 +30,9 @@ import {
ProfileDto,
type ProfileInput,
type RejectInput,
type RoleChangeInput,
SessionDto,
type ResolveErrorInput,
ScanResultDto,
TraceDto,
type TraceKind,
@@ -112,6 +121,10 @@ export function createApiClient(options: ApiClientOptions = {}) {
...(signal ? { signal } : {}),
}),
// Session
getSession: (signal?: AbortSignal) =>
request('GET', '/me/session', { schema: SessionDto, ...(signal ? { signal } : {}) }),
// Profile
getProfile: (signal?: AbortSignal) =>
request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }),
@@ -223,6 +236,46 @@ export function createApiClient(options: ApiClientOptions = {}) {
}),
patchNotificationPrefs: (body: NotificationPrefsInput) =>
request('PATCH', '/me/notification-prefs', { schema: NotificationPrefsDto, body }),
// Admin. Every one of these writes an audit row on the server.
searchUsers: (q: string, signal?: AbortSignal) =>
request('GET', '/admin/users/search', {
schema: AdminUserSearchDto,
query: { q },
...(signal ? { signal } : {}),
}),
getUserOverview: (id: string, signal?: AbortSignal) =>
request('GET', `/admin/users/${encodeURIComponent(id)}/overview`, {
schema: AdminUserOverviewDto,
...(signal ? { signal } : {}),
}),
setUserRole: (id: string, body: RoleChangeInput) =>
request('POST', `/admin/users/${encodeURIComponent(id)}/role`, { schema: OkDto, body }),
listAdminErrors: (query: AdminErrorListQuery, signal?: AbortSignal) =>
request('GET', '/admin/errors', {
schema: AdminErrorListDto,
query: query as Record<string, string | undefined>,
...(signal ? { signal } : {}),
}),
resolveAdminError: (id: string, body: ResolveErrorInput) =>
request('POST', `/admin/errors/${encodeURIComponent(id)}/resolve`, { schema: OkDto, body }),
retryJob: (id: string) =>
request('POST', `/admin/jobs/${encodeURIComponent(id)}/retry`, { schema: OkDto }),
listAudit: (query: AdminAuditQuery, signal?: AbortSignal) =>
request('GET', '/admin/audit', {
schema: AdminAuditListDto,
query: query as Record<string, string | undefined>,
...(signal ? { signal } : {}),
}),
/** A file download, so it is linked to rather than fetched. */
auditCsvUrl: (query: AdminAuditQuery) => {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value) params.set(key, value);
}
const search = params.toString();
return `/api/admin/audit/export.csv${search ? `?${search}` : ''}`;
},
};
}
+105
View File
@@ -11,6 +11,7 @@ import {
ObligationCode,
SupplierRegimeHint,
TaxpayerKind,
UserRole,
VerificationStatus,
} from './enums';
@@ -376,3 +377,107 @@ export type ReadyDto = z.infer<typeof ReadyDto>;
/** Re-exported so callers get the category vocabulary from one place. */
export { IrpCategory };
/**
* SPEC-GAP: CONTRACTS.md section 3 lists no way to ask who you are signed in as, and the
* admin console has to know a viewer's role before it renders anything. `GET /me/session`
* answers that and nothing else.
*/
export const SessionDto = z.object({
id: z.string(),
email: z.string(),
role: UserRole,
});
export type SessionDto = z.infer<typeof SessionDto>;
/* Admin (CONTRACTS.md section 3, FLOWS.md Flow H). Staff and superadmin only. */
export const AdminUserDto = z.object({
id: z.string(),
email: z.string(),
fullName: z.string(),
/** The RUC with its check digit, or the CI, whichever the profile carries. */
doc: z.string().nullable(),
role: UserRole,
createdAt: z.string(),
});
export type AdminUserDto = z.infer<typeof AdminUserDto>;
export const AdminUserSearchDto = z.object({ items: z.array(AdminUserDto) });
export type AdminUserSearchDto = z.infer<typeof AdminUserSearchDto>;
export const AdminUserOverviewDto = z.object({
user: AdminUserDto,
/**
* SPEC-GAP: CONTRACTS.md types this as ProfileDto, but an account that never finished
* onboarding has no profile row, and staff need to be able to see exactly that.
*/
profile: ProfileDto.nullable(),
counts: z.object({
documents: z.number().int(),
needsReview: z.number().int(),
declarations: z.number().int(),
}),
recentErrors: z.array(IngestErrorDto),
lastActivityAt: z.string().nullable(),
});
export type AdminUserOverviewDto = z.infer<typeof AdminUserOverviewDto>;
/**
* One row of the error queue. Ingest errors and dead jobs share the table, so the row
* carries which one it is: only a `job` row can be retried, only an `ingest` row resolved.
*/
export const AdminErrorDto = IngestErrorDto.extend({
source: z.enum(['ingest', 'job']),
/** Shown when the row is expanded. Null when nothing was captured. */
payload: z.record(z.string(), z.unknown()).nullable(),
userEmail: z.string().nullable(),
attempts: z.number().int().nullable(),
resolvedAt: z.string().nullable(),
});
export type AdminErrorDto = z.infer<typeof AdminErrorDto>;
export const AdminErrorListQuery = z.object({
stage: z.enum(['qr_parse', 'ocr', 'dedupe', 'verify', 'job', 'other']).optional(),
status: z.enum(['open', 'resolved']).optional(),
cursor: z.string().optional(),
});
export type AdminErrorListQuery = z.infer<typeof AdminErrorListQuery>;
export const AdminErrorListDto = listDto(AdminErrorDto);
export type AdminErrorListDto = z.infer<typeof AdminErrorListDto>;
export const ResolveErrorInput = z.object({ note: z.string().trim().min(1).max(500) });
export type ResolveErrorInput = z.infer<typeof ResolveErrorInput>;
export const AdminAuditDto = z.object({
id: z.string(),
actorUserId: z.string(),
actorEmail: z.string().nullable(),
actorRole: z.string(),
action: z.string(),
subjectUserId: z.string().nullable(),
subjectEmail: z.string().nullable(),
resource: z.string(),
detail: z.record(z.string(), z.unknown()).nullable(),
ip: z.string().nullable(),
createdAt: z.string(),
});
export type AdminAuditDto = z.infer<typeof AdminAuditDto>;
export const AdminAuditQuery = z.object({
actor: z.string().optional(),
action: z.string().optional(),
subject: z.string().optional(),
/** Inclusive YYYY-MM-DD bounds on the local calendar day the row was written. */
from: z.string().optional(),
to: z.string().optional(),
cursor: z.string().optional(),
});
export type AdminAuditQuery = z.infer<typeof AdminAuditQuery>;
export const AdminAuditListDto = listDto(AdminAuditDto);
export type AdminAuditListDto = z.infer<typeof AdminAuditListDto>;
export const RoleChangeInput = z.object({ role: UserRole });
export type RoleChangeInput = z.infer<typeof RoleChangeInput>;
+12
View File
@@ -57,6 +57,18 @@ export {
OkDto,
HealthDto,
ReadyDto,
SessionDto,
AdminUserDto,
AdminUserSearchDto,
AdminUserOverviewDto,
AdminErrorDto,
AdminErrorListQuery,
AdminErrorListDto,
ResolveErrorInput,
AdminAuditDto,
AdminAuditQuery,
AdminAuditListDto,
RoleChangeInput,
} from './dto';
export { createApiClient, type ApiClient, type ApiClientOptions } from './client';