# CR-14 — Role Management

Client request (2026-09-07): build Role Management per the wireframes
(`docs/wireframes-v12/role-management.html`, `add-role.html`,
`edit-role.html`), pixel perfect, with the client's own reductions below.
Follow `docs/adding-a-module.md` (the repo's recipe) except where §0
overrides it. This is the LAST "coming soon" sidebar entry going live.

## §0 Client-mandated overrides — do NOT fix back

1. **NO new test files.** Same override as CR-8/9/12/13 (carried forward
   by the BA — the client asked for it on every prior module; flagged
   for their confirmation in the close-out). Existing pinned tests
   (RolePermissionSeedTest, AppShellTest, TwinViewParityTest) MAY be
   minimally updated to stay green and honest — every edit quoted in
   the report.
2. **NO policy.** Bare spatie gates (`Gate::authorize('roles.view')`,
   FormRequest `authorize()`, `@can('roles.edit')`), same as
   bookings/cancellations/brokers/users. Security stays non-negotiable.
3. **Form = Role Name + Is System Role + Module Permissions ONLY.**
   Skip from the wireframe: Role Code, Description, Status toggle, the
   entire "Additional Permissions" section (its 7 fictional checkboxes),
   and the matrix's Export column.
4. **Listing columns reduced accordingly** ("skip other column"): Role
   Name · Users Assigned · Permissions Count · Created Date · Actions.
   Skip Role ID, Description, Status columns AND the status filter
   (there is no status). Filter bar = the search box alone.
5. **Matrix modules are the client's 10, not the wireframe's 17
   fictional ones** (§2.2).

## §1 Decisions (client answers 2026-09-07)

- **D1 (client)** "Is System Role": system roles cannot be DELETED
  (wireframe helper text, kept verbatim). The **Super Admin role is
  additionally fully locked** — no edit, no delete, ever: no Edit/Delete
  actions rendered for its row, and the server refuses both regardless
  of what is posted. Seeder marks Super Admin `is_system = true`; the
  other three seeded roles stay non-system.
- **D2 (client)** Roles ARE deletable (the wireframe draws no delete
  icon; the client chose to add one): a Delete row action for
  non-system roles, gated `roles.delete`, **refused with a clear
  validation message while ANY user still holds the role** ("reassign
  first"). Confirmation modal before the POST, following the brokers
  pattern.
- **D3 (client)** Pending tasks: every signed-in user **always sees and
  completes their OWN pending tasks with no permission required**;
  `follow_ups.manage` ("See All Pending Tasks") stays the see-all gate
  and is **NOT in the matrix** (Admin keeps it from seed; Super Admin
  bypasses). §6.4 has the verification step.
- **D4 (client)** `bookings.delete` (which gates deleting a booking's
  uploaded customer documents — bookings themselves were never
  deletable): **no checkbox; auto-granted server-side whenever a role
  is given `bookings.edit`** (§4.2 rule 3), and revoked when
  bookings.edit is revoked (the sync list is rebuilt from the posted
  set every save, so this falls out of normalization).
- **D5 (client)** View auto-assign: selecting Create/Edit/Delete in a
  module row auto-checks that row's View — client-side (§3.4) AND
  enforced server-side (§4.2 rule 1), so a hand-rolled POST cannot
  create edit-without-view.
- **D6 (client)** Any Cancellation permission → Booking View
  auto-checked client-side AND assigned server-side (§4.2 rule 2).
- **D7 (client)** No Booking Delete and no Cancellation Delete
  checkboxes: those two matrix cells render EMPTY (keep the cell so the
  grid stays aligned).
- **D8 (client)** "View permission = listing + view pages" — already
  how every module gates; nothing to change, stated for the record.
- **D9 (client)** Customer Documents: permissions EXIST in the matrix
  and the seeder (`customer_documents.*`), but NOTHING is gated on them
  yet — the standalone Customer Documents section is a future CR. Do
  not wire them anywhere.
- **D10 (BA)** The wireframe listing's View (eye) action links to
  `view-role.html`, **which does not exist in the wireframe pack** (dead
  link). No view screen is invented: row actions are Edit + Delete
  only (each behind its gate; Super Admin row and a viewer with neither
  permission get the em-dash).
- **D11 (BA)** Role name: required, string, max:255, unique among web-
  guard roles (spatie's own unique [name, guard_name] index is the
  arbiter; the rule mirrors it, `ignore()` on edit). Renaming is
  allowed for every editable role — users keep the role by id. The
  name "Super Admin" can never be taken: it exists and is unique.
- **D12 (BA)** Permissions Count cell: the Super Admin row displays the
  "all access" label (lang key), not its literal 0 direct permissions —
  it holds everything via Gate::before and a "0" would be a lie on
  screen. Every other row shows its direct count (implied grants
  included, since they are real rows on the pivot).
- **D13 (BA)** Default listing order: oldest first (wireframe runs
  ROLE-001 → ROLE-008). Ship the PROVEN pattern: `'order' => []` in
  html() parameters + a conditional `applyDefaultOrder()` ordering
  `roles.id` ASC that stands down when yajra's
  `Request::isOrderable()` is true (see UsersDataTable — copy it line
  for line). Never an unconditional query order (yajra APPENDS the
  requested order; a unique-id order first kills every header sort).

## §2 Schema, permissions, matrix

### §2.1 Migration + model
- Migration: `roles` table gains `is_system` boolean NOT NULL default
  false. Nothing else (no code/description/status — §0.3).
- New `App\Models\Role` extending `Spatie\Permission\Models\Role`
  (final), `is_system` cast to bool; registered as
  `config/permission.php` → `models.role`. Update imports to the app
  model where `is_system`/counts are read (seeder, new module code);
  existing spatie-import call sites that only read names
  (UserController, StoreUserRequest, RoleBadge context) may stay —
  same table — but say so in a comment where it would confuse.
- Seeder: `Role::firstOrCreate` for Super Admin then ensure
  `is_system = true` (idempotent, forceFill/update — do not rely on a
  fillable default).

### §2.2 The matrix — single source of truth
One support class (e.g. `App\Support\RolePermissionMatrix`) owns:
- the 10 module rows, in the client's order, with per-module available
  actions:

  | Module (label key)        | view | create | edit | delete |
  |---------------------------|------|--------|------|--------|
  | Project Management        | projects.view | .create | .edit | .delete |
  | Inquiry                   | inquiries.*  4 actions |
  | Booking                   | bookings.view / .create / .edit / **no delete cell** |
  | Cancellation              | cancellations.view / .create / .edit / **no delete cell** |
  | User Management           | users.* 4 |
  | Role Management           | roles.* 4 (NEW permissions) |
  | Broker Management         | brokers.* 4 |
  | Booking Types             | booking_types.* 4 |
  | Customer Document Types   | customer_doc_types.* 4 |
  | Customer Documents        | customer_documents.* 4 (NEW permissions) |

- `assignable()` — the flat closed list the FormRequest's `in:` rule
  and the form's checkboxes are BOTH built from (one list, recipe §2).
  `bookings.delete`, `cancellations.delete` (nonexistent),
  `follow_ups.manage` are NOT on it: a hand-post of any of them is a
  validation error.
- `normalize(list): list` — applies §4.2's three rules and returns the
  final sync set. The service calls it; nothing else re-implements it.

### §2.3 New permissions + seeder + lang
- 8 new permission rows: `roles.{view,create,edit,delete}`,
  `customer_documents.{view,create,edit,delete}` → **40 total**.
- RolePermissionSeeder: add both groups with the same grouped-comment
  style; Admin = all 40; Sales/Operations unchanged; Super Admin
  unchanged (empty + Gate::before) but now `is_system`.
- `lang/en/users.php` `permissions.*`: labels for both new groups (the
  seeder docblock's own contract, RolePermissionSeeder.php:48-55).
- Pinned RolePermissionSeedTest: counts 32→40 (lines ~103, ~328, ~331)
  and any exact-list assertions — minimal, honest comments, quoted in
  the report.

## §3 Screens (pixel fidelity to the pack, minus §0)

### §3.1 Listing — role-management.html
- Sidebar: Role Management goes LIVE — `@can('roles.view')`,
  `route('roles.index')`, `fa-shield-halved`, active-state var, in its
  wireframe position directly under User Management. Administration
  heading gate gains the `roles.view` arm. **Role Management was the
  LAST preview entry: if nothing else uses the `$previewMenu`
  machinery afterwards, remove it (and the entry's `nav.coming_soon`
  usage) rather than leaving dead scaffolding — verify other usages
  before deleting; keep `nav.coming_soon` itself if any other screen
  uses it.**
- Header row per wireframe: title/breadcrumb; **Add New Role** button
  gated `roles.create`. No stat cards (the wireframe draws none).
- One card: filter bar (search only, §0.4) + DataTable.
- RolesDataTable: whitelist = `name` (+ `created_at` orderable);
  counts via `withCount('users')` + `withCount('permissions')` —
  aggregate aliases stay OFF the whitelist, `orderable(false)` +
  `searchable(false)` (no constant-SQL handlers needed; the wireframe
  offers no sort there). Role name cell escaped (names are user
  input). Created Date via `config('nrems.date_format')`. D12's
  Super-Admin "all" label. D13 ordering. Actions partial per D10,
  raw column with the actions-partial escaping rules.
- Ajax gate `ajax() && wantsJson()` (the CR-7 lesson).

### §3.2 Add — add-role.html
- ONE card, two form-sections + footer (Cancel → index, "Save Role").
- **Role Information**: Role Name* text; Is System Role checkbox with
  the wireframe's helper sentence. (§0.3 skips the rest.)
- **Module Permissions**: the wireframe's `.permission-table` look —
  move its inline styles into the app-additions tail of
  nrems-wireframe.css under `role-`/`rl-` prefixed classes citing
  add-role.html line numbers, per house rule. Header row: Module |
  View | Create | Edit | Delete (NO Export, §0.3). 10 rows per §2.2;
  the two suppressed delete cells render empty (D7). Checkboxes named
  `permissions[]` with the permission name as value, labels from lang.
  No "select all" controls (the wireframe has none).
- jsvalidation for the name field; the checkbox matrix needs none.

### §3.3 Edit — edit-role.html
- Twin of §3.2 (twin rule, recipe §9): same fields, PUT, "Update
  Role", prefilled name/is_system/checkbox states from the role's
  DIRECT permissions.
- The wireframe's **info box** above the card: "This role is assigned
  to N users. Changes will apply to all users with this role." with the
  REAL count (lang key with :count).
- Super Admin can never reach this screen (D1): controller refuses
  before render.
- TwinViewParityTest: add `'roles'` to the dataset AND arms to all
  three `match` maps (the test file warns the match has no default) —
  minimal, honest, quoted.

### §3.4 Client-side matrix behavior — nrems-role-form.js (new)
ES5, no build, no innerHTML, delegated listener on the matrix table.
On every checkbox change, re-assert the invariants (idempotent — the
same rules the server applies, §4.2):
1. any of a row's create/edit/delete checked → that row's View checked;
2. any Cancellation checkbox checked → Booking View checked.
Auto-check only — never auto-UNcheck on the client (the server
normalization is authoritative either way). Checkboxes are identified
by `data-` attributes rendered from the matrix class (module key +
action), never by parsing labels.

## §4 Behavior

### §4.1 Routes + gates — routes/modules/roles.php (7th module file)
index (GET, `roles.view`, serves page + DataTables JSON), create/store
(`roles.create`), edit/update (`roles.edit`), destroy (`roles.delete`).
No show route (D10). Store/update gates in the FormRequests'
`authorize()`; the rest `Gate::authorize` in the controller (§0.2).

### §4.2 RoleService — the single writer
- `create(name, isSystem, permissions[])`: create role (web guard),
  set is_system, `syncPermissions(RolePermissionMatrix::normalize($posted))`.
  Normalization rules, in order:
  1. per module: any of create/edit/delete → add that module's view;
  2. any `cancellations.*` → add `bookings.view`;
  3. `bookings.edit` → add `bookings.delete` (D4).
  The sync set is rebuilt from the POSTED set every save, so implied
  grants disappear when their trigger does.
- `update(role, ...)`: **refuse Super Admin outright** (D1) — checked
  against `User::SUPER_ADMIN_ROLE` on the FRESH role row, not a name
  the payload could fake; otherwise same normalization + sync; name
  unique ignoring self (D11).
- `delete(role)`: refuse Super Admin; refuse `is_system` (D1); refuse
  while `users()->exists()` (D2, validation error). After a successful
  delete call `PermissionRegistrar::forgetCachedPermissions()`
  explicitly unless spatie's delete verifiably flushes it — verify and
  state which in a comment. (`syncPermissions` flushes on its own —
  UserService.php:140-143 records that.)
- Activity: spatie Role has no HasAudit, and permission syncs are pivot
  writes the log cannot see (the CR-9 lesson) — write explicit
  `activity()` rows: `created` / `updated` (old/new permission-name
  lists + name/is_system changes) / `deleted`, causer = actor.

### §4.3 FormRequests
StoreRoleRequest / UpdateRoleRequest (twin pair, Update extends Store):
`name` per D11; `is_system` boolean; `permissions` nullable array,
each value `Rule::in(RolePermissionMatrix::assignable())` — distinct
(no duplicates reach sync; `array_unique` in the service is fine too).
Update's `authorize()` also requires the bound role; the Super-Admin
refusal lives in the service AND as an `after()` validation error so
the user sees a sentence, not a 500/403 with no context.

### §4.4 Enforcement changes elsewhere (client instructions)
- **Inquiry listing Booking Action dropdown**: the `booking` option is
  now NOT RENDERED at all for a user without `bookings.create`
  (upgrades CR-6.1's inert-option behavior — the client has now asked
  for removal). Same `can('bookings.create')` check the URL already
  uses, wrapped around the `<option>`; rewrite the partial's docblock
  paragraphs that argue for keeping it visible (they now describe
  removed behavior); the JS needs no change (option + URL absent
  together).
- **Bookings listing buttons**: ALREADY exactly the client's rule
  (verified in recon — View ← bookings.view :57-64, Edit ←
  bookings.edit :66-73, Cancel ← cancellations.create :75-82 in
  bookings/partials/actions.blade.php). NO code change; re-verify and
  state it in the report so the claim is evidenced, not assumed.
- **Super Admin full access**: already Gate::before — no change.

## §5 lang
New `lang/en/roles.php` (booking_types.php pattern): titles, breadcrumbs,
buttons, columns, filters/search placeholder, matrix module labels +
column headers, the edit info box (:count), validation messages
(in-use delete refusal, Super-Admin refusal, name taken), the
Super-Admin "all permissions" count label, empty/pagination strings.
Plus the 8 `permissions.*` labels in users.php (§2.3).

## §6 Verification the implementer must run (throwaway, deleted after)
1. Matrix render: 10 rows, 4 columns, the 2 empty delete cells; no
   Export column; no Additional Permissions section.
2. Normalization: post edit-only → view implied; post any
   cancellation → bookings.view implied; post bookings.edit →
   bookings.delete implied; revoke bookings.edit on a later save →
   bookings.delete gone. Hand-post `bookings.delete` /
   `follow_ups.manage` / an invented name → 422.
3. Guards: edit Super Admin → refused; delete Super Admin / a system
   role / an in-use role → refused with the right messages; delete an
   unused non-system role → gone, cache flushed, listing updates.
4. **D3 check**: as a user with a role holding ZERO permissions —
   can they see and complete THEIR OWN pending tasks? If yes, state
   where the code guarantees it. If no, make the minimal change so
   they can (own tasks only; `follow_ups.manage` still gates see-all)
   and flag it prominently.
5. A user with `roles.view` only: sidebar entry visible, listing loads,
   no Add button, no Edit/Delete actions. A user with none of the
   `roles.*`: no sidebar entry, direct GET → 403.
6. New role appears in User Management's role dropdown immediately;
   assigning it works; its permissions take effect without re-login
   (spatie cache flushed by sync).
7. Inquiry listing: user WITHOUT bookings.create sees NO "Booking"
   option in the dropdown; with it, option + redirect work (CR-6.1).
8. Roles listing: oldest-first default, name header sorts both ways,
   hand-rolled draw on a count alias or out-of-range index → 200.

## §7 Pinned tests (minimal updates only, all quoted in the report)
- RolePermissionSeedTest: 32→40 and exact-list assertions.
- AppShellTest: move `nav.role_management` from the shared
  never-present assertion (:469) into the `$absent` dimension; add the
  5th dataset row (`roles.view`, 'Role Management', roles.index, the
  other four labels absent) and add Role Management to the four
  existing rows' `$absent` lists; update the :432-447 docblock and the
  section-absent test (:514) if it names the entry.
- TwinViewParityTest: `'roles'` dataset row + three match arms (§3.3).
- NO new test files (§0.1).

## §8 Definition of Done
Run everything with `C:\wamp64\bin\php\php8.3.28\php.exe` (PATH php is
8.2.29), FOREGROUND:
1. `php artisan migrate:fresh --seed` green; RolePermissionSeeder run
   twice → still 40 permissions, 4 roles, Super Admin is_system.
2. Full `php artisan test` green — baseline 1102 tests / 4 pre-existing
   skips, moved only by the §7 pinned updates.
3. `vendor/bin/phpstan analyse --memory-limit=1G` (level 6) = 0 errors.
4. `vendor/bin/pint --test --dirty` clean (never touch the corrupt
   `tests/Feature/Projects/ProjectCrudTest.php`).
5. Commit NOTHING; run no git write commands.
6. Report: files created/modified, every deviation flagged with
   reasoning and evidence, every pinned-test edit quoted, real gate
   output.
