# Flow — admin-backoffice

> Last verified: 2026-08-02 against commit `c841bde`

**Actor(s):** admin (`super_admin` · `admin` · `support` · `finance` · `moderation`) · **Status:** mocked
**Client:** partial (19 of 21 consoles on mocked seams, 2 on real) · **Server:** partial (real handlers, **403 for every seeded admin**)
**Business source:** [product/business/14-notifications-and-admin.md](../../product/business/14-notifications-and-admin.md)
**Integration:** [docs/integration/domains/admin.md](../integration/domains/admin.md)

## What it does

The single operator console behind Balinyaar: approve nurses, moderate reviews, run the weekly payout batch,
issue refunds, triage support alerts and tickets, edit the platform's runtime rates, and read the append-only
audit trail. It is the only surface where a human authorises money movement — a scheduled job may *generate* a
draft payout batch, but `process` is always an explicit admin action.

## ⚠ Read this first — the console cannot reach the server

**Every `[Authorize(ConstantPolicies.DynamicPermission)]` endpoint returns `403` for both seeded admin
accounts** (`09120000020` super_admin, `09120000021` finance). 23 controllers carry that policy. The chain:

| Link | file:line | What it does |
| --- | --- | --- |
| the gate | [`DynamicPermissionService.cs:9`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/PermissionManager/DynamicPermissionService.cs) | `if (user.IsInRole("admin")) return true;` — the **literal** string, not "any admin sub-role" |
| the fallback | same file `:15-19` | else requires a `DynamicPermission` claim whose value is exactly `"{area}:{controller}:"` |
| the vocabulary | [`RoleNames.cs:13-17`](../../server/src/Core/Baya.Domain/Entities/User/RoleNames.cs) | `Admin` · `Support` · `Finance` · `Moderation` · `SuperAdmin` are five **sibling** roles — `super_admin` is not a superset of `admin` |
| the seeder | [`DemoWorldDefinitions.cs:144-145`](../../server/src/Infrastructure/Baya.Infrastructure.Persistence/Services/Seeding/DemoWorldDefinitions.cs) | grants `RoleNames.SuperAdmin` / `RoleNames.Finance` — **never** `RoleNames.Admin` |
| the missing half | [`SeedDataBase.cs:50-71`](../../server/src/Infrastructure/Baya.Infrastructure.Identity/Identity/SeedDatabaseService/SeedDataBase.cs) | the only code path that calls `AddToRoleAsync(user, "admin")`, and it returns early unless `Seed:AdminUsername` **and** `Seed:AdminPassword` are configured. Neither is in `appsettings.Development.json` |

No code anywhere writes a `DynamicPermission` claim, so branch 2 can never fire either. **No seeded account
satisfies either branch.**

**Why this is invisible in the UI:** `USE_ADMIN_MOCK = true`
([`admin/constants.ts:9`](../../client/src/services/admin/constants.ts)), plus `verification`, `payouts`,
`refunds` and `partnerCenter` are all mock-primary — so 19 of the 21 consoles render a complete, filterable,
mutable world out of in-browser fixtures and never call the API at all. The console *looks* built.

Live-probed 2026-08-02 (`super_admin` token unless noted):

| Probe | Result |
| --- | --- |
| `GET /api/v1/me` | `200` · `{"id":6,"roles":["super_admin"]}` — the token is fine |
| `GET /api/v1/tickets?page=1` (user scope, same token) | `200`, 9 tickets — non-admin endpoints work normally |
| `GET /api/v1/admin/tickets?page=1` | **`403`** `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}` |
| `GET /api/v1/admin/reviews/moderation_queue?page=1` | **`403`** |
| `POST /api/v1/platform_config/update_platform_config` (valid body) | **`403`** — writes are blocked at authz, *before* validation. Nothing mutates |
| `GET /api/v1/admin_payouts/batches` (**finance** token) | **`403`** |

## Screens

