diff --git a/ARCHITECTURE_ESSENTIALS.md b/ARCHITECTURE_ESSENTIALS.md index 8618723..d4be416 100644 --- a/ARCHITECTURE_ESSENTIALS.md +++ b/ARCHITECTURE_ESSENTIALS.md @@ -183,6 +183,70 @@ Undo behaviour: start/complete are applied optimistically and can be reverted wi - `GET /facilities/:facilityId/fulfillment-queue` → `FulfillmentQueueItem[]` — queue items with action attribution (`attribution: { action, performedByUserId, performedByName, performedByRole, timestamp, notes }`). - `POST /facilities/:facilityId/fulfillment-queue/:itemId/action` (body: `{action: 'fulfilled' | 'rejected', notes?}`) → `FulfillmentQueueItem` — records the acting staff user's identity and role for PRD §7 audit trail. +### Phase W6 — Admin audit & access log viewer (issue #71) + +#### State-change events — real, docforum-core#43 (see its `docs/api/README.md` "Audit trail") +- `GET /admin/audit-events?entityType=&entityId=&actorUserId=&from=&to=&limit=&cursor=` → `{items: AuditEvent[], nextCursor: string | null}`, newest first, keyset-paginated on `(createdAt, id)`. +- `entityId` is only accepted together with `entityType`; `from`/`to` are inclusive ISO dates; `limit` is 1–200 (this UI sends 50). Unknown parameters are `400`; a malformed cursor is `400 INVALID_CURSOR` (the UI offers "Back to newest"). +- `AuditEvent` shape: `{id, actorUserId, actorRole, entityType, entityId, action, fromState, toState, requestId, metadata, createdAt}`, mirrored in `models.ts`. `metadata` never holds personal data (server-enforced). +- **API gap:** there is no `action` filter. Because unknown parameters are `400`, the viewer filters by action client-side, within the loaded page only, and says so on screen. Worth a `docforum-core` issue to add server-side `action` filtering. + +#### PHI access logs — assumed, docforum-core#45 not landed +- `GET /admin/access-logs` with the **same** query parameters and `{items, nextCursor}` envelope as audit events (#45 itself only specifies `?entityId=`). +- `AccessLog` shape: `{id, actorUserId, actorRole, entityType, entityId, route, createdAt}`. `actorRole` is assumed so the column matches the events tab. +- A `404` is shown as "docforum-core doesn't serve access logs yet" rather than a generic error. + +#### Frontend behaviour +- Filters, tab and cursor live in the URL query (`tab`, `entityType`, `entityId`, `actor`, `action`, `from`, `to`, `cursor`); values from the URL are validated (charset, length, real calendar dates, `from ≤ to`) and invalid ones are dropped with a visible notice. +- Entity links: only `PaymentIntent` has an admin-reachable view (`/admin?intent=`). Appointment and doctor-profile ids render as plain text until admin views exist. +- CSV export is client-side and covers exactly the rows on screen; cells starting with `= + - @` are prefixed with `'` to defuse spreadsheet formulas. + +### Phase W6 — Stuck referrals console (issue #72, depends on docforum-core#68 and #65) + +docforum-core#68 names the three endpoints but not their shapes, and its +`Referral` model is still field-sparse in `schema.prisma`. Everything below +is assumed; `ReferralStatus` and `Specialty` are the only real types, and the +`Referral` fields follow docforum-core's `ARCHITECTURE.md` §4.4 plus +`parentReferralId` from #65. + +- `GET /admin/referrals?status=pending&olderThan=<24h|48h|72h|168h>&cursor=` → `{items: StuckReferral[], nextCursor}`, oldest first (the console re-sorts the loaded pages by `createdAt` anyway). `olderThan` is the only filter sent; the reason filter is client-side over loaded pages, because #68 doesn't define one. +- `StuckReferral` = `Referral` (without `snapshotNotes`, which is encrypted clinical text the console never needs) plus `stuckReason: 'no_doctor'|'doctor_inactive'|'loop_flagged'`, embedded `targetSpecialty: Specialty`, `referringDoctor`/`targetDoctor` as `{id, fullName}`, and `chain: ReferralChainLink[] | null`, sent only for `loop_flagged`, oldest hop first, ending with the referral itself. `ReferralChainLink` = `{referralId, referringDoctor, targetSpecialty, targetDoctor, status, createdAt}`. No patient name or id is shown. +- `GET /admin/doctors?specialtyId=&verificationStatus=verified` → `DoctorAvailability[]` = `{id, fullName, verificationStatus, specialties: Specialty[], openSlotCount, nextAvailableAt}` (open slots in the next 7 days). +- `POST /admin/referrals/:id/reassign` (body: `{targetDoctorId, reason, expectedUpdatedAt}`) → `Referral`. +- `POST /admin/referrals/:id/cancel` (body: `{reason, expectedUpdatedAt}`) → `Referral`. docforum-core notifies the patient; the client doesn't. Cancellation's resulting status isn't assumed: `ReferralStatus` has no `cancelled` value yet, and the referral just leaves the queue. +- **Conflicts:** `expectedUpdatedAt` is the referral's `updatedAt` as the admin saw it, so docforum-core can answer `409` when someone else acted first. `409` and `404` close the dialog, explain, and refetch the queue. `400`/`422` keep the dialog open with the server's message (`422` also refetches the doctor list). A network failure keeps the dialog open; retrying is safe because a duplicate comes back as `409`. +- **Eligibility is enforced client-side as well:** the picker only offers doctors that are `verified`, list the referral's `targetSpecialtyId`, and are neither the referral's current target doctor nor its referring doctor (`eligibleCandidates`). Anything else docforum-core returns is hidden, and the picker says how many were hidden. Verification is per doctor today; when per-specialty verification lands (docforum-core#51), `DoctorAvailability` needs a per-specialty status and the check should use it. +- Reasons are required, 10–500 characters, with whitespace collapsed. The UI asks admins to keep clinical details out, because reasons land in the audit trail. After an action, the console links to `/admin/audit?entityType=Referral&entityId=`, which assumes the referral audit events use `entityType: 'Referral'`. +- `404` on the queue or doctor listing is shown as "docforum-core doesn't serve this yet (#68)". + +### Phase W6 — System health page (issue #73, depends on docforum-core#100, #104 and #94) + +None of the three backend issues has landed (docforum-core still serves only +`GET /health` → `{status: 'ok'}`), so every shape below is assumed and +mirrored in `models.ts` under "Phase W6 — System health". + +- **Readiness (docforum-core#104):** `GET /health/ready` → `ReadinessReport` = `{status: 'ready'|'degraded'|'not_ready', checkedAt, checks: ReadinessCheck[]}`, `ReadinessCheck` = `{name, status: 'pass'|'warn'|'fail', required, durationMs, message}`. Check names assumed: `database`, `migrations`, `config`, `stellar_rpc`; any other name is shown as sent. `200` for ready/degraded, `503` for not ready **with the same body**; the service unwraps a `503` only if its body looks like a report, so a proxy's 503 page still counts as unreachable. `degraded` means only optional checks are failing; #104 says a Stellar RPC outage should take out payments, not the whole API. The bearer token is sent in case docforum-core only shows check detail to admins. `message` is assumed never to hold secrets (server-side responsibility); the UI renders it as text. +- **Jobs (docforum-core#100):** `GET /admin/jobs` → `BackgroundJob[]` = `{name, schedule, lastRun, lastSuccess, lastFailure, nextRunAt, running}`, where each run is a `JobRun` mirroring #100's table: `{id, job, status: 'running'|'succeeded'|'failed', startedAt, finishedAt, error, itemsProcessed, trigger: 'schedule'|'manual'}`. `schedule` is assumed to be human-readable (an interval phrase or a cron expression), shown verbatim. `running` means some instance holds the job's advisory lock. +- **Run now:** `POST /admin/jobs/:name/run` → `JobRun`, which may still be `running` if the job outlives the request. `409` means another instance holds the lock (no second run); `404` means an unknown job or manual runs aren't served yet. Every outcome, network failures included, refetches all three sources. Retrying after an unconfirmed run is safe because #100 requires jobs to be idempotent. +- **Recent failures:** #100 describes no run-history listing, so the page builds this list from each job's `lastFailure` within the last 24 hours (at most one per job). A `GET /admin/jobs/runs?status=failed` would show more; worth a `docforum-core` issue if admins need it. +- **Outbox (docforum-core#94):** `GET /admin/outbox` → `OutboxSummary` = `{pendingCount, deadLetterCount, oldestPendingAt}`. The issue asks for the dead-letter count "if exposed", so a `404` shows "Not exposed" (neutral), not an error. +- **Derived states, all client-side:** a job is *overdue* once `nextRunAt` is more than 5 minutes past (this is how a stalled expiry job shows up); *running* beats *last run failed*, which beats *overdue*, which beats *never run* and *healthy*. A readiness round trip of 10 s or more is flagged as a likely free-tier cold start. +- **Refresh:** all three queries poll every 30 s through React Query's `refetchInterval`, which skips ticks while `document.visibilityState` is `hidden`. Refetch-on-focus catches the page up when the tab comes back. A failed poll after earlier success shows readiness as *Unreachable* (never a stale *Ready*) and keeps the last job list with a "couldn't refresh" banner. + +### Phase W6 — Feature flags console (issue #74, depends on docforum-core#132) + +docforum-core#132 describes the flag service (boolean and percentage flags, +targeting by role or user-id allowlist, admin list/toggle endpoints, audit +event per change) but no shapes, and hasn't landed. Everything below is +assumed and mirrored in `models.ts` under "Phase W6 — Feature flags console". + +- `GET /admin/flags` → `FeatureFlag[]`, unpaginated (tens of flags per environment). `FeatureFlag` = `{id, key, description, kind: 'boolean'|'percentage', enabled, rolloutPercentage, targeting: {roles: Role[], userIds: string[]}, createdAt, updatedAt}`. `rolloutPercentage` is a whole number 0–100 for percentage flags and `null` for boolean ones. Each environment has its own database, so the list is simply "this environment's flags"; there's no `environment` field. +- `PATCH /admin/flags/:key` (body: any of `{enabled, rolloutPercentage, targeting}` plus `expectedUpdatedAt`) → the saved `FeatureFlag`. Only changed fields are sent. `targeting` is replaced as a whole whenever roles or allowlist change. `409` means someone else changed the flag first, `404` means it's gone, and `400`/`422` carry the server's message. +- **Evaluation order assumed for the "who it reaches" text:** `enabled: false` is a kill switch (off for everyone, allowlist included). When enabled, allowlisted users always get the flag. Everyone else must match `roles` (empty means all roles) and, for percentage flags, fall in the sticky hash bucket. If #132 lands with different precedence, `describeAudience` in `features/admin-flags/flags.ts` is the one place to change. +- **Last changed by / when** comes from the *real* audit listing (#43): one `GET /admin/audit-events?entityType=FeatureFlag&limit=200`, newest event per `entityId` (the flag's `id`). This assumes #132 writes flag changes with `entityType: 'FeatureFlag'` and the flag id as `entityId`. Flags missing from that page say "before the latest 200 flag changes" if there are more pages, otherwise "no changes recorded since it was created". The actor is shown as role and short user id, or "you", because audit events carry no email. If the audit request fails, the console still works and says the last change is unavailable. +- **Validation (client-side, server presumed to re-check):** percentages must be whole numbers 0–100 (no decimals or exponents). Allowlist entries must be UUIDs, lower-cased and de-duplicated, at most 500. Ids already saved on the flag pass through unchecked, so an unexpected server-side id format can't block unrelated edits. +- **Optimistic updates:** confirming writes the change into the cached list straight away, and the flag shows "Saving…" with its controls disabled until docforum-core answers. On failure only that flag is restored, and only while the cached copy still has the `updatedAt` the save started from. A list snapshot is never restored, because that would undo a save to a different flag that is in flight or already done. Every outcome then refetches the list and the audit page (without awaiting, so a cold start doesn't hold "Saving…"). +- An open dialog compares its flag's `updatedAt` with the live list. If someone else saved in the meantime, it says so, blocks confirming, and offers to start again from the latest values. ### Scan-to-verify orders with device camera (issue #52, docforum-core#71) - `POST /facilities/me/verify-order` (body: `{ token: string }`) → `VerifyOrderResponse` - Token verification endpoint returning 5 explicit states: `valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`. diff --git a/ROADMAP.md b/ROADMAP.md index 71093d5..6cd4177 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,6 +86,12 @@ real API" (no refresh-on-reload flow). a session exists, same caveat as the facility dashboard already had. - [ ] Link transaction hashes/escrows to the Stellar testnet explorer. Tracked as [issue #10](https://github.com/DocForum/docforum-web/issues/10) (Trivial, 100 pts). +## Phase W6 — Admin console +Admin-only screens beyond payments. Everything goes through `docforum-core`'s admin API; admin accounts stay seeded/invited (PRD OQ-2). +- [x] Audit and access log viewer. Tracked as [issue #71](https://github.com/DocForum/docforum-web/issues/71) (High, 200 pts) — `src/features/admin-audit/`, `AdminAuditPage` at `/admin/audit`. Two tabs: state-change events against `docforum-core`'s real `GET /admin/audit-events` (docforum-core#43), and PHI access logs against an assumed `GET /admin/access-logs` (docforum-core#45, still open — the tab says so on a `404`). Entity type/id, actor, action and date-range filters plus the page cursor all live in the URL; one 50-row page per request; entity links where an admin view exists (payment intents → `/admin?intent=`); client-side CSV export of the page on screen. The action filter is client-side within the loaded page, because the backend rejects an `action` parameter — a `docforum-core` API gap, see `ARCHITECTURE_ESSENTIALS.md` "Known assumptions — Phase W6". +- [x] Stuck referrals console: reassign or cancel with a reason. Tracked as [issue #72](https://github.com/DocForum/docforum-web/issues/72) (High, 200 pts): `src/features/admin-referrals/`, `AdminReferralsPage` at `/admin/referrals`. Pending referrals past a 1/2/3/7-day threshold are listed oldest first with age, target specialty and stuck reason (no doctor, doctor inactive, loop flagged); loop-flagged referrals show their chain with the returning hop marked. Reassign offers only verified doctors in the target specialty (re-checked client-side, excluding the current and referring doctor) with 7-day availability; cancel tells the admin docforum-core notifies the patient. Both require a reason and send `expectedUpdatedAt`, so concurrent actions come back as conflicts and the queue refreshes. **Built against assumed shapes:** docforum-core#68 (admin referral operations) and #65 (loop chain) haven't landed. See `ARCHITECTURE_ESSENTIALS.md` "Known assumptions — Phase W6". +- [x] System health page: readiness, background jobs and recent failures. Tracked as [issue #73](https://github.com/DocForum/docforum-web/issues/73) (High, 200 pts): `src/features/admin-system/`, `AdminSystemPage` at `/admin/system`. Overview tiles answer "is it broken?" at a glance (API readiness, background jobs, outbox dead letters), backed by a readiness-check list (required vs optional, so a Stellar RPC outage shows as degraded, not down), one card per job with last success, last failure, next run and an overdue flag for stalled jobs, and a recent-failures list with error messages. "Run now" asks for confirmation (Cancel focused) and shows docforum-core's result in the dialog: finished with item count, failed with the error, still running, already running elsewhere (`409`), or unconfirmed after a network failure. Everything refreshes every 30 s while the tab is visible and catches up when it's shown again; each source fails on its own, a failed poll never leaves a stale "Ready" on screen, and slow readiness answers are flagged as likely cold starts. **Built against assumed shapes:** docforum-core#104 (readiness), #100 (job runner) and #94 (outbox) haven't landed. See `ARCHITECTURE_ESSENTIALS.md` "Known assumptions — Phase W6". +- [x] Feature flags console. Tracked as [issue #74](https://github.com/DocForum/docforum-web/issues/74) (High, 200 pts): `src/features/admin-flags/`, `AdminFlagsPage` at `/admin/flags`. Lists every flag with its state (on, rolling out, off), type, rollout percentage, role targeting and allowlist size, a plain-language "who it reaches" line, and who changed it last and when, taken from the audit trail. Search (key and description), state and type filters live in the URL. The switch and the edit dialog both go through a review step that shows each changed field before and after, the audience before and after, and the last change; turning a flag off is called out. Percentages must be whole numbers from 0 to 100, and allowlist entries must be user ids. Saves are optimistic. A failed save rolls back only that flag, never another save in flight, and the list refetches. `expectedUpdatedAt` turns concurrent edits into conflicts, and the dialog notices when the flag changes under it. **Built against assumed shapes:** docforum-core#132 hasn't landed. See `ARCHITECTURE_ESSENTIALS.md` "Known assumptions — Phase W6". ## Phase W7 — Platform quality: reliability, accessibility, performance, testing - [x] Mock API for local development (MSW). Tracked as [issue #95](https://github.com/DocForum/docforum-web/issues/95) (High, 200 pts) — `npm run dev:mock` serves every endpoint the app uses through the normal service layer via `src/mocks/`: seeded obviously-fake fixtures, a dev-panel role switcher (patient/doctor/facility/admin), scenario toggles (empty states, forced errors), a latency slider, and a fixture reset. 26 handler tests drive the real `src/services/` layer against MSW; CI fails if mock code ever enters a production bundle (`npm run check:dist`). Documented in `CONTRIBUTING.md`. - [ ] Generate MSW handlers and fixtures from the backend OpenAPI spec. Tracked as [issue #96](https://github.com/DocForum/docforum-web/issues/96) (High, 200 pts) — **blocked**: depends on [docforum-core#112](https://github.com/DocForum/docforum-core/issues/112) (OpenAPI 3.1 spec generated from the Zod schemas), which is still open — there is no authoritative `openapi.json` to generate from yet. Do not fabricate one; see that issue. @@ -364,6 +370,10 @@ real API" (no refresh-on-reload flow). - `#50 Doctor practice insights`: private insights page (`/doctor/insights`) with weekly slot utilisation, no-show and late-cancellation rates, referral acceptance turnaround, and messages response time. Includes SVG charts paired with accessible semantic `` alternatives, date range selector (presets + custom), clearly labelled small-sample caveats, and strict peer-comparison suppression (doctor's own confidential data only). Wired into `DoctorDashboardPage`, command palette (`⌘K`), and navigation. - `#51 Facility staff accounts & fulfillment attribution`: dedicated team management page (`/facility/team`) with member list, role assignment (`owner` vs `staff`), email invitations, resend/revoke pending invites, member removal with backend session revocation warning, and last-owner deletion protection. Enforced read-only view and action suppression for staff members. In the fulfillment queue (`/facility`), added action attribution display showing which staff member dispensed/fulfilled each order for PRD §7 audit trail. - Updated `ARCHITECTURE_ESSENTIALS.md` "Known assumptions" and `ROADMAP.md`. 24 test files and 112/112 tests passing; typecheck and build green. +- 2026-10-18 — Built Phase W6 (new section) audit and access log viewer (#71): `/admin/audit` with state-change and record-access tabs, URL-synced filters (entity type/id, actor, action, date range) and keyset cursor pagination (newer-page trail kept in history state so browser back/forward agrees with the pager), entity record links, entity-history/actor pivots, and formula-safe client-side CSV export of the current page. Audit events use `docforum-core`'s real contract (#43); access logs are an assumed shape pending docforum-core#45. Admin nav gained Payments/Audit trail links; `/admin?intent=` now opens a payment intent directly. Viewer never logs to the console (tested). 27 test files and 153/153 tests passing; typecheck and build green. +- 2026-10-18 — Built Phase W6 stuck referrals console (#72): `/admin/referrals` queue of referrals pending past an age threshold (URL-synced threshold and reason filter, oldest first, cursor "Load more"), loop-chain context for loop-flagged referrals, reassign dialog with a doctor picker limited to verified doctors in the target specialty (with availability, chain-member warning, ineligible rows hidden and counted), and cancel dialog with patient-notification notice. Both actions need a 10–500 character reason, send `expectedUpdatedAt` for conflict detection, handle 409/404 (someone else acted) by closing and refreshing, and link to the referral's audit trail afterwards. Admin nav and dashboard link to it. API shapes are assumed pending docforum-core#68/#65. 30 test files and 207/207 tests passing; typecheck and build green. +- 2026-10-18 — Built Phase W6 system health page (#73): `/admin/system` with readiness, background-job and outbox overview tiles, per-check readiness list (required/optional, duration, detail, cold-start hint), job cards (healthy, last run failed, overdue, running, never run; last success, last failure, next run), recent failures from the last 24 hours, and a confirmed "Run now" that shows the run's result in place (finished, failed, still running, `409` already running, unconfirmed). Polls every 30 s only while the tab is visible. 404s show as "not served yet"/"not exposed" rather than errors. Admin nav and dashboard link to it. API shapes are assumed pending docforum-core#104/#100/#94. 33 test files and 267/267 tests passing; typecheck and build green. +- 2026-10-18 — Built Phase W6 feature flags console (#74): `/admin/flags` lists flags with state, type, rollout bar, targeting, a "who it reaches" summary and last change (actor and time from `GET /admin/audit-events?entityType=FeatureFlag`, one request for all flags). Search, state and type filters are synced to the URL. The edit and quick-toggle dialogs share a review-then-confirm step (field diff, audience before and after, last change). Validation covers a whole-number 0–100 percentage and a UUID allowlist (max 500). Optimistic saves roll back per flag on failure, re-read the server's state afterwards, and handle 409/404 conflicts and mid-edit staleness. Admin nav and dashboard link to it. API shape assumed pending docforum-core#132. 37 test files and 335/335 tests passing; typecheck and build green. - 2026-10-18 — Built Phase W4 Scan-to-verify orders (#52) and Facility payout ledger with escrow terms (#53): - `#52 Scan-to-verify orders with device camera`: dedicated verification page (`/facility/verify`) enabling facility staff to scan order QR codes via device camera or enter tokens manually. Uses native `BarcodeDetector` API with fallback; guarantees complete media stream track teardown on screen unmount/exit. Dispatches to `POST /facilities/me/verify-order` and cleanly displays all 5 result states (`valid`, `superseded`, `expired`, `fulfilled`, `not_eligible`) alongside minimal clinical order summaries. Valid results feature an immediate CTA to jump into fulfillment (`/facility?orderId=...`). Surfaces HTTP 429 rate-limit protection against enumeration. - `#53 Facility payout ledger with escrow terms`: dedicated payments page (`/facility/payments`) displaying held, released, and refunded totals for both XLM and USDC. Uses BigInt-safe math (`formatStroops`) for Stellar 7-decimal stroop precision. Renders cursor-paginated escrow ledger with plain-language terms (releaser rules and refund deadline countdowns) adhering to threat model T8. Prominently flags approaching refund deadlines (< 48h) on unfulfilled orders. Integrates direct deep links to Stellar Expert testnet explorer for funding, release, and refund transactions. diff --git a/screenshots/admin-audit-access-desktop.png b/screenshots/admin-audit-access-desktop.png new file mode 100644 index 0000000..adf6b22 Binary files /dev/null and b/screenshots/admin-audit-access-desktop.png differ diff --git a/screenshots/admin-audit-access-mobile.png b/screenshots/admin-audit-access-mobile.png new file mode 100644 index 0000000..631c4c5 Binary files /dev/null and b/screenshots/admin-audit-access-mobile.png differ diff --git a/screenshots/admin-audit-events-desktop.png b/screenshots/admin-audit-events-desktop.png new file mode 100644 index 0000000..d0ea3d1 Binary files /dev/null and b/screenshots/admin-audit-events-desktop.png differ diff --git a/screenshots/admin-audit-events-filtered-desktop.png b/screenshots/admin-audit-events-filtered-desktop.png new file mode 100644 index 0000000..cb2cef6 Binary files /dev/null and b/screenshots/admin-audit-events-filtered-desktop.png differ diff --git a/screenshots/admin-audit-events-mobile.png b/screenshots/admin-audit-events-mobile.png new file mode 100644 index 0000000..8db7a17 Binary files /dev/null and b/screenshots/admin-audit-events-mobile.png differ diff --git a/screenshots/admin-flags-confirm-desktop.png b/screenshots/admin-flags-confirm-desktop.png new file mode 100644 index 0000000..e1e35f0 Binary files /dev/null and b/screenshots/admin-flags-confirm-desktop.png differ diff --git a/screenshots/admin-flags-confirm-mobile.png b/screenshots/admin-flags-confirm-mobile.png new file mode 100644 index 0000000..8c933ce Binary files /dev/null and b/screenshots/admin-flags-confirm-mobile.png differ diff --git a/screenshots/admin-flags-edit-desktop.png b/screenshots/admin-flags-edit-desktop.png new file mode 100644 index 0000000..ba9c515 Binary files /dev/null and b/screenshots/admin-flags-edit-desktop.png differ diff --git a/screenshots/admin-flags-edit-mobile.png b/screenshots/admin-flags-edit-mobile.png new file mode 100644 index 0000000..bdbce3e Binary files /dev/null and b/screenshots/admin-flags-edit-mobile.png differ diff --git a/screenshots/admin-flags-filtered-desktop.png b/screenshots/admin-flags-filtered-desktop.png new file mode 100644 index 0000000..e448c7d Binary files /dev/null and b/screenshots/admin-flags-filtered-desktop.png differ diff --git a/screenshots/admin-flags-filtered-mobile.png b/screenshots/admin-flags-filtered-mobile.png new file mode 100644 index 0000000..796f3cf Binary files /dev/null and b/screenshots/admin-flags-filtered-mobile.png differ diff --git a/screenshots/admin-flags-list-desktop.png b/screenshots/admin-flags-list-desktop.png new file mode 100644 index 0000000..2c3d85f Binary files /dev/null and b/screenshots/admin-flags-list-desktop.png differ diff --git a/screenshots/admin-flags-list-mobile.png b/screenshots/admin-flags-list-mobile.png new file mode 100644 index 0000000..851382a Binary files /dev/null and b/screenshots/admin-flags-list-mobile.png differ diff --git a/screenshots/admin-flags-rollback-desktop.png b/screenshots/admin-flags-rollback-desktop.png new file mode 100644 index 0000000..d6f9a6a Binary files /dev/null and b/screenshots/admin-flags-rollback-desktop.png differ diff --git a/screenshots/admin-flags-rollback-mobile.png b/screenshots/admin-flags-rollback-mobile.png new file mode 100644 index 0000000..22385b2 Binary files /dev/null and b/screenshots/admin-flags-rollback-mobile.png differ diff --git a/screenshots/admin-flags-toggle-confirm-desktop.png b/screenshots/admin-flags-toggle-confirm-desktop.png new file mode 100644 index 0000000..49d50ae Binary files /dev/null and b/screenshots/admin-flags-toggle-confirm-desktop.png differ diff --git a/screenshots/admin-flags-toggle-confirm-mobile.png b/screenshots/admin-flags-toggle-confirm-mobile.png new file mode 100644 index 0000000..50d01f3 Binary files /dev/null and b/screenshots/admin-flags-toggle-confirm-mobile.png differ diff --git a/screenshots/admin-referrals-cancel-desktop.png b/screenshots/admin-referrals-cancel-desktop.png new file mode 100644 index 0000000..bc1f240 Binary files /dev/null and b/screenshots/admin-referrals-cancel-desktop.png differ diff --git a/screenshots/admin-referrals-cancel-mobile.png b/screenshots/admin-referrals-cancel-mobile.png new file mode 100644 index 0000000..806c750 Binary files /dev/null and b/screenshots/admin-referrals-cancel-mobile.png differ diff --git a/screenshots/admin-referrals-queue-desktop.png b/screenshots/admin-referrals-queue-desktop.png new file mode 100644 index 0000000..15041e2 Binary files /dev/null and b/screenshots/admin-referrals-queue-desktop.png differ diff --git a/screenshots/admin-referrals-queue-mobile.png b/screenshots/admin-referrals-queue-mobile.png new file mode 100644 index 0000000..902c05d Binary files /dev/null and b/screenshots/admin-referrals-queue-mobile.png differ diff --git a/screenshots/admin-referrals-reassign-desktop.png b/screenshots/admin-referrals-reassign-desktop.png new file mode 100644 index 0000000..73d7efe Binary files /dev/null and b/screenshots/admin-referrals-reassign-desktop.png differ diff --git a/screenshots/admin-referrals-reassign-mobile.png b/screenshots/admin-referrals-reassign-mobile.png new file mode 100644 index 0000000..c2ff83b Binary files /dev/null and b/screenshots/admin-referrals-reassign-mobile.png differ diff --git a/screenshots/admin-system-desktop.png b/screenshots/admin-system-desktop.png new file mode 100644 index 0000000..cc75ef4 Binary files /dev/null and b/screenshots/admin-system-desktop.png differ diff --git a/screenshots/admin-system-mobile.png b/screenshots/admin-system-mobile.png new file mode 100644 index 0000000..c4a7393 Binary files /dev/null and b/screenshots/admin-system-mobile.png differ diff --git a/screenshots/admin-system-run-confirm-desktop.png b/screenshots/admin-system-run-confirm-desktop.png new file mode 100644 index 0000000..3a3e34f Binary files /dev/null and b/screenshots/admin-system-run-confirm-desktop.png differ diff --git a/screenshots/admin-system-run-confirm-mobile.png b/screenshots/admin-system-run-confirm-mobile.png new file mode 100644 index 0000000..5cdbd4c Binary files /dev/null and b/screenshots/admin-system-run-confirm-mobile.png differ diff --git a/screenshots/admin-system-run-result-desktop.png b/screenshots/admin-system-run-result-desktop.png new file mode 100644 index 0000000..3bddea9 Binary files /dev/null and b/screenshots/admin-system-run-result-desktop.png differ diff --git a/screenshots/admin-system-run-result-mobile.png b/screenshots/admin-system-run-result-mobile.png new file mode 100644 index 0000000..ba868dc Binary files /dev/null and b/screenshots/admin-system-run-result-mobile.png differ diff --git a/src/App.tsx b/src/App.tsx index f54586f..1b3aca1 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -16,6 +16,10 @@ import { ConsultationNote } from './features/consultation/components/Consultatio import { DoctorInbox } from './features/messaging/components/DoctorInbox'; import { DoctorPracticeInsightsPage } from './pages/DoctorPracticeInsightsPage'; import { FacilityTeamPage } from './pages/FacilityTeamPage'; +import { AdminAuditPage } from './pages/AdminAuditPage'; +import { AdminReferralsPage } from './pages/AdminReferralsPage'; +import { AdminSystemPage } from './pages/AdminSystemPage'; +import { AdminFlagsPage } from './pages/AdminFlagsPage'; import { OrderVerifyPage } from './pages/OrderVerifyPage'; import { FacilityPaymentsPage } from './pages/FacilityPaymentsPage'; @@ -96,6 +100,38 @@ export default function App() { } /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> )} + {user.role === 'admin' && ( + <> + + Payments + + + Referrals + + + Audit trail + + + Flags + + + System + + + )} {user.email} {user.role === 'doctor' && } diff --git a/src/features/admin-audit/components/AccessLogsPanel.tsx b/src/features/admin-audit/components/AccessLogsPanel.tsx new file mode 100644 index 0000000..92a5934 --- /dev/null +++ b/src/features/admin-audit/components/AccessLogsPanel.tsx @@ -0,0 +1,75 @@ +import { useMemo } from 'react'; +import { useAccessLogs } from '../hooks/useAuditLog'; +import { csvFilename, downloadCsv, toCsv } from '../csv'; +import { toAuditQuery } from '../filters'; +import { ActorCell, AuditResults, EntityCell, PAGE_SIZE, Timestamp } from './AuditResults'; +import type { CsvColumn } from '../csv'; +import type { AuditPanelProps } from './AuditResults'; +import type { AccessLog } from '../../../types/models'; +import styles from './AuditLogViewer.module.css'; + +export const ACCESS_LOG_CSV_COLUMNS: CsvColumn[] = [ + { header: 'createdAt', value: (entry) => entry.createdAt }, + { header: 'entityType', value: (entry) => entry.entityType }, + { header: 'entityId', value: (entry) => entry.entityId }, + { header: 'route', value: (entry) => entry.route }, + { header: 'actorUserId', value: (entry) => entry.actorUserId }, + { header: 'actorRole', value: (entry) => entry.actorRole }, + { header: 'id', value: (entry) => entry.id }, +]; + +export function AccessLogsPanel({ filters, pageNumber, onOlder, onNewer, onNewest }: AuditPanelProps) { + const query = useMemo(() => toAuditQuery(filters, PAGE_SIZE), [filters]); + const { data, error, isLoading, isFetching, isPlaceholderData, refetch } = useAccessLogs(query); + const rows = data?.items ?? []; + + return ( + void refetch()} + pageCount={rows.length} + visibleCount={rows.length} + nextCursor={data?.nextCursor ?? null} + pageNumber={pageNumber} + hasNewer={filters.cursor !== null} + onOlder={onOlder} + onNewer={onNewer} + onNewest={onNewest} + onExport={() => downloadCsv(csvFilename('docforum-access-logs'), toCsv(ACCESS_LOG_CSV_COLUMNS, rows))} + emptyMessage="No record views match these filters." + > +
+ + + + + + + + + + {rows.map((entry) => ( + + + + + + + ))} + +
WhenRecord viewedRouteViewed by
+ + + + + {entry.route} + + +
+ + ); +} diff --git a/src/features/admin-audit/components/AuditEventsPanel.tsx b/src/features/admin-audit/components/AuditEventsPanel.tsx new file mode 100644 index 0000000..dc0d36e --- /dev/null +++ b/src/features/admin-audit/components/AuditEventsPanel.tsx @@ -0,0 +1,133 @@ +import { useMemo } from 'react'; +import { useAuditEvents } from '../hooks/useAuditLog'; +import { csvFilename, downloadCsv, toCsv } from '../csv'; +import { matchesAction, toAuditQuery } from '../filters'; +import { ActorCell, AuditResults, EntityCell, PAGE_SIZE, Timestamp } from './AuditResults'; +import type { CsvColumn } from '../csv'; +import type { AuditPanelProps } from './AuditResults'; +import type { AuditEvent } from '../../../types/models'; +import styles from './AuditLogViewer.module.css'; + +export const AUDIT_EVENT_CSV_COLUMNS: CsvColumn[] = [ + { header: 'createdAt', value: (event) => event.createdAt }, + { header: 'action', value: (event) => event.action }, + { header: 'entityType', value: (event) => event.entityType }, + { header: 'entityId', value: (event) => event.entityId }, + { header: 'fromState', value: (event) => event.fromState }, + { header: 'toState', value: (event) => event.toState }, + { header: 'actorUserId', value: (event) => event.actorUserId }, + { header: 'actorRole', value: (event) => event.actorRole }, + { header: 'requestId', value: (event) => event.requestId }, + { header: 'metadata', value: (event) => (event.metadata ? JSON.stringify(event.metadata) : null) }, + { header: 'id', value: (event) => event.id }, +]; + +function formatMetadataValue(value: unknown): string { + return typeof value === 'string' ? value : JSON.stringify(value); +} + +function Metadata({ metadata }: { metadata: AuditEvent['metadata'] }) { + const entries = metadata ? Object.entries(metadata) : []; + if (entries.length === 0) return null; + return ( +
+ {entries.map(([key, value]) => ( +
+
{key}
+
{formatMetadataValue(value)}
+
+ ))} +
+ ); +} + +export function AuditEventsPanel({ filters, pageNumber, onOlder, onNewer, onNewest }: AuditPanelProps) { + const query = useMemo(() => toAuditQuery(filters, PAGE_SIZE), [filters]); + const { data, error, isLoading, isFetching, isPlaceholderData, refetch } = useAuditEvents(query); + + const items = data?.items ?? []; + const rows = useMemo(() => items.filter((event) => matchesAction(event.action, filters.action)), [items, filters.action]); + + return ( + <> + {filters.action && ( +

+ Action is matched within each loaded page — docforum-core's audit endpoint doesn't filter by action yet, so + older pages may hold more matches. +

+ )} + void refetch()} + pageCount={items.length} + visibleCount={rows.length} + nextCursor={data?.nextCursor ?? null} + pageNumber={pageNumber} + hasNewer={filters.cursor !== null} + onOlder={onOlder} + onNewer={onNewer} + onNewest={onNewest} + onExport={() => downloadCsv(csvFilename('docforum-audit-events'), toCsv(AUDIT_EVENT_CSV_COLUMNS, rows))} + emptyMessage={ + items.length > 0 + ? 'No events on this page match the action filter. Try older pages.' + : 'No audit events match these filters.' + } + > + + + + + + + + + + + + + {rows.map((event) => ( + + + + + + + + + ))} + +
WhenActionEntityTransitionActorRequest & details
+ + + {event.action} + + + + {event.fromState || event.toState ? ( + + {event.fromState ?? '∅'} + → + {event.toState ?? '∅'} + + ) : ( + — + )} + + + + {event.requestId && ( + + {event.requestId} + + )} + +
+
+ + ); +} diff --git a/src/features/admin-audit/components/AuditFilterForm.tsx b/src/features/admin-audit/components/AuditFilterForm.tsx new file mode 100644 index 0000000..1c92918 --- /dev/null +++ b/src/features/admin-audit/components/AuditFilterForm.tsx @@ -0,0 +1,85 @@ +import { useId, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Button } from '../../../components/Button'; +import { TextField } from '../../../components/TextField'; +import { checkFilters, FIELD_LABELS } from '../filters'; +import type { AuditTab, FilterDraft, FilterErrors } from '../filters'; +import styles from './AuditLogViewer.module.css'; + +// Suggestions only — the inputs accept any well-formed value, since +// docforum-core adds entity types and actions as Phases 3/4 land. Source: +// docforum-core docs/api/README.md "Audit trail". +const KNOWN_ENTITY_TYPES = ['Appointment', 'DoctorProfile', 'PaymentIntent']; +const KNOWN_ACTIONS = [ + 'appointment.booked', + 'doctor.verification_status_changed', + 'payment_intent.funded', + 'payment_intent.released', + 'payment_intent.refunded', +]; + +interface AuditFilterFormProps { + tab: AuditTab; + /** Values currently in the URL. Remount (via `key`) to resync the draft. */ + initial: FilterDraft; + canClear: boolean; + onApply: (filters: FilterDraft) => void; + onClear: () => void; +} + +export function AuditFilterForm({ tab, initial, canClear, onApply, onClear }: AuditFilterFormProps) { + const [draft, setDraft] = useState(initial); + const [errors, setErrors] = useState({}); + const entityTypesId = useId(); + const actionsId = useId(); + + const bind = (field: keyof FilterDraft) => ({ + label: FIELD_LABELS[field], + value: draft[field], + error: errors[field], + onChange: (event: { target: { value: string } }) => setDraft((current) => ({ ...current, [field]: event.target.value })), + }); + + const handleSubmit = (event: FormEvent) => { + event.preventDefault(); + const { filters, errors: found } = checkFilters(draft); + setErrors(found); + if (Object.keys(found).length > 0) return; + onApply(filters); + }; + + return ( +
+
+ + + + {tab === 'events' && ( + + )} + + +
+ + + {KNOWN_ENTITY_TYPES.map((type) => ( + + + {KNOWN_ACTIONS.map((action) => ( + + +
+ + {canClear && ( + + )} +
+
+ ); +} diff --git a/src/features/admin-audit/components/AuditLogViewer.module.css b/src/features/admin-audit/components/AuditLogViewer.module.css new file mode 100644 index 0000000..3d68cfb --- /dev/null +++ b/src/features/admin-audit/components/AuditLogViewer.module.css @@ -0,0 +1,345 @@ +/* Audit tables are wide (ids, codes, transitions) — the global .container's + 640px reading measure is too narrow, so the page sets its own. */ +.page { + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: var(--space-8) var(--space-6) var(--space-12); +} + +.backLink { + display: inline-block; + margin-bottom: var(--space-4); + font-size: var(--text-sm); + color: var(--color-accent); +} + +.viewer { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.header h1 { + margin: 0; + font-family: var(--font-display); + font-size: var(--text-2xl); +} + +.subtitle { + margin: var(--space-2) 0 0; + max-width: 62ch; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +/* ---- Tabs ---- */ + +.tabs { + display: flex; + gap: var(--space-2); + border-bottom: 1px solid var(--color-border); +} + +.tab { + padding: var(--space-2) var(--space-4); + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + background: none; + font-family: var(--font-sans); + font-size: var(--text-sm); + font-weight: 500; + color: var(--color-text-muted); + cursor: pointer; +} + +.tab:hover { + color: var(--color-text); +} + +.tab:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; + border-radius: var(--radius-sm); +} + +.tabActive { + border-bottom-color: var(--color-accent); + color: var(--color-text); + font-weight: 600; +} + +.panel { + display: flex; + flex-direction: column; + gap: var(--space-4); +} + +.tabDescription { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +/* ---- Filters ---- */ + +.filters { + padding: var(--space-4) var(--space-6); + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); +} + +.filterGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + column-gap: var(--space-4); +} + +.filterActions { + display: flex; + gap: var(--space-3); + flex-wrap: wrap; +} + +.notice, +.hint { + margin: 0; + padding: var(--space-3) var(--space-4); + border-radius: var(--radius-md); + font-size: var(--text-sm); + background: var(--color-warning-surface); + color: var(--color-text); + border: 1px solid var(--color-border); +} + +.notice p { + margin: 0; + font-weight: 600; +} + +.notice ul { + margin: var(--space-1) 0 0; + padding-left: var(--space-6); +} + +/* ---- Results ---- */ + +.results { + display: flex; + flex-direction: column; + gap: var(--space-3); +} + +.resultsBar { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: var(--space-3); +} + +.resultsSummary { + margin: 0; + font-size: var(--text-sm); + color: var(--color-text-muted); +} + +.status { + display: flex; + align-items: center; + gap: var(--space-3); + padding: var(--space-8); + justify-content: center; + color: var(--color-text-muted); + font-size: var(--text-sm); +} + +.errorBox { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-3); + padding: var(--space-4) var(--space-6); + border-radius: var(--radius-md); + background: var(--color-danger-surface); + color: var(--color-text); + font-size: var(--text-sm); +} + +.errorBox p { + margin: 0; +} + +.empty { + margin: 0; + padding: var(--space-8); + text-align: center; + color: var(--color-text-muted); + font-size: var(--text-sm); + background: var(--color-surface); + border: 1px dashed var(--color-border-strong); + border-radius: var(--radius-lg); +} + +.tableWrapper { + width: 100%; + overflow-x: auto; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + transition: opacity 0.15s ease; +} + +/* Previous page still on screen while the next one loads. */ +.stale { + opacity: 0.6; +} + +.table { + width: 100%; + min-width: 760px; + border-collapse: collapse; + font-size: var(--text-sm); + text-align: left; +} + +.table th, +.table td { + padding: var(--space-3) var(--space-4); + border-bottom: 1px solid var(--color-border); + vertical-align: top; +} + +.table th { + background: var(--color-surface-alt); + font-weight: 600; + white-space: nowrap; +} + +.table tbody tr:last-child td { + border-bottom: none; +} + +.table tbody tr:hover td { + background: var(--color-surface-hover); +} + +.time { + white-space: nowrap; +} + +.stack { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: var(--space-1); +} + +.entityType { + font-weight: 600; +} + +/* Monospace here is for real data: ids and codes admins copy and compare. */ +.id, +.idLink, +.action { + font-family: var(--font-mono); + font-size: var(--text-xs); +} + +/* UUIDs wrap at their hyphens; break-word only kicks in for ids without any. */ +.id, +.idLink { + overflow-wrap: break-word; +} + +/* Codes are read as one token — never split `payment_intent.released`. */ +.action { + white-space: nowrap; +} + +.idLink { + color: var(--color-accent); +} + +.inlineAction { + font-size: var(--text-xs); + color: var(--color-text-muted); + text-decoration: underline; +} + +.inlineAction:hover { + color: var(--color-text); +} + +.rolePill { + display: inline-block; + padding: 0.1rem 0.5rem; + border-radius: var(--radius-full); + background: var(--color-accent-subtle); + color: var(--color-accent); + font-size: var(--text-xs); + font-weight: 600; + text-transform: capitalize; +} + +.transition { + display: inline-flex; + flex-wrap: wrap; + gap: var(--space-1); + white-space: nowrap; +} + +.muted { + color: var(--color-text-faint); +} + +.metadata { + margin: var(--space-1) 0 0; + display: flex; + flex-direction: column; + gap: 2px; + font-size: var(--text-xs); +} + +.metadata div { + display: flex; + gap: var(--space-1); +} + +.metadata dt { + color: var(--color-text-muted); +} + +.metadata dt::after { + content: ':'; +} + +.metadata dd { + margin: 0; + font-family: var(--font-mono); + overflow-wrap: anywhere; +} + +.pager { + display: flex; + justify-content: flex-end; + gap: var(--space-2); + flex-wrap: wrap; +} + +@media (max-width: 640px) { + .page { + padding: var(--space-6) var(--space-4) var(--space-8); + } + + .filters { + padding: var(--space-4); + } + + .pager { + justify-content: space-between; + } +} diff --git a/src/features/admin-audit/components/AuditLogViewer.test.tsx b/src/features/admin-audit/components/AuditLogViewer.test.tsx new file mode 100644 index 0000000..c6200f6 --- /dev/null +++ b/src/features/admin-audit/components/AuditLogViewer.test.tsx @@ -0,0 +1,357 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from '@testing-library/react'; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { MemoryRouter, useLocation } from 'react-router-dom'; +import App from '../../../App'; +import { AuditLogViewer } from './AuditLogViewer'; +import { ApiError } from '../../../services/api-client'; +import * as auditService from '../../../services/audit-service'; +import { useAuthStore } from '../../../store/auth-store'; +import type { AccessLog, AuditEvent, Role } from '../../../types/models'; + +vi.mock('../../../services/audit-service'); + +const BOOKED: AuditEvent = { + id: 'evt-1', + actorUserId: 'user-patient-1', + actorRole: 'patient', + entityType: 'Appointment', + entityId: 'appt-1', + action: 'appointment.booked', + fromState: null, + toState: 'booked', + requestId: 'req-abc', + metadata: { availabilitySlotId: 'slot-9' }, + createdAt: '2026-10-18T09:00:00.000Z', +}; + +const RELEASED: AuditEvent = { + id: 'evt-2', + actorUserId: 'user-admin-1', + actorRole: 'admin', + entityType: 'PaymentIntent', + entityId: 'pi-42', + action: 'payment_intent.released', + fromState: 'escrowed', + toState: 'released', + requestId: 'req-def', + metadata: null, + createdAt: '2026-10-17T15:30:00.000Z', +}; + +const VIEWED: AccessLog = { + id: 'log-1', + actorUserId: 'user-doctor-7', + actorRole: 'doctor', + entityType: 'ConsultationNote', + entityId: 'note-3', + route: 'GET /consultation-notes/:appointmentId', + createdAt: '2026-10-18T10:15:00.000Z', +}; + +function LocationProbe() { + const location = useLocation(); + return {location.pathname + location.search}; +} + +function signIn(role: Role) { + useAuthStore.setState({ + accessToken: 'admin-token', + user: { id: `${role}-1`, email: `${role}@docforum.org`, role, createdAt: '', updatedAt: '' }, + }); +} + +function renderViewer(path = '/admin/audit') { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + , + ); +} + +const currentLocation = () => screen.getByTestId('location').textContent; +const lastEventsQuery = () => vi.mocked(auditService.listAuditEvents).mock.lastCall?.[0]; + +beforeEach(() => { + signIn('admin'); + vi.mocked(auditService.listAuditEvents).mockReset(); + vi.mocked(auditService.listAccessLogs).mockReset(); + vi.mocked(auditService.listAuditEvents).mockResolvedValue({ items: [BOOKED, RELEASED], nextCursor: null }); + vi.mocked(auditService.listAccessLogs).mockResolvedValue({ items: [VIEWED], nextCursor: null }); +}); + +afterEach(() => { + vi.restoreAllMocks(); + useAuthStore.getState().clearSession(); +}); + +describe('AuditLogViewer — URL-synced filters', () => { + it('loads the newest page of state-change events by default', async () => { + renderViewer(); + + expect(await screen.findByText('appointment.booked')).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'State changes' })).toHaveAttribute('aria-selected', 'true'); + expect(lastEventsQuery()).toEqual(expect.objectContaining({ cursor: null, limit: 50 })); + expect(vi.mocked(auditService.listAuditEvents).mock.lastCall?.[1]).toBe('admin-token'); + }); + + it('reads filters from the URL, fills the form, and sends them to the API', async () => { + renderViewer('/admin/audit?entityType=PaymentIntent&entityId=pi-42&actor=user-admin-1&from=2026-10-01&to=2026-10-18'); + + await screen.findByText('payment_intent.released'); + expect(lastEventsQuery()).toEqual({ + entityType: 'PaymentIntent', + entityId: 'pi-42', + actorUserId: 'user-admin-1', + from: '2026-10-01', + to: '2026-10-18', + cursor: null, + limit: 50, + }); + expect(screen.getByLabelText('Entity type')).toHaveValue('PaymentIntent'); + expect(screen.getByLabelText('Entity id')).toHaveValue('pi-42'); + expect(screen.getByLabelText('Actor user id')).toHaveValue('user-admin-1'); + expect(screen.getByLabelText('From')).toHaveValue('2026-10-01'); + expect(screen.getByLabelText('To')).toHaveValue('2026-10-18'); + }); + + it('writes applied filters to the URL and resets to the first page', async () => { + const user = userEvent.setup(); + renderViewer('/admin/audit?cursor=abc123'); + await screen.findByText('appointment.booked'); + + await user.type(screen.getByLabelText('Entity type'), 'Appointment'); + await user.type(screen.getByLabelText('Entity id'), 'appt-1'); + await user.type(screen.getByLabelText('Actor user id'), 'user-patient-1'); + await user.click(screen.getByRole('button', { name: 'Apply filters' })); + + await waitFor(() => + expect(currentLocation()).toBe('/admin/audit?entityType=Appointment&entityId=appt-1&actor=user-patient-1'), + ); + await waitFor(() => + expect(lastEventsQuery()).toEqual( + expect.objectContaining({ entityType: 'Appointment', entityId: 'appt-1', actorUserId: 'user-patient-1', cursor: null }), + ), + ); + }); + + it('blocks an entity id without an entity type instead of sending a request the API rejects', async () => { + const user = userEvent.setup(); + renderViewer(); + await screen.findByText('appointment.booked'); + const callsBefore = vi.mocked(auditService.listAuditEvents).mock.calls.length; + + await user.type(screen.getByLabelText('Entity id'), 'appt-1'); + await user.click(screen.getByRole('button', { name: 'Apply filters' })); + + expect(await screen.findByText('Choose an entity type to filter by entity id.')).toBeInTheDocument(); + expect(currentLocation()).toBe('/admin/audit'); + expect(vi.mocked(auditService.listAuditEvents).mock.calls.length).toBe(callsBefore); + }); + + it('reports and ignores invalid filters from a hand-edited link', async () => { + renderViewer('/admin/audit?entityType=%3Cscript%3E&from=2026-13-40'); + + await screen.findByText('appointment.booked'); + const notice = screen.getByText(/Some filters in this link were ignored/).closest('div')!; + expect(within(notice).getAllByRole('listitem')).toHaveLength(2); + expect(lastEventsQuery()).toEqual(expect.objectContaining({ entityType: undefined, from: undefined })); + }); + + it('clears filters back to an empty query string', async () => { + const user = userEvent.setup(); + renderViewer('/admin/audit?actor=user-admin-1'); + await screen.findByText('appointment.booked'); + + await user.click(screen.getByRole('button', { name: 'Clear' })); + + await waitFor(() => expect(currentLocation()).toBe('/admin/audit')); + }); +}); + +describe('AuditLogViewer — cursor pagination', () => { + it('pages older via nextCursor and back to newer, keeping the cursor in the URL', async () => { + const user = userEvent.setup(); + vi.mocked(auditService.listAuditEvents).mockImplementation(async (query) => + query.cursor === 'page-2' + ? { items: [RELEASED], nextCursor: null } + : { items: [BOOKED], nextCursor: 'page-2' }, + ); + renderViewer('/admin/audit?actor=user-admin-1'); + + await screen.findByText('appointment.booked'); + expect(screen.getByRole('button', { name: '← Newer' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: 'Older →' })); + await screen.findByText('payment_intent.released'); + expect(currentLocation()).toBe('/admin/audit?actor=user-admin-1&cursor=page-2'); + expect(lastEventsQuery()).toEqual(expect.objectContaining({ actorUserId: 'user-admin-1', cursor: 'page-2' })); + expect(screen.getByText(/page 2/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Older →' })).toBeDisabled(); + + await user.click(screen.getByRole('button', { name: '← Newer' })); + await screen.findByText('appointment.booked'); + expect(currentLocation()).toBe('/admin/audit?actor=user-admin-1'); + }); + + it('offers a way back to the newest page from a stale cursor link', async () => { + const user = userEvent.setup(); + vi.mocked(auditService.listAuditEvents).mockImplementation(async (query) => { + if (query.cursor) throw new ApiError('INVALID_CURSOR', 400, { message: 'INVALID_CURSOR' }); + return { items: [BOOKED], nextCursor: null }; + }); + renderViewer('/admin/audit?cursor=expired'); + + expect(await screen.findByText('This page position is no longer valid.')).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: 'Back to newest' })); + + await screen.findByText('appointment.booked'); + expect(currentLocation()).toBe('/admin/audit'); + }); +}); + +describe('AuditLogViewer — tabs', () => { + it('switches to access logs, carrying entity filters over and dropping the action filter', async () => { + const user = userEvent.setup(); + renderViewer('/admin/audit?entityType=ConsultationNote&entityId=note-3&action=booked&cursor=abc'); + await screen.findByText(/Action is matched within each loaded page/); + + await user.click(screen.getByRole('tab', { name: 'Record access' })); + + expect(await screen.findByText('GET /consultation-notes/:appointmentId')).toBeInTheDocument(); + expect(currentLocation()).toBe('/admin/audit?tab=access&entityType=ConsultationNote&entityId=note-3'); + expect(vi.mocked(auditService.listAccessLogs).mock.lastCall?.[0]).toEqual( + expect.objectContaining({ entityType: 'ConsultationNote', entityId: 'note-3', cursor: null }), + ); + expect(screen.queryByLabelText('Action')).not.toBeInTheDocument(); + expect(screen.getByRole('tab', { name: 'Record access' })).toHaveAttribute('aria-selected', 'true'); + }); + + it('supports arrow-key navigation between tabs', async () => { + const user = userEvent.setup(); + renderViewer(); + await screen.findByText('appointment.booked'); + + screen.getByRole('tab', { name: 'State changes' }).focus(); + await user.keyboard('{ArrowRight}'); + + await waitFor(() => expect(currentLocation()).toBe('/admin/audit?tab=access')); + expect(screen.getByRole('tab', { name: 'Record access' })).toHaveFocus(); + }); + + it("explains when docforum-core doesn't serve access logs yet", async () => { + vi.mocked(auditService.listAccessLogs).mockRejectedValue(new ApiError('Not Found', 404)); + renderViewer('/admin/audit?tab=access'); + + expect(await screen.findByRole('alert')).toHaveTextContent(/doesn't serve access logs yet/); + }); +}); + +describe('AuditLogViewer — rows', () => { + it('filters actions within the loaded page and says so', async () => { + renderViewer('/admin/audit?action=released'); + + await screen.findByText('payment_intent.released'); + expect(screen.queryByText('appointment.booked')).not.toBeInTheDocument(); + expect(screen.getByText(/1 of 2 audit events on this page match the action filter/)).toBeInTheDocument(); + expect(lastEventsQuery()).not.toHaveProperty('action'); + }); + + it('links entities to their record view where one exists, and offers an entity history filter', async () => { + renderViewer(); + await screen.findByText('appointment.booked'); + + expect(screen.getByRole('link', { name: 'pi-42' })).toHaveAttribute('href', '/admin?intent=pi-42'); + // Appointments have no admin-facing view, so the id is plain text. + expect(screen.queryByRole('link', { name: 'appt-1' })).not.toBeInTheDocument(); + expect(screen.getByText('appt-1')).toBeInTheDocument(); + + const historyLinks = screen.getAllByRole('link', { name: 'Entity history' }); + expect(historyLinks[0]).toHaveAttribute('href', '/admin/audit?entityType=Appointment&entityId=appt-1'); + }); + + it('narrows to an actor when their id is clicked', async () => { + const user = userEvent.setup(); + renderViewer(); + await screen.findByText('appointment.booked'); + + await user.click(screen.getByRole('link', { name: 'user-admin-1' })); + + await waitFor(() => expect(currentLocation()).toBe('/admin/audit?actor=user-admin-1')); + expect(screen.getByLabelText('Actor user id')).toHaveValue('user-admin-1'); + }); + + it('shows an empty state when nothing matches', async () => { + vi.mocked(auditService.listAuditEvents).mockResolvedValue({ items: [], nextCursor: null }); + renderViewer('/admin/audit?actor=nobody'); + + expect(await screen.findByText('No audit events match these filters.')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Export page as CSV' })).toBeDisabled(); + }); + + it('exports exactly the rows on screen as CSV', async () => { + const user = userEvent.setup(); + let exported: Blob | undefined; + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + exported = blob as Blob; + return 'blob:audit'; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}); + renderViewer('/admin/audit?action=released'); + await screen.findByText('payment_intent.released'); + + await user.click(screen.getByRole('button', { name: 'Export page as CSV' })); + + const text = (await exported!.text()).replace(/^/, ''); + const lines = text.trim().split('\r\n'); + expect(lines[0]).toBe('createdAt,action,entityType,entityId,fromState,toState,actorUserId,actorRole,requestId,metadata,id'); + expect(lines).toHaveLength(2); + expect(lines[1]).toContain('payment_intent.released,PaymentIntent,pi-42,escrowed,released'); + expect(vi.mocked(auditService.listAuditEvents)).toHaveBeenCalledTimes(1); + }); + + it('never writes audit rows to the console', async () => { + const spies = (['log', 'info', 'debug', 'warn', 'error'] as const).map((method) => + vi.spyOn(console, method).mockImplementation(() => {}), + ); + renderViewer(); + await screen.findByText('appointment.booked'); + + for (const spy of spies) expect(spy).not.toHaveBeenCalled(); + }); +}); + +describe('/admin/audit route', () => { + function renderApp() { + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + return render( + + + + + + , + ); + } + + it('is reachable by admins', async () => { + renderApp(); + expect(await screen.findByRole('heading', { name: 'Audit trail' })).toBeInTheDocument(); + }); + + // RequireAuth sends them to `/`, which forwards signed-in users to their own dashboard. + it.each(['patient', 'doctor', 'facility'])('redirects a %s away without fetching anything', async (role) => { + signIn(role); + renderApp(); + + await waitFor(() => expect(currentLocation()).toBe(`/${role}`)); + expect(screen.queryByRole('heading', { name: 'Audit trail' })).not.toBeInTheDocument(); + expect(auditService.listAuditEvents).not.toHaveBeenCalled(); + }); +}); diff --git a/src/features/admin-audit/components/AuditLogViewer.tsx b/src/features/admin-audit/components/AuditLogViewer.tsx new file mode 100644 index 0000000..7d1e9e0 --- /dev/null +++ b/src/features/admin-audit/components/AuditLogViewer.tsx @@ -0,0 +1,124 @@ +import { useId, useRef } from 'react'; +import type { KeyboardEvent } from 'react'; +import { useAuditFilters } from '../hooks/useAuditFilters'; +import { hasActiveFilters } from '../filters'; +import { AuditFilterForm } from './AuditFilterForm'; +import { AuditEventsPanel } from './AuditEventsPanel'; +import { AccessLogsPanel } from './AccessLogsPanel'; +import type { AuditTab } from '../filters'; +import styles from './AuditLogViewer.module.css'; + +const TABS: { id: AuditTab; label: string; description: string }[] = [ + { + id: 'events', + label: 'State changes', + description: 'Who booked, verified, funded or released what — every attributable transition (PRD §7).', + }, + { + id: 'access', + label: 'Record access', + description: 'Who viewed which clinical record, and through which route.', + }, +]; + +/** + * Admin audit console. All state — tab, filters and page position — lives in + * the URL (see `useAuditFilters`), and only one server page is ever held. + * Rows are rendered, never logged: nothing here writes to the console. + */ +export function AuditLogViewer() { + const { filters, issues, pageNumber, applyFilters, clearFilters, selectTab, goOlder, goNewer, goNewest } = + useAuditFilters(); + const baseId = useId(); + const tabRefs = useRef>({ events: null, access: null }); + + // Arrow-key roving focus, per the WAI-ARIA tabs pattern. + const handleTabKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'ArrowLeft' && event.key !== 'ArrowRight') return; + event.preventDefault(); + const index = TABS.findIndex((tab) => tab.id === filters.tab); + const next = TABS[(index + (event.key === 'ArrowRight' ? 1 : TABS.length - 1)) % TABS.length].id; + selectTab(next); + tabRefs.current[next]?.focus(); + }; + + const activeTab = TABS.find((tab) => tab.id === filters.tab) ?? TABS[0]; + const draft = { + entityType: filters.entityType, + entityId: filters.entityId, + actor: filters.actor, + action: filters.action, + from: filters.from, + to: filters.to, + }; + const panelProps = { filters, pageNumber, onOlder: goOlder, onNewer: goNewer, onNewest: goNewest }; + + return ( +
+
+

Audit trail

+

+ Read-only view of docforum-core's append-only audit records. Filters are kept in the address bar, so any + view can be bookmarked or shared with another admin. +

+
+ +
+ {TABS.map((tab) => { + const selected = tab.id === filters.tab; + return ( + + ); + })} +
+ +
+

{activeTab.description}

+ + {issues.length > 0 && ( +
+

Some filters in this link were ignored:

+
    + {issues.map((issue) => ( +
  • {issue}
  • + ))} +
+
+ )} + + + + {filters.tab === 'events' ? : } +
+
+ ); +} diff --git a/src/features/admin-audit/components/AuditResults.tsx b/src/features/admin-audit/components/AuditResults.tsx new file mode 100644 index 0000000..a417d5d --- /dev/null +++ b/src/features/admin-audit/components/AuditResults.tsx @@ -0,0 +1,225 @@ +import type { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { Button } from '../../../components/Button'; +import { Spinner } from '../../../components/Spinner'; +import { ApiError } from '../../../services/api-client'; +import { entityRecordPath } from '../entity-links'; +import { toAuditSearch } from '../filters'; +import type { AuditFilters, AuditTab } from '../filters'; +import type { Role } from '../../../types/models'; +import styles from './AuditLogViewer.module.css'; + +// One page per request, always. The backend caps `limit` at 200; 50 keeps +// the table scannable and the CSV export small. +export const PAGE_SIZE = 50; + +export interface AuditPanelProps { + filters: AuditFilters; + pageNumber: number | null; + onOlder: (cursor: string) => void; + onNewer: () => void; + onNewest: () => void; +} + +/** Search string that narrows the current view, starting from the newest entry. */ +function narrowedSearch(filters: AuditFilters, patch: Partial): string { + return `?${toAuditSearch({ ...filters, ...patch, cursor: null }).toString()}`; +} + +export function Timestamp({ value }: { value: string }) { + const date = new Date(value); + const label = Number.isNaN(date.getTime()) + ? value + : date.toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'medium' }); + return ( + + ); +} + +interface ActorCellProps { + actorUserId: string | null; + actorRole: Role | null; + filters: AuditFilters; +} + +export function ActorCell({ actorUserId, actorRole, filters }: ActorCellProps) { + if (!actorUserId) return System; + return ( +
+ {actorRole && {actorRole}} + + {actorUserId} + +
+ ); +} + +interface EntityCellProps { + entityType: string; + entityId: string; + filters: AuditFilters; +} + +export function EntityCell({ entityType, entityId, filters }: EntityCellProps) { + const recordPath = entityRecordPath(entityType, entityId); + return ( +
+ {entityType} + {recordPath ? ( + + {entityId} + + ) : ( + {entityId} + )} + + Entity history + +
+ ); +} + +const NOUNS: Record = { + events: 'audit events', + access: 'access logs', +}; + +function isInvalidCursor(error: ApiError): boolean { + const code = (error.body as { code?: unknown } | undefined)?.code; + return code === 'INVALID_CURSOR' || error.message.includes('INVALID_CURSOR'); +} + +/** Turns a failed listing into something an admin can act on. Server + * messages are only surfaced for 400s, where they describe the filter. */ +export function describeAuditError(error: unknown, tab: AuditTab): { message: string; staleCursor: boolean } { + if (error instanceof ApiError) { + if (error.status === 400 && isInvalidCursor(error)) { + return { message: 'This page position is no longer valid.', staleCursor: true }; + } + if (error.status === 400) { + return { message: `docforum-core rejected these filters: ${error.message}`, staleCursor: false }; + } + if (error.status === 401 || error.status === 403) { + return { message: 'Your session no longer has admin access. Sign in again with an admin account.', staleCursor: false }; + } + if (error.status === 404 && tab === 'access') { + return { + message: "docforum-core doesn't serve access logs yet (docforum-core#45). State-change events are available.", + staleCursor: false, + }; + } + } + return { + message: `Couldn't load ${NOUNS[tab]}. The preview backend may be waking up — try again in a minute.`, + staleCursor: false, + }; +} + +interface AuditResultsProps { + tab: AuditTab; + isLoading: boolean; + isFetching: boolean; + isPlaceholderData: boolean; + error: unknown; + onRetry: () => void; + /** Rows on the page as returned by the server. */ + pageCount: number; + /** Rows left after client-side filtering. */ + visibleCount: number; + nextCursor: string | null; + pageNumber: number | null; + hasNewer: boolean; + onOlder: (cursor: string) => void; + onNewer: () => void; + onNewest: () => void; + onExport: () => void; + emptyMessage: string; + children: ReactNode; +} + +export function AuditResults(props: AuditResultsProps) { + const { tab, isLoading, isFetching, isPlaceholderData, error, pageCount, visibleCount, nextCursor } = props; + + if (isLoading) { + return ( +
+ + Loading {NOUNS[tab]}… +
+ ); + } + + if (error) { + const { message, staleCursor } = describeAuditError(error, tab); + return ( +
+

{message}

+ {staleCursor ? ( + + ) : ( + + )} +
+ ); + } + + const filteredOut = pageCount - visibleCount; + + return ( +
+
+

+ {pageCount === 0 + ? `No ${NOUNS[tab]} on this page` + : filteredOut > 0 + ? `${visibleCount} of ${pageCount} ${NOUNS[tab]} on this page match the action filter` + : `${pageCount} ${NOUNS[tab]} on this page`} + {props.pageNumber !== null && ` · page ${props.pageNumber}`} + {isFetching && ' · updating…'} +

+ +
+ + {visibleCount === 0 ? ( +

{props.emptyMessage}

+ ) : ( +
{props.children}
+ )} + + +
+ ); +} diff --git a/src/features/admin-audit/csv.test.ts b/src/features/admin-audit/csv.test.ts new file mode 100644 index 0000000..77e8782 --- /dev/null +++ b/src/features/admin-audit/csv.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; +import { csvFilename, escapeCsvCell, toCsv } from './csv'; +import { entityRecordPath } from './entity-links'; + +describe('audit CSV export', () => { + it('leaves plain values alone and blanks null/undefined', () => { + expect(escapeCsvCell('appointment.booked')).toBe('appointment.booked'); + expect(escapeCsvCell(42)).toBe('42'); + expect(escapeCsvCell(null)).toBe(''); + expect(escapeCsvCell(undefined)).toBe(''); + }); + + it('quotes cells containing commas, quotes, newlines or edge whitespace', () => { + expect(escapeCsvCell('a,b')).toBe('"a,b"'); + expect(escapeCsvCell('say "hi"')).toBe('"say ""hi"""'); + expect(escapeCsvCell('line\nbreak')).toBe('"line\nbreak"'); + expect(escapeCsvCell(' padded')).toBe('" padded"'); + }); + + it('neutralises spreadsheet formulas (CSV injection)', () => { + expect(escapeCsvCell('=HYPERLINK("http://evil")')).toBe('"\'=HYPERLINK(""http://evil"")"'); + expect(escapeCsvCell('+1')).toBe("'+1"); + expect(escapeCsvCell('-1')).toBe("'-1"); + expect(escapeCsvCell('@SUM(A1)')).toBe("'@SUM(A1)"); + }); + + it('writes a header row and CRLF-terminated rows', () => { + const csv = toCsv( + [ + { header: 'id', value: (row: { id: string; note: string | null }) => row.id }, + { header: 'note', value: (row) => row.note }, + ], + [ + { id: 'e1', note: 'x,y' }, + { id: 'e2', note: null }, + ], + ); + expect(csv).toBe('id,note\r\ne1,"x,y"\r\ne2,\r\n'); + }); + + it('writes only the header for an empty page', () => { + expect(toCsv([{ header: 'id', value: (row: { id: string }) => row.id }], [])).toBe('id\r\n'); + }); + + it('builds a filesystem-safe timestamped filename', () => { + expect(csvFilename('docforum-audit-events', new Date('2026-10-18T09:30:05.123Z'))).toBe( + 'docforum-audit-events-2026-10-18T09-30-05Z.csv', + ); + }); +}); + +describe('audit entity links', () => { + it('links payment intents to the admin payments console', () => { + expect(entityRecordPath('PaymentIntent', 'pi 1')).toBe('/admin?intent=pi%201'); + }); + + it('returns null for types with no admin view, including prototype keys', () => { + expect(entityRecordPath('Appointment', 'a1')).toBeNull(); + expect(entityRecordPath('constructor', 'a1')).toBeNull(); + expect(entityRecordPath('__proto__', 'a1')).toBeNull(); + }); +}); diff --git a/src/features/admin-audit/csv.ts b/src/features/admin-audit/csv.ts new file mode 100644 index 0000000..d4a8ee8 --- /dev/null +++ b/src/features/admin-audit/csv.ts @@ -0,0 +1,50 @@ +// Client-side CSV export of whatever page the admin is looking at. Never +// fetches more data — the export is exactly the rows already on screen. + +export interface CsvColumn { + header: string; + value: (row: T) => string | number | null | undefined; +} + +// Spreadsheet apps evaluate cells starting with these as formulas. Audit +// rows carry server data (ids, codes, routes), but an entity id or route +// is still not ours to trust once it lands in Excel (CSV injection). +const FORMULA_PREFIX = /^[=+\-@\t\r]/; + +export function escapeCsvCell(value: string | number | null | undefined): string { + if (value === null || value === undefined) return ''; + let text = String(value); + if (FORMULA_PREFIX.test(text)) text = `'${text}`; + if (/[",\r\n]/.test(text) || text !== text.trim()) { + text = `"${text.replace(/"/g, '""')}"`; + } + return text; +} + +/** RFC 4180-style CSV: CRLF line endings, quoted only where needed. */ +export function toCsv(columns: CsvColumn[], rows: T[]): string { + const lines = [columns.map((column) => escapeCsvCell(column.header)).join(',')]; + for (const row of rows) { + lines.push(columns.map((column) => escapeCsvCell(column.value(row))).join(',')); + } + return lines.join('\r\n') + '\r\n'; +} + +/** Timestamped, filesystem-safe name, e.g. `docforum-audit-events-2026-10-18T09-30-00Z.csv`. */ +export function csvFilename(prefix: string, now: Date = new Date()): string { + const stamp = now.toISOString().replace(/\.\d{3}Z$/, 'Z').replace(/:/g, '-'); + return `${prefix}-${stamp}.csv`; +} + +export function downloadCsv(filename: string, csv: string): void { + // The BOM makes Excel read the file as UTF-8 instead of the system codepage. + const blob = new Blob(['', csv], { type: 'text/csv;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = filename; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/features/admin-audit/entity-links.ts b/src/features/admin-audit/entity-links.ts new file mode 100644 index 0000000..948b52f --- /dev/null +++ b/src/features/admin-audit/entity-links.ts @@ -0,0 +1,14 @@ +// Where an audited entity can be opened in this app, by docforum-core +// entity type. Only types with a view an admin can actually reach are +// listed — appointments and doctor profiles have no admin-facing screen yet +// (the consultation view is doctor-only), so they render as plain ids. +const RECORD_VIEWS: Record string> = { + PaymentIntent: (entityId) => `/admin?intent=${encodeURIComponent(entityId)}`, +}; + +export function entityRecordPath(entityType: string, entityId: string): string | null { + // hasOwn, not `in`: an entity type of "constructor" must not resolve to + // Object.prototype.constructor. + if (!Object.hasOwn(RECORD_VIEWS, entityType)) return null; + return RECORD_VIEWS[entityType](entityId); +} diff --git a/src/features/admin-audit/filters.test.ts b/src/features/admin-audit/filters.test.ts new file mode 100644 index 0000000..87a7074 --- /dev/null +++ b/src/features/admin-audit/filters.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; +import { + EMPTY_FILTERS, + checkFilters, + hasActiveFilters, + matchesAction, + parseAuditSearch, + toAuditQuery, + toAuditSearch, +} from './filters'; +import type { AuditFilters } from './filters'; + +const FULL: AuditFilters = { + tab: 'events', + entityType: 'PaymentIntent', + entityId: '7b1c9a52-3f0e-4c1a-9d55-2f6c8e0a1b11', + actor: 'a4e0f3d2-1111-4c1a-9d55-2f6c8e0a1b11', + action: 'payment_intent.released', + from: '2026-10-01', + to: '2026-10-18', + cursor: 'eyJjcmVhdGVkQXQiOiIyMDI2LTEwLTE4In0=', +}; + +const draftOf = ({ entityType, entityId, actor, action, from, to }: AuditFilters) => ({ + entityType, + entityId, + actor, + action, + from, + to, +}); + +describe('audit URL filters', () => { + it('round-trips every filter through the query string', () => { + const search = toAuditSearch(FULL); + expect(parseAuditSearch(new URLSearchParams(search.toString()))).toEqual({ filters: FULL, issues: [] }); + }); + + it('round-trips the access tab, dropping the events-only action filter', () => { + const access = { ...FULL, tab: 'access' as const }; + const search = toAuditSearch(access); + expect(search.get('tab')).toBe('access'); + expect(search.has('action')).toBe(false); + expect(parseAuditSearch(search).filters).toEqual({ ...access, action: '' }); + }); + + it('omits defaults so an unfiltered view has an empty query string', () => { + expect(toAuditSearch(EMPTY_FILTERS).toString()).toBe(''); + expect(parseAuditSearch(new URLSearchParams()).filters).toEqual(EMPTY_FILTERS); + }); + + it('trims values read from the URL', () => { + const { filters } = parseAuditSearch(new URLSearchParams('entityType=%20Appointment%20&actor=%20u-1')); + expect(filters.entityType).toBe('Appointment'); + expect(filters.actor).toBe('u-1'); + }); + + it('drops and reports values that are not ids or codes', () => { + const { filters, issues } = parseAuditSearch( + new URLSearchParams({ entityType: '