diff --git a/.gitignore b/.gitignore index 56046e0..bc448b6 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/DECISIONS.md b/DECISIONS.md index 5f9ecbf..b1961f9 100644 --- a/DECISIONS.md +++ b/DECISIONS.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. diff --git a/README.md b/README.md index 3d4b394..6f9ecf0 100644 --- a/README.md +++ b/README.md @@ -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 +``` diff --git a/apps/api/src/db/migrations/003_error_resolution_note.ts b/apps/api/src/db/migrations/003_error_resolution_note.ts new file mode 100644 index 0000000..9d07c9e --- /dev/null +++ b/apps/api/src/db/migrations/003_error_resolution_note.ts @@ -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): Promise { + await db.schema.alterTable('ingest_errors').addColumn('resolution_note', 'text').execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.alterTable('ingest_errors').dropColumn('resolution_note').execute(); +} diff --git a/apps/api/src/db/migrations/index.ts b/apps/api/src/db/migrations/index.ts index 67629cf..fb556ce 100644 --- a/apps/api/src/db/migrations/index.ts +++ b/apps/api/src/db/migrations/index.ts @@ -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 = { '001_core': core, '002_insight_dismissals': insightDismissals, + '003_error_resolution_note': errorResolutionNote, }; export const migrationProvider: MigrationProvider = { diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 65ee345..bff29e5 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -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; } diff --git a/apps/api/src/db/seed.ts b/apps/api/src/db/seed.ts index a43e3f8..cc39289 100644 --- a/apps/api/src/db/seed.ts +++ b/apps/api/src/db/seed.ts @@ -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 { + 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 { const row = await handle.db .selectFrom('user') diff --git a/apps/api/src/http/app.ts b/apps/api/src/http/app.ts index ed6173d..dd34aa2 100644 --- a/apps/api/src/http/app.ts +++ b/apps/api/src/http/app.ts @@ -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); diff --git a/apps/api/src/http/routes/admin.ts b/apps/api/src/http/routes/admin.ts new file mode 100644 index 0000000..707f7a3 --- /dev/null +++ b/apps/api/src/http/routes/admin.ts @@ -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 { + const routes = new Hono(); + 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 } }): Promise { + try { + return await c.req.json(); + } catch { + throw new HttpError('validation_error'); + } +} + +function parse(schema: z.ZodType, 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; + }, +): Promise { + return writeAudit(deps.handle.db, { + actorUserId: actor.id, + actorRole: actor.role, + ip: c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? null, + ...entry, + }); +} diff --git a/apps/api/src/http/routes/me.ts b/apps/api/src/http/routes/me.ts index bda97c2..ad4c826 100644 --- a/apps/api/src/http/routes/me.ts +++ b/apps/api/src/http/routes/me.ts @@ -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 { const routes = new Hono(); 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); diff --git a/apps/api/src/modules/admin/admin.test.ts b/apps/api/src/modules/admin/admin.test.ts new file mode 100644 index 0000000..88ce2d9 --- /dev/null +++ b/apps/api/src/modules/admin/admin.test.ts @@ -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().as('total')) + .executeTakeFirstOrThrow(); + + await as(staff)('/api/admin/audit'); + + const after = await h.deps.handle.db + .selectFrom('audit_log') + .select((eb) => eb.fn.countAll().as('total')) + .executeTakeFirstOrThrow(); + expect(Number(after.total)).toBe(Number(before.total)); + }); +}); + +async function auditCount(action: string): Promise { + const row = await h.deps.handle.db + .selectFrom('audit_log') + .select((eb) => eb.fn.countAll().as('total')) + .where('action', '=', action) + .executeTakeFirstOrThrow(); + return Number(row.total); +} diff --git a/apps/api/src/modules/admin/audit.ts b/apps/api/src/modules/admin/audit.ts new file mode 100644 index 0000000..864e8bd --- /dev/null +++ b/apps/api/src/modules/admin/audit.ts @@ -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, + query: AdminAuditQuery, +): Promise<{ items: AdminAuditDto[]; total: number; cursor?: string }> { + const base = filtered(db, query); + + const { total } = await base + .select((eb) => eb.fn.countAll().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, + query: AdminAuditQuery, +): Promise { + 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, 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) { + 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 | 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; + } + } 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, + }; +} diff --git a/apps/api/src/modules/admin/errors.ts b/apps/api/src/modules/admin/errors.ts new file mode 100644 index 0000000..8027ac1 --- /dev/null +++ b/apps/api/src/modules/admin/errors.ts @@ -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, + 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, + 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().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, + 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().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 => { + 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, + id: string, + by: { userId: string; note: string }, +): Promise { + 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, id: string): Promise { + 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, userId: string): Promise { + 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 | null { + if (!value) return null; + try { + const parsed: unknown = JSON.parse(value); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? (parsed as Record) + : null; + } catch { + return null; + } +} diff --git a/apps/api/src/modules/admin/index.ts b/apps/api/src/modules/admin/index.ts new file mode 100644 index 0000000..409a288 --- /dev/null +++ b/apps/api/src/modules/admin/index.ts @@ -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'; diff --git a/apps/api/src/modules/admin/roles.ts b/apps/api/src/modules/admin/roles.ts new file mode 100644 index 0000000..47c1442 --- /dev/null +++ b/apps/api/src/modules/admin/roles.ts @@ -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, + args: { actorUserId: string; targetUserId: string; role: UserRole }, +): Promise { + 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 }; +} diff --git a/apps/api/src/modules/admin/users.ts b/apps/api/src/modules/admin/users.ts new file mode 100644 index 0000000..d115298 --- /dev/null +++ b/apps/api/src/modules/admin/users.ts @@ -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, term: string): Promise { + 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, id: string): Promise { + 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, + id: string, +): Promise { + const user = await findUser(db, id); + if (!user) return null; + + const counts = await db + .selectFrom('documents') + .select((eb) => [ + eb.fn.countAll().as('documents'), + eb.fn + .sum(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().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); +} diff --git a/apps/api/src/modules/audit/index.ts b/apps/api/src/modules/audit/index.ts index 97194b8..91efb34 100644 --- a/apps/api/src/modules/audit/index.ts +++ b/apps/api/src/modules/audit/index.ts @@ -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 | undefined; @@ -41,7 +53,7 @@ export async function writeAudit(db: Kysely, 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, diff --git a/apps/api/src/modules/ingest/errors.ts b/apps/api/src/modules/ingest/errors.ts index 8c1edb6..e920c02 100644 --- a/apps/api/src/modules/ingest/errors.ts +++ b/apps/api/src/modules/ingest/errors.ts @@ -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(); diff --git a/apps/api/src/modules/jobs/jobs.test.ts b/apps/api/src/modules/jobs/jobs.test.ts index 674ca65..9b3c613 100644 --- a/apps/api/src/modules/jobs/jobs.test.ts +++ b/apps/api/src/modules/jobs/jobs.test.ts @@ -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(); diff --git a/apps/web/app/[locale]/(admin)/auditoria/audit-screen.tsx b/apps/web/app/[locale]/(admin)/auditoria/audit-screen.tsx new file mode 100644 index 0000000..305baf6 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/auditoria/audit-screen.tsx @@ -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>({}); + const [filters, setFilters] = useState(EMPTY); + const [pages, setPages] = useState([]); + const [cursor, setCursor] = useState(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 ( +
+
+

{t('admin.audit.title')}

+ + {t('admin.audit.export')} + +
+ +
+ {( + [ + ['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]) => ( +
+ + setDraft({ ...draft, [key]: event.target.value })} + className="h-9 w-44 rounded-lg text-sm" + autoComplete="off" + /> +
+ ))} + {/* Grouped, so the two buttons wrap together rather than one at a time. */} +
+ + +
+
+ + {audit.data ? ( +

+ {t('admin.audit.total', { count: audit.data.total })} +

+ ) : null} + + {audit.isPending && rows.length === 0 ? : null} + + {audit.isError ? ( +
+

+ {t('common.error.generic')} +

+ +
+ ) : null} + + {audit.data && rows.length === 0 ? : null} + + {rows.length > 0 ? ( + + + + + + + + + + + + + {rows.map((row) => ( + + + + + + + + + ))} + +
{t('admin.audit.col.when')}{t('admin.audit.col.actor')}{t('admin.audit.col.action')}{t('admin.audit.col.subject')}{t('admin.audit.col.resource')}{t('admin.audit.col.ip')}
+ {row.createdAt.slice(0, 16).replace('T', ' ')} + {row.actorEmail ?? row.actorUserId}{row.action}{row.subjectEmail ?? t('common.none')} + {row.resource} + {row.ip ?? t('common.none')}
+ ) : null} + + {audit.data?.cursor ? ( + + ) : null} +
+ ); +} + +function clean(draft: Record): AdminAuditQuery { + const out: Record = {}; + for (const [key, value] of Object.entries(draft)) { + if (value.trim().length > 0) out[key] = value.trim(); + } + return out as AdminAuditQuery; +} diff --git a/apps/web/app/[locale]/(admin)/auditoria/page.tsx b/apps/web/app/[locale]/(admin)/auditoria/page.tsx new file mode 100644 index 0000000..e05cd7f --- /dev/null +++ b/apps/web/app/[locale]/(admin)/auditoria/page.tsx @@ -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 ; +} diff --git a/apps/web/app/[locale]/(admin)/errores/errors-screen.tsx b/apps/web/app/[locale]/(admin)/errores/errors-screen.tsx new file mode 100644 index 0000000..819c18e --- /dev/null +++ b/apps/web/app/[locale]/(admin)/errores/errors-screen.tsx @@ -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(''); + const [status, setStatus] = useState('open'); + const [expanded, setExpanded] = useState(null); + + const errors = useQuery({ + queryKey: ['admin', 'errors', stage, status], + queryFn: ({ signal }) => + api.listAdminErrors({ ...(stage ? { stage } : {}), ...(status ? { status } : {}) }, signal), + }); + + return ( +
+

{t('admin.errors.title')}

+ +
+ + +
+ + {errors.isPending ? : null} + + {errors.isError ? ( +
+

+ {t('common.error.generic')} +

+ +
+ ) : null} + + {errors.data && errors.data.items.length === 0 ? ( + + ) : null} + + {errors.data && errors.data.items.length > 0 ? ( + + + + + + + + + + + + {errors.data.items.map((row) => ( + setExpanded(expanded === row.id ? null : row.id)} + /> + ))} + +
{t('admin.errors.col.when')}{t('admin.errors.col.stage')}{t('admin.errors.col.user')}{t('admin.errors.col.message')}{t('admin.errors.col.status')}
+ ) : null} +
+ ); +} + +function ErrorRow({ + row, + expanded, + onToggle, +}: { + row: AdminErrorDto; + expanded: boolean; + onToggle: () => void; +}) { + const t = useT(); + + return ( + <> + + {/* A timestamp is machine data here, so it stays sortable rather than prose. */} + {row.createdAt.slice(0, 16).replace('T', ' ')} + {t(`admin.errors.stage.${row.stage}` as const)} + {row.userEmail ?? t('common.none')} + + + + {t(`admin.errors.status.${row.status}` as const)} + + {expanded ? ( + + + + + + ) : 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 ( +
+ {row.attempts !== null ? ( +

+ {t('admin.errors.attempts', { count: row.attempts })} +

+ ) : null} + +
+        {JSON.stringify(row.payload ?? {}, null, 2)}
+      
+ + {row.source === 'job' ? ( +
+ + {retry.isError ? ( +

+ {t('common.error.generic')} +

+ ) : null} +
+ ) : null} + + {row.source === 'ingest' && row.status === 'open' ? ( +
{ + event.preventDefault(); + resolve.mutate(); + }} + > +
+ + setNote(event.target.value)} + placeholder={t('admin.errors.notePlaceholder')} + className="h-9 rounded-lg text-sm" + /> +
+ + {resolve.isError ? ( +

+ {t('common.error.generic')} +

+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/errores/page.tsx b/apps/web/app/[locale]/(admin)/errores/page.tsx new file mode 100644 index 0000000..43f632f --- /dev/null +++ b/apps/web/app/[locale]/(admin)/errores/page.tsx @@ -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 ; +} diff --git a/apps/web/app/[locale]/(admin)/layout.tsx b/apps/web/app/[locale]/(admin)/layout.tsx new file mode 100644 index 0000000..a6de03a --- /dev/null +++ b/apps/web/app/[locale]/(admin)/layout.tsx @@ -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 ( +
+ + + +
+ ); + } + + return ( +
+
+
+ {t('admin.nav.console')} + +
+
+ {session.email} + + + {t('common.back')} + +
+
+ + {/* The reminder sits above every screen in the console, not only the user search. */} +

+ {t('admin.users.auditBanner')} +

+ +
{children}
+
+ ); +} diff --git a/apps/web/app/[locale]/(admin)/usuarios/[id]/page.tsx b/apps/web/app/[locale]/(admin)/usuarios/[id]/page.tsx new file mode 100644 index 0000000..81cd82b --- /dev/null +++ b/apps/web/app/[locale]/(admin)/usuarios/[id]/page.tsx @@ -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 ; +} diff --git a/apps/web/app/[locale]/(admin)/usuarios/[id]/user-overview.tsx b/apps/web/app/[locale]/(admin)/usuarios/[id]/user-overview.tsx new file mode 100644 index 0000000..92d898b --- /dev/null +++ b/apps/web/app/[locale]/(admin)/usuarios/[id]/user-overview.tsx @@ -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 ( + + + + ); + } + + if (overview.isError || !overview.data) { + return ( + +

+ {t('common.error.generic')} +

+ +
+ ); + } + + const { user, profile, counts, recentErrors, lastActivityAt } = overview.data; + + return ( +
+
+
+

{user.fullName}

+

{user.email}

+
+ + {t('common.back')} + +
+ +
+ {( + [ + ['documents', counts.documents], + ['needsReview', counts.needsReview], + ['declarations', counts.declarations], + ] as const + ).map(([key, value]) => ( + +

{t(`admin.users.counts.${key}`)}

+

{value}

+
+ ))} +
+ + + {profile ? ( +
+ + + obligation.active) + .map((obligation) => t(`obligation.${obligation.code}` as const)) + .join(', ')} + /> + +
+ ) : ( +

{t('admin.users.noProfile')}

+ )} +

+ {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')} +

+
+ +
+

{t('admin.users.openErrors')}

+ {recentErrors.length === 0 ? ( +

{t('admin.users.noErrors')}

+ ) : ( + +
    + {recentErrors.map((error) => ( +
  • + + {t(`admin.errors.stage.${error.stage}` as const)} + + {error.message} + + {error.createdAt.slice(0, 10)} + +
  • + ))} +
+
+ )} + {/* The queue itself, for the rows this summary does not show. */} + + {t('admin.errors.goToQueue')} + +
+ + {viewerRole === 'superadmin' ? : null} +
+ ); +} + +function RoleManager({ user }: { user: { id: string; email: string; role: UserRole } }) { + const t = useT(); + const queryClient = useQueryClient(); + const [role, setRole] = useState(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 ( + +

{t('admin.role.title')}

+
+ + +
+ + {confirming ? ( +
+

+ {t('admin.role.confirm', { email: user.email, role: t(`admin.role.${role}` as const) })} +

+
+ + +
+
+ ) : null} + + {change.isError ? ( +

+ {t('common.error.generic')} +

+ ) : null} + {change.isSuccess ?

{t('admin.role.saved')}

: null} +
+ ); +} + +/** + * 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 ( +
+
{label}
+
{value}
+
+ ); +} + +function docOf(ruc: string | null, rucDv: string | null, ci: string | null): string { + if (ruc) return rucDv ? `${ruc}-${rucDv}` : ruc; + return ci ?? ''; +} diff --git a/apps/web/app/[locale]/(admin)/usuarios/page.tsx b/apps/web/app/[locale]/(admin)/usuarios/page.tsx new file mode 100644 index 0000000..c5560e1 --- /dev/null +++ b/apps/web/app/[locale]/(admin)/usuarios/page.tsx @@ -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 ; +} diff --git a/apps/web/app/[locale]/(admin)/usuarios/users-screen.tsx b/apps/web/app/[locale]/(admin)/usuarios/users-screen.tsx new file mode 100644 index 0000000..a114fef --- /dev/null +++ b/apps/web/app/[locale]/(admin)/usuarios/users-screen.tsx @@ -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 ( +
+

{t('admin.users.title')}

+ +
+
+ + setDraft(event.target.value)} + className="h-10 rounded-lg text-sm" + autoComplete="off" + /> +

{t('admin.users.searchHint')}

+
+ +
+ + {term.length < MIN_LENGTH ? : null} + + {results.isPending && term.length >= MIN_LENGTH ? ( + + ) : null} + + {results.isError ? ( +
+

+ {t('common.error.generic')} +

+ +
+ ) : null} + + {results.data && results.data.items.length === 0 ? ( + + ) : null} + + {results.data && results.data.items.length > 0 ? ( + + + + + + + + + + + + {results.data.items.map((user) => ( + + + + + + + + ))} + +
{t('admin.users.col.email')}{t('admin.users.col.name')}{t('admin.users.col.doc')}{t('admin.users.col.role')}{t('admin.users.col.created')}
+ + {user.email} + + {user.fullName}{user.doc ?? t('common.none')}{t(`admin.role.${user.role}` as const)}{user.createdAt.slice(0, 10)}
+ ) : null} +
+ ); +} diff --git a/apps/web/src/components/ui/select.tsx b/apps/web/src/components/ui/select.tsx new file mode 100644 index 0000000..129d37c --- /dev/null +++ b/apps/web/src/components/ui/select.tsx @@ -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) { + return ( +