21 routes, all inside the 480 px phone frame. `RoleGuard expected=admin` admits any of the five codes;
`useAdminCapabilities()` ([`hooks/capabilities.ts:43`](../../client/src/hooks/capabilities.ts)) then hides
group tabs and rows per code — **a UI hint only**, the consoles stay URL-reachable.

| Group | Route | Client seam | Notes |
| --- | --- | --- | --- |
| — | `/fa/admin` | none | Overview hub; `NavHubList` filtered by capabilities |
| اعتماد | `/fa/admin/trust` | none | Group root |
| | `/fa/admin/verification` · `/…/[nurseId]` | `verification` **MOCK** | Queue folded per-nurse; case view with `DocumentViewer` re-signing its own URL |
| | `/fa/admin/reviews` | `reviews` **REAL** | 4 moderation tabs → **403s live** |
| مالی | `/fa/admin/finance` | none | Group root |
| | `/fa/admin/payouts` · `/…/[batchId]` | `payouts` **MOCK** | Dry-run preview → idempotency-keyed run; `gross − clawback = net` |
| پشتیبانی | `/fa/admin/support` | none | Group root |
| | `/fa/admin/tickets` · `/…/[id]` | `tickets` **REAL** (+ `refunds` MOCK via `RefundPanel`) | Internal-notes composer → **403s live** |
| | `/fa/admin/alerts` | `admin` **MOCK** | `support_alerts` triage; assign-to-self, resolve with note |
| سیستم | `/fa/admin/system` | `auth` | Always shown — carries sign-out |
| | `/fa/admin/config` | `admin` **MOCK** | Typed editor per `data_type`; rate keys validated to `[0, 1)` |
| | `/fa/admin/holidays` | `admin` **MOCK** | `iranian_holidays` + the `is_bank_closed` flag |
| | `/fa/admin/audit` | `admin` **MOCK** | Read-only; rows expand to the `changed_fields` diff |
| | `/fa/admin/partners` · `/…/[id]` | `admin` + `partnerCenter` **MOCK** | IBAN write-then-masked, never displayed |
| | `/fa/admin/users` | `admin` **MOCK** | Backed by `admin_users/search` — **route does not exist** |
| | `/fa/admin/roles` | `admin` **MOCK** | RBAC grid; in-page banner says it is deferred |
| | `/fa/admin/notifications` | none | `PlaceholderScreen` stub; **true orphan**, nothing links to it |

## API

Shapes belong to the integration tree — do not restate them here.

| Console | Endpoints | Contract |
| --- | --- | --- |
| config · holidays · audit · alerts | `platform_config/*`, `holidays/*`, `audit/get_audit_trail`, `support_alerts/*` | [admin.md](../integration/domains/admin.md) |
| verification queue + case | `admin_verifications/*` | [verification.md](../integration/domains/verification.md) |
| payout batches | `admin_payouts/*` | [payouts.md](../integration/domains/payouts.md) |
| refunds (in the ticket thread) | `admin_refunds/*` | [refunds.md](../integration/domains/refunds.md) |
| review moderation | `admin/reviews/moderation_queue` | [reviews.md](../integration/domains/reviews.md) |
| ticket queue + admin thread | `admin/tickets`, `admin/tickets/{id}` | [tickets.md](../integration/domains/tickets.md) |
| partner centers | `admin/partner-centers*` | [partner-center.md](../integration/domains/partner-center.md) |
| **roles · user directory** | `admin_roles/*`, `admin_users/*` | **phantom — not on the wire** (REQ-031 deferred; REQ-061 never filed) |

Client chain, verified link by link for the config console: `admin/config/page.tsx` → `usePlatformConfigs()`
([`services/admin/hooks/usePlatformConfigs.ts`](../../client/src/services/admin/hooks/usePlatformConfigs.ts))
→ `adminApi` ([`apis/index.ts:10`](../../client/src/services/admin/apis/index.ts), the one-line selector,
currently resolving to `adminMockApi`) → `adminClientApi.listPlatformConfigs`
([`apis/clientApi.ts:75`](../../client/src/services/admin/apis/clientApi.ts)) → `clientFetch` →
`GET /api/v1/platform_config/get_platform_configs` → `PlatformConfigController` → `ListPlatformConfigs`
handler. Every link exists. Only the selector and the authz gate stand in the way.

