phase-6: the staff console, and a log that says who did what
Flow H, three screens behind a role check: find an account, work the ingestion error queue, read and export the audit log. Superadmins can change a role, never their own. The error queue merges ingest errors and dead jobs into one table with a cursor that pages both sources; only a job can be retried and only an ingest row resolved, with a note that migration 003 gives it somewhere to live. writeAudit no longer defaults a missing subject to the actor, which had been recording a user search as staff looking themselves up. Omitting the subject still means acting on yourself; null now means the action has no subject, which is what a search, a retry and an export are. Reading the log is not audited. Exporting it is: a copy leaving the building is a different act from looking. e2e/global-setup.ts asks for every screen once before the suite starts, so a dev server's first-request compile is paid before the first test rather than by it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6620650e9e
commit
4c39926483
+3
-2
@@ -10,8 +10,9 @@ apps/*/data/
|
||||
# Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build.
|
||||
apps/web/public/zxing/
|
||||
data/
|
||||
# Copied from node_modules by apps/web/scripts/copy-zxing-wasm.mjs before dev and build.
|
||||
apps/web/public/zxing/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.DS_Store
|
||||
# Written by `next dev` on every run. Tool output, not part of the product.
|
||||
apps/web/AGENTS.md
|
||||
apps/web/CLAUDE.md
|
||||
|
||||
@@ -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
|
||||
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.
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -7,9 +7,10 @@ and ready to file yourself.
|
||||
Working name. See `docs/` for the specifications, `DECISIONS.md` for choices made along the
|
||||
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
|
||||
> 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.
|
||||
|
||||
---
|
||||
@@ -28,6 +29,11 @@ pnpm dev
|
||||
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.
|
||||
|
||||
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
|
||||
`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
|
||||
@@ -183,3 +189,13 @@ Then start the stack and run it:
|
||||
```bash
|
||||
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();
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { Migration, MigrationProvider } from 'kysely/migration';
|
||||
import * as core from './001_core';
|
||||
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
|
||||
@@ -9,6 +10,7 @@ import * as insightDismissals from './002_insight_dismissals';
|
||||
const migrations: Record<string, Migration> = {
|
||||
'001_core': core,
|
||||
'002_insight_dismissals': insightDismissals,
|
||||
'003_error_resolution_note': errorResolutionNote,
|
||||
};
|
||||
|
||||
export const migrationProvider: MigrationProvider = {
|
||||
|
||||
@@ -228,6 +228,8 @@ export interface IngestErrorsTable {
|
||||
status: 'open' | 'resolved';
|
||||
resolved_by: string | null;
|
||||
resolved_at: string | null;
|
||||
/** What the staff member who closed the row said about it. */
|
||||
resolution_note: string | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,7 @@ export async function seed(
|
||||
await seedDocuments(handle, options.now ?? new Date());
|
||||
await seedDeclarations(handle, options.now ?? new Date());
|
||||
await seedAuditTrail(handle);
|
||||
await seedDeadJob(handle, options.now ?? new Date());
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -248,6 +249,42 @@ async function upsertProfileRow(handle: DbHandle, seed: ProfileSeed): Promise<vo
|
||||
.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> {
|
||||
const row = await handle.db
|
||||
.selectFrom('user')
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { AppDeps, AppEnv } from './context';
|
||||
import { HttpError, toEnvelope } from './errors';
|
||||
import { liveness, readiness } from './health';
|
||||
import { localeMiddleware, sessionMiddleware } from './middleware';
|
||||
import { adminRoutes } from './routes/admin';
|
||||
import { dashboardRoutes, deadlineRoutes } from './routes/dashboard';
|
||||
import { declarationRoutes } from './routes/declarations';
|
||||
import { documentRoutes } from './routes/documents';
|
||||
@@ -57,6 +58,7 @@ export function createApp(deps: AppDeps): AppHandle {
|
||||
api.route('/dashboard', dashboardRoutes(deps));
|
||||
api.route('/deadlines', deadlineRoutes(deps));
|
||||
api.route('/declarations', declarationRoutes(deps));
|
||||
api.route('/admin', adminRoutes(deps));
|
||||
|
||||
app.route('/api', api);
|
||||
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DependentInput,
|
||||
NotificationPrefsInput,
|
||||
ProfileInput,
|
||||
UserRole,
|
||||
} from '@impuestos/contracts';
|
||||
import { Hono } from 'hono';
|
||||
import type { z } from 'zod';
|
||||
@@ -28,6 +29,16 @@ export function meRoutes(deps: AppDeps): Hono<AppEnv> {
|
||||
const routes = new Hono<AppEnv>();
|
||||
const db = deps.handle.db;
|
||||
|
||||
// Who you are, for a client that needs the role before it renders (the admin console).
|
||||
routes.get('/session', (c) => {
|
||||
const user = requireUser(c);
|
||||
// The session carries whatever string the user row holds. A value outside the enum
|
||||
// would fail the client's schema and take a page down, so it reads as the least
|
||||
// privileged role instead.
|
||||
const role = UserRole.safeParse(user.role);
|
||||
return c.json({ id: user.id, email: user.email, role: role.success ? role.data : 'user' });
|
||||
});
|
||||
|
||||
// 404 until setup is complete: the client routes to onboarding (CONTRACTS.md section 3).
|
||||
routes.get('/profile', async (c) => {
|
||||
const user = requireUser(c);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -17,16 +17,28 @@ export type AuditAction =
|
||||
| 'notification_prefs.update'
|
||||
| 'data.export'
|
||||
| '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_view'
|
||||
| 'admin.role_change'
|
||||
| 'admin.error_resolve'
|
||||
| 'admin.job_retry'
|
||||
| 'admin.audit_export'
|
||||
| 'admin.file_access';
|
||||
|
||||
export interface AuditEntry {
|
||||
actorUserId: string;
|
||||
actorRole: string;
|
||||
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;
|
||||
resource: string;
|
||||
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_role: entry.actorRole,
|
||||
action: entry.action,
|
||||
subject_user_id: entry.subjectUserId ?? entry.actorUserId,
|
||||
subject_user_id: entry.subjectUserId === undefined ? entry.actorUserId : entry.subjectUserId,
|
||||
resource: entry.resource,
|
||||
detail: entry.detail === undefined ? null : JSON.stringify(entry.detail),
|
||||
ip: entry.ip ?? null,
|
||||
|
||||
@@ -32,6 +32,7 @@ export async function recordIngestError(
|
||||
status: 'open',
|
||||
resolved_by: null,
|
||||
resolved_at: null,
|
||||
resolution_note: null,
|
||||
created_at: new Date().toISOString(),
|
||||
})
|
||||
.execute();
|
||||
|
||||
@@ -61,14 +61,20 @@ describe('backoff', () => {
|
||||
describe('failure handling', () => {
|
||||
it('retries a failing job with backoff, then marks it dead', async () => {
|
||||
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',
|
||||
payload: {}, // no documentId: the handler throws
|
||||
maxAttempts: 2,
|
||||
});
|
||||
|
||||
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.attempts).toBe(1);
|
||||
expect(row.last_error).toContain('documentId');
|
||||
@@ -79,10 +85,15 @@ describe('failure handling', () => {
|
||||
await h.deps.handle.db
|
||||
.updateTable('jobs')
|
||||
.set({ run_at: new Date(Date.now() - 1000).toISOString() })
|
||||
.where('id', '=', id)
|
||||
.execute();
|
||||
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.attempts).toBe(2);
|
||||
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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
await expect(page.getByRole('heading', { name: es['decl.preview.title'] })).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();
|
||||
|
||||
// D3: approving asks once, restating the number.
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import type { z } from 'zod';
|
||||
import {
|
||||
AdminAuditListDto,
|
||||
type AdminAuditQuery,
|
||||
AdminErrorListDto,
|
||||
type AdminErrorListQuery,
|
||||
AdminUserOverviewDto,
|
||||
AdminUserSearchDto,
|
||||
type ClassificationPatchInput,
|
||||
type ConsentInput,
|
||||
DataExportDto,
|
||||
@@ -24,6 +30,9 @@ import {
|
||||
ProfileDto,
|
||||
type ProfileInput,
|
||||
type RejectInput,
|
||||
type RoleChangeInput,
|
||||
SessionDto,
|
||||
type ResolveErrorInput,
|
||||
ScanResultDto,
|
||||
TraceDto,
|
||||
type TraceKind,
|
||||
@@ -112,6 +121,10 @@ export function createApiClient(options: ApiClientOptions = {}) {
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
|
||||
// Session
|
||||
getSession: (signal?: AbortSignal) =>
|
||||
request('GET', '/me/session', { schema: SessionDto, ...(signal ? { signal } : {}) }),
|
||||
|
||||
// Profile
|
||||
getProfile: (signal?: AbortSignal) =>
|
||||
request('GET', '/me/profile', { schema: ProfileDto, ...(signal ? { signal } : {}) }),
|
||||
@@ -223,6 +236,46 @@ export function createApiClient(options: ApiClientOptions = {}) {
|
||||
}),
|
||||
patchNotificationPrefs: (body: NotificationPrefsInput) =>
|
||||
request('PATCH', '/me/notification-prefs', { schema: NotificationPrefsDto, body }),
|
||||
|
||||
// Admin. Every one of these writes an audit row on the server.
|
||||
searchUsers: (q: string, signal?: AbortSignal) =>
|
||||
request('GET', '/admin/users/search', {
|
||||
schema: AdminUserSearchDto,
|
||||
query: { q },
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
getUserOverview: (id: string, signal?: AbortSignal) =>
|
||||
request('GET', `/admin/users/${encodeURIComponent(id)}/overview`, {
|
||||
schema: AdminUserOverviewDto,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
setUserRole: (id: string, body: RoleChangeInput) =>
|
||||
request('POST', `/admin/users/${encodeURIComponent(id)}/role`, { schema: OkDto, body }),
|
||||
listAdminErrors: (query: AdminErrorListQuery, signal?: AbortSignal) =>
|
||||
request('GET', '/admin/errors', {
|
||||
schema: AdminErrorListDto,
|
||||
query: query as Record<string, string | undefined>,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
resolveAdminError: (id: string, body: ResolveErrorInput) =>
|
||||
request('POST', `/admin/errors/${encodeURIComponent(id)}/resolve`, { schema: OkDto, body }),
|
||||
retryJob: (id: string) =>
|
||||
request('POST', `/admin/jobs/${encodeURIComponent(id)}/retry`, { schema: OkDto }),
|
||||
listAudit: (query: AdminAuditQuery, signal?: AbortSignal) =>
|
||||
request('GET', '/admin/audit', {
|
||||
schema: AdminAuditListDto,
|
||||
query: query as Record<string, string | undefined>,
|
||||
...(signal ? { signal } : {}),
|
||||
}),
|
||||
/** A file download, so it is linked to rather than fetched. */
|
||||
auditCsvUrl: (query: AdminAuditQuery) => {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value) params.set(key, value);
|
||||
}
|
||||
const search = params.toString();
|
||||
return `/api/admin/audit/export.csv${search ? `?${search}` : ''}`;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ObligationCode,
|
||||
SupplierRegimeHint,
|
||||
TaxpayerKind,
|
||||
UserRole,
|
||||
VerificationStatus,
|
||||
} from './enums';
|
||||
|
||||
@@ -376,3 +377,107 @@ export type ReadyDto = z.infer<typeof ReadyDto>;
|
||||
|
||||
/** Re-exported so callers get the category vocabulary from one place. */
|
||||
export { IrpCategory };
|
||||
|
||||
/**
|
||||
* SPEC-GAP: CONTRACTS.md section 3 lists no way to ask who you are signed in as, and the
|
||||
* admin console has to know a viewer's role before it renders anything. `GET /me/session`
|
||||
* answers that and nothing else.
|
||||
*/
|
||||
export const SessionDto = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
role: UserRole,
|
||||
});
|
||||
export type SessionDto = z.infer<typeof SessionDto>;
|
||||
|
||||
/* Admin (CONTRACTS.md section 3, FLOWS.md Flow H). Staff and superadmin only. */
|
||||
|
||||
export const AdminUserDto = z.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
fullName: z.string(),
|
||||
/** The RUC with its check digit, or the CI, whichever the profile carries. */
|
||||
doc: z.string().nullable(),
|
||||
role: UserRole,
|
||||
createdAt: z.string(),
|
||||
});
|
||||
export type AdminUserDto = z.infer<typeof AdminUserDto>;
|
||||
|
||||
export const AdminUserSearchDto = z.object({ items: z.array(AdminUserDto) });
|
||||
export type AdminUserSearchDto = z.infer<typeof AdminUserSearchDto>;
|
||||
|
||||
export const AdminUserOverviewDto = z.object({
|
||||
user: AdminUserDto,
|
||||
/**
|
||||
* SPEC-GAP: CONTRACTS.md types this as ProfileDto, but an account that never finished
|
||||
* onboarding has no profile row, and staff need to be able to see exactly that.
|
||||
*/
|
||||
profile: ProfileDto.nullable(),
|
||||
counts: z.object({
|
||||
documents: z.number().int(),
|
||||
needsReview: z.number().int(),
|
||||
declarations: z.number().int(),
|
||||
}),
|
||||
recentErrors: z.array(IngestErrorDto),
|
||||
lastActivityAt: z.string().nullable(),
|
||||
});
|
||||
export type AdminUserOverviewDto = z.infer<typeof AdminUserOverviewDto>;
|
||||
|
||||
/**
|
||||
* One row of the error queue. Ingest errors and dead jobs share the table, so the row
|
||||
* carries which one it is: only a `job` row can be retried, only an `ingest` row resolved.
|
||||
*/
|
||||
export const AdminErrorDto = IngestErrorDto.extend({
|
||||
source: z.enum(['ingest', 'job']),
|
||||
/** Shown when the row is expanded. Null when nothing was captured. */
|
||||
payload: z.record(z.string(), z.unknown()).nullable(),
|
||||
userEmail: z.string().nullable(),
|
||||
attempts: z.number().int().nullable(),
|
||||
resolvedAt: z.string().nullable(),
|
||||
});
|
||||
export type AdminErrorDto = z.infer<typeof AdminErrorDto>;
|
||||
|
||||
export const AdminErrorListQuery = z.object({
|
||||
stage: z.enum(['qr_parse', 'ocr', 'dedupe', 'verify', 'job', 'other']).optional(),
|
||||
status: z.enum(['open', 'resolved']).optional(),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
export type AdminErrorListQuery = z.infer<typeof AdminErrorListQuery>;
|
||||
|
||||
export const AdminErrorListDto = listDto(AdminErrorDto);
|
||||
export type AdminErrorListDto = z.infer<typeof AdminErrorListDto>;
|
||||
|
||||
export const ResolveErrorInput = z.object({ note: z.string().trim().min(1).max(500) });
|
||||
export type ResolveErrorInput = z.infer<typeof ResolveErrorInput>;
|
||||
|
||||
export const AdminAuditDto = z.object({
|
||||
id: z.string(),
|
||||
actorUserId: z.string(),
|
||||
actorEmail: z.string().nullable(),
|
||||
actorRole: z.string(),
|
||||
action: z.string(),
|
||||
subjectUserId: z.string().nullable(),
|
||||
subjectEmail: z.string().nullable(),
|
||||
resource: z.string(),
|
||||
detail: z.record(z.string(), z.unknown()).nullable(),
|
||||
ip: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
export type AdminAuditDto = z.infer<typeof AdminAuditDto>;
|
||||
|
||||
export const AdminAuditQuery = z.object({
|
||||
actor: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
subject: z.string().optional(),
|
||||
/** Inclusive YYYY-MM-DD bounds on the local calendar day the row was written. */
|
||||
from: z.string().optional(),
|
||||
to: z.string().optional(),
|
||||
cursor: z.string().optional(),
|
||||
});
|
||||
export type AdminAuditQuery = z.infer<typeof AdminAuditQuery>;
|
||||
|
||||
export const AdminAuditListDto = listDto(AdminAuditDto);
|
||||
export type AdminAuditListDto = z.infer<typeof AdminAuditListDto>;
|
||||
|
||||
export const RoleChangeInput = z.object({ role: UserRole });
|
||||
export type RoleChangeInput = z.infer<typeof RoleChangeInput>;
|
||||
|
||||
@@ -57,6 +57,18 @@ export {
|
||||
OkDto,
|
||||
HealthDto,
|
||||
ReadyDto,
|
||||
SessionDto,
|
||||
AdminUserDto,
|
||||
AdminUserSearchDto,
|
||||
AdminUserOverviewDto,
|
||||
AdminErrorDto,
|
||||
AdminErrorListQuery,
|
||||
AdminErrorListDto,
|
||||
ResolveErrorInput,
|
||||
AdminAuditDto,
|
||||
AdminAuditQuery,
|
||||
AdminAuditListDto,
|
||||
RoleChangeInput,
|
||||
} from './dto';
|
||||
|
||||
export { createApiClient, type ApiClient, type ApiClientOptions } from './client';
|
||||
|
||||
@@ -421,4 +421,73 @@ export const en: Record<MessageKey, string> = {
|
||||
"admin.errors.resolve": "Mark resolved",
|
||||
"admin.audit.title": "Audit",
|
||||
"admin.audit.export": "Export CSV",
|
||||
// Admin console (phase 6)
|
||||
"common.loadMore": "Show more",
|
||||
"common.none": "No data",
|
||||
"admin.nav.console": "Console",
|
||||
"admin.forbidden": "This section is for the team only.",
|
||||
"admin.users.searchLabel": "Find an account",
|
||||
"admin.users.searchHint": "Email, RUC, CI or name. Type at least 2 characters.",
|
||||
"admin.users.empty": "No account matches that.",
|
||||
"admin.users.start": "Search for an account to begin.",
|
||||
"admin.users.col.email": "Email",
|
||||
"admin.users.col.name": "Name",
|
||||
"admin.users.col.doc": "RUC or CI",
|
||||
"admin.users.col.role": "Role",
|
||||
"admin.users.col.created": "Joined",
|
||||
"admin.users.counts.documents": "Comprobantes",
|
||||
"admin.users.counts.needsReview": "To review",
|
||||
"admin.users.counts.declarations": "Declarations",
|
||||
"admin.users.lastActivity": "Last activity: {date}",
|
||||
"admin.users.noActivity": "No activity yet",
|
||||
"admin.users.noProfile": "This account has not finished its profile.",
|
||||
"admin.users.openErrors": "Open errors on this account",
|
||||
"admin.users.noErrors": "No open errors.",
|
||||
"admin.users.obligations": "Obligations",
|
||||
"admin.role.title": "Role",
|
||||
"admin.role.change": "Change role",
|
||||
"admin.role.confirm": "You are changing {email} to {role}. This is logged under your name.",
|
||||
"admin.role.saved": "Role updated.",
|
||||
"admin.role.user": "User",
|
||||
"admin.role.accountant": "Accountant",
|
||||
"admin.role.staff": "Staff",
|
||||
"admin.role.superadmin": "Superadmin",
|
||||
"admin.errors.empty": "The queue is empty.",
|
||||
"admin.errors.col.when": "When",
|
||||
"admin.errors.col.stage": "Stage",
|
||||
"admin.errors.col.user": "Account",
|
||||
"admin.errors.col.message": "Message",
|
||||
"admin.errors.col.status": "Status",
|
||||
"admin.errors.filterStage": "Stage",
|
||||
"admin.errors.filterStatus": "Status",
|
||||
"admin.errors.all": "All",
|
||||
"admin.errors.allStatus": "All",
|
||||
"admin.errors.stage.qr_parse": "QR",
|
||||
"admin.errors.stage.ocr": "OCR",
|
||||
"admin.errors.stage.dedupe": "Duplicates",
|
||||
"admin.errors.stage.verify": "Verification",
|
||||
"admin.errors.stage.job": "Job",
|
||||
"admin.errors.stage.other": "Other",
|
||||
"admin.errors.status.open": "Open",
|
||||
"admin.errors.status.resolved": "Resolved",
|
||||
"admin.errors.retry": "Retry the job",
|
||||
"admin.errors.note": "Resolution note",
|
||||
"admin.errors.notePlaceholder": "What happened and what you did",
|
||||
"admin.errors.attempts": "{count} attempts",
|
||||
"admin.errors.goToQueue": "Open the full error queue",
|
||||
"admin.audit.empty": "Nothing matches those filters.",
|
||||
"admin.audit.col.when": "When",
|
||||
"admin.audit.col.actor": "Who",
|
||||
"admin.audit.col.action": "Action",
|
||||
"admin.audit.col.subject": "About whom",
|
||||
"admin.audit.col.resource": "Resource",
|
||||
"admin.audit.col.ip": "IP",
|
||||
"admin.audit.filterActor": "Who (email)",
|
||||
"admin.audit.filterAction": "Action",
|
||||
"admin.audit.filterSubject": "About whom (email)",
|
||||
"admin.audit.filterFrom": "From",
|
||||
"admin.audit.filterTo": "To",
|
||||
"admin.audit.apply": "Filter",
|
||||
"admin.audit.clear": "Clear",
|
||||
"admin.audit.total": "{count} entries",
|
||||
};
|
||||
|
||||
@@ -210,6 +210,76 @@ export const esExtra = {
|
||||
"decl.approvedAt": "Aprobada el {date}",
|
||||
"decl.filedAt": "Presentada el {date}",
|
||||
|
||||
// Admin console (phase 6). Plain and dense: this is a tool, not a product surface.
|
||||
"common.loadMore": "Ver mas",
|
||||
"common.none": "Sin datos",
|
||||
"admin.nav.console": "Consola",
|
||||
"admin.forbidden": "Esta seccion es solo para el equipo.",
|
||||
"admin.users.searchLabel": "Buscar una cuenta",
|
||||
"admin.users.searchHint": "Email, RUC, CI o nombre. Escribí al menos 2 caracteres.",
|
||||
"admin.users.empty": "No encontramos ninguna cuenta con ese dato.",
|
||||
"admin.users.start": "Buscá una cuenta para empezar.",
|
||||
"admin.users.col.email": "Email",
|
||||
"admin.users.col.name": "Nombre",
|
||||
"admin.users.col.doc": "RUC o CI",
|
||||
"admin.users.col.role": "Rol",
|
||||
"admin.users.col.created": "Alta",
|
||||
"admin.users.counts.documents": "Comprobantes",
|
||||
"admin.users.counts.needsReview": "Para revisar",
|
||||
"admin.users.counts.declarations": "Declaraciones",
|
||||
"admin.users.lastActivity": "Ultima actividad: {date}",
|
||||
"admin.users.noActivity": "Sin actividad todavia",
|
||||
"admin.users.noProfile": "Esta cuenta todavia no completo el perfil.",
|
||||
"admin.users.openErrors": "Errores abiertos de esta cuenta",
|
||||
"admin.users.noErrors": "Sin errores abiertos.",
|
||||
"admin.users.obligations": "Obligaciones",
|
||||
"admin.role.title": "Rol",
|
||||
"admin.role.change": "Cambiar rol",
|
||||
"admin.role.confirm": "Vas a cambiar el rol de {email} a {role}. Queda registrado con tu nombre.",
|
||||
"admin.role.saved": "Rol actualizado.",
|
||||
"admin.role.user": "Usuario",
|
||||
"admin.role.accountant": "Contador",
|
||||
"admin.role.staff": "Staff",
|
||||
"admin.role.superadmin": "Superadmin",
|
||||
"admin.errors.empty": "No hay errores en la cola.",
|
||||
"admin.errors.col.when": "Cuando",
|
||||
"admin.errors.col.stage": "Etapa",
|
||||
"admin.errors.col.user": "Cuenta",
|
||||
"admin.errors.col.message": "Mensaje",
|
||||
"admin.errors.col.status": "Estado",
|
||||
"admin.errors.filterStage": "Etapa",
|
||||
"admin.errors.filterStatus": "Estado",
|
||||
"admin.errors.all": "Todas",
|
||||
"admin.errors.allStatus": "Todos",
|
||||
"admin.errors.stage.qr_parse": "QR",
|
||||
"admin.errors.stage.ocr": "OCR",
|
||||
"admin.errors.stage.dedupe": "Duplicados",
|
||||
"admin.errors.stage.verify": "Verificacion",
|
||||
"admin.errors.stage.job": "Job",
|
||||
"admin.errors.stage.other": "Otro",
|
||||
"admin.errors.status.open": "Abierto",
|
||||
"admin.errors.status.resolved": "Resuelto",
|
||||
"admin.errors.retry": "Reintentar el job",
|
||||
"admin.errors.note": "Nota de resolucion",
|
||||
"admin.errors.notePlaceholder": "Que paso y que hiciste",
|
||||
"admin.errors.attempts": "{count} intentos",
|
||||
"admin.errors.goToQueue": "Ver toda la cola de errores",
|
||||
"admin.audit.empty": "Ningun movimiento con esos filtros.",
|
||||
"admin.audit.col.when": "Cuando",
|
||||
"admin.audit.col.actor": "Quien",
|
||||
"admin.audit.col.action": "Accion",
|
||||
"admin.audit.col.subject": "Sobre quien",
|
||||
"admin.audit.col.resource": "Recurso",
|
||||
"admin.audit.col.ip": "IP",
|
||||
"admin.audit.filterActor": "Quien (email)",
|
||||
"admin.audit.filterAction": "Accion",
|
||||
"admin.audit.filterSubject": "Sobre quien (email)",
|
||||
"admin.audit.filterFrom": "Desde",
|
||||
"admin.audit.filterTo": "Hasta",
|
||||
"admin.audit.apply": "Filtrar",
|
||||
"admin.audit.clear": "Limpiar",
|
||||
"admin.audit.total": "{count} movimientos",
|
||||
|
||||
// Chrome
|
||||
"common.language": "Idioma",
|
||||
"common.languageEs": "Español",
|
||||
|
||||
@@ -16,6 +16,8 @@ export default defineConfig({
|
||||
forbidOnly: Boolean(process.env['CI']),
|
||||
retries: process.env['CI'] ? 2 : 0,
|
||||
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' },
|
||||
projects: [
|
||||
{ name: 'mobile', use: { ...devices['Pixel 7'] } },
|
||||
|
||||
Reference in New Issue
Block a user