From 5486145a24bcc3c1691811c92136c2c8bc522711 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Mon, 17 Aug 2026 14:18:10 -0400 Subject: [PATCH] Aug 17 - Update customer roles management --- CLAUDE.md | 92 +++- app/enrollment/schema.py | 46 +- app/enrollment/templates/enrollment/form.html | 11 + app/models/__init__.py | 1 + app/models/notification_matrix.py | 7 +- app/models/user.py | 58 ++- app/models/user_notification_matrix.py | 124 +++++ app/routes/auth.py | 90 ++-- app/routes/broadcast.py | 2 +- app/routes/customers.py | 470 +++++++++++++++--- app/routes/inspections.py | 2 +- app/routes/issues.py | 2 +- app/routes/reports.py | 2 +- app/templates/auth/user_form.html | 39 +- app/templates/customers/index.html | 20 +- app/templates/customers/invite.html | 17 +- app/templates/customers/manage.html | 179 ++++++- app/templates/inspections/execute.html | 2 +- app/templates/issues/list.html | 2 +- app/templates/modern/issues/list.html | 2 +- .../reports/inspector_performance.html | 4 +- app/utils/forms.py | 26 +- app/utils/notifications.py | 49 +- .../phase51_user_notification_matrix.py | 63 +++ 24 files changed, 1119 insertions(+), 191 deletions(-) create mode 100644 app/models/user_notification_matrix.py create mode 100644 migrations/versions/phase51_user_notification_matrix.py diff --git a/CLAUDE.md b/CLAUDE.md index e0f5651..ef944a9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,7 +2,7 @@ > **Audience:** AI assistants and developers working on this codebase. > **Purpose:** Authoritative reference for architecture, conventions, gotchas, and decisions. -> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact) +> **Last reviewed:** July 2026 (Phase 19 complete + mobile API gap-fill Phases A–E + customer UI refinements + Phase 22 comment visibility + Phase 23 support chat/tickets + inspector performance Excel export + inspection list filters + customer issue logging + AI chatbot + dashboard grouped sections + issues/inspections PDF export + date/ID filters + Reports expansion Phases R1–R4 + Phase 24 issue_created notify defaults + Phase 25 inspection GPS + Phase 26 issue vendor fields + Phase 27 facility score alerts + Phase 28 inspection-notify fix + Phase 29 admin broadcasts + Phases 30–32 device registry consolidation + ProxyFix reverse-proxy fix + Phase 33 per-contract notification recipients + grouped Admin nav dropdown + forgot-password case-insensitive lookup & email normalization + transactional email sender/branding fix + Phase 34 facility QR public pages & report-a-problem + Phase 35 issue handler_type (our staff / facility / vendor) + Phase 36 scheduled inspections + Phase 37 support chat persistence + Phase 38 support knowledge base + Phase 39 per-area QR public pages + Phase 40 auditor role + Phase 41 issue internal handler name + Phase 42 internal handler contact + Phase 51 customer roles: Customer Director / Customer Inspector, both owned by Customer Management, role switching, per-account notification overrides, enrollment form narrowed to the two customer seats) --- @@ -199,13 +199,30 @@ users: id, username (unique, indexed), full_name, email (unique, indexed), **Role ENUM:** `admin`, `director`, `inspector`, `project_manager`, `customer`, `auditor`, `external_inspector` -**`external_inspector` (Phase 49):** An inspector employed by **the customer or a third party** rather than by us. It has **exactly the same capabilities as `inspector`** and is scoped the **same way** — through `InspectorAssignment` rows resolved by `get_inspector_scope()`, i.e. an admin grants it the customer's contracts on the existing **Assign Contracts** page (`/auth/users//assign-contracts`, now gated on `user.is_inspector`). Strict scoping applies unchanged: no assignments = sees nothing. +### The two customer-side roles (Phase 51) -The two roles are distinguished by **display only**. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` (backed by the `ROLE_LABELS` map) drive the "External" badges: users list, dashboard **Inspector Activity**, **Inspector Performance** report (HTML + the Excel export, where the name cell is suffixed `(External)` rather than gaining a column so the index-based cell styling stays correct), and every assignee dropdown (`(External)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). +Both roles below belong to the **customer**, not to us. They are the two seats the enrollment form offers, and both are created, invited, assigned, switched and disabled in **Customer Management** (`/customers`) — User Management excludes them entirely. -**Invited, not provisioned (Aug 2026).** An external inspector works outside the business, so an admin never sets their password. Creating one at `/auth/users/new` follows the customer invitation flow instead: the account is stored with `password_set=False` and a random placeholder hash, a 72-hour `set_password_token` is minted, and `customers._send_invite_email()` (reused unchanged — its copy already fits any invited account) sends a link to **`/customers/set-password/`**, where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /auth/users//resend-invite` (admin-only) mints a fresh token and re-sends — without it a bounced or expired invitation would leave the account permanently unusable. The users list shows an **"Invite pending"** badge and the resend button while `password_set` is false. Every other role is unaffected: they are still created with an admin-set password, and `create_user()` now **rejects a blank password** for them rather than storing the hash of an empty string. +| Stored ENUM value | Display label | Scoped by | Capabilities | +|---|---|---|---| +| `customer` | **Customer Director** | `CustomerAssignment` (contract **or** single facility) | The portal, unchanged — read-mostly, own-facility issues/comments/follow-up requests | +| `external_inspector` | **Customer Inspector** | `InspectorAssignment` (whole contracts only) | Identical to the internal `inspector`, limited to their contracts | -Assignable (rule 80 set becomes `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access** — `external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`), whose defaults mirror the Inspector column (see §11). +**This is a LABEL-only rename** — the same posture as rule 19 ("Project" → "Contract"). The ENUM values are unchanged, so phase51 needed **no user migration** and moved none of the ~63 `external_inspector` call sites or the many `role == 'customer'` checks. `User.ROLE_LABELS` is the one place the names live. + +**`CUSTOMER_ROLES` is not interchangeable with `role == 'customer'` — see rule 89.** `User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and `User.is_customer_account` answer an *account-management* question ("is this managed under /customers?"). Every *capability* check — the portal gates, `@customer_required`, `get_customer_scope()`, support chat, the customer branch in each API module, `notify_customers_for_facility()` — must keep testing `role == 'customer'` exactly, because a Customer Inspector is an **inspector** there. + +**Switching between them** — `POST /customers//switch-role`, admin-only. The two roles read different scoping tables, so the switch **mirrors the contracts across** (a bare role flip would leave the account correctly labelled and seeing nothing). Rows for the role being left are **kept**, not deleted. Two consequences worth knowing: +- Director → Inspector **widens** any facility-level narrowing to the whole contract — inspectors have no per-facility row. The confirm dialog and the flash both say so. +- Inspector → Director is **lossless on a round trip**: the reverse mirror skips contracts the account can already reach *by any* `CustomerAssignment` row, so it cannot stack a contract-wide grant on top of the original facility-level one. + +API access changes in both directions (`external_inspector` has mobile-API access, `customer` is 403 everywhere), so the switch **revokes all `api_refresh_tokens` and deletes `api_device_tokens`** for the account — otherwise an issued JWT would keep working until expiry and a signed-in iPad would keep syncing. + +**`external_inspector` (Phase 49) details, still current:** it has **exactly the same capabilities as `inspector`** and is scoped the **same way** — `InspectorAssignment` rows resolved by `get_inspector_scope()`. Strict scoping applies unchanged: no assignments = sees nothing. `User.INSPECTOR_ROLES = ('inspector', 'external_inspector')` and the `User.is_inspector` property are the single definition — **every** capability/scoping check tests `is_inspector`, never `role == 'inspector'` (rule 87). `User.is_external_inspector` and `User.role_label` drive the badges: dashboard **Inspector Activity**, **Inspector Performance** report (HTML badge reads "Customer"; the Excel export suffixes the name cell `(Customer)` rather than gaining a column, so the index-based cell styling stays correct), and every assignee dropdown (`(Customer)` suffix — issues create/update, issue-list quick-assign, inspection flag-issue). Phase 51 changed those strings from "External"; the *attribute* names did not move (rule 84). + +**Invited, never provisioned.** Neither customer role is given a password we chose. `customers.create()` stores the account with `password_set=False` and a random placeholder hash, mints a 72-hour `set_password_token`, and `_send_invite_email()` sends a link to **`/customers/set-password/`** where they choose their own **username and password**. `login()` refuses `password_set=False` until they finish. `POST /customers//resend-invite` mints a fresh token and re-sends — without it a bounced or expired invitation leaves the account permanently unusable. Phase 51 moved this branch out of `auth.create_user()`, which now **requires** a password for every role it still offers (all of them ours). + +Assignable (rule 80 set: `director`/`inspector`/`external_inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown), included in Inspector Performance and Inspector Activity, and has **mobile-API access** — `external_inspector` is in the `_ALLOWED_ROLES` of every `app/api/*` module and falls into the inspector branch of every scoping check there. It gets its **own Notification Matrix column** (`external_inspector`, labelled "Customer Inspector"), whose defaults mirror the Inspector column (see §11), and both customer roles additionally support **per-account overrides** (§11). **`auditor` (Phase 40):** A staff role with the **same access as `project_manager`** (it is included in `@project_manager_required` and everywhere `project_manager` is checked) **plus full issue-management powers** — create, assign, quick-assign, handler/vendor triage, request-verification, and verify/bulk-verify/verification-queue (via the new `@issue_manager_required` decorator). **Auditor does NOT get issue deletion** (that stays admin/director via `@supervisor_required`), nor any other admin/director-only area PM lacks (users, audit trail, notification matrix, customers, templates). Auditors are **assignable** as an issue/inspection assignee; **admin was removed** from the assignable set at the same time (assignee dropdowns are now `director`/`inspector`/`auditor`, plus `project_manager` on the inspection flag-issue dropdown). The issue-update route defensively keeps any pre-existing out-of-set assignee (e.g. a legacy admin assignment) in the dropdown so saving never silently unassigns. Auditor **has mobile-API access** — it is included in the `_ALLOWED_ROLES` set of every `app/api/*` module (comments, inspections, issues, photos, scheduled, stats, templates), so the iPad app accepts auditor logins. In every API endpoint that scopes by role, auditor falls into the non-inspector/non-customer (privileged) branch — org-wide data, same as admin/director/PM. @@ -377,6 +394,28 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON) UniqueConstraint(event_type, role_key) ``` +### UserNotificationMatrix (Phase 51) + +``` +user_notification_matrix: id, user_id (FK→users CASCADE, indexed), + event_type VARCHAR(50), enabled BOOL + UniqueConstraint(user_id, event_type) +``` + +**Per-account override of the global matrix**, for the two customer-side roles only. Each customer organisation states on its enrollment form which notifications each of its people wants, and the global matrix's grain (whole roles) cannot express that. + +| Row state | Meaning | +|---|---| +| `enabled=True` | send even if the global column for this role is OFF | +| `enabled=False` | do not send even if the global column is ON | +| **no row** | **inherit** — follow the global column, including later changes to it | + +Inherit is the default and the safe state, so the table shipped empty and changed routing for nobody. Setting a row back to inherit **deletes** it rather than snapshotting the current global value — that is what keeps an account that never expressed an opinion tracking the global matrix. + +Helpers in `app/models/user_notification_matrix.py`: `overrides_for_user(user_id)` → `{event: bool}` (the editor), `overrides_for_event(event_type)` → `{user_id: bool}` (one query per dispatch, fails soft to `{}`), `set_overrides(user_id, {event: True|False|None})` (does **not** commit — caller owns the transaction, same contract as `notify()`). + +Edited admin-side on the account's Customer Management page as a tri-state (Inherit / On / Off) with the global column's current value shown under "Inherit". Staff roles are unaffected — they use the global matrix alone; `NotificationPreference` remains a different question (how to deliver, not whether to route). + ### AuditLog ``` @@ -531,7 +570,7 @@ Management (`/scheduled-inspections/new|edit|delete`) is `@project_manager_requi | `facilities` | `/facilities` | CRUD + area management + QR code: `//qr` printable page, `//qr.png` image, `POST //qr/regenerate` (invalidates old printed code), `/qr/print-all[?contract_id=]` bulk sheet. **Per-area QR (Phase 39):** `/areas//qr`, `/areas//qr.png`, `POST /areas//qr/regenerate` — mirror the facility QR routes; scope enforced by `_area_for_qr_or_403()` via the area's parent facility. **Customers may use all QR actions (including regenerate) for their own assigned facilities**; inspectors/PM/admin/director for any. Scope enforced by `_facility_for_qr_or_403()` (customers) / `get_customer_scope` (print-all). Regenerate is limited to admin/director + scoped customer (PM/inspector excluded). **QR print/export page:** `GET /qr/print-all` is a selectable sheet with filters `?contract_id=` / `?facility_id=` / `?include_areas=1` (contract narrows the facility dropdown; areas render each facility's per-area QR cards). Each card is a `