## Rules that must hold

| Rule | Value | Source of truth | Product |
| --- | --- | --- | --- |
| Money movement stays human-approved | the scheduler opens a **draft** batch; `process` is an explicit admin action | `WeeklyPayoutGenerationJob` + `ExecutePayoutBatch` | [10-payouts.md](../../product/business/10-payouts.md) |
| Config is rows read at compute time, and a rate change is **never retroactive** | rates snapshotted onto the row (`Bookings.PlatformFeeRate`, `Invoices.VatRate`) | CONFIG via `IPlatformConfig` | [14-…-admin.md](../../product/business/14-notifications-and-admin.md) |
| Commission / VAT | `platform_fee_rate = 0.15`, `vat_rate = 0.10`, VAT on the **commission line only** | CONFIG keys (0.15 is a seeded default, not a product mandate) | [13-tax-invoicing-and-legal.md](../../product/business/13-tax-invoicing-and-legal.md) |
| Rate keys are in `[0, 1)` | console validates before writing | `RATE_CONFIG_KEYS`, `admin/constants.ts:27` | — |
| Holidays shift the payout date | server resolves the next business day; **the client never computes a shift** | `IHolidayCalendar` ROWS | [10-payouts.md](../../product/business/10-payouts.md) |
| Audit trail is append-only | no edit/delete affordance; retention `730` d general / `2555` d financial | CONFIG `audit_retention_*_days` | — |
| `is_internal` never leaves the query layer | admin thread is the *only* surface that carries internal notes | `TicketRepository` projections | [12-messaging-and-emergencies.md](../../product/business/12-messaging-and-emergencies.md) |
| Refunds are admin-only and ticket-anchored | no customer self-service; every refund splits across both fee legs | `AdminRefundsController`, `CK_Refunds_LegSplit` | [07-cancellation-and-refunds.md](../../product/business/07-cancellation-and-refunds.md) |
| Low-rating alert threshold | `≤ 2` | CONFIG `min_rating_for_support_alert` | [11-reviews-trust-and-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
| Publishing a review recomputes the nurse aggregate **server-side** | on every status transition | review moderation handlers | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) |
| Suspending a nurse flips `is_searchable = 0` on every one of her rows | rows are kept, never deleted | `SearchIndexMaintainer.cs:177,248` | [11-…-safety.md](../../product/business/11-reviews-trust-and-safety.md) |

## How to test

1. Log in as `09120000020` (نگار مدیری, super_admin) — see [testing-setup.md](testing-setup.md).
   **Expect:** `RoleGuard` admits you and the app lands on `/fa/admin` with all four group tabs visible.
2. Walk `/fa/admin/config` → `/fa/admin/holidays` → `/fa/admin/audit` → `/fa/admin/alerts`.
   **Expect:** each renders a populated, filterable console; edits appear to save.
   **This proves nothing about the server** — these are `adminMockApi` fixtures held in module state, and they
   reset on every page reload or HMR. Open devtools Network: **there is no request**.
3. Open `/fa/admin/tickets` and `/fa/admin/reviews` — the two consoles on real seams.
   **Expect:** an error state, not a queue. `clientFetch` toasts the 403 itself.
4. Log in as `09120000021` (کامران مالی, finance) and open `/fa/admin`.
   **Expect:** «اعتماد» and «پشتیبانی» tabs are gone; only «مالی» and «سیستم» remain. `/fa/admin/payouts`
   renders a mock batch list. `/fa/admin/audit` is hidden (audit is `admin`/`super_admin` only) but still
   URL-reachable.
