Aug 19 - Update code to catch up with ST

This commit is contained in:
2026-08-19 14:05:18 -04:00
parent c9984e7ae6
commit 12141c2f75
52 changed files with 3321 additions and 342 deletions
+163 -2
View File
@@ -429,6 +429,36 @@ notification_matrix: id, event_type, role_key, enabled, custom_emails (JSON)
UniqueConstraint(event_type, role_key)
```
### UserNotificationMatrix (phase54)
```
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. `enabled=True` = send even if the global column is OFF; `enabled=False` =
never send even if it is ON; **no row = inherit**. Setting a row back to
inherit DELETES it, which is what keeps an account that never expressed an
opinion tracking the global matrix. Helpers: `overrides_for_user()`,
`override_for()`, `overrides_for_event()`, `set_overrides()` (none commit).
See §27b and rules 100101.
### TemplateContract (phase55)
```
template_contracts: id, template_id (FK→inspection_templates CASCADE, indexed),
project_id (FK→projects CASCADE, indexed), created_at
UniqueConstraint(template_id, project_id)
```
Restricts a form to specific contracts. **No rows means the form is SHARED**
(available on every contract) — rule 102. `InspectionTemplate` helpers:
`contract_ids`, `is_shared`, `available_for_project()`, `set_contracts()` (does
not commit), and the static `available_query(project_id)` — the single
definition of "which forms may this contract use".
### InspectionSchedule (phase34)
```
@@ -813,7 +843,7 @@ limiter = Limiter(
## 17. Alembic Migration Chain
**Current HEAD:** `phase52_user_ui_theme` (51 migrations total).
**Current HEAD:** `phase55_template_contracts`.
**Chain root:** `0003_add_user_active` — a guarded squashed baseline (MT-2) that recreates the full 25-table schema with INFORMATION_SCHEMA guards. The original baseline migrations (0001/0002/0003) were lost; this file restores the chain root so Alembic can build the revision map. `down_revision = None`.
@@ -853,7 +883,34 @@ limiter = Limiter(
→ phase46_schedule_recurrence → phase47_schedule_end_date
→ phase48_schedule_parent_inspection → phase49_followup_requested_by
→ phase50_sched_acknowledged → phase51_external_inspector
→ phase52_user_ui_theme ← HEAD
→ phase52_user_ui_theme → phase53_knowledge_sort_order
→ phase54_user_notif_matrix → phase55_template_contracts ← HEAD
```
`phase54` / `phase55` port the ST August-2026 work (ST calls them phase51 /
phase52; the ids differ because MT's chain was already past those numbers —
match by NAME, not number, when comparing the two repos).
#### phase54 — per-account notification overrides
Creates `user_notification_matrix` (§5). **No backfill, deliberately** — an
empty table means every account inherits the global matrix, i.e. exactly
today's routing, so the migration cannot change who gets notified. Backfilling
from the current global columns would freeze every account at today's routing
and silently break later changes to those columns. Table-existence check —
safe to re-run.
#### phase55 — restrict forms to specific contracts
Creates `template_contracts` (§5 `TemplateContract`). **No rows for a template
means SHARED**, so every pre-existing form stays available everywhere and the
migration cannot change behaviour on deploy. Table-existence check — safe to
re-run.
**Deploy order for both (tenant DBs):**
```bash
python -m control.tenant_migrate upgrade --tenant all
sudo systemctl restart gunicorn
```
`phase41``phase52` are the single-tenant feature-parity track — see
@@ -1441,6 +1498,15 @@ set -a; . /etc/jqc/control.env; set +a
| 95 | **Chat history is loaded from DB — never pass client-sent history to Groq** | phase40. `POST /support/chat/message` loads prior turns from `SupportChatMessage` (newest-first, limit 40, reversed). The JSON body sends only `{ message, session_id }` — no history array. This prevents history tampering by clients and ensures accuracy across page reloads. |
| 96 | **`db.session.flush()` to get session ID before first message insert** | When creating a new `SupportChatSession` in `chat_message()`, call `db.session.flush()` after `db.session.add(chat_session)` to get the autoincrement `id` before constructing `SupportChatMessage` rows. If Groq fails, `db.session.rollback()` undoes the flush — no orphaned empty session is left in the DB. |
| 97 | **`send_billing_email()` derives the From address from `APP_BASE_URL` — same pattern as rule 64** | `urlparse(app.config['APP_BASE_URL']).netloc` is extracted before the background thread starts and passed as `sender=f'noreply@{netloc}'` to `Message()`. Falls back to `MAIL_DEFAULT_SENDER` when `APP_BASE_URL` is absent or yields an empty netloc (`sender=None` triggers Flask-Mail's default). Do not hardcode a sender string or duplicate the derivation logic — extend via `send_billing_email()` only. |
| 99 | **`User.CUSTOMER_ROLES` is for ACCOUNT MANAGEMENT; `role == 'customer'` is for CAPABILITY — never swap them** | Widening a capability check to `CUSTOMER_ROLES` hands a third-party Customer Inspector the customer portal (fails OPEN, nothing errors). Narrowing an account-management check back to `'customer'` strands every Customer Inspector in a page that no longer lists or edits them. `CUSTOMER_ROLES` / `is_customer_account` appear ONLY in: the `/customers` list query and its guards, the `auth.list_users` exclusion, the customer-facing support surface (`_is_customer_side()`), and narrowing uses that WITHHOLD something from an external account (`_assignable_staff_for()`). |
| 100 | **A per-account notification opt-IN must survive a globally-OFF column** | `notify_by_matrix()` skips a role column early when the matrix says off. For the two customer columns that `continue` must also ask `any(overrides.values())`, or the override saves, displays as on, and never sends. Equally, `notify_customers_for_facility()` re-queries recipients from assignment rows, so `notify_by_matrix()` must hand it `allowed_user_ids` or the facility-scoped path bypasses every override. Both halves are needed. |
| 101 | **Per-account overrides are enforced in `notify()`, not only `notify_by_matrix()`** | Follower fan-out and direct assignee notifications call `notify()` straight. Gating only the matrix left the editor offering rows ("Issue assigned") that read as Off while the notifications kept arriving. Only an explicit `False` suppresses; the `getattr(recipient, 'is_customer_account', False)` test is deliberate so an unavailable attribute SENDS rather than silently dropping. |
| 102 | **A template with NO `template_contracts` rows is SHARED, not hidden** | The empty set means "available on every contract" — that is what makes phase55 additive and why it needed no backfill. Reading it the other way hides every pre-existing form from every contract at once. The convention lives in exactly one place, `InspectionTemplate.available_query()`. A facility with no contract gets shared forms only (fail-closed). |
| 103 | **A bulk-action form must live OUTSIDE the table; row checkboxes join it via the HTML5 `form=` attribute** | Wrapping the table nests the per-row delete/unfollow forms inside the bulk form, and browsers silently discard nested forms (rule 9) — the row buttons post nothing, with no console error and no server log. Applies to all four list templates (classic + modern). |
| 104 | **Bulk deletes: DB rows first, storage files second** | Collect the keys, `db.session.delete()` every row, `commit()`, and only then `storage.delete()`. `_collect_inspection_photos()` is shared by the single and bulk inspection delete paths so the two cannot drift — a key missed there is an invisible permanent storage leak. |
| 105 | **The flag-issue assignee list is contract-scoped, and BOTH call sites must use `_assignable_staff_for()`; a failed flag-issue POST must return non-2xx** | `execute()` renders the dropdown, `flag_issue()` builds the choices that validate the POST — the choices are the security boundary. An org-wide list let anyone assign another client's Customer Inspector, who was then emailed the facility name and issue description. And the offcanvas JS branches on `res.ok`, so a 200 re-render of an invalid form reads as success: the panel closes, the page reloads, and no issue exists. |
| 106 | **Name the Groq model in the chat error log, and keep `_DEFAULT_GROQ_MODEL` current** | Groq retires models without notice; when the configured one disappears the API 404s and EVERY question returns the generic "problem reaching the AI assistant" reply, with nothing else broken — invisible until a customer complains. The fix needs no deploy, only `GROQ_MODEL`, which is exactly what the log line must say. |
| 107 | **`viewer_is_our_staff` in `issues/view.html` is an explicit role ALLOWLIST, and `external_inspector` is absent on purpose** | `not current_user.is_customer_account` fails OPEN — a missing attribute yields Jinja `Undefined`, `not Undefined` is true, and the internal-process chrome renders for exactly the accounts it must be hidden from. This is not a rule-87 violation: rule 87 governs capability/scoping, where a Customer Inspector must behave like our inspector; this asks "does this person work for us?", the one place the two genuinely differ. |
| 98 | **Reports R1 + R2 contract cascade is client-side only — facility_id is the sole DB filter** | The Contract dropdown in `reports/issues_aging.html` and `reports/sla_compliance.html` has no `name` attribute and is never submitted. It exists only to narrow the Facility `<select>` in the browser via `GET /inspections/facilities_for_project/<id>`. The routes receive and filter on `facility_id`; `contract_id` plays no role server-side. Do not add server-side `contract_id` filtering to these routes — it would duplicate what `facility_id` already provides. |
---
@@ -1807,6 +1873,101 @@ plus an emailed 72-hour token, with `auth.resend_invite` for bounced invitations
---
## 27b. Customer-side roles, per-account notifications, per-contract forms (Aug 2026 ST parity)
Ported from the single-tenant app (its phase51 + phase52 + the August fixes).
Nothing here is tenant-aware in its own right — every table lives in the tenant
DB and every query routes through `RoutingSession` as usual.
### The two customer-side roles
| Stored ENUM value | Display label | Scoped by | Capabilities |
|---|---|---|---|
| `customer` | **Customer Director** | `CustomerAssignment` | The portal, unchanged — plus planning scheduled inspections at their own facilities |
| `external_inspector` | **Customer Inspector** | `InspectorAssignment` | Identical to the internal `inspector`, plus the customer support surface (AI chat + tickets) |
**LABEL-only rename** — the ENUM values are untouched, so no migration and no
role check moved. `User.ROLE_LABELS` is the one place the names live.
`User.CUSTOMER_ROLES = ('customer', 'external_inspector')` and
`User.is_customer_account` answer an **account-management** question ("is this
managed under `/customers`?"). Every **capability** check — portal gates,
`@customer_required`, `get_customer_scope()`, `notify_customers_for_facility()`,
the customer branch of each `app/api/*` module — keeps testing
`role == 'customer'` exactly (rule 99).
Both roles are now created, invited, assigned and switched in **Customer
Management** (`/customers`); `auth.list_users` excludes them and the
`/auth/users/...` URLs redirect to `customers.manage`. `UserForm` no longer
offers `external_inspector`, and `auth.create_user` always requires a password
— customer-side accounts are invited (they choose their own username and
password) via `customers.create()`. `POST /customers/<id>/switch-role`
(admin only) mirrors contracts across the two scoping tables and revokes the
account's refresh tokens + device rows, because API access differs between them.
### `UserNotificationMatrix` (per-account overrides)
Row `enabled=True` = send even if the global column is OFF; `enabled=False` =
never send even if it is ON; **no row = inherit**. Inherit is the default, so
the table shipped empty and changed routing for nobody, and setting a row back
to inherit DELETES it. Helpers live in
`app/models/user_notification_matrix.py`; edited on the account's Customer
Management page as a tri-state. Enforced in **`notify()` as well as**
`notify_by_matrix()` — follower fan-out and direct assignee notifications reach
`notify()` straight, so gating only the matrix left rows that read as Off while
notifications kept arriving. See rules 100 and 101.
### `TemplateContract` (forms per contract)
`InspectionTemplate.available_query(project_id)` is the single definition of
"which forms may this contract use" — pickers, the POST validation behind them,
the schedule form and `GET /api/v1/templates` all call it. **No rows = shared**
(rule 102). Managed in three places: the template list's Edit modal
(`POST /templates/<id>/rename`, carrying a hidden `contracts_present=1`
marker), Create Template, and the full form editor. `duplicate_template()`
copies the restrictions.
### Other ported behaviour
* **Bulk actions** on the issues and inspections lists (`POST /issues/bulk`,
`POST /inspections/bulk`) with shared partials in `templates/partials/`.
Toolbar form sits OUTSIDE the table; row checkboxes join it with the HTML5
`form=` attribute (rule 103). Deletes remove DB rows first, storage keys
second (rule 104).
* **List filter preservation** — `current_url()` (Jinja global) +
`return_url(fallback)` (`utils/decorators`) round-trip the full list URL as
`next`, so an edit or delete returns to the filtered page.
`safe_redirect_url` still guards every hop.
* **Flag-issue assignee scoping** — `_assignable_staff_for(inspection, actor)`
in `routes/inspections.py` is the single source for both the offcanvas
dropdown and `form.assigned_to.choices` (the actual POST validation). A
failed flag-issue POST now returns **400**, because the offcanvas JS branches
on `res.ok` (rule 105).
* **Customer Directors plan inspections** — `schedule_manager_required` in
`routes/inspection_schedules.py` = the manager set plus `role == 'customer'`.
Because that blueprint builds its form by hand (no WTForms SelectField),
narrowing the choice lists is NOT the validation: `_scope_errors()`
re-checks facility, inspector and form-vs-contract on every POST, and
`_schedule_in_scope()` guards edit/delete. Start remains the assignee's.
* **Support chat serves both customer roles** — `_is_customer_side()` opens the
door, then `_support_facilities()` branches per role (CustomerAssignment vs
InspectorAssignment) and `_system_prompt_for()` appends
`_INSPECTOR_ADDENDUM` for a Customer Inspector. The curated knowledge base is
now spliced in **before** the `Rules:` heading (`_STYLE_MARKER`) — appended
after it, the prompt's own "ground answers in everything above" put it out of
scope, which is why KB entries looked ignored. `/support/admin/knowledge/preview`
shows the exact prompt. `GROQ_MODEL` defaults to `_DEFAULT_GROQ_MODEL`
(`openai/gpt-oss-120b`); Groq retires models without notice, and the error
handler names the model and says to set `GROQ_MODEL` (rule 106).
* **`COMMENTS_VISIBLE_TO_ALL`** (config, default true) lifts the phase22 read
filter so customers see every comment. `is_customer_visible` is still
written, so flipping it back restores the old behaviour with nothing to
repair. `issues/view.html` gates the internal chrome on
`viewer_is_our_staff` — an explicit allowlist of OUR roles, which fails
closed and deliberately excludes `external_inspector` (rule 107).
---
## 28. Coding Rules for AI Assistants
These rules apply to every change made to this codebase, without exception.