Aug 19 - Update code to catch up with ST
This commit is contained in:
@@ -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 `<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.
|
||||
|
||||
Reference in New Issue
Block a user