# Scout C — Clinic (HIS) Permission + Role System + FE Write-Button Guard Coverage

Read-only research. No code changed.

Repos:
- BE: `/home/moonui/moon-erp-be`
- FE: `/home/moonui/public_html/moon-erp` (clinic features under `src/app/features/clinic`)

---

## 1. The `clinic.*` permission catalog

**Single source of truth (seeder, not a DB-driven registry):**
`Modules/Core/database/seeders/RolePermissionSeeder.php`

- Class uses `Spatie\Permission\Models\Permission` (Spatie laravel-permission package) — confirmed `Modules/Core/database/seeders/RolePermissionSeeder.php:7`.
- `run()` (`:11-27`) forgets the permission cache, seeds every permission string returned by `permissions()` via `Permission::firstOrCreate(['name'=>..., 'guard_name'=>'web'])` (`:17`), then calls `createRoles()` (`:20`).
- All permission strings for the whole app (not just clinic) live in one big flat array returned by `private function permissions(): array` (`:46` onward). Clinic's block starts at `Modules/Core/database/seeders/RolePermissionSeeder.php:1054` (`// ── Clinic / HIS (Modules/Clinic) ────`) and runs to `:1149`.
- **Current count: 85 `clinic.*` permission strings** (grown from an earlier ~81 baseline as L1/L3 work added `clinic.intents.*` / `clinic.results.external`).
- **Grouping**: the array itself is grouped by inline `// P0`…`// P7`/`// L1` phase comments (not a formal data structure) mirroring the clinic build phases:
  - P0 foundation (`:1056-1070`): `clinic.patients.view`, `clinic.doctors.*`, `clinic.doctor-grades.*`, `clinic.rooms.*`, `clinic.departments.*`
  - P1 catalog & pricing (`:1072-1077`): `clinic.service.*`, `clinic.pricing.*`, `clinic.payer.*`
  - P2 order/money/split (`:1079-1091`): `clinic.orders.*`, `clinic.payments.*`, `clinic.cashier.*`, `clinic.ledger.view`, `clinic.split.*`
  - P3 scheduling & booking (`:1093-1107`): `clinic.schedule.*`, `clinic.closure.*`, `clinic.availability.view`, `clinic.appointment.*`, `clinic.queue.view`, `clinic.dashboard.view`, `clinic.visits.create`
  - P4 encounter & clinical (`:1109-1121`): `clinic.encounters.*`, `clinic.vitals.*`, `clinic.history.*`, `clinic.diagnosis.*`
  - P5 clinical orders (`:1123-1137`): `clinic.lab-orders.*`, `clinic.rad-procedures.*`, `clinic.rad-orders.*`, `clinic.rad-worklist.view`, `clinic.rad-reports.{enter,verify,release}`, `clinic.prescriptions.*`
  - P6 insurance & claims (`:1139-1141`): `clinic.coverage.compute`, `clinic.claim.{view,assemble}`
  - P7 reports/KPIs (`:1143-1144`): `clinic.reports.{view,export}`
  - L1 clinical intents (`:1146-1149`): `clinic.intents.{view,create,decide}`, `clinic.results.external`
- **This grouping is independently re-derived (workflow order, not just phase order) in the runtime `groupOrder()`** contract — see §3, `Modules/Clinic/app/Support/ClinicPermissionDependencies.php:190-209` — used to order the (not-yet-built) roles UI by real clinic flow rather than alphabetically.
- The generic permission-listing API endpoint groups purely by string prefix (`explode('.', $permission)[0]`), not by these phase/workflow groups — see §2, `RoleController::permissions()`.

## 2. Roles: creation, assignment, and the role-management UI

