July 7 - Implement notification per contract

This commit is contained in:
2026-07-07 10:03:55 -04:00
parent 0d4a10d01d
commit e578fb6ca1
7 changed files with 634 additions and 6 deletions
+41 -2
View File
@@ -338,6 +338,23 @@ broadcasts: id, title VARCHAR(255), body TEXT, target_roles (JSON list of role s
Admin-authored broadcast messages. Sending a broadcast fans out one `Notification` row per targeted user; the iPad picks them up through its existing `GET /api/v1/notifications?since=...` poll — **no dedicated broadcast API endpoint exists**. `recipient_count` snapshots how many notifications were created. Managed at `/admin/broadcast` (see §7 `broadcast` blueprint).
### ContractNotificationRecipient
```
contract_notification_recipients:
id, project_id (FK→projects CASCADE, indexed),
user_id (FK→users CASCADE, nullable, indexed), -- staff-user recipient
email VARCHAR(200) nullable, -- external email recipient
event_types TEXT (JSON list of event_type keys), created_at
```
**Per-contract additional notification recipients.** Each row is ONE extra recipient attached to a Contract who is notified — for the `event_types` they subscribe to — whenever those events fire within that contract's facilities, **in addition to** the global `NotificationMatrix` routing. Exactly one of `user_id` / `email` is set (enforced in the route, not the DB):
- `user_id` set → existing staff user → **in-app notification + email**
- `email` set → free-form external address → **email only**
`event_types` is a JSON list of `MATRIX_EVENTS` keys. A recipient fires only when the event is in its list. Managed admin-only on the **Contract detail page** (`/projects/<id>`) via `add_notify_recipient` / `remove_notify_recipient`. Dispatch is resolved centrally in `notify_by_matrix()` — see §11.
---
## 6. Role & Permission Matrix
@@ -381,7 +398,7 @@ Admin-authored broadcast messages. Sending a broadcast fans out one `Notificatio
| `auth` | `/auth` | `/login`, `/logout`, `/profile`, `/users/*`, `/notification-matrix` |
| `dashboard` | `/` | `GET /`, `/facility-trend` (AJAX) |
| `facilities` | `/facilities` | CRUD + area management |
| `projects` | `/projects` | CRUD + customer assignment management |
| `projects` | `/projects` | CRUD + customer assignment management + notification-recipient add/remove (`/<id>/notify-recipients/add`, `/notify-recipients/<rid>/remove` — admin only) |
| `customers` | `/customers` | list, invite, set-password, manage, import CSV |
| `inspections` | `/inspections` | list, start, execute, view, PDF export, flag-issue, save-draft (AJAX), flag-followup, reinspect, upload-photo (AJAX) |
| `templates` | `/templates` | list, create, edit, delete, form editor, preview |
@@ -589,6 +606,16 @@ EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated'
EVENT_SCORE_ALERT = 'score_alert' ← Phase 27
```
### Per-Contract Additional Recipients (Phase 33)
`notify_by_matrix()` is the single dispatch point for all broadcast events. After routing to the global matrix roles + global custom emails, it calls `_notify_contract_recipients()`, which:
1. Resolves the owning contract via `_resolve_project_id(facility_id, issue_id, inspection_id)` — tries `facility_id`, then the issue's facility (or `issue.area.facility_id`), then the inspection's facility.
2. Loads `ContractNotificationRecipient` rows for that project and notifies each one whose `event_types` contains the firing event.
3. **Deduplicates** against users already notified this dispatch (shared `notified` set) and emails already sent (shared `sent_emails` set), so a user who is both a matrix role AND a contract recipient gets exactly one notification.
Contract recipients fire **regardless of matrix role toggles** — they are additive, not gated by the matrix. Staff-user recipients use `respect_preferences=False` (contract config is authoritative, mirroring matrix broadcasts). Commit is the **caller's** responsibility, same as the rest of `notify_by_matrix()`.
### Cron Endpoints (all require `token=DIGEST_SECRET`)
| Endpoint | Purpose | Schedule |
@@ -673,7 +700,8 @@ phase1_projects_roles → phase6_features → phase7_mobile_api → phase8_notif
→ phase29_broadcasts
→ phase30_device_registry
→ phase31_device_registry
→ phase32_device_token_columns ← HEAD
→ phase32_device_token_columns
→ phase33_contract_notify_recipients ← HEAD
```
### phase21_performance_indexes
@@ -748,6 +776,16 @@ These three migrations are the history of a **false start** in device tracking.
**The dead `DeviceRegistration` model, `app/api/devices.py` endpoint, and `api_devices` blueprint were removed (July 2026).** They defined a *second* `POST /api/v1/devices/register` that was shadowed at routing time by the `api_auth` copy and would have crashed anyway (it queried the dropped `device_registrations` table). Device registration now has a single implementation: `register_device()` in `app/api/auth.py`, writing to `api_device_tokens`. Do not reintroduce a competing device model or a duplicate register route.
### phase33_contract_notify_recipients
Creates the `contract_notification_recipients` table backing **per-contract additional notification recipients** (see §5 `ContractNotificationRecipient` and §11). Uses table existence check — safe to re-run.
**Deploy order:**
```bash
flask db upgrade
sudo systemctl restart gunicorn
```
**Deploy order for phases 2432:**
```bash
flask db upgrade
@@ -1074,6 +1112,7 @@ timeout = 30
| 70 | **`notify()` does NOT commit — caller must `db.session.commit()` after all `notify()` calls** | `notify()` adds a `Notification` row to the session but leaves the commit to the caller. The support helpers (`_notify_admins_new_ticket`, `_notify_customer_reply`, `_notify_admins_customer_reply`) each call `db.session.commit()` after the `notify()` loop. |
| 71 | **`ProxyFix` must wrap `app.wsgi_app` in `create_app()`** | Behind Nginx, `remote_addr` is `127.0.0.1` for every request without it, collapsing all Flask-Limiter keys into one bucket (global instead of per-client rate limiting). `x_for=1` trusts exactly one proxy hop. See §19. |
| 72 | **Device registration has exactly ONE implementation — `register_device()` in `app/api/auth.py` → `api_device_tokens`** | A second `POST /api/v1/devices/register` (`app/api/devices.py` + `DeviceRegistration` model) was removed July 2026. It was shadowed by the `api_auth` route at routing time and queried the dropped `device_registrations` table. Do not reintroduce a competing device model or duplicate register route. |
| 73 | **Per-contract recipients are dispatched ONLY inside `notify_by_matrix()` — never add a parallel path** | `_notify_contract_recipients()` runs after role + global-custom-email routing and shares the `notified` / `sent_emails` dedup sets. Any new event that should reach contract recipients must go through `notify_by_matrix()` (passing `facility_id`, or an `issue_id`/`inspection_id` that resolves to one). Bypassing it means contract recipients are silently skipped and dedup breaks. Commit stays the caller's responsibility. |
---