diff --git a/CLAUDE.md b/CLAUDE.md index 66ddef5..f3fd87d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/`) 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 (`//notify-recipients/add`, `/notify-recipients//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 24–32:** ```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. | --- diff --git a/app/models/__init__.py b/app/models/__init__.py index dd90aff..0ca28c2 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -5,4 +5,5 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem, from app.models.issue import Issue from app.models.project import Project, CustomerAssignment from app.models.api_token import RefreshToken, DeviceToken -from app.models.notification_matrix import NotificationMatrix \ No newline at end of file +from app.models.notification_matrix import NotificationMatrix +from app.models.notification_recipient import ContractNotificationRecipient \ No newline at end of file diff --git a/app/models/notification_recipient.py b/app/models/notification_recipient.py new file mode 100644 index 0000000..2edd9c9 --- /dev/null +++ b/app/models/notification_recipient.py @@ -0,0 +1,79 @@ +""" +app/models/notification_recipient.py +------------------------------------ +Per-contract additional notification recipients. + +Each row is ONE extra recipient attached to a Contract (Project) who should be +notified when selected notification events fire within that contract's +facilities — in ADDITION to whoever the global NotificationMatrix already +routes to. + +A recipient is either: + - an existing app user (user_id set, email NULL) → in-app notification + email + - a free-form email (email set, user_id NULL) → email only + +`event_types` is a JSON list of event_type keys (matching MATRIX_EVENTS) that +this recipient subscribes to for this contract. A recipient is notified only +when the firing event_type is present in this list. An empty list means the +recipient receives nothing (the add form requires at least one event). + +Resolution happens centrally in notify_by_matrix() (app/utils/notifications.py), +which derives the contract from the event's facility / issue / inspection. +""" + +import json +from app import db +from app.utils.time_utils import now_eastern + + +class ContractNotificationRecipient(db.Model): + __tablename__ = 'contract_notification_recipients' + + id = db.Column(db.Integer, primary_key=True) + project_id = db.Column( + db.Integer, + db.ForeignKey('projects.id', ondelete='CASCADE'), + nullable=False, index=True, + ) + # Exactly one of user_id / email is set (enforced in the route, not the DB). + user_id = db.Column( + db.Integer, + db.ForeignKey('users.id', ondelete='CASCADE'), + nullable=True, index=True, + ) + email = db.Column(db.String(200), nullable=True) + # JSON list of event_type keys this recipient is subscribed to for this contract. + event_types = db.Column(db.Text, nullable=True) # JSON-encoded list[str] + created_at = db.Column(db.DateTime, nullable=False, default=now_eastern) + + project = db.relationship('Project', foreign_keys=[project_id], + backref=db.backref('notification_recipients', + lazy='dynamic', + cascade='all, delete-orphan')) + user = db.relationship('User', foreign_keys=[user_id]) + + def get_event_types(self) -> list: + """Return event_types as a Python list of strings.""" + if not self.event_types: + return [] + try: + result = json.loads(self.event_types) + return [e for e in result if isinstance(e, str) and e.strip()] + except (json.JSONDecodeError, TypeError): + return [] + + def set_event_types(self, values) -> None: + """Store a list of event_type keys as a JSON string.""" + clean = [v.strip() for v in (values or []) if isinstance(v, str) and v.strip()] + self.event_types = json.dumps(clean) + + @property + def display_target(self) -> str: + """Human-readable recipient label for the UI.""" + if self.user_id and self.user: + return f'{self.user.display_name} ({self.user.email})' + return self.email or '—' + + def __repr__(self): + who = f'user={self.user_id}' if self.user_id else f'email={self.email}' + return f'' diff --git a/app/routes/projects.py b/app/routes/projects.py index c32b463..2337ca2 100644 --- a/app/routes/projects.py +++ b/app/routes/projects.py @@ -83,11 +83,37 @@ def view(project_id): .order_by(User.username) .all() ) + + # ── Additional notification recipients (admin-only panel) ── + # Only loaded for admins — the panel is admin-gated in the template, and + # the add/remove routes are @admin_required. No point querying otherwise. + from app.models.notification_matrix import MATRIX_EVENTS + notify_recipients = [] + staff_users = [] + if current_user.role == 'admin': + from app.models.notification_recipient import ContractNotificationRecipient + notify_recipients = ( + ContractNotificationRecipient.query + .filter_by(project_id=project_id) + .order_by(ContractNotificationRecipient.created_at.asc()) + .all() + ) + # Internal staff users selectable as recipients (exclude customers) + staff_users = ( + User.query + .filter(User.active == True, User.role != 'customer') # noqa: E712 + .order_by(User.username) + .all() + ) + return render_template( 'projects/view.html', project=project, facilities=facilities, assignments=assignments, + notify_recipients=notify_recipients, + matrix_events=MATRIX_EVENTS, + staff_users=staff_users, ) @@ -236,6 +262,129 @@ def remove_assignment(assignment_id): return redirect(url_for('projects.view', project_id=project_id)) +# ── Additional Notification Recipients — Add ────────────────────────────────── + +@bp.route('//notify-recipients/add', methods=['POST']) +@login_required +@admin_required +def add_notify_recipient(project_id): + """Attach an additional notification recipient (user OR email) to a contract. + + These recipients are notified — for the selected event types — whenever + those events fire within this contract's facilities, in ADDITION to the + global NotificationMatrix routing. See app/utils/notifications.py. + """ + from app.models.notification_recipient import ContractNotificationRecipient + from app.models.notification_matrix import MATRIX_EVENTS + + project = db.session.get(Project, project_id) + if project is None: + abort(404) + + recipient_type = (request.form.get('recipient_type') or '').strip() + event_types = request.form.getlist('event_types') + # Keep only valid, known event keys + event_types = [e for e in event_types if e in MATRIX_EVENTS] + + if not event_types: + flash('Select at least one event type for the recipient.', 'warning') + return redirect(url_for('projects.view', project_id=project_id)) + + user_id = None + email = None + + if recipient_type == 'user': + try: + user_id = int(request.form.get('user_id') or 0) + except (TypeError, ValueError): + user_id = 0 + user = db.session.get(User, user_id) if user_id else None + if user is None or user.role == 'customer' or not user.active: + flash('Select a valid staff user.', 'warning') + return redirect(url_for('projects.view', project_id=project_id)) + # Guard against duplicate user recipient on the same contract + existing = ContractNotificationRecipient.query.filter_by( + project_id=project_id, user_id=user_id + ).first() + if existing: + existing.set_event_types(event_types) + db.session.commit() + log_action(ACTION_UPDATE, 'ContractNotificationRecipient', existing.id, + f'{user.display_name} → {project.name}', + f'events={",".join(event_types)}') + flash(f'Updated notification events for "{user.display_name}".', 'success') + return redirect(url_for('projects.view', project_id=project_id)) + target_label = user.display_name + + elif recipient_type == 'email': + email = (request.form.get('email') or '').strip() + # Minimal email sanity check — a single '@' with text on both sides + if '@' not in email or email.startswith('@') or email.endswith('@') or ' ' in email: + flash('Enter a valid email address.', 'warning') + return redirect(url_for('projects.view', project_id=project_id)) + email = email[:200] + existing = ContractNotificationRecipient.query.filter_by( + project_id=project_id, email=email + ).first() + if existing: + existing.set_event_types(event_types) + db.session.commit() + log_action(ACTION_UPDATE, 'ContractNotificationRecipient', existing.id, + f'{email} → {project.name}', + f'events={",".join(event_types)}') + flash(f'Updated notification events for "{email}".', 'success') + return redirect(url_for('projects.view', project_id=project_id)) + target_label = email + + else: + flash('Choose whether to add a user or an email address.', 'warning') + return redirect(url_for('projects.view', project_id=project_id)) + + recipient = ContractNotificationRecipient( + project_id=project_id, + user_id=user_id, + email=email, + ) + recipient.set_event_types(event_types) + db.session.add(recipient) + db.session.commit() + + logger.info('PROJECTS | notify_recipient_add | admin=%s project_id=%s target=%s events=%s', + current_user.username, project_id, target_label, event_types) + log_action(ACTION_CREATE, 'ContractNotificationRecipient', recipient.id, + f'{target_label} → {project.name}', + f'events={",".join(event_types)}') + flash(f'Notification recipient "{target_label}" added.', 'success') + return redirect(url_for('projects.view', project_id=project_id)) + + +# ── Additional Notification Recipients — Remove ─────────────────────────────── + +@bp.route('/notify-recipients//remove', methods=['POST']) +@login_required +@admin_required +def remove_notify_recipient(recipient_id): + from app.models.notification_recipient import ContractNotificationRecipient + + recipient = db.session.get(ContractNotificationRecipient, recipient_id) + if recipient is None: + abort(404) + project_id = recipient.project_id + project = db.session.get(Project, project_id) + label = recipient.display_target + recipient_id_snap = recipient.id + + db.session.delete(recipient) + db.session.commit() + + logger.info('PROJECTS | notify_recipient_remove | admin=%s project_id=%s target=%s', + current_user.username, project_id, label) + log_action(ACTION_DELETE, 'ContractNotificationRecipient', recipient_id_snap, + f'{label} → {project.name if project else project_id}') + flash(f'Notification recipient "{label}" removed.', 'success') + return redirect(url_for('projects.view', project_id=project_id)) + + # ── Bulk Import — Excel template download ───────────────────────────────────── @bp.route('/import/template') diff --git a/app/templates/projects/view.html b/app/templates/projects/view.html index 097f0a4..854bb3a 100644 --- a/app/templates/projects/view.html +++ b/app/templates/projects/view.html @@ -165,5 +165,180 @@ {% endif %} + {# ── Additional Notification Recipients ── #} + {% if current_user.role == 'admin' %} +
+
+
+ + Additional Notification Recipients + + +
+ + {# Add form (collapsed by default) #} +
+
+ + +
+ {# Recipient type + target #} +
+ +
+ + + + +
+ +
+ +
+ +
+ +
External address — receives email only.
+
+
+ + {# Event subscription #} +
+ +
+ {% for ekey, elabel in matrix_events.items() %} +
+
+ + +
+
+ {% endfor %} +
+
+
+ +
+ +
+
+
+ + {# Existing recipients #} +
+ {% if notify_recipients %} +
+ + + + + + + + + + + {% for r in notify_recipients %} + + + + + + + {% endfor %} + +
RecipientTypeNotified Events
+ {% if r.user_id %} + {{ r.user.display_name if r.user else '—' }} +
{{ r.user.email if r.user else '' }}
+ {% else %} + {{ r.email }} + {% endif %} +
+ {% if r.user_id %} + Staff · in-app + email + {% else %} + External · email + {% endif %} + + {% set ev = r.get_event_types() %} + {% if ev %} +
+ {% for e in ev %} + + {{ matrix_events.get(e, e) }} + + {% endfor %} +
+ {% else %} + None + {% endif %} +
+
+ + +
+
+
+ {% else %} +
+ No additional recipients. These are notified — for the events you pick — + on top of the global + Notification Matrix. +
+ {% endif %} +
+
+
+ {% endif %} + + +{% if current_user.role == 'admin' %} + +{% endif %} {% endblock %} diff --git a/app/utils/notifications.py b/app/utils/notifications.py index f5d5e73..7eef8c1 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -598,17 +598,150 @@ def notify_by_matrix( ) notified.add(user.id) - # ── Custom email recipients ─────────────────────────────────────────── + # ── Custom email recipients (global, per-event) ─────────────────────── custom_emails = get_custom_emails_for(event_type) + sent_emails = set() # dedupe email sends across global + per-contract for email in custom_emails: + norm = (email or '').strip().lower() + if not norm or norm in sent_emails: + continue _send_custom_email(email, title, body, link) + sent_emails.add(norm) + + # ── Per-contract additional recipients ──────────────────────────────── + # Extra users/emails attached to the contract that owns this event's + # facility. Fires in ADDITION to the global matrix routing above. + contract_count = _notify_contract_recipients( + event_type = event_type, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + facility_id = facility_id, + exclude = exclude, + notified = notified, + sent_emails = sent_emails, + ) logger.info( - 'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s', - event_type, len(notified), len(custom_emails), + 'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s | contract_extra=%s', + event_type, len(notified), len(sent_emails), contract_count, ) +def _resolve_project_id(facility_id=None, issue_id=None, inspection_id=None): + """Best-effort resolution of the owning contract (project) id for an event. + + Tries facility_id first, then the issue's facility, then the inspection's + facility. Returns None if no contract can be determined. + """ + from app.models.facility import Facility + + fid = facility_id + try: + if fid is None and issue_id: + from app.models.issue import Issue + iss = db.session.get(Issue, issue_id) + if iss is not None: + fid = iss.facility_id + if fid is None and iss.area is not None: + fid = iss.area.facility_id + if fid is None and inspection_id: + from app.models.inspection import Inspection + insp = db.session.get(Inspection, inspection_id) + if insp is not None: + fid = insp.facility_id + if fid is None: + return None + facility = db.session.get(Facility, fid) + return facility.project_id if facility is not None else None + except Exception as exc: + logger.error('CONTRACT NOTIFY | project resolution failed | error=%s', exc) + return None + + +def _notify_contract_recipients( + event_type, title, body, link=None, + issue_id=None, inspection_id=None, facility_id=None, + exclude=None, notified=None, sent_emails=None, +): + """Notify a contract's additional recipients for this event. + + Deduplicates against the users already notified by the matrix (`notified`), + the excluded actor set (`exclude`), and emails already sent (`sent_emails`). + Best-effort: never raises into the caller. + + Returns the number of extra recipients notified (users + emails). + """ + exclude = exclude if exclude is not None else set() + notified = notified if notified is not None else set() + sent_emails = sent_emails if sent_emails is not None else set() + + try: + from app.models.notification_recipient import ContractNotificationRecipient + from app.models.user import User + + project_id = _resolve_project_id(facility_id, issue_id, inspection_id) + if project_id is None: + return 0 + + rows = ContractNotificationRecipient.query.filter_by(project_id=project_id).all() + count = 0 + + for r in rows: + # Event subscription check + if event_type not in r.get_event_types(): + continue + + # ── Existing-user recipient → in-app + email ── + if r.user_id: + if r.user_id in exclude or r.user_id in notified: + continue + user = db.session.get(User, r.user_id) + if not user or not user.active: + continue + notify( + recipient = user, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + event_type = event_type, + send_email = True, + respect_preferences = False, # contract config is authoritative + ) + notified.add(user.id) + count += 1 + logger.info( + 'CONTRACT NOTIFY | project=%s | event=%s | user=%s', + project_id, event_type, user.username, + ) + + # ── Free-form email recipient → email only ── + elif r.email: + norm = r.email.strip().lower() + if not norm or norm in sent_emails: + continue + _send_custom_email(r.email, title, body, link) + sent_emails.add(norm) + count += 1 + logger.info( + 'CONTRACT NOTIFY | project=%s | event=%s | email=%s', + project_id, event_type, r.email, + ) + + return count + + except Exception as exc: + logger.error( + 'CONTRACT NOTIFY | unexpected error | event=%s | error=%s', + event_type, exc, + ) + return 0 + + def _send_custom_email(to_email: str, title: str, body: str, link: str = None): """Send a plain email to a custom (non-user) address. Best-effort.""" try: diff --git a/migrations/versions/phase33_contract_notify_recipients.py b/migrations/versions/phase33_contract_notify_recipients.py new file mode 100644 index 0000000..37773a0 --- /dev/null +++ b/migrations/versions/phase33_contract_notify_recipients.py @@ -0,0 +1,52 @@ +"""phase33 — contract_notification_recipients table + +Per-contract additional notification recipients (users or free-form emails) +with a per-recipient event_types subscription list. Notified in addition to +the global NotificationMatrix routing, scoped to the contract's facilities. + +Uses INFORMATION_SCHEMA table-existence check — safe to re-run. +""" + +revision = 'phase33_contract_notify_recipients' +down_revision = 'phase32_device_token_columns' +branch_labels = None +depends_on = None + +from alembic import op +import sqlalchemy as sa + + +def _table_exists(conn, table): + result = conn.execute(sa.text( + "SELECT COUNT(*) FROM INFORMATION_SCHEMA.TABLES " + "WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = :t" + ), {"t": table}) + return result.scalar() > 0 + + +def upgrade(): + bind = op.get_bind() + if _table_exists(bind, 'contract_notification_recipients'): + return + + op.create_table( + 'contract_notification_recipients', + sa.Column('id', sa.Integer, primary_key=True), + sa.Column('project_id', sa.Integer, + sa.ForeignKey('projects.id', ondelete='CASCADE'), nullable=False), + sa.Column('user_id', sa.Integer, + sa.ForeignKey('users.id', ondelete='CASCADE'), nullable=True), + sa.Column('email', sa.String(200), nullable=True), + sa.Column('event_types', sa.Text, nullable=True), + sa.Column('created_at', sa.DateTime, nullable=False), + ) + op.create_index('ix_cnr_project_id', + 'contract_notification_recipients', ['project_id']) + op.create_index('ix_cnr_user_id', + 'contract_notification_recipients', ['user_id']) + + +def downgrade(): + bind = op.get_bind() + if _table_exists(bind, 'contract_notification_recipients'): + op.drop_table('contract_notification_recipients')