**Package**: Spatie `laravel-permission`, wrapped by `Modules\Core\Models\Role` (extends Spatie's Role model — not separately inspected but referenced at `RolePermissionSeeder.php:6`).

**BE — generic (cross-module) role CRUD:**
- Controller: `Modules/Core/app/Http/Controllers/RoleController.php`
- Routes: `Modules/Core/routes/api.php:69-70` — `GET permissions` → `RoleController::permissions`, `Route::apiResource('roles', RoleController::class)`.
- Middleware gates: `core.roles.view` (index/show/permissions), `core.roles.create` (store), `core.roles.update` (update), `core.roles.delete` (destroy) — `RoleController.php:28-36`.
- `index()`/`show()` list/return roles **tenant-scoped** via `RoleSaveService::visibleRolesQuery()` (GLOBAL roles + acting user's company roles) — `:49-75`.
- `store()`/`update()` delegate to `Modules\Core\Services\RoleSaveService` (`:84-138`), which is the **single save path**: validates permission names, expands dependencies via the Core `PermissionDependencyRegistry` (see §3), persists `home_page` + `data_scope` (fail-closed `branch` default), all in one transaction (per the class's own doc comment, `RoleSaveService.php:19-`, `:257-280`).
- `permissions()` (`:181-198`) — lists every seeded `Permission`, grouped **only by string prefix** (`explode('.', $p)[0]`) into `{key,label}` pairs. **Does NOT expose dependency map or presets** (contrast with LIS's dedicated endpoint, §3).
- `destroy()` blocks deleting `PROTECTED_ROLES = ['owner','admin','manager','accountant','cashier','employee','super-admin']` (`:39`, `:154-158`) and blocks deleting a role that still has users (`:163-167`).
- **How a role gets its permissions**: either (a) hand-picked via `store`/`update` `permissions` array (expanded through registered per-module dependency contributors, see §3), or (b) one of the seeder's hardcoded built-in roles below.

**Seeded built-in roles** (`RolePermissionSeeder.php:1183-1350`, `createRoles()`):
- `super-admin` (`:1189-1190`) — **zero permissions attached on purpose**; bypasses everything via `Gate::before` (per comment `:1185-1188`).
- `owner` (`:1193-1195`) — `syncPermissions($allPermissions)` = **gets every clinic.\* permission** (and everything else).
- `admin` (`:1198-1203`) — `core.*` + every `*.view` permission across all modules → gets **only the `.view` half** of clinic permissions (e.g. `clinic.patients.view`, `clinic.orders.view`, but not `clinic.orders.create`).
- `manager` (`:1206-1243`) — explicit whitelist (`core.*` subset) + `accounting.*.view` + `inventory.*.view` + all `hrm.*` — **touches zero `clinic.*` permissions**.
- `accountant` (`:1246-1258`) — `accounting.*` + a few `core.*` — **zero `clinic.*`**.
- `cashier` (`:1261-1285`) — explicit whitelist, all POS/sales — **zero `clinic.*`** (this is the generic/POS cashier role, distinct from the clinic cashier workflow).
- `employee` (`:1288-1306`) — basic HRM read access — **zero `clinic.*`**.
- `hr_manager` (`:1309-1322`) — all `hrm.*` — **zero `clinic.*`**.
- `lab_manager` (`:1325-1349`) — all `lis.*` + a few `core.*` + attachment perms — **zero `clinic.*`** (this is LIS's dedicated seeded role; there is **no equivalent seeded clinic role**).

**Conclusion: no seeded role currently holds a partial, clinic-workflow-shaped permission set.** Only `owner` (all) and `admin` (all `.view`) touch `clinic.*` at all among the 9 seeded roles. Any clinic-specific role (reception, doctor, cashier, manager) must be hand-built today through the generic Roles UI, permission-by-permission, with no starting bundle — UNLESS the not-yet-wired presets in §3 are exposed.

**FE — generic role-management UI (the permission matrix screen):**
- Component: `src/app/features/roles/roles.component.ts` (620 lines) + `roles.component.html`.
- Route: `src/app/app.routes.ts:77` — `path: 'roles'`, `data: { permissions: ['core.roles'] }`, lazy-loads `RolesComponent`. (Full path is under `/core/...` per the app's routing convention noted in CLAUDE.md, i.e. `/core/roles`.)
- Fetches roles from `${environment.apiUrl}/core/roles` and permissions from `${environment.apiUrl}/core/permissions` (`roles.component.ts:69-70`).
- Renders permission groups in an Accordion, one panel per prefix-group returned by the BE (`loadPermissions()`, `:214-228`).
- **`permGroupToModule` map** (`:208-212`) used to hide a permission group if its owning system-module is deactivated: `accounting, sales, purchases, inventory, hrm, lis, pos, production, crm, cmms, qms, core` — **`clinic` is absent from this map**. Effect: `filteredPermissionGroups()` (`:104-121`) treats an unmapped group as `!modId → true`, so the `clinic` permission group is **always shown regardless of whether the Clinic system-module is enabled/disabled** — likely a (minor, cosmetic) gap, not a security issue since BE still gates by permission.
- **No preset/dependency-auto-select UI exists in this component** — confirmed by `grep -n "preset|dependencies" roles.component.ts roles.component.html` → zero hits. Ticking a clinic permission here does not auto-tick its prerequisites in the UI (though the BE save path still silently expands them server-side per §3).

## 3. Role PRESETS

**They exist in Clinic backend code already, but are NOT wired to any endpoint or FE screen — dead/unused for clinic today.**

- **Registry contract**: `Modules/Core/app/Support/PermissionDependencyRegistry.php` — a module registers a "contributor" class per permission prefix at boot (`register(string $prefix, string $contributorClass)`, `:25-28`). Contract methods (doc comment `:10-17`): `expand()`, `mapFor()`, `presets()`, `groupOrder()`.
- **Clinic's contributor**: `Modules/Clinic/app/Support/ClinicPermissionDependencies.php`, registered at `Modules/Clinic/app/Providers/ClinicServiceProvider.php:27` (`PermissionDependencyRegistry::register('clinic', ClinicPermissionDependencies::class)`).
  - `EDGES` const (`:21-86`) — explicit cross-resource prerequisite graph (e.g. `clinic.orders.create` needs `clinic.orders.view` + `clinic.patients.view` + `clinic.service.view` + `clinic.pricing.view`, `:37`).
  - `expand()` (`:95-119`) — transitive closure over EDGES + a generic rule (any non-`.view` action needs the matching `.view`, `:130-134`). **This IS live** — `RoleSaveService` calls `PermissionDependencyRegistry::expand('clinic', ...)` for every registered prefix on every role save (`RoleSaveService.php:262-277`), so hand-picked clinic permissions on the generic Roles screen (§2) DO get silently expanded server-side even though the FE never shows this.
  - `mapFor()` (`:146-157`) — permission → direct deps map, meant to drive FE auto-select ticking.
  - **`presets()` (`:165-181`)** — 3 ready-made role bundles already defined:
    - `clinic_setup` (`:168-169`, "إعداد العيادة"/"Clinic Setup") — anchors: `clinic.rooms.create`, `clinic.rooms.update`, `clinic.doctor-grades.create`, `clinic.doctors.update`.
    - `clinic_doctor` (`:172-174`, "طبيب العيادة"/"Clinic Doctor") — anchors: `clinic.encounters.update`, `clinic.encounters.sign`, `clinic.vitals.record`, `clinic.history.record`, `clinic.diagnosis.record`, `clinic.intents.create`, `clinic.intents.view`, `clinic.prescriptions.create`, `clinic.queue.view`.
    - `clinic_reception` (`:177-179`, "استقبال العيادة"/"Clinic Reception") — anchors: `clinic.visits.create`, `clinic.appointment.create`, `clinic.appointment.update`, `clinic.payments.create`, `clinic.intents.view`, `clinic.intents.decide`, `clinic.queue.view`, `clinic.dashboard.view`.
  - `groupOrder()` (`:190-209`) — workflow-ordered resource-group list for a future roles UI.
- **The gap**: there is **no `ClinicRoleController`** (`find Modules/Clinic -iname "*Role*"` → only an unrelated `Modules/Clinic/app/Enums/PartyRole.php`), and the generic `Modules/Core/app/Http/Controllers/RoleController::permissions()` (§2) does **not** call `presets()`/`mapFor()`/`groupOrder()` for any prefix — it only returns flat `{key,label}` groups. So `ClinicPermissionDependencies::presets()` is currently **unreachable from any HTTP endpoint** and the FE has nothing to render. The 3 presets above are ready-made and load-bearing-correct (mirrors the LIS pattern's structure exactly) but sit dormant.

**Closest existing, fully-wired pattern to clone: LIS.**
- Contributor: `Modules/LIS/app/Support/LisPermissionDependencies.php`, registered `Modules/LIS/app/Providers/LISServiceProvider.php:26`.
- **Dedicated BE controller**: `Modules/LIS/app/Http/Controllers/LabRoleController.php` — a **separate, module-scoped role screen** (not the generic Core one), gated by `lis.settings.view`/`lis.settings.manage` (`:48-54`), listing only lab-confined roles (`Modules\LIS\Support\LabRoleAssignment::isAssignable`, `:82-96`).
  - `catalog()` (`:102-143`) is the key endpoint: returns `{groups (ordered by groupOrder()), home_pages, data_scopes, dependencies: mapFor(...), presets: presets()}` (`:115-141`) — this is exactly the shape Clinic would need to add.
  - `store()`/`update()` call `RoleSaveService->create()/update()` with `lisOnly: true`, meaning **only `lis.*` permissions are touched/synced**, other module grants on the same role are preserved untouched (`:145-156`, doc comment `:17-22`).
- **FE**: `src/app/features/lis/roles/lis-roles.component.ts` + `.html`, routed at `/lab/roles` (`src/app/app.routes.ts:228`, also `lis-standalone.routes.ts:406-410`).
  - Preset chips UI: `lis-roles.component.html:66-74` (`@if (catalog()?.presets?.length)`, `@for (ps of catalog()!.presets!; ...)`).
  - `applyPreset(preset: LisRolePreset)` (`lis-roles.component.ts:282-289`) — ticks the preset's anchor permissions, then (implicitly, via the dependency map) expands prerequisites, and prefills `home_page` from a `PRESET_HOME` lookup (`:278`).
- **To give clinic presets/dependency-auto-select a UI, the fastest path is: (a) build a `ClinicRoleController` mirroring `LabRoleController` (catalog/index/store/update, `permission:clinic.settings.*`-style gate — note clinic has no `clinic.settings.*` perm yet, would need one or reuse another gate), (b) a `clinic-roles.component.ts` mirroring `lis-roles.component.ts`/`.html`, wired to a new `/clinic/roles` (or similar) route.** All the BE dependency-graph plumbing (`ClinicPermissionDependencies`) already exists and is registered — only the controller + FE screen are missing.

## 4. FE write-button guard coverage (`*appCan` directive)

**Directive**: `src/app/shared/directives/can.directive.ts`
- Selector `[appCan]`, structural directive: `<button *appCan="'clinic.patients.view'">` or `*appCan="['a','b']"` (any-of) — `can.directive.ts:10-11`.
- `update()` (`:40-49`): shows the embedded view iff `this.required.some(p => this.permission.can(p))`; re-evaluates via an `effect()` once `PermissionService.userLoaded()` resolves (`:32-38`).
- Explicitly documented as **"Defence-in-depth only — the backend still enforces every permission"** (`can.directive.ts:13`).

**Scope of scan**: all 33 `.html` templates under `src/app/features/clinic/`.

**Aggregate**: 175 total `(click)="…"`/`(onClick)="…"` action bindings (includes non-write actions like retry/filter/close-dialog/tab-switch) across the 33 files; only **8 total `*appCan` usages, spread across 6 files**. The other **27 of 33 files have zero `appCan` guards** despite containing write actions (add/edit/delete/save/submit/sign/collect/void/release/etc).

**Guarded (file:line, permission):**
| File:line | Action | Permission |
|---|---|---|
| `cashier/cashier-collect.component.html:213` | Refund-dialog trigger button (`openRefundDialog`) | `clinic.payments.void` |
| `encounter/orders/lab-orders.component.html:257` | Open external-result entry (`openExternalResult`) | `clinic.results.external` |
| `encounter/orders/rad-orders.component.html:200` | Open external-result entry (`openExternalResult`) | `clinic.results.external` |
| `reception/reception-order.component.html:211` | Open external-result entry (`openExternalResult`) | `clinic.results.external` |
| `service-categories/clinic-service-categories.component.html:12` | "New Category" button (`openNew`) | `clinic.service.manage` |
| `service-categories/clinic-service-categories.component.html:118` | Row Edit button (`editItem`) | `clinic.service.manage` |
| `service-categories/clinic-service-categories.component.html:128` | Row Delete button (`deleteItem`) | `clinic.service.manage` |
| `services/clinic-service-list.component.html:12` | **"Import" button** (`openImport`) — NOT the "New Service" button | `clinic.service.manage` |

Note the `clinic-service-list` inconsistency: only the *Import* button is guarded (`:12`); the adjacent **"New Service" add button (`:19-23`, `openNew()`) is unguarded**, and the row **Edit (`:218-225`, `editItem`) / Delete (`:227-234`, `deleteItem`) buttons are also unguarded** — same screen, same permission, inconsistent application. `service-categories` (sibling screen, same permission) guards all three (add/edit/delete) correctly — a good template to copy from.

**Unguarded — representative sample (file:line, action, likely permission by seeder mapping):**
| File:line | Action (method) | Permission that should gate it |
|---|---|---|
| `doctors/clinic-doctors.component.html:14` | `openAddDialog()` | (no `clinic.doctors.create` exists — doctor is created via LIS doctor entity; identity edit only) |
| `doctors/clinic-doctors.component.html:114` | `openIdentityDialog(doctor)` (edit) | `clinic.doctors.update` |
| `rooms/clinic-rooms.component.html:14,109,118` | `openNew()` / `editItem()` / `deleteItem()` | `clinic.rooms.create` / `.update` / `.delete` |
| `departments/clinic-departments.component.html:14,106,115` | `openNew()` / `editItem()` / `deleteItem()` | `clinic.departments.create` / `.update` / `.delete` |
| `doctor-grades/doctor-grades.component.html:9,38,45` | `openNew()` / `editGrade()` / `deleteGrade()` | `clinic.doctor-grades.create` / `.update` / `.delete` |
| `revenue-split/revenue-split.component.html:15,166,175` | `openNew()` / `editItem()` / `deleteItem()` | `clinic.split.manage` |
| `insurance/payer-contracts.component.html:14,111,120` | `openNew()` / `editItem()` / `deleteItem()` | `clinic.payer.manage` |
| `insurance/claims-list.component.html:138` | `submitNphies(item)` (claim assembly/submission) | `clinic.claim.assemble` |
| `radiology/rad-procedures.component.html:14,112,121` | `openNew()` / `editItem()` / `deleteItem()` | `clinic.rad-procedures.create` / `.update` / `.delete` |
| `radiology/rad-report-editor.component.html:172,182,192` | `onEnterReport()` / `onVerifyReport()` / `onReleaseReport()` — **clinical report entry/verify/release, no FE gate at all** | `clinic.rad-reports.enter` / `.verify` / `.release` |
| `encounter/consultation.component.html:307` | `confirmSign()` — **encounter sign, no FE gate** | `clinic.encounters.sign` |
| `encounter/consultation.component.html:297` | `sendToCashier()` | `clinic.orders.update` (routing) |
| `encounter/diagnosis-panel/diagnosis-panel.component.html:100,111,157,172` | `addDiagnosis()` / `setPrimary()` / `removeDiagnosis()` | `clinic.diagnosis.record` |
| `encounter/history-panel/history-panel.component.html:182` | `saveNow()` | `clinic.history.record` |
| `encounter/prescription/prescription.component.html:123` | `issuePrescription()` | `clinic.prescriptions.create` |
| `encounter/orders/lab-orders.component.html:134,197,271` | `submitOrders()` / `recordIntents()` / `confirmDeleteIntent()` | `clinic.lab-orders.create` / `clinic.intents.create` / `clinic.intents.create` |
| `encounter/orders/rad-orders.component.html:84,140,214` | `submitOrder()` / `recordIntents()` / `confirmDeleteIntent()` | `clinic.rad-orders.create` / `clinic.intents.create` / `clinic.intents.create` |
| `reception/reception-order.component.html:142,150,159,233,388,412` | `confirmExecuteHere()` / `markExternal()` / `submitDecline()` (via `openDeclineDialog`) / `createOrOpenOrder()` / `submitLines()` / `sendToCashier()` | `clinic.intents.decide` / `clinic.orders.create` / `clinic.orders.update` |
| `cashier/cashier-collect.component.html:364` | `collectAndPrint()` — **the actual money-collection action, no FE gate** (only the *refund* dialog trigger is gated, `:213`) | `clinic.payments.create` |
| `cashier/cashier-collect.component.html:532` | `submitRefund()` — actual refund submit inside the dialog, unguarded itself (only the dialog-open trigger at `:213` is gated) | `clinic.payments.void` |
| `pricing/pricing-matrix.component.html` | (price-cell edit interactions, whole file) | `clinic.pricing.manage` |
| `schedule/schedule-editor.component.html:57,113,120,130,157,158,162,182,234,237,258,312,315` | slot/period/closure add/edit/delete (13 handlers) | `clinic.schedule.*` / `clinic.closure.*` |
| `booking/create-visit.component.html` (23 handlers incl. `submit()` at `:511`, `saveNewPatient()`/`saveNewDoctor()`) | full visit-booking flow | `clinic.visits.create` |
| `booking/reception-booking.component.html` | booking actions | `clinic.appointment.create` |

**BE contrast (important context)**: `Modules/Clinic/routes/api.php` has **157 of 158 routes** carrying `->middleware('permission:clinic.*')` (`grep -c "middleware('permission:clinic\." Modules/Clinic/routes/api.php` → 157; `grep -c "Route::"` → 158). So the FE gap is **UX-only** (unauthorized users would see/click a live button and get a 403 toast rather than never seeing the button) — the money/clinical actions themselves (payments, refunds, rad report release, encounter sign, intents decide) are independently enforced server-side per §-verified route middleware, e.g.:
- `Modules/Clinic/routes/api.php:147` `receipts.store` → `permission:clinic.payments.create`
- `:148` `receipts/{receipt}/void` → `permission:clinic.payments.void`
- `:150` `service-order-lines/{line}/refund` → `permission:clinic.payments.void`
- `:223` `encounters/{encounter}/sign` → `permission:clinic.encounters.sign`
- `:278-280` rad-studies/report enter/verify/release → matching `clinic.rad-reports.*`
- `:288-294` intents store/decide/external-result → matching `clinic.intents.*`/`clinic.results.external`

---

## Summary for caller

1. **Permission catalog**: `Modules/Core/database/seeders/RolePermissionSeeder.php:1054-1149` — 85 flat `clinic.*` strings, grouped only by inline `// P0..P7/L1` comments (workflow phase order also re-derived in `ClinicPermissionDependencies::groupOrder()`).
2. **Role assignment**: generic Spatie-backed CRUD, `Modules/Core/app/Http/Controllers/RoleController.php` + FE `src/app/features/roles/roles.component.ts` (route `/core/roles`, permission `core.roles.*`). Saves go through `RoleSaveService`, which silently expands clinic dependency edges server-side. 9 seeded roles total; only `owner` (all) and `admin` (`.view`-only) carry any `clinic.*` permissions — **no dedicated clinic role is seeded**.
3. **Presets DO exist for Clinic** — `Modules/Clinic/app/Support/ClinicPermissionDependencies.php:165-181` defines `clinic_setup`/`clinic_doctor`/`clinic_reception` bundles, registered with Core's `PermissionDependencyRegistry`, but **no controller/FE screen exposes them** (unlike LIS's fully-wired `LabRoleController`/`lis-roles.component.ts` at `/lab/roles`, which is the pattern to clone).
4. **FE guard directive**: `*appCan="'perm.key'"` (`src/app/shared/directives/can.directive.ts`), explicitly documented as defense-in-depth only. Coverage across clinic screens is sparse: **8 `*appCan` usages across 6 of 33 templates**, vs 175 total click/onClick action bindings — most add/edit/delete/sign/collect/void/release buttons are unguarded on the FE (money-collection `collectAndPrint()` and clinical `onEnterReport/onVerifyReport/onReleaseReport`/`confirmSign()` included). BE route middleware is near-total (157/158 routes), so this is a UX polish gap, not a security hole.

Full detail with all file:line citations: `/tmp/claude-0/-home-moonui-public-html/655c74d3-bf57-464f-a224-d776e4aa876f/scratchpad/m2/scout-c-perms-roles.md`
