15 KiB
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
Integration: docs/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 |
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 |
Admin · Support · Finance · Moderation · SuperAdmin are five sibling roles — super_admin is not a superset of admin |
| the seeder | DemoWorldDefinitions.cs:144-145 |
grants RoleNames.SuperAdmin / RoleNames.Finance — never RoleNames.Admin |
| the missing half | SeedDataBase.cs:50-71 |
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), 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) 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 |
| verification queue + case | admin_verifications/* |
verification.md |
| payout batches | admin_payouts/* |
payouts.md |
| refunds (in the ticket thread) | admin_refunds/* |
refunds.md |
| review moderation | admin/reviews/moderation_queue |
reviews.md |
| ticket queue + admin thread | admin/tickets, admin/tickets/{id} |
tickets.md |
| partner centers | admin/partner-centers* |
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)
→ adminApi (apis/index.ts:10, the one-line selector,
currently resolving to adminMockApi) → adminClientApi.listPlatformConfigs
(apis/clientApi.ts:75) → 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 |
| 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 |
| 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 |
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 |
| 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 |
| 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 |
| Low-rating alert threshold | ≤ 2 |
CONFIG min_rating_for_support_alert |
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 |
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 |
How to test
- Log in as
09120000020(نگار مدیری, super_admin) — see testing-setup.md. Expect:RoleGuardadmits you and the app lands on/fa/adminwith all four group tabs visible. - 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 areadminMockApifixtures held in module state, and they reset on every page reload or HMR. Open devtools Network: there is no request. - Open
/fa/admin/ticketsand/fa/admin/reviews— the two consoles on real seams. Expect: an error state, not a queue.clientFetchtoasts the 403 itself. - Log in as
09120000021(کامران مالی, finance) and open/fa/admin. Expect: «اعتماد» and «پشتیبانی» tabs are gone; only «مالی» and «سیستم» remain./fa/admin/payoutsrenders a mock batch list./fa/admin/auditis hidden (audit isadmin/super_adminonly) but still URL-reachable. - 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"}, whileGET /api/v1/mewith the same token returns200.
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). 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.CanAccessgrants only on the literal role"admin". The four sibling admin roles —super_admin,support,finance,moderation— get 403 on all 23DynamicPermissioncontrollers.DynamicPermissionService.cs:9. Asuper_adminhas less access than anadmin.- No seeded account holds the literal
adminrole.DemoWorldDefinitions.cs:144-145grantssuper_admin/finance;SeedDataBase.cs:55-56returns early becauseSeed:AdminUsername/AdminPasswordare unset. The entire backoffice is untestable end-to-end out of the box. - No code path ever writes a
DynamicPermissionclaim, 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 = trueplus mockedverification/payouts/refunds/partnerCentermeans 19 of 21 consoles never call the API. A reviewer clicking through the console concludes it works. /fa/admin/ticketsand/fa/admin/reviewsare broken for the operator right now — real client seams onto 403ing controllers. The only two consoles where the defect surfaces.admin/apis/clientApi.tspageQuery()sendspage_size; the controllers declarePageSize. Model binding is case-insensitive, not separator-insensitive, so every admin list silently falls back to the server default page size the momentUSE_ADMIN_MOCKflips./fa/admin/roleshas no server —admin_roles/list_roles|grant_role|revoke_roleare phantom (REQ-031 deferred). Admin roles are seeded, never managed./fa/admin/usershas no server —admin_users/search|lookupare phantom, and REQ-061 was never filed in the ledger despite ten client files citing it.AuditLogRowshows#idinstead of a name./fa/admin/notificationsis aPlaceholderScreenstub and a true orphan — no link reaches it, andAdminLayoutrenders noNotificationBell.POST /api/v1/holidays/delete_holidayis unwired — the console offers no delete affordance.admin_cancellation_policies/list|upsertare unwired — no screen edits the cancellation tiers, so the seededstandard_24h/standard_inside_24hrows are effectively read-only in production.admin_search/rebuild_indexandadmin_booking_requests/expirehave no UI — ops one-shots reachable only by curl (and both 403 for a seeded admin).useAdminCapabilitieshides 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.