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:
co-authored by
Claude Opus 5
parent
6620650e9e
commit
4c39926483
@@ -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}` : ''}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -421,4 +421,73 @@ export const en: Record<MessageKey, string> = {
|
||||
"admin.errors.resolve": "Mark resolved",
|
||||
"admin.audit.title": "Audit",
|
||||
"admin.audit.export": "Export CSV",
|
||||
// Admin console (phase 6)
|
||||
"common.loadMore": "Show more",
|
||||
"common.none": "No data",
|
||||
"admin.nav.console": "Console",
|
||||
"admin.forbidden": "This section is for the team only.",
|
||||
"admin.users.searchLabel": "Find an account",
|
||||
"admin.users.searchHint": "Email, RUC, CI or name. Type at least 2 characters.",
|
||||
"admin.users.empty": "No account matches that.",
|
||||
"admin.users.start": "Search for an account to begin.",
|
||||
"admin.users.col.email": "Email",
|
||||
"admin.users.col.name": "Name",
|
||||
"admin.users.col.doc": "RUC or CI",
|
||||
"admin.users.col.role": "Role",
|
||||
"admin.users.col.created": "Joined",
|
||||
"admin.users.counts.documents": "Comprobantes",
|
||||
"admin.users.counts.needsReview": "To review",
|
||||
"admin.users.counts.declarations": "Declarations",
|
||||
"admin.users.lastActivity": "Last activity: {date}",
|
||||
"admin.users.noActivity": "No activity yet",
|
||||
"admin.users.noProfile": "This account has not finished its profile.",
|
||||
"admin.users.openErrors": "Open errors on this account",
|
||||
"admin.users.noErrors": "No open errors.",
|
||||
"admin.users.obligations": "Obligations",
|
||||
"admin.role.title": "Role",
|
||||
"admin.role.change": "Change role",
|
||||
"admin.role.confirm": "You are changing {email} to {role}. This is logged under your name.",
|
||||
"admin.role.saved": "Role updated.",
|
||||
"admin.role.user": "User",
|
||||
"admin.role.accountant": "Accountant",
|
||||
"admin.role.staff": "Staff",
|
||||
"admin.role.superadmin": "Superadmin",
|
||||
"admin.errors.empty": "The queue is empty.",
|
||||
"admin.errors.col.when": "When",
|
||||
"admin.errors.col.stage": "Stage",
|
||||
"admin.errors.col.user": "Account",
|
||||
"admin.errors.col.message": "Message",
|
||||
"admin.errors.col.status": "Status",
|
||||
"admin.errors.filterStage": "Stage",
|
||||
"admin.errors.filterStatus": "Status",
|
||||
"admin.errors.all": "All",
|
||||
"admin.errors.allStatus": "All",
|
||||
"admin.errors.stage.qr_parse": "QR",
|
||||
"admin.errors.stage.ocr": "OCR",
|
||||
"admin.errors.stage.dedupe": "Duplicates",
|
||||
"admin.errors.stage.verify": "Verification",
|
||||
"admin.errors.stage.job": "Job",
|
||||
"admin.errors.stage.other": "Other",
|
||||
"admin.errors.status.open": "Open",
|
||||
"admin.errors.status.resolved": "Resolved",
|
||||
"admin.errors.retry": "Retry the job",
|
||||
"admin.errors.note": "Resolution note",
|
||||
"admin.errors.notePlaceholder": "What happened and what you did",
|
||||
"admin.errors.attempts": "{count} attempts",
|
||||
"admin.errors.goToQueue": "Open the full error queue",
|
||||
"admin.audit.empty": "Nothing matches those filters.",
|
||||
"admin.audit.col.when": "When",
|
||||
"admin.audit.col.actor": "Who",
|
||||
"admin.audit.col.action": "Action",
|
||||
"admin.audit.col.subject": "About whom",
|
||||
"admin.audit.col.resource": "Resource",
|
||||
"admin.audit.col.ip": "IP",
|
||||
"admin.audit.filterActor": "Who (email)",
|
||||
"admin.audit.filterAction": "Action",
|
||||
"admin.audit.filterSubject": "About whom (email)",
|
||||
"admin.audit.filterFrom": "From",
|
||||
"admin.audit.filterTo": "To",
|
||||
"admin.audit.apply": "Filter",
|
||||
"admin.audit.clear": "Clear",
|
||||
"admin.audit.total": "{count} entries",
|
||||
};
|
||||
|
||||
@@ -210,6 +210,76 @@ export const esExtra = {
|
||||
"decl.approvedAt": "Aprobada el {date}",
|
||||
"decl.filedAt": "Presentada el {date}",
|
||||
|
||||
// Admin console (phase 6). Plain and dense: this is a tool, not a product surface.
|
||||
"common.loadMore": "Ver mas",
|
||||
"common.none": "Sin datos",
|
||||
"admin.nav.console": "Consola",
|
||||
"admin.forbidden": "Esta seccion es solo para el equipo.",
|
||||
"admin.users.searchLabel": "Buscar una cuenta",
|
||||
"admin.users.searchHint": "Email, RUC, CI o nombre. Escribí al menos 2 caracteres.",
|
||||
"admin.users.empty": "No encontramos ninguna cuenta con ese dato.",
|
||||
"admin.users.start": "Buscá una cuenta para empezar.",
|
||||
"admin.users.col.email": "Email",
|
||||
"admin.users.col.name": "Nombre",
|
||||
"admin.users.col.doc": "RUC o CI",
|
||||
"admin.users.col.role": "Rol",
|
||||
"admin.users.col.created": "Alta",
|
||||
"admin.users.counts.documents": "Comprobantes",
|
||||
"admin.users.counts.needsReview": "Para revisar",
|
||||
"admin.users.counts.declarations": "Declaraciones",
|
||||
"admin.users.lastActivity": "Ultima actividad: {date}",
|
||||
"admin.users.noActivity": "Sin actividad todavia",
|
||||
"admin.users.noProfile": "Esta cuenta todavia no completo el perfil.",
|
||||
"admin.users.openErrors": "Errores abiertos de esta cuenta",
|
||||
"admin.users.noErrors": "Sin errores abiertos.",
|
||||
"admin.users.obligations": "Obligaciones",
|
||||
"admin.role.title": "Rol",
|
||||
"admin.role.change": "Cambiar rol",
|
||||
"admin.role.confirm": "Vas a cambiar el rol de {email} a {role}. Queda registrado con tu nombre.",
|
||||
"admin.role.saved": "Rol actualizado.",
|
||||
"admin.role.user": "Usuario",
|
||||
"admin.role.accountant": "Contador",
|
||||
"admin.role.staff": "Staff",
|
||||
"admin.role.superadmin": "Superadmin",
|
||||
"admin.errors.empty": "No hay errores en la cola.",
|
||||
"admin.errors.col.when": "Cuando",
|
||||
"admin.errors.col.stage": "Etapa",
|
||||
"admin.errors.col.user": "Cuenta",
|
||||
"admin.errors.col.message": "Mensaje",
|
||||
"admin.errors.col.status": "Estado",
|
||||
"admin.errors.filterStage": "Etapa",
|
||||
"admin.errors.filterStatus": "Estado",
|
||||
"admin.errors.all": "Todas",
|
||||
"admin.errors.allStatus": "Todos",
|
||||
"admin.errors.stage.qr_parse": "QR",
|
||||
"admin.errors.stage.ocr": "OCR",
|
||||
"admin.errors.stage.dedupe": "Duplicados",
|
||||
"admin.errors.stage.verify": "Verificacion",
|
||||
"admin.errors.stage.job": "Job",
|
||||
"admin.errors.stage.other": "Otro",
|
||||
"admin.errors.status.open": "Abierto",
|
||||
"admin.errors.status.resolved": "Resuelto",
|
||||
"admin.errors.retry": "Reintentar el job",
|
||||
"admin.errors.note": "Nota de resolucion",
|
||||
"admin.errors.notePlaceholder": "Que paso y que hiciste",
|
||||
"admin.errors.attempts": "{count} intentos",
|
||||
"admin.errors.goToQueue": "Ver toda la cola de errores",
|
||||
"admin.audit.empty": "Ningun movimiento con esos filtros.",
|
||||
"admin.audit.col.when": "Cuando",
|
||||
"admin.audit.col.actor": "Quien",
|
||||
"admin.audit.col.action": "Accion",
|
||||
"admin.audit.col.subject": "Sobre quien",
|
||||
"admin.audit.col.resource": "Recurso",
|
||||
"admin.audit.col.ip": "IP",
|
||||
"admin.audit.filterActor": "Quien (email)",
|
||||
"admin.audit.filterAction": "Accion",
|
||||
"admin.audit.filterSubject": "Sobre quien (email)",
|
||||
"admin.audit.filterFrom": "Desde",
|
||||
"admin.audit.filterTo": "Hasta",
|
||||
"admin.audit.apply": "Filtrar",
|
||||
"admin.audit.clear": "Limpiar",
|
||||
"admin.audit.total": "{count} movimientos",
|
||||
|
||||
// Chrome
|
||||
"common.language": "Idioma",
|
||||
"common.languageEs": "Español",
|
||||
|
||||
Reference in New Issue
Block a user