Files
baya-monorepo/docs/flows/admin-backoffice.md
T
2026-08-02 17:18:36 +03:30

175 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.