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
+3 -2
View File
@@ -10,8 +10,9 @@ apps/*/data/
# Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build. # Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build.
apps/web/public/zxing/ apps/web/public/zxing/
data/ data/
# Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build.
apps/web/public/zxing/
test-results/ test-results/
playwright-report/ playwright-report/
.DS_Store .DS_Store
# Written by `next dev` on every run. Tool output, not part of the product.
apps/web/AGENTS.md
apps/web/CLAUDE.md
+85
View File
@@ -536,3 +536,88 @@ tab, since every one of them is a place the user needs to reach.
- **Payment reminders after filing.** FLOWS.md D3 mentions scheduling one on "Ya lo - **Payment reminders after filing.** FLOWS.md D3 mentions scheduling one on "Ya lo
presente". The deadline sweep already covers T-2 and T-0 for the period; a separate presente". The deadline sweep already covers T-2 and T-0 for the period; a separate
payment date is not in RULES.md and was not invented. payment date is not in RULES.md and was not invented.
## Phase 6
### The console is a separate route group with its own shell
FLOWS.md Flow H asks for plain and dense, desktop first. `(admin)` gets a wide layout, a
text nav and no tab bar, no floating scan button and no playfulness. The audit reminder
sits above every screen in the group rather than only the user search: staff reading the
error queue are reading user data too.
### The role is checked in the layout and again in every handler
The layout calls `GET /me/session` and renders "solo para el equipo" for anyone else, which
is a convenience. Every admin handler calls `requireRole` itself, which is the rule. A
plain user who guesses the URL gets a plain page from the layout and a 403 from the API.
### SPEC-GAP: GET /me/session
CONTRACTS.md section 3 has no way to ask who you are signed in as, and a console that must
know a viewer's role before it renders needs one. It returns id, email and role, and
nothing else.
### A search has no subject, and the log now says so
`writeAudit` used to default a missing `subjectUserId` to the actor, which made a user
search read as staff looking themselves up. `undefined` still means "acting on yourself";
`null` now means "no subject", which is what a search, a retry, an error resolution and an
export are. The distinction is a fact about what happened, so the log keeps it.
### Reading the audit log is not audited, exporting it is
A row for every scroll of the audit screen would bury the accesses that matter under the
act of looking for them. Taking a copy out of the building is a different act, so
`GET /admin/audit/export.csv` writes `admin.audit_export` with the filters that produced it.
### The error queue merges two sources into one table
Ingest errors and dead jobs are one queue with a `source` on each row, filtered and paged
together by a `createdAt|id` cursor applied to both sides before the merge. Only a job row
can be retried and only an ingest row can be resolved, and a dead job never appears under
the resolved filter: retrying it takes it out of the queue instead. A manual retry resets
`attempts` to zero, so the retry gets the whole backoff schedule rather than dying on its
first stumble.
### SPEC-GAP: ingest_errors.resolution_note
Flow H resolves an error "with note" and SPEC.md section 5 gives the row nowhere to put
one. Migration 003 adds a column, because the note is what the next person reads, not
another key inside the payload the failure wrote.
### Role changes are superadmin only and never on yourself
The one thing worse than an account with too much power is the last superadmin demoting
themselves out of the console. Setting the role a user already has is a 409 rather than a
silent success. No session shuffling is needed: `getSession` reads the role off the user
row on every request, so a demotion takes effect on the demoted user's next call.
### The audit table shows the action code, not a translated phrase
It is the same token the filter takes and the same one in the CSV. An operator matching a
log wants to see what they can search for, and fourteen action names in two languages would
be copy that has to stay in step with an enum.
### Timestamps in the console are ISO, not prose
`2026-09-04 20:42` rather than "4 de septiembre". A console sorts, compares and copies
timestamps; the year is part of the fact and the format has to be unambiguous. Everywhere
the user sees a date, it is still formatted for the reader.
### CONTRACTS.md 5.5 is pinned against the API, not the browser
"Exactly one `admin.user_lookup` row per overview" is asserted in `admin.test.ts`, where it
is exact. It cannot be asserted through Playwright: React strict mode mounts a client
component twice in development, so the browser asks for the overview twice and writes two
rows. The e2e asserts the flow writes a lookup that then shows up on the audit screen.
### Seed: one dead job
CONTRACTS.md section 4 asks for two open ingest errors, which `seedDocuments` has written
since phase 3. The dead job is an addition: the queue merges two sources and the retry
action has nothing to act on without one. It also cost a test its assumption that the jobs
table starts empty, which was an assumption worth removing anyway.
### Two defects the screenshots caught
**The user summary read as the wrong pairs.** Label and value side by side across a wide
card put each value next to the following pair's label, so "4123456-1" and "Rol" read as
one field. Label above value fixed it.
**A search recorded itself against the searcher.** See above: visible only once real rows
were on screen next to each other.
### Deferred, deliberately
- **Impersonation and account freezing.** Neither is in SPEC.md or FLOWS.md. better-auth's
admin plugin ships both; leaving them off is the smaller surface.
- **Filtering the error queue by account.** The overview lists a user's own open errors and
links to the queue. CONTRACTS.md gives `/admin/errors` a stage and status filter and no
user filter, and one was not invented.
+18 -2
View File
@@ -7,9 +7,10 @@ and ready to file yourself.
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
way and the gaps that still need answers. way and the gaps that still need answers.
> **Status: phase 5 of 8.** The whole taxpayer path works: scan a comprobante, confirm it, > **Status: phase 6 of 8.** The whole taxpayer path works: scan a comprobante, confirm it,
> watch the position move, and take the resulting Formulario 120 or 515 from review to > watch the position move, and take the resulting Formulario 120 or 515 from review to
> approved to a PDF you file yourself in Marangatu. The admin area, the PWA and the > approved to a PDF you file yourself in Marangatu. Staff have a console: look an account
> up, work the ingestion error queue, read and export the audit log. The PWA polish and the
> scale-out work are still ahead. > scale-out work are still ahead.
--- ---
@@ -28,6 +29,11 @@ pnpm dev
The web app is on http://localhost:3005, the API on http://localhost:4000. `db:seed` The web app is on http://localhost:3005, the API on http://localhost:4000. `db:seed`
prints the development sign in details for the four demo accounts. prints the development sign in details for the four demo accounts.
Two of those accounts reach the staff console at `/es/usuarios`, `/es/errores` and
`/es/auditoria`: `staff@demo.local` can search accounts, work the error queue and read the
audit log, and `superadmin@demo.local` can also change roles. Everyone else gets a plain
"team only" page and a 403 from the API.
The web port lives in `apps/web/.env` and `apps/api/.env` has to name the same one in The web port lives in `apps/web/.env` and `apps/api/.env` has to name the same one in
`APP_PUBLIC_URL` and `BETTER_AUTH_URL`. Auth checks the request Origin, so a mismatch `APP_PUBLIC_URL` and `BETTER_AUTH_URL`. Auth checks the request Origin, so a mismatch
fails sign in with a 403 that looks nothing like a port problem. `localhost` and fails sign in with a 403 that looks nothing like a port problem. `localhost` and
@@ -183,3 +189,13 @@ Then start the stack and run it:
```bash ```bash
E2E_BASE_URL=http://localhost:3005 pnpm test:e2e E2E_BASE_URL=http://localhost:3005 pnpm test:e2e
``` ```
A dev server compiles each route the first time it is asked for, which is slow enough to
push a sign in past the five seconds Playwright waits on a navigation. `e2e/global-setup.ts`
asks for every screen once before the suite starts, so the cost is paid before the first
test rather than by it. Against a heavily loaded dev server, one worker is still steadier
than two:
```bash
E2E_BASE_URL=http://localhost:3005 npx playwright test --workers=1
```
@@ -0,0 +1,15 @@
import type { Kysely } from 'kysely';
/**
* SPEC-GAP: FLOWS.md Flow H has staff resolve an error "with note" and SPEC.md section 5
* gives `ingest_errors` nowhere to put it. The note is what the next person reads to find
* out what happened, so it is a column of its own rather than another key inside the
* payload blob the failure wrote.
*/
export async function up(db: Kysely<unknown>): Promise<void> {
await db.schema.alterTable('ingest_errors').addColumn('resolution_note', 'text').execute();
}
export async function down(db: Kysely<unknown>): Promise<void> {
await db.schema.alterTable('ingest_errors').dropColumn('resolution_note').execute();
}
+2
View File
@@ -1,6 +1,7 @@
import type { Migration, MigrationProvider } from 'kysely/migration'; import type { Migration, MigrationProvider } from 'kysely/migration';
import * as core from './001_core'; import * as core from './001_core';
import * as insightDismissals from './002_insight_dismissals'; import * as insightDismissals from './002_insight_dismissals';
import * as errorResolutionNote from './003_error_resolution_note';
/** /**
* Migrations are listed statically rather than read from disk: the production image * Migrations are listed statically rather than read from disk: the production image
@@ -9,6 +10,7 @@ import * as insightDismissals from './002_insight_dismissals';
const migrations: Record<string, Migration> = { const migrations: Record<string, Migration> = {
'001_core': core, '001_core': core,
'002_insight_dismissals': insightDismissals, '002_insight_dismissals': insightDismissals,
'003_error_resolution_note': errorResolutionNote,
}; };
export const migrationProvider: MigrationProvider = { export const migrationProvider: MigrationProvider = {
+2
View File
@@ -228,6 +228,8 @@ export interface IngestErrorsTable {
status: 'open' | 'resolved'; status: 'open' | 'resolved';
resolved_by: string | null; resolved_by: string | null;
resolved_at: string | null; resolved_at: string | null;
/** What the staff member who closed the row said about it. */
resolution_note: string | null;
created_at: string; created_at: string;
} }
+37
View File
@@ -80,6 +80,7 @@ export async function seed(
await seedDocuments(handle, options.now ?? new Date()); await seedDocuments(handle, options.now ?? new Date());
await seedDeclarations(handle, options.now ?? new Date()); await seedDeclarations(handle, options.now ?? new Date());
await seedAuditTrail(handle); await seedAuditTrail(handle);
await seedDeadJob(handle, options.now ?? new Date());
return result; return result;
} }
@@ -248,6 +249,42 @@ async function upsertProfileRow(handle: DbHandle, seed: ProfileSeed): Promise<vo
.execute(); .execute();
} }
/**
* One dead job, so the error queue has something from both of its sources and the retry
* action has something to act on. The two open ingest errors CONTRACTS.md section 4 asks
* for are written by seedDocuments, next to the documents they failed on.
*/
async function seedDeadJob(handle: DbHandle, now: Date): Promise<void> {
const db = handle.db;
const existing = await db
.selectFrom('jobs')
.select('id')
.where('status', '=', 'dead')
.executeTakeFirst();
if (existing) return;
const maria = await userIdFor(handle, 'maria@demo.local');
const deadAt = new Date(now.getTime() - 48 * 60 * 60 * 1000).toISOString();
await db
.insertInto('jobs')
.values({
id: uuidv7(),
type: 'verify_cdc',
payload: JSON.stringify({ userId: maria, cdc: '01801234567001001000000012024011512345678901' }),
status: 'dead',
run_at: deadAt,
attempts: 5,
max_attempts: 5,
locked_by: null,
locked_at: null,
last_error: 'dnit lookup timed out',
created_at: deadAt,
updated_at: deadAt,
})
.execute();
}
async function userIdFor(handle: DbHandle, email: string): Promise<string> { async function userIdFor(handle: DbHandle, email: string): Promise<string> {
const row = await handle.db const row = await handle.db
.selectFrom('user') .selectFrom('user')
+2
View File
@@ -3,6 +3,7 @@ import type { AppDeps, AppEnv } from './context';
import { HttpError, toEnvelope } from './errors'; import { HttpError, toEnvelope } from './errors';
import { liveness, readiness } from './health'; import { liveness, readiness } from './health';
import { localeMiddleware, sessionMiddleware } from './middleware'; import { localeMiddleware, sessionMiddleware } from './middleware';
import { adminRoutes } from './routes/admin';
import { dashboardRoutes, deadlineRoutes } from './routes/dashboard'; import { dashboardRoutes, deadlineRoutes } from './routes/dashboard';
import { declarationRoutes } from './routes/declarations'; import { declarationRoutes } from './routes/declarations';
import { documentRoutes } from './routes/documents'; import { documentRoutes } from './routes/documents';
@@ -57,6 +58,7 @@ export function createApp(deps: AppDeps): AppHandle {
api.route('/dashboard', dashboardRoutes(deps)); api.route('/dashboard', dashboardRoutes(deps));
api.route('/deadlines', deadlineRoutes(deps)); api.route('/deadlines', deadlineRoutes(deps));
api.route('/declarations', declarationRoutes(deps)); api.route('/declarations', declarationRoutes(deps));
api.route('/admin', adminRoutes(deps));
app.route('/api', api); app.route('/api', api);
+224
View File
@@ -0,0 +1,224 @@
import {
AdminAuditQuery,
AdminErrorListQuery,
ResolveErrorInput,
RoleChangeInput,
} from '@impuestos/contracts';
import { Hono } from 'hono';
import type { z } from 'zod';
import { ADMIN_ROLES } from '../../auth/options';
import {
auditRowsForExport,
changeRole,
listAdminErrors,
listAudit,
resolveIngestError,
retryJob,
searchUsers,
toCsv,
userOverview,
} from '../../modules/admin';
import { type AuditAction, writeAudit } from '../../modules/audit';
import type { AppDeps, AppEnv, SessionUser } from '../context';
import { HttpError } from '../errors';
import { requireRole } from '../middleware';
const SUPERADMIN_ONLY = ['superadmin'] as const;
/**
* FLOWS.md Flow H. Two rules hold for every handler here: the role is checked in the
* handler and not only at the router, and anything that reads or changes a user's data
* writes an audit row before it answers.
*/
export function adminRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>();
const db = deps.handle.db;
routes.get('/users/search', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const term = c.req.query('q') ?? '';
const items = await searchUsers(db, term);
await audit(c, deps, staff, {
action: 'admin.user_search',
subjectUserId: null,
resource: 'user',
detail: { q: term, results: items.length },
});
return c.json({ items });
});
routes.get('/users/:id/overview', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const id = c.req.param('id');
const overview = await userOverview(db, id);
if (!overview) throw new HttpError('not_found');
// CONTRACTS.md section 5.5 pins this: one view, exactly one `admin.user_lookup` row.
await audit(c, deps, staff, {
action: 'admin.user_lookup',
subjectUserId: id,
resource: `user/${id}`,
});
return c.json(overview);
});
routes.post('/users/:id/role', async (c) => {
const actor = requireRole(c, SUPERADMIN_ONLY);
const id = c.req.param('id');
const input = parse(RoleChangeInput, await body(c));
const result = await changeRole(db, {
actorUserId: actor.id,
targetUserId: id,
role: input.role,
});
if (!result.ok) {
if (result.reason === 'not_found') throw new HttpError('not_found');
throw new HttpError('conflict', { field: 'role', detail: { reason: result.reason } });
}
await audit(c, deps, actor, {
action: 'admin.role_change',
subjectUserId: id,
resource: `user/${id}`,
detail: { from: result.previous, to: input.role },
});
return c.json({ ok: true } as const);
});
routes.get('/errors', async (c) => {
requireRole(c, ADMIN_ROLES);
const query = parse(AdminErrorListQuery, {
stage: c.req.query('stage'),
status: c.req.query('status'),
cursor: c.req.query('cursor'),
});
return c.json(await listAdminErrors(db, query));
});
routes.post('/errors/:id/resolve', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const id = c.req.param('id');
const input = parse(ResolveErrorInput, await body(c));
const result = await resolveIngestError(db, id, { userId: staff.id, note: input.note });
if (!result.ok) {
if (result.reason === 'not_found') throw new HttpError('not_found');
throw new HttpError('conflict', { detail: { reason: result.reason } });
}
await audit(c, deps, staff, {
action: 'admin.error_resolve',
subjectUserId: null,
resource: `ingest_errors/${id}`,
detail: { note: input.note },
});
return c.json({ ok: true } as const);
});
routes.post('/jobs/:id/retry', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const id = c.req.param('id');
const result = await retryJob(db, id);
if (!result.ok) {
if (result.reason === 'not_found') throw new HttpError('not_found');
throw new HttpError('conflict', { detail: { reason: result.reason } });
}
await audit(c, deps, staff, {
action: 'admin.job_retry',
subjectUserId: null,
resource: `jobs/${id}`,
});
return c.json({ ok: true } as const);
});
/**
* Reading the log is not itself audited. A row for every scroll of the audit screen
* would bury the accesses that matter under the act of looking for them. Taking a copy
* out of the building is a different thing, so the CSV export below is audited.
*/
routes.get('/audit', async (c) => {
requireRole(c, ADMIN_ROLES);
return c.json(await listAudit(db, auditQuery(c)));
});
routes.get('/audit/export.csv', async (c) => {
const staff = requireRole(c, ADMIN_ROLES);
const query = auditQuery(c);
const rows = await auditRowsForExport(db, query);
await audit(c, deps, staff, {
action: 'admin.audit_export',
subjectUserId: null,
resource: 'audit_log',
detail: { rows: rows.length, ...query },
});
return new Response(toCsv(rows), {
headers: {
'content-type': 'text/csv; charset=utf-8',
'content-disposition': `attachment; filename="audit-${new Date().toISOString().slice(0, 10)}.csv"`,
},
});
});
return routes;
}
function auditQuery(c: { req: { query: (name: string) => string | undefined } }): AdminAuditQuery {
return parse(AdminAuditQuery, {
actor: c.req.query('actor'),
action: c.req.query('action'),
subject: c.req.query('subject'),
from: c.req.query('from'),
to: c.req.query('to'),
cursor: c.req.query('cursor'),
});
}
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,
actor: SessionUser,
entry: {
action: AuditAction;
subjectUserId: string | null;
resource: string;
detail?: Record<string, unknown>;
},
): Promise<void> {
return writeAudit(deps.handle.db, {
actorUserId: actor.id,
actorRole: actor.role,
ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null,
...entry,
});
}
+11
View File
@@ -4,6 +4,7 @@ import {
DependentInput, DependentInput,
NotificationPrefsInput, NotificationPrefsInput,
ProfileInput, ProfileInput,
UserRole,
} from '@impuestos/contracts'; } from '@impuestos/contracts';
import { Hono } from 'hono'; import { Hono } from 'hono';
import type { z } from 'zod'; import type { z } from 'zod';
@@ -28,6 +29,16 @@ export function meRoutes(deps: AppDeps): Hono<AppEnv> {
const routes = new Hono<AppEnv>(); const routes = new Hono<AppEnv>();
const db = deps.handle.db; 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). // 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3).
routes.get('/profile', async (c) => { routes.get('/profile', async (c) => {
const user = requireUser(c); const user = requireUser(c);
+348
View File
@@ -0,0 +1,348 @@
import {
AdminAuditListDto,
AdminErrorListDto,
AdminUserOverviewDto,
AdminUserSearchDto,
} from '@impuestos/contracts';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { createHarness, type Harness } from '../../test/harness';
let h: Harness;
let staff: string;
let superadmin: string;
let maria: string;
let mariaId: string;
beforeAll(async () => {
h = await createHarness();
staff = await h.signIn('staff@demo.local', 'demo-staff-1');
superadmin = await h.signIn('superadmin@demo.local', 'demo-superadmin-1');
maria = await h.signIn('maria@demo.local', 'demo-maria-1');
const row = await h.deps.handle.db
.selectFrom('user')
.select('id')
.where('email', '=', 'maria@demo.local')
.executeTakeFirstOrThrow();
mariaId = row.id;
});
afterAll(async () => {
await h.close();
});
const as = (cookie: string) => (path: string, init: RequestInit = {}) =>
h.app.request(path, {
...init,
headers: { cookie, 'content-type': 'application/json', ...(init.headers ?? {}) },
});
describe('who may reach the console', () => {
it('turns away an anonymous request', async () => {
expect((await h.app.request('/api/admin/users/search?q=maria')).status).toBe(401);
});
it('turns away a signed in user', async () => {
expect((await as(maria)('/api/admin/users/search?q=maria')).status).toBe(403);
expect((await as(maria)(`/api/admin/users/${mariaId}/overview`)).status).toBe(403);
expect((await as(maria)('/api/admin/errors')).status).toBe(403);
expect((await as(maria)('/api/admin/audit')).status).toBe(403);
});
});
describe('GET /admin/users/search', () => {
it('finds a user by email, by name and by document number', async () => {
for (const term of ['maria@demo', 'gonzalez', '4123456']) {
const response = await as(staff)(`/api/admin/users/search?q=${encodeURIComponent(term)}`);
expect(response.status, term).toBe(200);
const { items } = AdminUserSearchDto.parse(await response.json());
expect(items.map((item) => item.email), term).toContain('maria@demo.local');
}
});
it('shows the RUC with its check digit and the role', async () => {
const response = await as(staff)('/api/admin/users/search?q=maria@demo.local');
const { items } = AdminUserSearchDto.parse(await response.json());
expect(items).toHaveLength(1);
expect(items[0]?.doc).toBe('4123456-1');
expect(items[0]?.role).toBe('user');
expect(items[0]?.fullName).toBe('Maria Gonzalez');
});
it('returns nothing for a term too short to be a search', async () => {
const response = await as(staff)('/api/admin/users/search?q=a');
const { items } = AdminUserSearchDto.parse(await response.json());
expect(items).toEqual([]);
});
});
describe('GET /admin/users/:id/overview', () => {
it('counts what the account holds', async () => {
const response = await as(staff)(`/api/admin/users/${mariaId}/overview`);
expect(response.status).toBe(200);
const overview = AdminUserOverviewDto.parse(await response.json());
expect(overview.user.email).toBe('maria@demo.local');
expect(overview.profile?.fullName).toBe('Maria Gonzalez');
expect(overview.counts.documents).toBeGreaterThan(0);
expect(overview.counts.needsReview).toBeGreaterThan(0);
expect(overview.counts.declarations).toBeGreaterThan(0);
// The two seeded ingest errors belong to her.
expect(overview.recentErrors.length).toBeGreaterThanOrEqual(2);
expect(overview.lastActivityAt).not.toBeNull();
});
it('is a 404 for an id nobody has', async () => {
expect((await as(staff)('/api/admin/users/nope/overview')).status).toBe(404);
});
/** CONTRACTS.md section 5.5. */
it('writes exactly one admin.user_lookup row, visible on the audit endpoint', async () => {
const before = await auditCount('admin.user_lookup');
expect((await as(staff)(`/api/admin/users/${mariaId}/overview`)).status).toBe(200);
const response = await as(staff)('/api/admin/audit?action=admin.user_lookup');
const list = AdminAuditListDto.parse(await response.json());
expect(list.total).toBe(before + 1);
const newest = list.items[0];
expect(newest?.actorEmail).toBe('staff@demo.local');
expect(newest?.actorRole).toBe('staff');
expect(newest?.subjectEmail).toBe('maria@demo.local');
expect(newest?.resource).toBe(`user/${mariaId}`);
});
it('records a search separately from a view', async () => {
await as(staff)('/api/admin/users/search?q=maria');
const response = await as(staff)('/api/admin/audit?action=admin.user_search');
const list = AdminAuditListDto.parse(await response.json());
expect(list.total).toBeGreaterThan(0);
expect(list.items[0]?.detail?.['q']).toBe('maria');
// A search is about nobody in particular. Recording the searcher as its own subject
// would make the log say staff looked themselves up.
expect(list.items[0]?.subjectUserId).toBeNull();
});
});
describe('GET /admin/errors', () => {
it('merges ingest errors and dead jobs into one queue', async () => {
const response = await as(staff)('/api/admin/errors');
expect(response.status).toBe(200);
const list = AdminErrorListDto.parse(await response.json());
const stages = list.items.map((item) => item.stage);
expect(stages).toContain('ocr');
expect(stages).toContain('qr_parse');
expect(stages).toContain('job');
// The row expands to show what the failure captured.
const ocr = list.items.find((item) => item.stage === 'ocr');
expect(ocr?.payload?.['seeded']).toBe(true);
expect(ocr?.userEmail).toBe('maria@demo.local');
const job = list.items.find((item) => item.source === 'job');
expect(job?.attempts).toBe(5);
expect(job?.message).toContain('dnit lookup timed out');
});
it('filters by stage', async () => {
const response = await as(staff)('/api/admin/errors?stage=job');
const list = AdminErrorListDto.parse(await response.json());
expect(list.items.length).toBeGreaterThan(0);
expect(list.items.every((item) => item.source === 'job')).toBe(true);
});
it('rejects a stage that is not one of ours', async () => {
expect((await as(staff)('/api/admin/errors?stage=whatever')).status).toBe(400);
});
});
describe('POST /admin/errors/:id/resolve', () => {
it('closes the row with a note, once', async () => {
const list = AdminErrorListDto.parse(
await (await as(staff)('/api/admin/errors?stage=qr_parse&status=open')).json(),
);
const target = list.items[0];
expect(target).toBeDefined();
const response = await as(staff)(`/api/admin/errors/${target?.id}/resolve`, {
method: 'POST',
body: JSON.stringify({ note: 'supplier reissued the comprobante' }),
});
expect(response.status).toBe(200);
const row = await h.deps.handle.db
.selectFrom('ingest_errors')
.selectAll()
.where('id', '=', target?.id ?? '')
.executeTakeFirstOrThrow();
expect(row.status).toBe('resolved');
expect(row.resolution_note).toBe('supplier reissued the comprobante');
expect(row.resolved_by).not.toBeNull();
// Resolving it again is a conflict, not a silent no-op.
const again = await as(staff)(`/api/admin/errors/${target?.id}/resolve`, {
method: 'POST',
body: JSON.stringify({ note: 'again' }),
});
expect(again.status).toBe(409);
expect(await auditCount('admin.error_resolve')).toBe(1);
});
it('needs a note', async () => {
const response = await as(staff)('/api/admin/errors/whatever/resolve', {
method: 'POST',
body: JSON.stringify({ note: '' }),
});
expect(response.status).toBe(400);
});
});
describe('POST /admin/jobs/:id/retry', () => {
it('puts a dead job back in the queue and out of the error list', async () => {
const list = AdminErrorListDto.parse(
await (await as(staff)('/api/admin/errors?stage=job')).json(),
);
const job = list.items[0];
expect(job).toBeDefined();
const response = await as(staff)(`/api/admin/jobs/${job?.id}/retry`, { method: 'POST' });
expect(response.status).toBe(200);
const row = await h.deps.handle.db
.selectFrom('jobs')
.selectAll()
.where('id', '=', job?.id ?? '')
.executeTakeFirstOrThrow();
expect(row.status).toBe('pending');
// The full backoff schedule again, rather than dying on the first stumble.
expect(row.attempts).toBe(0);
const after = AdminErrorListDto.parse(
await (await as(staff)('/api/admin/errors?stage=job')).json(),
);
expect(after.items).toHaveLength(0);
expect(await auditCount('admin.job_retry')).toBe(1);
// Retrying something that is not dead is a conflict.
const again = await as(staff)(`/api/admin/jobs/${job?.id}/retry`, { method: 'POST' });
expect(again.status).toBe(409);
});
});
describe('POST /admin/users/:id/role', () => {
it('is refused to staff', async () => {
const response = await as(staff)(`/api/admin/users/${mariaId}/role`, {
method: 'POST',
body: JSON.stringify({ role: 'accountant' }),
});
expect(response.status).toBe(403);
});
it('refuses a superadmin changing their own role', async () => {
const self = await h.deps.handle.db
.selectFrom('user')
.select('id')
.where('email', '=', 'superadmin@demo.local')
.executeTakeFirstOrThrow();
const response = await as(superadmin)(`/api/admin/users/${self.id}/role`, {
method: 'POST',
body: JSON.stringify({ role: 'user' }),
});
expect(response.status).toBe(409);
});
it('promotes a user and records who did it', async () => {
const carlos = await h.deps.handle.db
.selectFrom('user')
.select('id')
.where('email', '=', 'carlos@demo.local')
.executeTakeFirstOrThrow();
const response = await as(superadmin)(`/api/admin/users/${carlos.id}/role`, {
method: 'POST',
body: JSON.stringify({ role: 'accountant' }),
});
expect(response.status).toBe(200);
const audit = AdminAuditListDto.parse(
await (await as(superadmin)('/api/admin/audit?action=admin.role_change')).json(),
);
const newest = audit.items[0];
expect(newest?.actorEmail).toBe('superadmin@demo.local');
expect(newest?.subjectEmail).toBe('carlos@demo.local');
expect(newest?.detail).toEqual({ from: 'user', to: 'accountant' });
// Setting the role it already has changes nothing and says so.
const again = await as(superadmin)(`/api/admin/users/${carlos.id}/role`, {
method: 'POST',
body: JSON.stringify({ role: 'accountant' }),
});
expect(again.status).toBe(409);
});
});
describe('the audit view', () => {
it('filters by actor email and by date', async () => {
const byActor = AdminAuditListDto.parse(
await (await as(staff)('/api/admin/audit?actor=staff@demo.local')).json(),
);
expect(byActor.total).toBeGreaterThan(0);
expect(byActor.items.every((item) => item.actorEmail === 'staff@demo.local')).toBe(true);
// A window that closed before anything happened holds nothing.
const empty = AdminAuditListDto.parse(
await (await as(staff)('/api/admin/audit?from=2020-01-01&to=2020-01-02')).json(),
);
expect(empty.items).toEqual([]);
expect(empty.total).toBe(0);
});
it('exports CSV with a header row and quoted fields', async () => {
const response = await as(staff)('/api/admin/audit/export.csv?action=admin.user_lookup');
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toContain('text/csv');
expect(response.headers.get('content-disposition')).toContain('attachment');
const lines = (await response.text()).split('\r\n');
expect(lines[0]).toBe(
'"id","created_at","actor_email","actor_role","action","subject_email","resource","ip","detail"',
);
expect(lines.length).toBeGreaterThan(1);
expect(lines[1]).toContain('"admin.user_lookup"');
});
it('audits the export, since a copy leaves the building', async () => {
const before = await auditCount('admin.audit_export');
await as(staff)('/api/admin/audit/export.csv');
expect(await auditCount('admin.audit_export')).toBe(before + 1);
});
it('does not audit merely reading the log', async () => {
const before = await h.deps.handle.db
.selectFrom('audit_log')
.select((eb) => eb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
await as(staff)('/api/admin/audit');
const after = await h.deps.handle.db
.selectFrom('audit_log')
.select((eb) => eb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
expect(Number(after.total)).toBe(Number(before.total));
});
});
async function auditCount(action: string): Promise<number> {
const row = await h.deps.handle.db
.selectFrom('audit_log')
.select((eb) => eb.fn.countAll<number>().as('total'))
.where('action', '=', action)
.executeTakeFirstOrThrow();
return Number(row.total);
}
+169
View File
@@ -0,0 +1,169 @@
import { type AdminAuditDto, type AdminAuditQuery, PAGE_SIZE } from '@impuestos/contracts';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
/** The CSV export walks the whole result set, so it needs a ceiling that a browser can open. */
const EXPORT_LIMIT = 10_000;
/**
* Read only view over the append only log (SPEC.md section 14). `actor` and `subject`
* match on email or id, because staff have an email in front of them and the log stores
* an id.
*/
export async function listAudit(
db: Kysely<Database>,
query: AdminAuditQuery,
): Promise<{ items: AdminAuditDto[]; total: number; cursor?: string }> {
const base = filtered(db, query);
const { total } = await base
.select((eb) => eb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
let page = selectRows(base).limit(PAGE_SIZE + 1);
if (query.cursor) page = page.where('audit_log.id', '<', query.cursor);
const rows = await page.execute();
const hasMore = rows.length > PAGE_SIZE;
const visible = hasMore ? rows.slice(0, PAGE_SIZE) : rows;
const last = visible.at(-1);
return {
items: visible.map(toDto),
total: Number(total),
...(hasMore && last ? { cursor: last.id } : {}),
};
}
export async function auditRowsForExport(
db: Kysely<Database>,
query: AdminAuditQuery,
): Promise<AdminAuditDto[]> {
const rows = await selectRows(filtered(db, query)).limit(EXPORT_LIMIT).execute();
return rows.map(toDto);
}
/** RFC 4180: quote every field, double the quotes inside it. Excel opens this. */
export function toCsv(rows: AdminAuditDto[]): string {
const header = [
'id',
'created_at',
'actor_email',
'actor_role',
'action',
'subject_email',
'resource',
'ip',
'detail',
];
const lines = rows.map((row) =>
[
row.id,
row.createdAt,
row.actorEmail ?? '',
row.actorRole,
row.action,
row.subjectEmail ?? '',
row.resource,
row.ip ?? '',
row.detail ? JSON.stringify(row.detail) : '',
]
.map(escape)
.join(','),
);
return [header.map(escape).join(','), ...lines].join('\r\n');
}
function escape(value: string): string {
return `"${value.replaceAll('"', '""')}"`;
}
function filtered(db: Kysely<Database>, query: AdminAuditQuery) {
let base = db.selectFrom('audit_log');
if (query.action) base = base.where('audit_log.action', '=', query.action);
if (query.actor) {
const actor = query.actor;
base = base.where((eb) =>
eb.or([
eb('audit_log.actor_user_id', '=', actor),
eb(
'audit_log.actor_user_id',
'in',
eb.selectFrom('user').select('user.id').where('user.email', '=', actor),
),
]),
);
}
if (query.subject) {
const subject = query.subject;
base = base.where((eb) =>
eb.or([
eb('audit_log.subject_user_id', '=', subject),
eb(
'audit_log.subject_user_id',
'in',
eb.selectFrom('user').select('user.id').where('user.email', '=', subject),
),
]),
);
}
// Timestamps are ISO-8601 text, so a date bound is a string comparison. `to` is
// inclusive of the whole day, which is what a person picking a date means.
if (query.from) base = base.where('audit_log.created_at', '>=', `${query.from}T00:00:00.000Z`);
if (query.to) base = base.where('audit_log.created_at', '<=', `${query.to}T23:59:59.999Z`);
return base;
}
function selectRows(base: ReturnType<typeof filtered>) {
return base
.leftJoin('user as actor', 'actor.id', 'audit_log.actor_user_id')
.leftJoin('user as subject', 'subject.id', 'audit_log.subject_user_id')
.selectAll('audit_log')
.select(['actor.email as actorEmail', 'subject.email as subjectEmail'])
.orderBy('audit_log.created_at', 'desc')
.orderBy('audit_log.id', 'desc');
}
interface AuditRow {
id: string;
actor_user_id: string;
actorEmail: string | null;
actor_role: string;
action: string;
subject_user_id: string | null;
subjectEmail: string | null;
resource: string;
detail: string | null;
ip: string | null;
created_at: string;
}
function toDto(row: AuditRow): AdminAuditDto {
let detail: Record<string, unknown> | null = null;
if (row.detail) {
try {
const parsed: unknown = JSON.parse(row.detail);
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) {
detail = parsed as Record<string, unknown>;
}
} catch {
detail = null;
}
}
return {
id: row.id,
actorUserId: row.actor_user_id,
actorEmail: row.actorEmail,
actorRole: row.actor_role,
action: row.action,
subjectUserId: row.subject_user_id,
subjectEmail: row.subjectEmail,
resource: row.resource,
detail,
ip: row.ip,
createdAt: row.created_at,
};
}
+232
View File
@@ -0,0 +1,232 @@
import { type AdminErrorDto, type AdminErrorListQuery, PAGE_SIZE } from '@impuestos/contracts';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
/**
* The error queue (FLOWS.md Flow H). Two sources feed one table: rows the ingestion
* pipeline wrote, and jobs that exhausted their retries. They are merged here rather than
* in the UI so that paging and filtering mean the same thing for both.
*
* The cursor is `createdAt|id` of the last row on the page. Both sources are filtered by
* it before the merge, so a row is never shown twice and never skipped.
*/
export async function listAdminErrors(
db: Kysely<Database>,
query: AdminErrorListQuery,
): Promise<{ items: AdminErrorDto[]; total: number; cursor?: string }> {
const after = parseCursor(query.cursor);
const wantsIngest = query.stage !== 'job';
// A dead job is a live problem, so it never appears under the resolved filter: retrying
// it takes it out of the queue entirely.
const wantsJobs =
(query.stage === undefined || query.stage === 'job') && query.status !== 'resolved';
const [ingest, ingestTotal] = wantsIngest
? await ingestPage(db, query, after)
: [[] as AdminErrorDto[], 0];
const [jobs, jobTotal] = wantsJobs ? await deadJobPage(db, after) : [[] as AdminErrorDto[], 0];
const merged = [...ingest, ...jobs].sort(byNewest);
const visible = merged.slice(0, PAGE_SIZE);
const last = visible.at(-1);
const hasMore = merged.length > PAGE_SIZE;
return {
items: visible,
total: ingestTotal + jobTotal,
...(hasMore && last ? { cursor: `${last.createdAt}|${last.id}` } : {}),
};
}
async function ingestPage(
db: Kysely<Database>,
query: AdminErrorListQuery,
after: { createdAt: string; id: string } | null,
): Promise<[AdminErrorDto[], number]> {
let base = db.selectFrom('ingest_errors');
if (query.stage) base = base.where('stage', '=', query.stage);
if (query.status) base = base.where('status', '=', query.status);
const { total } = await base
.select((eb) => eb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
let page = base
.leftJoin('user', 'user.id', 'ingest_errors.user_id')
.selectAll('ingest_errors')
.select('user.email as userEmail')
.orderBy('ingest_errors.created_at', 'desc')
.orderBy('ingest_errors.id', 'desc')
.limit(PAGE_SIZE + 1);
if (after) {
page = page.where((eb) =>
eb.or([
eb('ingest_errors.created_at', '<', after.createdAt),
eb.and([
eb('ingest_errors.created_at', '=', after.createdAt),
eb('ingest_errors.id', '<', after.id),
]),
]),
);
}
const rows = await page.execute();
return [
rows.map((row) => ({
id: row.id,
userId: row.user_id,
documentId: row.document_id,
stage: row.stage,
message: row.message,
status: row.status,
createdAt: row.created_at,
source: 'ingest' as const,
payload: parseJson(row.payload),
userEmail: row.userEmail ?? null,
attempts: null,
resolvedAt: row.resolved_at,
})),
Number(total),
];
}
/** A job that ran out of attempts is a support problem, so it joins the same queue. */
async function deadJobPage(
db: Kysely<Database>,
after: { createdAt: string; id: string } | null,
): Promise<[AdminErrorDto[], number]> {
const base = db.selectFrom('jobs').where('status', '=', 'dead');
const { total } = await base
.select((eb) => eb.fn.countAll<number>().as('total'))
.executeTakeFirstOrThrow();
let page = base.selectAll().orderBy('created_at', 'desc').orderBy('id', 'desc').limit(PAGE_SIZE + 1);
if (after) {
page = page.where((eb) =>
eb.or([
eb('created_at', '<', after.createdAt),
eb.and([eb('created_at', '=', after.createdAt), eb('id', '<', after.id)]),
]),
);
}
const rows = await page.execute();
const items = await Promise.all(
rows.map(async (row): Promise<AdminErrorDto> => {
const payload = parseJson(row.payload);
const userId = typeof payload?.['userId'] === 'string' ? payload['userId'] : null;
const documentId = typeof payload?.['documentId'] === 'string' ? payload['documentId'] : null;
return {
id: row.id,
userId,
documentId,
stage: 'job',
// The job type alone when nothing was captured: an invented sentence here would
// be copy, and copy belongs in the catalogs.
message: row.last_error ? `${row.type}: ${row.last_error}` : row.type,
status: 'open',
createdAt: row.created_at,
source: 'job',
payload,
userEmail: userId ? await emailFor(db, userId) : null,
attempts: row.attempts,
resolvedAt: null,
};
}),
);
return [items, Number(total)];
}
export interface ResolveResult {
ok: boolean;
reason?: 'not_found' | 'already_resolved';
}
export async function resolveIngestError(
db: Kysely<Database>,
id: string,
by: { userId: string; note: string },
): Promise<ResolveResult> {
const row = await db
.selectFrom('ingest_errors')
.select(['id', 'status'])
.where('id', '=', id)
.executeTakeFirst();
if (!row) return { ok: false, reason: 'not_found' };
if (row.status === 'resolved') return { ok: false, reason: 'already_resolved' };
await db
.updateTable('ingest_errors')
.set({
status: 'resolved',
resolved_by: by.userId,
resolved_at: new Date().toISOString(),
resolution_note: by.note,
})
.where('id', '=', id)
.execute();
return { ok: true };
}
export interface RetryResult {
ok: boolean;
reason?: 'not_found' | 'not_retryable';
}
/**
* Puts a dead job back at the front of the queue. Attempts reset to zero so the retry
* gets the whole backoff schedule again rather than dying on its first stumble.
*/
export async function retryJob(db: Kysely<Database>, id: string): Promise<RetryResult> {
const row = await db
.selectFrom('jobs')
.select(['id', 'status'])
.where('id', '=', id)
.executeTakeFirst();
if (!row) return { ok: false, reason: 'not_found' };
if (row.status !== 'dead' && row.status !== 'failed') return { ok: false, reason: 'not_retryable' };
const now = new Date().toISOString();
await db
.updateTable('jobs')
.set({ status: 'pending', run_at: now, attempts: 0, locked_by: null, locked_at: null, updated_at: now })
.where('id', '=', id)
.execute();
return { ok: true };
}
async function emailFor(db: Kysely<Database>, userId: string): Promise<string | null> {
const row = await db
.selectFrom('user')
.select('email')
.where('id', '=', userId)
.executeTakeFirst();
return row?.email ?? null;
}
function byNewest(a: AdminErrorDto, b: AdminErrorDto): number {
if (a.createdAt !== b.createdAt) return a.createdAt < b.createdAt ? 1 : -1;
return a.id < b.id ? 1 : -1;
}
function parseCursor(cursor: string | undefined): { createdAt: string; id: string } | null {
if (!cursor) return null;
const [createdAt, id] = cursor.split('|');
return createdAt && id ? { createdAt, id } : null;
}
function parseJson(value: string | null): Record<string, unknown> | null {
if (!value) return null;
try {
const parsed: unknown = JSON.parse(value);
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
? (parsed as Record<string, unknown>)
: null;
} catch {
return null;
}
}
+10
View File
@@ -0,0 +1,10 @@
export { searchUsers, findUser, userOverview } from './users';
export {
listAdminErrors,
resolveIngestError,
retryJob,
type ResolveResult,
type RetryResult,
} from './errors';
export { listAudit, auditRowsForExport, toCsv } from './audit';
export { changeRole, type RoleChangeResult } from './roles';
+41
View File
@@ -0,0 +1,41 @@
import type { UserRole } from '@impuestos/contracts';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
export interface RoleChangeResult {
ok: boolean;
reason?: 'not_found' | 'self' | 'unchanged';
previous?: string;
}
/**
* Superadmin only, and never on yourself: the one thing worse than an account with too
* much power is the last superadmin demoting themselves out of the console.
*
* No session shuffling is needed. `getSession` reads the role off the user row on every
* request, so a demotion takes effect on the next call the demoted user makes.
*/
export async function changeRole(
db: Kysely<Database>,
args: { actorUserId: string; targetUserId: string; role: UserRole },
): Promise<RoleChangeResult> {
if (args.actorUserId === args.targetUserId) return { ok: false, reason: 'self' };
const target = await db
.selectFrom('user')
.select(['id', 'role'])
.where('id', '=', args.targetUserId)
.executeTakeFirst();
if (!target) return { ok: false, reason: 'not_found' };
const previous = target.role ?? 'user';
if (previous === args.role) return { ok: false, reason: 'unchanged', previous };
await db
.updateTable('user')
.set({ role: args.role, updatedAt: new Date().toISOString() })
.where('id', '=', args.targetUserId)
.execute();
return { ok: true, previous };
}
+152
View File
@@ -0,0 +1,152 @@
import type { AdminUserDto, AdminUserOverviewDto } from '@impuestos/contracts';
import type { Kysely } from 'kysely';
import type { Database } from '../../db/schema';
import { listIngestErrorsForUser } from '../ingest/errors';
import { getProfile } from '../pii';
/** A search that returns everyone is not a search: staff have to name who they are looking for. */
const MIN_QUERY_LENGTH = 2;
const MAX_RESULTS = 25;
/**
* Finds users by email, name or document number (FLOWS.md Flow H). Matching is
* case insensitive on both sides so that "GONZALEZ" and "gonzalez" behave the same, and
* punctuation in a RUC is ignored: staff read numbers off a screen, not out of the table.
*/
export async function searchUsers(db: Kysely<Database>, term: string): Promise<AdminUserDto[]> {
const trimmed = term.trim();
if (trimmed.length < MIN_QUERY_LENGTH) return [];
const like = `%${trimmed.toLowerCase()}%`;
const digits = trimmed.replace(/\D/g, '');
const rows = await db
.selectFrom('user')
.leftJoin('profiles', 'profiles.user_id', 'user.id')
.select([
'user.id as id',
'user.email as email',
'user.name as name',
'user.role as role',
'user.createdAt as createdAt',
'profiles.full_name as fullName',
'profiles.ruc as ruc',
'profiles.ruc_dv as rucDv',
'profiles.ci as ci',
])
.where((eb) => {
const clauses = [
eb(eb.fn('lower', ['user.email']), 'like', like),
eb(eb.fn('lower', ['user.name']), 'like', like),
eb(eb.fn('lower', ['profiles.full_name']), 'like', like),
];
// An empty digit string would match every RUC, which is the opposite of a search.
if (digits.length > 0) {
clauses.push(eb('profiles.ruc', 'like', `%${digits}%`));
clauses.push(eb('profiles.ci', 'like', `%${digits}%`));
}
return eb.or(clauses);
})
.orderBy('user.createdAt', 'asc')
.limit(MAX_RESULTS)
.execute();
return rows.map(toAdminUser);
}
export async function findUser(db: Kysely<Database>, id: string): Promise<AdminUserDto | null> {
const row = await db
.selectFrom('user')
.leftJoin('profiles', 'profiles.user_id', 'user.id')
.select([
'user.id as id',
'user.email as email',
'user.name as name',
'user.role as role',
'user.createdAt as createdAt',
'profiles.full_name as fullName',
'profiles.ruc as ruc',
'profiles.ruc_dv as rucDv',
'profiles.ci as ci',
])
.where('user.id', '=', id)
.executeTakeFirst();
return row ? toAdminUser(row) : null;
}
/** Everything the support screen shows about one account, in one round trip. */
export async function userOverview(
db: Kysely<Database>,
id: string,
): Promise<AdminUserOverviewDto | null> {
const user = await findUser(db, id);
if (!user) return null;
const counts = await db
.selectFrom('documents')
.select((eb) => [
eb.fn.countAll<number>().as('documents'),
eb.fn
.sum<number>(eb.case().when('status', '=', 'needs_review').then(1).else(0).end())
.as('needsReview'),
])
.where('user_id', '=', id)
.executeTakeFirstOrThrow();
const declarations = await db
.selectFrom('declarations')
.select((eb) => eb.fn.countAll<number>().as('total'))
.where('user_id', '=', id)
.executeTakeFirstOrThrow();
const lastDocument = await db
.selectFrom('documents')
.select('created_at')
.where('user_id', '=', id)
.orderBy('created_at', 'desc')
.executeTakeFirst();
return {
user,
profile: await getProfile(db, id),
counts: {
documents: Number(counts.documents),
needsReview: Number(counts.needsReview ?? 0),
declarations: Number(declarations.total),
},
recentErrors: await listIngestErrorsForUser(db, id),
lastActivityAt: lastDocument?.created_at ?? null,
};
}
interface UserRow {
id: string;
email: string;
name: string;
role: string | null;
createdAt: string;
fullName: string | null;
ruc: string | null;
rucDv: string | null;
ci: string | null;
}
function toAdminUser(row: UserRow): AdminUserDto {
const doc = row.ruc ? `${row.ruc}-${row.rucDv ?? ''}`.replace(/-$/, '') : row.ci;
return {
id: row.id,
email: row.email,
fullName: row.fullName ?? row.name,
doc: doc ?? null,
// better-auth leaves the column null for an account created before a role was set.
role: isRole(row.role) ? row.role : 'user',
createdAt: row.createdAt,
};
}
const ROLE_VALUES = ['user', 'accountant', 'staff', 'superadmin'] as const;
function isRole(value: string | null): value is (typeof ROLE_VALUES)[number] {
return value !== null && (ROLE_VALUES as readonly string[]).includes(value);
}
+15 -3
View File
@@ -17,16 +17,28 @@ export type AuditAction =
| 'notification_prefs.update' | 'notification_prefs.update'
| 'data.export' | 'data.export'
| 'account.delete' | 'account.delete'
| 'admin.user_search'
/**
* Reading one user's data. CONTRACTS.md section 5.5 pins this action to the overview
* endpoint, so the name stays even though `admin.user_search` reads more naturally
* next to it.
*/
| 'admin.user_lookup' | 'admin.user_lookup'
| 'admin.user_view'
| 'admin.role_change' | 'admin.role_change'
| 'admin.error_resolve'
| 'admin.job_retry'
| 'admin.audit_export'
| 'admin.file_access'; | 'admin.file_access';
export interface AuditEntry { export interface AuditEntry {
actorUserId: string; actorUserId: string;
actorRole: string; actorRole: string;
action: AuditAction; action: AuditAction;
/** Whose data this touched. Equal to the actor for a user acting on themselves. */ /**
* Whose data this touched. Omit it for a user acting on themselves and it defaults to
* the actor; pass `null` when the action has no subject at all, such as a search or an
* export. The two are different facts and the log must not blur them.
*/
subjectUserId?: string | null; subjectUserId?: string | null;
resource: string; resource: string;
detail?: Record<string, unknown> | undefined; detail?: Record<string, unknown> | undefined;
@@ -41,7 +53,7 @@ export async function writeAudit(db: Kysely<Database>, entry: AuditEntry): Promi
actor_user_id: entry.actorUserId, actor_user_id: entry.actorUserId,
actor_role: entry.actorRole, actor_role: entry.actorRole,
action: entry.action, action: entry.action,
subject_user_id: entry.subjectUserId ?? entry.actorUserId, subject_user_id: entry.subjectUserId === undefined ? entry.actorUserId : entry.subjectUserId,
resource: entry.resource, resource: entry.resource,
detail: entry.detail === undefined ? null : JSON.stringify(entry.detail), detail: entry.detail === undefined ? null : JSON.stringify(entry.detail),
ip: entry.ip ?? null, ip: entry.ip ?? null,
+1
View File
@@ -32,6 +32,7 @@ export async function recordIngestError(
status: 'open', status: 'open',
resolved_by: null, resolved_by: null,
resolved_at: null, resolved_at: null,
resolution_note: null,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
}) })
.execute(); .execute();
+14 -3
View File
@@ -61,14 +61,20 @@ describe('backoff', () => {
describe('failure handling', () => { describe('failure handling', () => {
it('retries a failing job with backoff, then marks it dead', async () => { it('retries a failing job with backoff, then marks it dead', async () => {
const h = await createHarness(); const h = await createHarness();
await enqueue(h.deps.handle.db, { // The seed leaves a dead job behind for the admin error queue, so this test names the
// row it queued rather than assuming it is the only one in the table.
const { id } = await enqueue(h.deps.handle.db, {
type: 'classify_document', type: 'classify_document',
payload: {}, // no documentId: the handler throws payload: {}, // no documentId: the handler throws
maxAttempts: 2, maxAttempts: 2,
}); });
await h.runJobs(); await h.runJobs();
let row = await h.deps.handle.db.selectFrom('jobs').selectAll().executeTakeFirstOrThrow(); let row = await h.deps.handle.db
.selectFrom('jobs')
.selectAll()
.where('id', '=', id)
.executeTakeFirstOrThrow();
expect(row.status).toBe('pending'); expect(row.status).toBe('pending');
expect(row.attempts).toBe(1); expect(row.attempts).toBe(1);
expect(row.last_error).toContain('documentId'); expect(row.last_error).toContain('documentId');
@@ -79,10 +85,15 @@ describe('failure handling', () => {
await h.deps.handle.db await h.deps.handle.db
.updateTable('jobs') .updateTable('jobs')
.set({ run_at: new Date(Date.now() - 1000).toISOString() }) .set({ run_at: new Date(Date.now() - 1000).toISOString() })
.where('id', '=', id)
.execute(); .execute();
await h.runJobs(); await h.runJobs();
row = await h.deps.handle.db.selectFrom('jobs').selectAll().executeTakeFirstOrThrow(); row = await h.deps.handle.db
.selectFrom('jobs')
.selectAll()
.where('id', '=', id)
.executeTakeFirstOrThrow();
expect(row.status).toBe('dead'); expect(row.status).toBe('dead');
expect(row.attempts).toBe(2); expect(row.attempts).toBe(2);
await h.close(); await h.close();
@@ -0,0 +1,173 @@
'use client';
import type { AdminAuditDto, AdminAuditQuery } from '@impuestos/contracts';
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, Td, Th, Tr } from '@/components/ui/table';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
const EMPTY: AdminAuditQuery = {};
/**
* Flow H: read only, filterable, exportable. The action is shown as the code it is stored
* as rather than a translated phrase: it is the same token the filter takes, and an
* operator searching a log wants to match what they see.
*/
export function AuditScreen() {
const t = useT();
const [draft, setDraft] = useState<Record<string, string>>({});
const [filters, setFilters] = useState<AdminAuditQuery>(EMPTY);
const [pages, setPages] = useState<AdminAuditDto[]>([]);
const [cursor, setCursor] = useState<string | undefined>(undefined);
const audit = useQuery({
queryKey: ['admin', 'audit', filters, cursor],
queryFn: ({ signal }) => api.listAudit({ ...filters, ...(cursor ? { cursor } : {}) }, signal),
});
// Pages accumulate, so "show more" appends rather than replacing what is on screen.
const rows = cursor ? [...pages, ...(audit.data?.items ?? [])] : (audit.data?.items ?? []);
function apply(event: FormEvent) {
event.preventDefault();
setPages([]);
setCursor(undefined);
setFilters(clean(draft));
}
function clear() {
setDraft({});
setPages([]);
setCursor(undefined);
setFilters(EMPTY);
}
return (
<div className="space-y-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<h1 className="text-xl font-semibold tracking-tight">{t('admin.audit.title')}</h1>
<a
href={api.auditCsvUrl(filters)}
className="text-sm text-accent-700 hover:underline"
download
>
{t('admin.audit.export')}
</a>
</div>
<form onSubmit={apply} className="flex flex-wrap items-end gap-3">
{(
[
['actor', 'admin.audit.filterActor', 'text'],
['action', 'admin.audit.filterAction', 'text'],
['subject', 'admin.audit.filterSubject', 'text'],
['from', 'admin.audit.filterFrom', 'date'],
['to', 'admin.audit.filterTo', 'date'],
] as const
).map(([key, label, type]) => (
<div key={key} className="space-y-1">
<label htmlFor={`audit-${key}`} className="block text-xs font-medium">
{t(label)}
</label>
<Input
id={`audit-${key}`}
type={type}
value={draft[key] ?? ''}
onChange={(event) => setDraft({ ...draft, [key]: event.target.value })}
className="h-9 w-44 rounded-lg text-sm"
autoComplete="off"
/>
</div>
))}
{/* Grouped, so the two buttons wrap together rather than one at a time. */}
<div className="flex gap-2">
<Button type="submit" className="h-9 rounded-lg">
{t('admin.audit.apply')}
</Button>
<Button type="button" variant="ghost" className="h-9 rounded-lg" onClick={clear}>
{t('admin.audit.clear')}
</Button>
</div>
</form>
{audit.data ? (
<p className="text-xs text-[var(--text-muted)]">
{t('admin.audit.total', { count: audit.data.total })}
</p>
) : null}
{audit.isPending && rows.length === 0 ? <Skeleton name="admin-user-row" count={6} /> : null}
{audit.isError ? (
<div className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" className="h-9 rounded-lg" onClick={() => void audit.refetch()}>
{t('common.retry')}
</Button>
</div>
) : null}
{audit.data && rows.length === 0 ? <EmptyState title={t('admin.audit.empty')} /> : null}
{rows.length > 0 ? (
<Table>
<thead>
<tr>
<Th>{t('admin.audit.col.when')}</Th>
<Th>{t('admin.audit.col.actor')}</Th>
<Th>{t('admin.audit.col.action')}</Th>
<Th>{t('admin.audit.col.subject')}</Th>
<Th>{t('admin.audit.col.resource')}</Th>
<Th>{t('admin.audit.col.ip')}</Th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<Tr key={row.id}>
<Td className="tnum whitespace-nowrap">
{row.createdAt.slice(0, 16).replace('T', ' ')}
</Td>
<Td className="whitespace-nowrap">{row.actorEmail ?? row.actorUserId}</Td>
<Td className="whitespace-nowrap font-medium">{row.action}</Td>
<Td className="whitespace-nowrap">{row.subjectEmail ?? t('common.none')}</Td>
<Td className="max-w-64 truncate" title={row.resource}>
{row.resource}
</Td>
<Td className="tnum whitespace-nowrap">{row.ip ?? t('common.none')}</Td>
</Tr>
))}
</tbody>
</Table>
) : null}
{audit.data?.cursor ? (
<Button
type="button"
variant="secondary"
className="h-9 rounded-lg"
onClick={() => {
setPages(rows);
setCursor(audit.data.cursor);
}}
>
{t('common.loadMore')}
</Button>
) : null}
</div>
);
}
function clean(draft: Record<string, string>): AdminAuditQuery {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(draft)) {
if (value.trim().length > 0) out[key] = value.trim();
}
return out as AdminAuditQuery;
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { AuditScreen } from './audit-screen';
export default async function AuditoriaPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <AuditScreen />;
}
@@ -0,0 +1,230 @@
'use client';
import type { AdminErrorDto } from '@impuestos/contracts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, Td, Th, Tr } from '@/components/ui/table';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
const STAGES = ['qr_parse', 'ocr', 'dedupe', 'verify', 'job', 'other'] as const;
const STATUSES = ['open', 'resolved'] as const;
type Stage = (typeof STAGES)[number];
type Status = (typeof STATUSES)[number];
/** Flow H: everything that failed on the way in, with the two things staff can do about it. */
export function ErrorsScreen() {
const t = useT();
const [stage, setStage] = useState<Stage | ''>('');
const [status, setStatus] = useState<Status | ''>('open');
const [expanded, setExpanded] = useState<string | null>(null);
const errors = useQuery({
queryKey: ['admin', 'errors', stage, status],
queryFn: ({ signal }) =>
api.listAdminErrors({ ...(stage ? { stage } : {}), ...(status ? { status } : {}) }, signal),
});
return (
<div className="space-y-5">
<h1 className="text-xl font-semibold tracking-tight">{t('admin.errors.title')}</h1>
<div className="flex flex-wrap items-end gap-4">
<label className="space-y-1 text-xs font-medium">
<span className="block">{t('admin.errors.filterStage')}</span>
<Select value={stage} onChange={(event) => setStage(event.target.value as Stage | '')}>
<option value="">{t('admin.errors.all')}</option>
{STAGES.map((value) => (
<option key={value} value={value}>
{t(`admin.errors.stage.${value}` as const)}
</option>
))}
</Select>
</label>
<label className="space-y-1 text-xs font-medium">
<span className="block">{t('admin.errors.filterStatus')}</span>
<Select value={status} onChange={(event) => setStatus(event.target.value as Status | '')}>
<option value="">{t('admin.errors.allStatus')}</option>
{STATUSES.map((value) => (
<option key={value} value={value}>
{t(`admin.errors.status.${value}` as const)}
</option>
))}
</Select>
</label>
</div>
{errors.isPending ? <Skeleton name="admin-user-row" count={5} /> : null}
{errors.isError ? (
<div className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" className="h-9 rounded-lg" onClick={() => void errors.refetch()}>
{t('common.retry')}
</Button>
</div>
) : null}
{errors.data && errors.data.items.length === 0 ? (
<EmptyState title={t('admin.errors.empty')} />
) : null}
{errors.data && errors.data.items.length > 0 ? (
<Table>
<thead>
<tr>
<Th>{t('admin.errors.col.when')}</Th>
<Th>{t('admin.errors.col.stage')}</Th>
<Th>{t('admin.errors.col.user')}</Th>
<Th>{t('admin.errors.col.message')}</Th>
<Th>{t('admin.errors.col.status')}</Th>
</tr>
</thead>
<tbody>
{errors.data.items.map((row) => (
<ErrorRow
key={row.id}
row={row}
expanded={expanded === row.id}
onToggle={() => setExpanded(expanded === row.id ? null : row.id)}
/>
))}
</tbody>
</Table>
) : null}
</div>
);
}
function ErrorRow({
row,
expanded,
onToggle,
}: {
row: AdminErrorDto;
expanded: boolean;
onToggle: () => void;
}) {
const t = useT();
return (
<>
<Tr>
{/* A timestamp is machine data here, so it stays sortable rather than prose. */}
<Td className="tnum whitespace-nowrap">{row.createdAt.slice(0, 16).replace('T', ' ')}</Td>
<Td className="whitespace-nowrap">{t(`admin.errors.stage.${row.stage}` as const)}</Td>
<Td className="whitespace-nowrap">{row.userEmail ?? t('common.none')}</Td>
<Td>
<button
type="button"
onClick={onToggle}
aria-expanded={expanded}
className="text-left hover:underline"
>
{row.message}
</button>
</Td>
<Td className="whitespace-nowrap">{t(`admin.errors.status.${row.status}` as const)}</Td>
</Tr>
{expanded ? (
<tr>
<Td colSpan={5} className="bg-[var(--surface)]">
<ExpandedRow row={row} />
</Td>
</tr>
) : null}
</>
);
}
function ExpandedRow({ row }: { row: AdminErrorDto }) {
const t = useT();
const queryClient = useQueryClient();
const [note, setNote] = useState('');
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['admin', 'errors'] });
const resolve = useMutation({
mutationFn: () => api.resolveAdminError(row.id, { note: note.trim() }),
onSuccess: invalidate,
});
const retry = useMutation({
mutationFn: () => api.retryJob(row.id),
onSuccess: invalidate,
});
return (
<div className="space-y-3">
{row.attempts !== null ? (
<p className="text-xs text-[var(--text-muted)]">
{t('admin.errors.attempts', { count: row.attempts })}
</p>
) : null}
<pre className="max-h-64 overflow-auto rounded-lg border bg-[var(--surface-raised)] p-3 text-xs">
{JSON.stringify(row.payload ?? {}, null, 2)}
</pre>
{row.source === 'job' ? (
<div className="space-y-2">
<Button
type="button"
className="h-9 rounded-lg"
disabled={retry.isPending}
onClick={() => retry.mutate()}
>
{t('admin.errors.retry')}
</Button>
{retry.isError ? (
<p role="alert" className="text-sm text-overdue">
{t('common.error.generic')}
</p>
) : null}
</div>
) : null}
{row.source === 'ingest' && row.status === 'open' ? (
<form
className="flex flex-wrap items-end gap-2"
onSubmit={(event) => {
event.preventDefault();
resolve.mutate();
}}
>
<div className="min-w-64 flex-1 space-y-1">
<label htmlFor={`note-${row.id}`} className="text-xs font-medium">
{t('admin.errors.note')}
</label>
<Input
id={`note-${row.id}`}
value={note}
onChange={(event) => setNote(event.target.value)}
placeholder={t('admin.errors.notePlaceholder')}
className="h-9 rounded-lg text-sm"
/>
</div>
<Button
type="submit"
className="h-9 rounded-lg"
disabled={note.trim().length === 0 || resolve.isPending}
>
{t('admin.errors.resolve')}
</Button>
{resolve.isError ? (
<p role="alert" className="text-sm text-overdue">
{t('common.error.generic')}
</p>
) : null}
</form>
) : null}
</div>
);
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { ErrorsScreen } from './errors-screen';
export default async function ErroresPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <ErrorsScreen />;
}
+86
View File
@@ -0,0 +1,86 @@
import { isApiError, type SessionDto } from '@impuestos/contracts';
import { setRequestLocale } from 'next-intl/server';
import type { ReactNode } from 'react';
import { Card } from '@/components/ui/card';
import { EmptyState } from '@/components/ui/empty-state';
import { LanguageSwitcher } from '@/components/language-switcher';
import { Link, redirect } from '@/i18n/navigation';
import { getT } from '@/i18n/t';
import { serverApi } from '@/lib/api-server';
const ADMIN_ROLES = new Set(['staff', 'superadmin']);
const TABS = [
{ href: '/usuarios', key: 'admin.users.title' },
{ href: '/errores', key: 'admin.errors.title' },
{ href: '/auditoria', key: 'admin.audit.title' },
] as const;
/**
* FLOWS.md Flow H. No tab bar, no floating button, no playfulness: a wide page with a
* plain nav. The role is checked here and again on every API call, because a layout is a
* convenience and the server is the rule.
*/
export default async function AdminLayout({
children,
params,
}: {
children: ReactNode;
params: Promise<{ locale: string }>;
}) {
const { locale } = await params;
setRequestLocale(locale);
const t = await getT(locale);
const api = await serverApi();
const session: SessionDto | null = await api.getSession().catch((error: unknown) => {
if (isApiError(error) && error.code === 'unauthorized') return null;
throw error;
});
if (!session) redirect({ href: '/login', locale });
if (!session || !ADMIN_ROLES.has(session.role)) {
return (
<main className="mx-auto w-full max-w-md px-5 py-16">
<Card>
<EmptyState title={t('admin.forbidden')} />
</Card>
</main>
);
}
return (
<div className="mx-auto flex min-h-dvh w-full max-w-6xl flex-col">
<header className="flex flex-wrap items-center justify-between gap-4 border-b px-5 py-3">
<div className="flex items-center gap-6">
<span className="text-sm font-semibold tracking-tight">{t('admin.nav.console')}</span>
<nav aria-label={t('admin.nav.console')}>
<ul className="flex items-center gap-4">
{TABS.map((tab) => (
<li key={tab.href}>
<Link href={tab.href} className="text-sm hover:underline">
{t(tab.key)}
</Link>
</li>
))}
</ul>
</nav>
</div>
<div className="flex items-center gap-4">
<span className="text-xs text-[var(--text-muted)]">{session.email}</span>
<LanguageSwitcher />
<Link href="/inicio" className="text-sm text-accent-700 hover:underline">
{t('common.back')}
</Link>
</div>
</header>
{/* The reminder sits above every screen in the console, not only the user search. */}
<p className="border-b bg-attention-soft px-5 py-2 text-xs text-attention">
{t('admin.users.auditBanner')}
</p>
<main className="flex-1 px-5 py-6">{children}</main>
</div>
);
}
@@ -0,0 +1,22 @@
import { isApiError } from '@impuestos/contracts';
import { setRequestLocale } from 'next-intl/server';
import { serverApi } from '@/lib/api-server';
import { UserOverview } from './user-overview';
export default async function UsuarioPage({
params,
}: {
params: Promise<{ locale: string; id: string }>;
}) {
const { locale, id } = await params;
setRequestLocale(locale);
// Role management is superadmin only, so the viewer's role decides what renders.
const api = await serverApi();
const session = await api.getSession().catch((error: unknown) => {
if (isApiError(error)) return null;
throw error;
});
return <UserOverview id={id} viewerRole={session?.role ?? 'user'} />;
}
@@ -0,0 +1,227 @@
'use client';
import type { UserRole } from '@impuestos/contracts';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { Skeleton } from '@/components/ui/skeleton';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
const ROLES: UserRole[] = ['user', 'accountant', 'staff', 'superadmin'];
/** Flow H: one account at a glance, plus the one destructive thing a superadmin can do. */
export function UserOverview({ id, viewerRole }: { id: string; viewerRole: string }) {
const t = useT();
const overview = useQuery({
queryKey: ['admin', 'user', id],
queryFn: ({ signal }) => api.getUserOverview(id, signal),
});
if (overview.isPending) {
return (
<Card>
<Skeleton name="profile-section" />
</Card>
);
}
if (overview.isError || !overview.data) {
return (
<Card className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" className="h-9 rounded-lg" onClick={() => void overview.refetch()}>
{t('common.retry')}
</Button>
</Card>
);
}
const { user, profile, counts, recentErrors, lastActivityAt } = overview.data;
return (
<div className="space-y-5">
<div className="flex flex-wrap items-baseline justify-between gap-3">
<div>
<h1 className="text-xl font-semibold tracking-tight">{user.fullName}</h1>
<p className="text-sm text-[var(--text-muted)]">{user.email}</p>
</div>
<Link href="/usuarios" className="text-sm text-accent-700 hover:underline">
{t('common.back')}
</Link>
</div>
<div className="grid gap-4 sm:grid-cols-3">
{(
[
['documents', counts.documents],
['needsReview', counts.needsReview],
['declarations', counts.declarations],
] as const
).map(([key, value]) => (
<Card key={key} className="p-4">
<p className="text-xs text-[var(--text-muted)]">{t(`admin.users.counts.${key}`)}</p>
<p className="tnum text-2xl font-semibold">{value}</p>
</Card>
))}
</div>
<Card className="space-y-2 p-4 text-sm">
{profile ? (
<dl className="grid gap-4 sm:grid-cols-4">
<Row label={t('admin.users.col.doc')} value={docOf(profile.ruc, profile.rucDv, profile.ci)} />
<Row label={t('admin.role.title')} value={t(`admin.role.${user.role}` as const)} />
<Row
label={t('admin.users.obligations')}
value={profile.obligations
.filter((obligation) => obligation.active)
.map((obligation) => t(`obligation.${obligation.code}` as const))
.join(', ')}
/>
<Row label={t('admin.users.col.created')} value={user.createdAt.slice(0, 10)} />
</dl>
) : (
<p className="text-[var(--text-muted)]">{t('admin.users.noProfile')}</p>
)}
<p className="text-xs text-[var(--text-muted)]">
{lastActivityAt
? // A full date, not "15 ene": in a console the year is part of the fact.
t('admin.users.lastActivity', { date: lastActivityAt.slice(0, 10) })
: t('admin.users.noActivity')}
</p>
</Card>
<section className="space-y-2">
<h2 className="text-sm font-medium">{t('admin.users.openErrors')}</h2>
{recentErrors.length === 0 ? (
<p className="text-sm text-[var(--text-muted)]">{t('admin.users.noErrors')}</p>
) : (
<Card className="p-0">
<ul className="divide-y">
{recentErrors.map((error) => (
<li key={error.id} className="flex flex-wrap gap-x-3 px-4 py-2 text-sm">
<span className="text-[var(--text-muted)]">
{t(`admin.errors.stage.${error.stage}` as const)}
</span>
<span className="min-w-0 flex-1">{error.message}</span>
<span className="tnum text-xs text-[var(--text-muted)]">
{error.createdAt.slice(0, 10)}
</span>
</li>
))}
</ul>
</Card>
)}
{/* The queue itself, for the rows this summary does not show. */}
<Link href="/errores" className="inline-block text-sm text-accent-700 hover:underline">
{t('admin.errors.goToQueue')}
</Link>
</section>
{viewerRole === 'superadmin' ? <RoleManager user={user} /> : null}
</div>
);
}
function RoleManager({ user }: { user: { id: string; email: string; role: UserRole } }) {
const t = useT();
const queryClient = useQueryClient();
const [role, setRole] = useState<UserRole>(user.role);
const [confirming, setConfirming] = useState(false);
const change = useMutation({
mutationFn: () => api.setUserRole(user.id, { role }),
onSuccess: async () => {
setConfirming(false);
await queryClient.invalidateQueries({ queryKey: ['admin', 'user', user.id] });
},
});
return (
<Card className="space-y-3 p-4">
<h2 className="text-sm font-medium">{t('admin.role.title')}</h2>
<div className="flex flex-wrap items-center gap-3">
<Select
aria-label={t('admin.role.title')}
value={role}
onChange={(event) => {
setRole(event.target.value as UserRole);
setConfirming(false);
}}
>
{ROLES.map((value) => (
<option key={value} value={value}>
{t(`admin.role.${value}` as const)}
</option>
))}
</Select>
<Button
type="button"
variant="secondary"
className="h-9 rounded-lg"
disabled={role === user.role || change.isPending}
onClick={() => setConfirming(true)}
>
{t('admin.role.change')}
</Button>
</div>
{confirming ? (
<div className="space-y-2 rounded-lg border p-3">
<p className="text-sm">
{t('admin.role.confirm', { email: user.email, role: t(`admin.role.${role}` as const) })}
</p>
<div className="flex gap-2">
<Button
type="button"
className="h-9 rounded-lg"
disabled={change.isPending}
onClick={() => change.mutate()}
>
{t('common.confirm')}
</Button>
<Button
type="button"
variant="ghost"
className="h-9 rounded-lg"
onClick={() => setConfirming(false)}
>
{t('common.cancel')}
</Button>
</div>
</div>
) : null}
{change.isError ? (
<p role="alert" className="text-sm text-overdue">
{t('common.error.generic')}
</p>
) : null}
{change.isSuccess ? <p className="text-sm text-positive">{t('admin.role.saved')}</p> : null}
</Card>
);
}
/**
* Label above value. Side by side across a wide card, a right aligned value ends up next
* to the following pair's label and the two read as one field.
*/
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="min-w-0">
<dt className="text-xs text-[var(--text-muted)]">{label}</dt>
<dd className="tnum truncate">{value}</dd>
</div>
);
}
function docOf(ruc: string | null, rucDv: string | null, ci: string | null): string {
if (ruc) return rucDv ? `${ruc}-${rucDv}` : ruc;
return ci ?? '';
}
@@ -0,0 +1,8 @@
import { setRequestLocale } from 'next-intl/server';
import { UsersScreen } from './users-screen';
export default async function UsuariosPage({ params }: { params: Promise<{ locale: string }> }) {
const { locale } = await params;
setRequestLocale(locale);
return <UsersScreen />;
}
@@ -0,0 +1,111 @@
'use client';
import { useQuery } from '@tanstack/react-query';
import { useState, type FormEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { EmptyState } from '@/components/ui/empty-state';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, Td, Th, Tr } from '@/components/ui/table';
import { Link } from '@/i18n/navigation';
import { useT } from '@/i18n/t';
import { api } from '@/lib/api';
/** Two characters is the shortest thing the server treats as a search. */
const MIN_LENGTH = 2;
/** Flow H: search by email, RUC or name, then open one account. */
export function UsersScreen() {
const t = useT();
const [draft, setDraft] = useState('');
const [term, setTerm] = useState('');
const results = useQuery({
queryKey: ['admin', 'users', term],
queryFn: ({ signal }) => api.searchUsers(term, signal),
enabled: term.length >= MIN_LENGTH,
});
function submit(event: FormEvent) {
event.preventDefault();
setTerm(draft.trim());
}
return (
<div className="space-y-5">
<h1 className="text-xl font-semibold tracking-tight">{t('admin.users.title')}</h1>
<form onSubmit={submit} className="flex flex-wrap items-end gap-3">
<div className="min-w-64 flex-1 space-y-1">
<label htmlFor="admin-user-search" className="text-xs font-medium">
{t('admin.users.searchLabel')}
</label>
<Input
id="admin-user-search"
value={draft}
onChange={(event) => setDraft(event.target.value)}
className="h-10 rounded-lg text-sm"
autoComplete="off"
/>
<p className="text-xs text-[var(--text-muted)]">{t('admin.users.searchHint')}</p>
</div>
<Button type="submit" className="h-10 rounded-lg">
{t('common.search')}
</Button>
</form>
{term.length < MIN_LENGTH ? <EmptyState title={t('admin.users.start')} /> : null}
{results.isPending && term.length >= MIN_LENGTH ? (
<Skeleton name="admin-user-row" count={4} />
) : null}
{results.isError ? (
<div className="space-y-3">
<p role="alert" className="text-sm">
{t('common.error.generic')}
</p>
<Button type="button" className="h-9 rounded-lg" onClick={() => void results.refetch()}>
{t('common.retry')}
</Button>
</div>
) : null}
{results.data && results.data.items.length === 0 ? (
<EmptyState title={t('admin.users.empty')} />
) : null}
{results.data && results.data.items.length > 0 ? (
<Table>
<thead>
<tr>
<Th>{t('admin.users.col.email')}</Th>
<Th>{t('admin.users.col.name')}</Th>
<Th>{t('admin.users.col.doc')}</Th>
<Th>{t('admin.users.col.role')}</Th>
<Th>{t('admin.users.col.created')}</Th>
</tr>
</thead>
<tbody>
{results.data.items.map((user) => (
<Tr key={user.id}>
<Td>
<Link
href={`/usuarios/${user.id}`}
className="font-medium text-accent-700 hover:underline"
>
{user.email}
</Link>
</Td>
<Td>{user.fullName}</Td>
<Td className="tnum">{user.doc ?? t('common.none')}</Td>
<Td>{t(`admin.role.${user.role}` as const)}</Td>
<Td className="tnum whitespace-nowrap">{user.createdAt.slice(0, 10)}</Td>
</Tr>
))}
</tbody>
</Table>
) : null}
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import type { SelectHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/** A native select: the admin filters are short lists and this is the fastest thing to use. */
export function Select({ className, ...props }: SelectHTMLAttributes<HTMLSelectElement>) {
return (
<select
className={cn(
'h-9 rounded-lg border bg-[var(--surface-raised)] px-2 text-sm',
'focus-visible:border-accent-500 focus-visible:outline-2 focus-visible:outline-offset-0',
'focus-visible:outline-accent-500/40',
className,
)}
{...props}
/>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { HTMLAttributes, TdHTMLAttributes, ThHTMLAttributes } from 'react';
import { cn } from '@/lib/utils';
/**
* The admin console is the one place in the product that is a table (FLOWS.md Flow H:
* plain, dense, desktop first). Everything scrolls inside its own box so a long resource
* string never pushes the page sideways.
*/
export function Table({ className, ...props }: HTMLAttributes<HTMLTableElement>) {
return (
<div className="w-full overflow-x-auto rounded-xl border bg-[var(--surface-raised)]">
<table className={cn('w-full border-collapse text-sm', className)} {...props} />
</div>
);
}
export function Th({ className, ...props }: ThHTMLAttributes<HTMLTableCellElement>) {
return (
<th
scope="col"
className={cn(
'border-b px-3 py-2 text-left text-xs font-medium whitespace-nowrap text-[var(--text-muted)]',
className,
)}
{...props}
/>
);
}
export function Td({ className, ...props }: TdHTMLAttributes<HTMLTableCellElement>) {
return <td className={cn('border-b px-3 py-2 align-top', className)} {...props} />;
}
export function Tr({ className, ...props }: HTMLAttributes<HTMLTableRowElement>) {
return <tr className={cn('hover:bg-accent-50/50', className)} {...props} />;
}
+100
View File
@@ -0,0 +1,100 @@
import { es } from '@impuestos/i18n';
import { expect, test, type Page } from '@playwright/test';
import { readAuditActions } from './db';
async function signIn(page: Page, email: string, password: string): Promise<void> {
await page.goto('/es/login');
await page.getByLabel(es['auth.register.email']).fill(email);
await page.getByLabel(es['auth.login.password']).fill(password);
await page.getByRole('button', { name: es['auth.login.submit'] }).click();
// Staff accounts have no taxpayer profile, so they land on setup rather than the
// dashboard. Either way, leaving the login screen is what says the session took.
// Generous, because the first visit to a route in a dev server compiles it.
await expect(page).not.toHaveURL(/\/login$/, { timeout: 20_000 });
}
async function openMaria(page: Page): Promise<void> {
await page.goto('/es/usuarios');
await page.getByLabel(es['admin.users.searchLabel']).fill('maria@demo.local');
await page.getByRole('button', { name: es['common.search'] }).click();
await page.getByRole('link', { name: 'maria@demo.local' }).click();
}
/** Golden path 5 (SPEC.md section 13): admin lookup, then the audit row it wrote. */
test.describe('admin console', () => {
test('staff look a user up and the lookup lands in the audit log', async ({ page }) => {
await signIn(page, 'staff@demo.local', 'demo-staff-1');
await page.goto('/es/usuarios');
await expect(page.getByRole('heading', { name: es['admin.users.title'] })).toBeVisible();
// The reminder is on the screen before anyone searches anything.
await expect(page.getByText(es['admin.users.auditBanner'])).toBeVisible();
const before = readAuditActions('maria@demo.local').filter(
(action) => action === 'admin.user_lookup',
).length;
// How many rows one view writes is pinned against the API in admin.test.ts, where it
// is exactly one. It cannot be pinned here: React strict mode mounts a client
// component twice in development, so the browser asks for the overview twice.
await page.getByLabel(es['admin.users.searchLabel']).fill('maria@demo.local');
await page.getByRole('button', { name: es['common.search'] }).click();
const row = page.getByRole('link', { name: 'maria@demo.local' });
await expect(row).toBeVisible();
await row.click();
await expect(page.getByRole('heading', { name: 'Maria Gonzalez' })).toBeVisible();
await expect(page.getByText(es['admin.users.counts.documents'])).toBeVisible();
await expect(async () => {
const after = readAuditActions('maria@demo.local').filter(
(action) => action === 'admin.user_lookup',
).length;
expect(after).toBeGreaterThan(before);
}).toPass({ timeout: 10_000 });
// And the row is visible on the audit screen, which is where staff would look.
await page.goto('/es/auditoria');
await page.getByLabel(es['admin.audit.filterAction']).fill('admin.user_lookup');
await page.getByRole('button', { name: es['admin.audit.apply'] }).click();
await expect(page.locator('tbody tr').first()).toContainText('staff@demo.local');
await expect(page.locator('tbody tr').first()).toContainText('maria@demo.local');
});
test('the error queue shows both sources and expands a row', async ({ page }) => {
await signIn(page, 'staff@demo.local', 'demo-staff-1');
await page.goto('/es/errores');
await expect(page.getByRole('heading', { name: es['admin.errors.title'] })).toBeVisible();
const rows = page.locator('tbody tr');
await expect(rows.first()).toBeVisible();
// Expanding a row shows what the failure captured, rather than a stack trace.
await rows.first().getByRole('button').click();
await expect(page.locator('pre').first()).toBeVisible();
});
test('a plain user cannot reach the console', async ({ page }) => {
await signIn(page, 'maria@demo.local', 'demo-maria-1');
for (const path of ['/es/usuarios', '/es/errores', '/es/auditoria']) {
await page.goto(path);
await expect(page.getByText(es['admin.forbidden'])).toBeVisible();
}
});
test('staff are not offered role management', async ({ page }) => {
await signIn(page, 'staff@demo.local', 'demo-staff-1');
await openMaria(page);
await expect(page.getByRole('heading', { name: 'Maria Gonzalez' })).toBeVisible();
await expect(page.getByRole('button', { name: es['admin.role.change'] })).toHaveCount(0);
});
test('a superadmin is offered role management', async ({ page }) => {
await signIn(page, 'superadmin@demo.local', 'demo-superadmin-1');
await openMaria(page);
await expect(page.getByRole('button', { name: es['admin.role.change'] })).toBeVisible();
});
});
+3 -1
View File
@@ -61,7 +61,9 @@ test.describe('golden path 4: review, approve, download, file', () => {
// The official layout, in Spanish, marked as a draft until it is approved. // The official layout, in Spanish, marked as a draft until it is approved.
await expect(page.getByRole('heading', { name: es['decl.preview.title'] })).toBeVisible(); await expect(page.getByRole('heading', { name: es['decl.preview.title'] })).toBeVisible();
await expect(page.getByText(es['decl.preview.spanishNote'])).toBeVisible(); await expect(page.getByText(es['decl.preview.spanishNote'])).toBeVisible();
await expect(page.getByText('Debito fiscal')).toBeVisible(); // The cell, not any text: the summary above it names the debito too, and this line is
// about the form laying out its casillas.
await expect(page.getByRole('cell', { name: 'Debito fiscal', exact: true })).toBeVisible();
await expect(page.getByText(es['decl.preview.draftMark'])).toBeVisible(); await expect(page.getByText(es['decl.preview.draftMark'])).toBeVisible();
// D3: approving asks once, restating the number. // D3: approving asks once, restating the number.
+46
View File
@@ -0,0 +1,46 @@
import { request } from '@playwright/test';
/**
* Asks for every screen once before the suite starts.
*
* Against `pnpm dev` a route is compiled the first time it is requested, and whichever test
* happens to be first pays for it: a sign in navigation runs past the five seconds
* Playwright waits by default and fails for a reason that has nothing to do with the
* product. Against a built stack these are cheap 200s and 307s.
*/
const ROUTES = [
'/es',
'/en',
'/es/login',
'/es/registro',
'/es/verificar',
'/es/inicio',
'/es/bandeja',
'/es/comprobantes',
'/es/comprobantes/nuevo',
'/es/declaraciones',
'/es/vencimientos',
'/es/escanear',
'/es/perfil',
'/es/configuracion',
'/es/consentimiento',
'/es/usuarios',
'/es/errores',
'/es/auditoria',
'/en/login',
];
export default async function globalSetup(): Promise<void> {
const baseURL = process.env['E2E_BASE_URL'] ?? 'http://localhost:3005';
const context = await request.newContext({ baseURL });
try {
for (const route of ROUTES) {
// A redirect to sign in is a compiled route, which is all this is after.
await context
.get(route, { failOnStatusCode: false, timeout: 120_000 })
.catch(() => undefined);
}
} finally {
await context.dispose();
}
}
+53
View File
@@ -1,5 +1,11 @@
import type { z } from 'zod'; import type { z } from 'zod';
import { import {
AdminAuditListDto,
type AdminAuditQuery,
AdminErrorListDto,
type AdminErrorListQuery,
AdminUserOverviewDto,
AdminUserSearchDto,
type ClassificationPatchInput, type ClassificationPatchInput,
type ConsentInput, type ConsentInput,
DataExportDto, DataExportDto,
@@ -24,6 +30,9 @@ import {
ProfileDto, ProfileDto,
type ProfileInput, type ProfileInput,
type RejectInput, type RejectInput,
type RoleChangeInput,
SessionDto,
type ResolveErrorInput,
ScanResultDto, ScanResultDto,
TraceDto, TraceDto,
type TraceKind, type TraceKind,
@@ -112,6 +121,10 @@ export function createApiClient(options: ApiClientOptions = {}) {
...(signal ? { signal } : {}), ...(signal ? { signal } : {}),
}), }),
// Session
getSession: (signal?: AbortSignal) =>
request('GET', '/me/session', { schema: SessionDto, ...(signal ? { signal } : {}) }),
// Profile // Profile
getProfile: (signal?: AbortSignal) => getProfile: (signal?: AbortSignal) =>
request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }), request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }),
@@ -223,6 +236,46 @@ export function createApiClient(options: ApiClientOptions = {}) {
}), }),
patchNotificationPrefs: (body: NotificationPrefsInput) => patchNotificationPrefs: (body: NotificationPrefsInput) =>
request('PATCH', '/me/notification-prefs', { schema: NotificationPrefsDto, body }), 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, ObligationCode,
SupplierRegimeHint, SupplierRegimeHint,
TaxpayerKind, TaxpayerKind,
UserRole,
VerificationStatus, VerificationStatus,
} from './enums'; } from './enums';
@@ -376,3 +377,107 @@ export type ReadyDto = z.infer<typeof ReadyDto>;
/** Re-exported so callers get the category vocabulary from one place. */ /** Re-exported so callers get the category vocabulary from one place. */
export { IrpCategory }; 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, OkDto,
HealthDto, HealthDto,
ReadyDto, ReadyDto,
SessionDto,
AdminUserDto,
AdminUserSearchDto,
AdminUserOverviewDto,
AdminErrorDto,
AdminErrorListQuery,
AdminErrorListDto,
ResolveErrorInput,
AdminAuditDto,
AdminAuditQuery,
AdminAuditListDto,
RoleChangeInput,
} from './dto'; } from './dto';
export { createApiClient, type ApiClient, type ApiClientOptions } from './client'; export { createApiClient, type ApiClient, type ApiClientOptions } from './client';
+69
View File
@@ -421,4 +421,73 @@ export const en: Record<MessageKey, string> = {
"admin.errors.resolve": "Mark resolved", "admin.errors.resolve": "Mark resolved",
"admin.audit.title": "Audit", "admin.audit.title": "Audit",
"admin.audit.export": "Export CSV", "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",
}; };
+70
View File
@@ -210,6 +210,76 @@ export const esExtra = {
"decl.approvedAt": "Aprobada el {date}", "decl.approvedAt": "Aprobada el {date}",
"decl.filedAt": "Presentada 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 // Chrome
"common.language": "Idioma", "common.language": "Idioma",
"common.languageEs": "Español", "common.languageEs": "Español",
+2
View File
@@ -16,6 +16,8 @@ export default defineConfig({
forbidOnly: Boolean(process.env['CI']), forbidOnly: Boolean(process.env['CI']),
retries: process.env['CI'] ? 2 : 0, retries: process.env['CI'] ? 2 : 0,
reporter: process.env['CI'] ? 'github' : 'list', reporter: process.env['CI'] ? 'github' : 'list',
// Compiles every screen before the first test asks for one. See the file for why.
globalSetup: './e2e/global-setup.ts',
use: { baseURL, trace: 'on-first-retry' }, use: { baseURL, trace: 'on-first-retry' },
projects: [ projects: [
{ name: 'mobile', use: { ...devices['Pixel 7'] } }, { name: 'mobile', use: { ...devices['Pixel 7'] } },