5. Confirm the gap directly, without the browser:
   `curl -s --noproxy '*' "http://localhost:5002/api/v1/platform_config/get_platform_configs?page=1" -H "Authorization: Bearer $T_09120000020"`
   **Expect:** `403` `{"isSuccess":false,"statusCode":403,"message":"Authorization Error"}`, while
   `GET /api/v1/me` with the same token returns `200`.

**Break-glass (UNVERIFIED — not executed for this stamp):** add `"Seed": { "AdminUsername": "…",
"AdminPassword": "…" }` to `appsettings.Development.json` **before boot**. `SeedDataBase.SeedBootstrapAdminAsync`
then mints a user in the literal `admin` role, which satisfies branch 1. That account is
**username/password, not phone-OTP**, so it cannot log in through the web UI — drive the API directly. It was
not tested here because it needs a server restart shared with other agents.

**Seeded-world caveat:** the demo world is 7 days stale (see
[testing-setup.md](testing-setup.md#-it-has-aged-out--and-this-is-not-cosmetic)). Even with the RBAC gap
fixed, the verification queue has no `in_review` step awaiting a decision beyond nurse 3's two blocking steps,
and every dispute window has closed — so a live payout preview would sweep bookings you did not stage.

## Known gaps

- **`DynamicPermissionService.CanAccess` grants only on the literal role `"admin"`.** The four sibling admin
  roles — `super_admin`, `support`, `finance`, `moderation` — get 403 on all 23 `DynamicPermission`
  controllers. `DynamicPermissionService.cs:9`. A `super_admin` has *less* access than an `admin`.
- **No seeded account holds the literal `admin` role.** `DemoWorldDefinitions.cs:144-145` grants
  `super_admin`/`finance`; `SeedDataBase.cs:55-56` returns early because `Seed:AdminUsername`/`AdminPassword`
  are unset. The entire backoffice is untestable end-to-end out of the box.
- **No code path ever writes a `DynamicPermission` claim**, so the per-controller fallback branch
  (`DynamicPermissionService.cs:15-19`) is dead — the claim key `"{area}:{controller}:"` has no producer.
- **The 403 is invisible in the UI.** `USE_ADMIN_MOCK = true` plus mocked `verification`/`payouts`/`refunds`/
  `partnerCenter` means 19 of 21 consoles never call the API. A reviewer clicking through the console concludes
  it works.
- **`/fa/admin/tickets` and `/fa/admin/reviews` are broken for the operator right now** — real client seams
  onto 403ing controllers. The only two consoles where the defect surfaces.
- **`admin/apis/clientApi.ts` `pageQuery()` sends `page_size`; the controllers declare `PageSize`.** Model
  binding is case-insensitive, not separator-insensitive, so every admin list silently falls back to the
  server default page size the moment `USE_ADMIN_MOCK` flips.
- **`/fa/admin/roles` has no server** — `admin_roles/list_roles|grant_role|revoke_role` are phantom (REQ-031
  deferred). Admin roles are seeded, never managed.
- **`/fa/admin/users` has no server** — `admin_users/search|lookup` are phantom, and **REQ-061 was never filed
  in the ledger** despite ten client files citing it. `AuditLogRow` shows `#id` instead of a name.
- **`/fa/admin/notifications` is a `PlaceholderScreen` stub and a true orphan** — no link reaches it, and
  `AdminLayout` renders no `NotificationBell`.
- **`POST /api/v1/holidays/delete_holiday` is unwired** — the console offers no delete affordance.
- **`admin_cancellation_policies/list|upsert` are unwired** — no screen edits the cancellation tiers, so the
  seeded `standard_24h`/`standard_inside_24h` rows are effectively read-only in production.
- **`admin_search/rebuild_index` and `admin_booking_requests/expire` have no UI** — ops one-shots reachable
  only by curl (and both 403 for a seeded admin).
- **`useAdminCapabilities` hides tabs but does not block routes.** Every console stays URL-reachable for any
  admin code; the enforcement is expected to be server-side, which is currently a blanket 403.
