diff --git a/CLAUDE.md b/CLAUDE.md index 273ffac..05273ec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 100–101. + +### 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 ` replaces its + intrinsic box with a flex container: the control keeps painting its glyph + at its natural ~16px size while the element claims a 44px-tall box, so the + visible dot and the actual hit area stop coinciding. Taps land next to the + control and nothing happens — which is exactly how the per-account + notification matrix (Inherit / On / Off) came to look unclickable. + + Grow the target without touching `display`: keep the native box, scale the + glyph up, and give it margin so neighbouring options stay separable. Any + control that needs a genuinely large tap area should wrap the input in a +