Apr 2 2026: implement notification settings matrix

This commit is contained in:
2026-04-02 10:43:34 -04:00
parent 2be5348a00
commit bda4b3f5b3
11 changed files with 893 additions and 164 deletions
+1
View File
@@ -5,3 +5,4 @@ from app.models.inspection import (InspectionTemplate, ChecklistItem,
from app.models.issue import Issue from app.models.issue import Issue
from app.models.project import Project, CustomerAssignment from app.models.project import Project, CustomerAssignment
from app.models.api_token import RefreshToken, DeviceToken from app.models.api_token import RefreshToken, DeviceToken
from app.models.notification_matrix import NotificationMatrix
+219
View File
@@ -0,0 +1,219 @@
"""
app/models/notification_matrix.py
----------------------------------
Admin-controlled notification matrix.
One row per (event_type, role_key) pair.
role_key values
---------------
admin — all users with role='admin'
supervisor — all users with role='supervisor'
inspector — all users with role='inspector'
project_manager — all users with role='project_manager'
customer — all customer-portal users assigned to the relevant facility
assignee — the specific user the issue/inspection is assigned to
(implicit; always notified regardless of matrix)
custom — free-form extra email addresses stored in custom_emails JSON
Default matrix (mirrors current hardcoded behaviour)
-----------------------------------------------------
inspection_completed : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✓
issue_assigned : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_status : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_comment : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee implicit)
issue_follow_update : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✗ (followers implicit)
issue_flagged : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_created : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓ (assignee implicit)
issue_updated_customer : admin ✗ supervisor ✗ inspector ✗ pm ✗ customer ✓
verification_requested : admin ✓ supervisor ✓ inspector ✗ pm ✗ customer ✗
sla_alert : admin ✓ supervisor ✗ inspector ✗ pm ✗ customer ✗ (assignee + followers implicit)
"""
import json
from app import db
# Role keys available in the matrix UI
MATRIX_ROLES = [
('admin', 'Admin'),
('supervisor', 'Supervisor'),
('inspector', 'Inspector'),
('project_manager', 'Project Manager'),
('customer', 'Customer'),
('custom', 'Custom Recipients'),
]
# Events shown in the matrix — maps event_key → display label
# event_key is used as the DB event_type value
MATRIX_EVENTS = {
'inspection_completed': 'Inspection completed',
'issue_assigned': 'Issue assigned (new)',
'issue_reassigned': 'Issue reassigned',
'issue_unassigned': 'Issue unassigned',
'issue_status': 'Issue status changed',
'issue_comment': 'Issue comment added',
'issue_follow_update': 'Issue follow update',
'issue_flagged': 'Issue flagged (from inspection)',
'issue_created': 'Issue created (standalone)',
'issue_updated_customer': 'Issue updated (customer)',
'verification_requested': 'Verification requested',
'sla_alert': 'SLA at-risk / breached',
}
# Default enabled state: (event_key, role_key) → True/False
# Mirrors the current hardcoded behaviour exactly.
MATRIX_DEFAULTS = {
# inspection_completed
('inspection_completed', 'admin'): True,
('inspection_completed', 'supervisor'): True,
('inspection_completed', 'inspector'): False,
('inspection_completed', 'project_manager'): False,
('inspection_completed', 'customer'): True,
('inspection_completed', 'custom'): False,
# issue_assigned (assignee is always notified implicitly)
('issue_assigned', 'admin'): False,
('issue_assigned', 'supervisor'): False,
('issue_assigned', 'inspector'): False,
('issue_assigned', 'project_manager'): False,
('issue_assigned', 'customer'): False,
('issue_assigned', 'custom'): False,
# issue_reassigned
('issue_reassigned', 'admin'): False,
('issue_reassigned', 'supervisor'): False,
('issue_reassigned', 'inspector'): False,
('issue_reassigned', 'project_manager'): False,
('issue_reassigned', 'customer'): False,
('issue_reassigned', 'custom'): False,
# issue_unassigned
('issue_unassigned', 'admin'): False,
('issue_unassigned', 'supervisor'): False,
('issue_unassigned', 'inspector'): False,
('issue_unassigned', 'project_manager'): False,
('issue_unassigned', 'customer'): False,
('issue_unassigned', 'custom'): False,
# issue_status
('issue_status', 'admin'): False,
('issue_status', 'supervisor'): False,
('issue_status', 'inspector'): False,
('issue_status', 'project_manager'): False,
('issue_status', 'customer'): False,
('issue_status', 'custom'): False,
# issue_comment
('issue_comment', 'admin'): False,
('issue_comment', 'supervisor'): False,
('issue_comment', 'inspector'): False,
('issue_comment', 'project_manager'): False,
('issue_comment', 'customer'): False,
('issue_comment', 'custom'): False,
# issue_follow_update (followers always notified implicitly)
('issue_follow_update', 'admin'): False,
('issue_follow_update', 'supervisor'): False,
('issue_follow_update', 'inspector'): False,
('issue_follow_update', 'project_manager'): False,
('issue_follow_update', 'customer'): False,
('issue_follow_update', 'custom'): False,
# issue_flagged (from inspection)
('issue_flagged', 'admin'): False,
('issue_flagged', 'supervisor'): False,
('issue_flagged', 'inspector'): False,
('issue_flagged', 'project_manager'): False,
('issue_flagged', 'customer'): True,
('issue_flagged', 'custom'): False,
# issue_created (standalone)
('issue_created', 'admin'): False,
('issue_created', 'supervisor'): False,
('issue_created', 'inspector'): False,
('issue_created', 'project_manager'): False,
('issue_created', 'customer'): True,
('issue_created', 'custom'): False,
# issue_updated_customer
('issue_updated_customer', 'admin'): False,
('issue_updated_customer', 'supervisor'): False,
('issue_updated_customer', 'inspector'): False,
('issue_updated_customer', 'project_manager'): False,
('issue_updated_customer', 'customer'): True,
('issue_updated_customer', 'custom'): False,
# verification_requested
('verification_requested', 'admin'): True,
('verification_requested', 'supervisor'): True,
('verification_requested', 'inspector'): False,
('verification_requested', 'project_manager'): False,
('verification_requested', 'customer'): False,
('verification_requested', 'custom'): False,
# sla_alert (assignee + followers always notified implicitly)
('sla_alert', 'admin'): True,
('sla_alert', 'supervisor'): False,
('sla_alert', 'inspector'): False,
('sla_alert', 'project_manager'): False,
('sla_alert', 'customer'): False,
('sla_alert', 'custom'): False,
}
class NotificationMatrix(db.Model):
"""Admin-controlled per-event notification routing."""
__tablename__ = 'notification_matrix'
id = db.Column(db.Integer, primary_key=True)
event_type = db.Column(db.String(50), nullable=False)
role_key = db.Column(db.String(30), nullable=False)
enabled = db.Column(db.Boolean, nullable=False, default=True)
custom_emails = db.Column(db.Text, nullable=True) # JSON list, only used when role_key='custom'
__table_args__ = (
db.UniqueConstraint('event_type', 'role_key', name='uq_notif_matrix_event_role'),
)
def get_custom_emails(self):
"""Return custom_emails as a Python list."""
if not self.custom_emails:
return []
try:
result = json.loads(self.custom_emails)
return [e.strip() for e in result if isinstance(e, str) and e.strip()]
except (json.JSONDecodeError, TypeError):
return []
def __repr__(self):
return f'<NotificationMatrix {self.event_type} / {self.role_key} enabled={self.enabled}>'
def get_matrix_row(event_type: str, role_key: str) -> NotificationMatrix:
"""
Return the matrix row for (event_type, role_key), creating it from
defaults if it doesn't exist yet. Safe to call without seeding.
"""
row = NotificationMatrix.query.filter_by(
event_type=event_type, role_key=role_key
).first()
if row is None:
default = MATRIX_DEFAULTS.get((event_type, role_key), False)
row = NotificationMatrix(
event_type=event_type,
role_key=role_key,
enabled=default,
)
db.session.add(row)
db.session.flush()
return row
def is_enabled(event_type: str, role_key: str) -> bool:
"""Return True if the matrix enables notifications for this event/role pair."""
row = NotificationMatrix.query.filter_by(
event_type=event_type, role_key=role_key
).first()
if row is None:
return MATRIX_DEFAULTS.get((event_type, role_key), False)
return row.enabled
def get_custom_emails_for(event_type: str) -> list:
"""Return the custom email list for this event type."""
row = NotificationMatrix.query.filter_by(
event_type=event_type, role_key='custom'
).first()
if row is None:
return []
return row.get_custom_emails()
+52
View File
@@ -231,3 +231,55 @@ def toggle_active(user_id):
) )
flash(f'User {user.username} has been {action_label}.', 'success') flash(f'User {user.username} has been {action_label}.', 'success')
return redirect(request.referrer or url_for('auth.list_users')) return redirect(request.referrer or url_for('auth.list_users'))
# ── Notification Matrix ───────────────────────────────────────────────────────
@bp.route('/notification-matrix', methods=['GET', 'POST'])
@login_required
@admin_required
def notification_matrix():
"""Admin-only notification matrix — controls who receives each event type."""
import json as _json
from app.models.notification_matrix import (
NotificationMatrix, MATRIX_EVENTS, MATRIX_ROLES, MATRIX_DEFAULTS,
)
if request.method == 'POST':
for event_key in MATRIX_EVENTS:
for role_key, _ in MATRIX_ROLES:
row = NotificationMatrix.query.filter_by(
event_type=event_key, role_key=role_key
).first()
if row is None:
row = NotificationMatrix(event_type=event_key, role_key=role_key)
db.session.add(row)
if role_key == 'custom':
raw = request.form.get(f'custom_{event_key}', '').strip()
# Parse comma-separated emails into a JSON list
emails = [e.strip() for e in raw.split(',') if e.strip()]
row.custom_emails = _json.dumps(emails)
row.enabled = bool(emails)
else:
row.enabled = bool(request.form.get(f'matrix_{event_key}_{role_key}'))
db.session.commit()
log_action(ACTION_UPDATE, 'NotificationMatrix', None,
'Notification Matrix', 'admin updated notification matrix')
logger.info('NOTIFICATION MATRIX UPDATED | by=%s', current_user.username)
flash('Notification matrix saved successfully.', 'success')
return redirect(url_for('auth.notification_matrix'))
# Build current state dict: {event_key: {role_key: enabled/emails}}
all_rows = NotificationMatrix.query.all()
state = {} # event_key -> role_key -> row
for row in all_rows:
state.setdefault(row.event_type, {})[row.role_key] = row
return render_template(
'auth/notification_matrix.html',
matrix_events = MATRIX_EVENTS,
matrix_roles = MATRIX_ROLES,
defaults = MATRIX_DEFAULTS,
state = state,
)
+8 -22
View File
@@ -15,7 +15,7 @@ from app.models.user import User
from app.utils.forms import StartInspectionForm, IssueForm from app.utils.forms import StartInspectionForm, IssueForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.pdf_export import generate_inspection_pdf from app.utils.pdf_export import generate_inspection_pdf
from app.utils.notifications import notify, notify_customers_for_facility from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.models.notification import ( from app.models.notification import (
EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED, EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED,
EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED, EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED,
@@ -371,13 +371,10 @@ def execute(inspection_id):
_save_responses(inspection, responses) _save_responses(inspection, responses)
db.session.commit() db.session.commit()
supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all()
inspection_link = url_for('inspections.view', inspection_id=inspection.id) inspection_link = url_for('inspections.view', inspection_id=inspection.id)
score_display = f'{score:.1f}%' if score is not None else 'N/A' score_display = f'{score:.1f}%' if score is not None else 'N/A'
for supervisor in supervisors: notify_by_matrix(
if supervisor.id != current_user.id: event_type = 'inspection_completed',
notify(
recipient = supervisor,
title = f'Inspection #{inspection.id} Completed', title = f'Inspection #{inspection.id} Completed',
body = ( body = (
f'{current_user.username} completed an inspection at ' f'{current_user.username} completed an inspection at '
@@ -387,20 +384,8 @@ def execute(inspection_id):
), ),
link = inspection_link, link = inspection_link,
inspection_id = inspection.id, inspection_id = inspection.id,
event_type = EVENT_INSPECTION_DONE,
send_email = True,
)
notify_customers_for_facility(
facility_id = inspection.facility_id, facility_id = inspection.facility_id,
event_type = EVENT_CUSTOMER_INSPECTION_DONE, exclude_user_ids = {current_user.id},
title = f'Inspection Completed at {inspection.facility.name}',
body = (
f'An inspection using the "{inspection.template.name}" template '
f'was completed at {inspection.facility.name}. '
f'Overall score: {score_display}.'
),
link = url_for('inspections.view', inspection_id=inspection.id),
inspection_id = inspection.id,
) )
db.session.commit() db.session.commit()
log_action(ACTION_UPDATE, 'Inspection', inspection.id, log_action(ACTION_UPDATE, 'Inspection', inspection.id,
@@ -736,9 +721,8 @@ def flag_issue(inspection_id):
) )
db.session.commit() db.session.commit()
notify_customers_for_facility( notify_by_matrix(
facility_id = inspection.facility_id, event_type = 'issue_flagged',
event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
title = f'New Issue #{issue.id} at {inspection.facility.name}', title = f'New Issue #{issue.id} at {inspection.facility.name}',
body = ( body = (
f'A new {issue.severity.title()}-severity issue has been logged ' f'A new {issue.severity.title()}-severity issue has been logged '
@@ -749,6 +733,8 @@ def flag_issue(inspection_id):
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id, issue_id = issue.id,
facility_id = inspection.facility_id,
exclude_user_ids = {current_user.id},
) )
db.session.commit() db.session.commit()
+17 -21
View File
@@ -14,7 +14,7 @@ from app.models.notification import (
) )
from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.forms import IssueForm, IssueUpdateForm
from app.utils.decorators import supervisor_required from app.utils.decorators import supervisor_required
from app.utils.notifications import notify, notify_customers_for_facility from app.utils.notifications import notify, notify_customers_for_facility, notify_by_matrix
from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE
from app.utils.scope import get_customer_scope from app.utils.scope import get_customer_scope
from app.utils.sla import sla_status from app.utils.sla import sla_status
@@ -268,13 +268,12 @@ def view(issue_id):
exclude_user_ids = exclude_ids, exclude_user_ids = exclude_ids,
) )
# ── Notify customer portal users for this facility ────────── # ── Notify via matrix (issue_updated_customer) ───────────────
facility_id = issue.area.facility_id if issue.area else None facility_id = issue.area.facility_id if issue.area else None
if facility_id: if facility_id and changes:
changes_summary = '; '.join(changes) if changes else 'updated' changes_summary = '; '.join(changes)
notify_customers_for_facility( notify_by_matrix(
facility_id = facility_id, event_type = 'issue_updated_customer',
event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
title = f'Issue #{issue.id} Updated at {issue.area.facility.name}', title = f'Issue #{issue.id} Updated at {issue.area.facility.name}',
body = ( body = (
f'Issue #{issue.id} ({issue.severity.title()} severity) ' f'Issue #{issue.id} ({issue.severity.title()} severity) '
@@ -283,6 +282,8 @@ def view(issue_id):
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id, issue_id = issue.id,
facility_id = facility_id,
exclude_user_ids = {current_user.id},
) )
db.session.commit() # Commit all notifications db.session.commit() # Commit all notifications
log_action(ACTION_UPDATE, 'Issue', issue.id, log_action(ACTION_UPDATE, 'Issue', issue.id,
@@ -398,12 +399,11 @@ def create():
) )
db.session.commit() db.session.commit()
# ── Notify customer portal users for this facility ────────── # ── Notify via matrix (issue_created) ────────────────────────
area = db.session.get(Area, issue.area_id) area = db.session.get(Area, issue.area_id)
if area: if area:
notify_customers_for_facility( notify_by_matrix(
facility_id = area.facility_id, event_type = 'issue_created',
event_type = EVENT_CUSTOMER_ISSUE_UPDATED,
title = f'New Issue #{issue.id} at {area.facility.name}', title = f'New Issue #{issue.id} at {area.facility.name}',
body = ( body = (
f'A new {issue.severity.title()}-severity issue has been logged ' f'A new {issue.severity.title()}-severity issue has been logged '
@@ -413,6 +413,8 @@ def create():
), ),
link = url_for('issues.view', issue_id=issue.id), link = url_for('issues.view', issue_id=issue.id),
issue_id = issue.id, issue_id = issue.id,
facility_id = area.facility_id,
exclude_user_ids = {current_user.id},
) )
db.session.commit() db.session.commit()
flash('Issue created.', 'success') flash('Issue created.', 'success')
@@ -489,14 +491,9 @@ def request_verification(issue_id):
f'#{issue_id} in {issue.area.name}', f'#{issue_id} in {issue.area.name}',
f'status=pending_verification; requested_by={current_user.username}') f'status=pending_verification; requested_by={current_user.username}')
# Notify supervisors # Notify via matrix (verification_requested)
from app.utils.notifications import notify notify_by_matrix(
from app.models.notification import EVENT_ISSUE_STATUS event_type = 'verification_requested',
supervisors = User.query.filter(User.role.in_(['admin', 'supervisor'])).all()
for sup in supervisors:
if sup.id != current_user.id:
notify(
recipient = sup,
title = f'Issue #{issue_id} Awaiting Verification', title = f'Issue #{issue_id} Awaiting Verification',
body = ( body = (
f'{current_user.username} has marked Issue #{issue_id} ' f'{current_user.username} has marked Issue #{issue_id} '
@@ -505,8 +502,7 @@ def request_verification(issue_id):
), ),
link = url_for('issues.view', issue_id=issue_id), link = url_for('issues.view', issue_id=issue_id),
issue_id = issue_id, issue_id = issue_id,
event_type = EVENT_ISSUE_STATUS, exclude_user_ids = {current_user.id},
send_email = True,
) )
db.session.commit() db.session.commit()
flash('Issue marked as pending verification. Supervisors have been notified.', 'info') flash('Issue marked as pending verification. Supervisors have been notified.', 'info')
+218
View File
@@ -0,0 +1,218 @@
{% extends "base.html" %}
{% block title %}Notification Matrix{% endblock %}
{% block extra_css %}
<style>
.matrix-wrap { max-width: 1100px; margin: 0 auto; }
/* ── Matrix table ── */
.matrix-tbl { border-collapse: collapse; width: 100%; font-size: .82rem; }
.matrix-tbl th, .matrix-tbl td {
border: 1px solid #e2e8f0;
padding: .35rem .55rem;
vertical-align: middle;
}
.matrix-tbl thead th {
background: #1a1d23; color: #fff; font-weight: 600;
text-align: center; white-space: nowrap;
}
.matrix-tbl thead th.event-col { text-align: left; }
.matrix-tbl thead th.group-hdr {
background: #2563eb; font-size: .75rem; letter-spacing: .04em;
text-transform: uppercase;
}
.matrix-tbl tbody tr:nth-child(even) td { background: #f8fafc; }
.matrix-tbl tbody tr:hover td { background: #eff6ff; }
.matrix-tbl td.event-name { font-weight: 500; color: #1a1d23; white-space: nowrap; }
.matrix-tbl td.check-cell { text-align: center; }
/* Checkbox styling */
.matrix-check {
width: 1.1rem; height: 1.1rem; cursor: pointer;
accent-color: #2563eb;
}
/* Disabled row (event has no meaningful broadcast) */
.matrix-tbl tr.implicit td { color: #94a3b8; }
.matrix-tbl tr.implicit td.event-name { color: #64748b; font-style: italic; }
/* Custom emails cell */
.custom-cell { min-width: 180px; }
.custom-input {
width: 100%; font-size: .75rem;
border: 1px solid #e2e8f0; border-radius: 4px;
padding: .2rem .4rem; color: #374151;
background: #f8fafc;
}
.custom-input:focus {
outline: none; border-color: #2563eb;
box-shadow: 0 0 0 2px rgba(37,99,235,.15);
}
/* Legend */
.legend { font-size: .78rem; color: #64748b; }
.legend span { display: inline-flex; align-items: center; gap: .3rem; margin-right: 1rem; }
/* Implicit badge */
.badge-implicit {
font-size: .62rem; background: #f1f5f9; color: #64748b;
border: 1px solid #e2e8f0; border-radius: 4px;
padding: 1px 5px; vertical-align: middle;
}
</style>
{% endblock %}
{% block content %}
<div class="matrix-wrap mt-3">
{# ── Page header ── #}
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h4 class="mb-0"><i class="bi bi-grid-3x3-gap-fill text-primary me-2"></i>Notification Matrix</h4>
<p class="text-muted mb-0 mt-1" style="font-size:.85rem;">
Control which roles receive email notifications for each event.
<strong>Assignee</strong>, <strong>followers</strong>, and <strong>customer portal</strong>
recipients are handled automatically where marked.
</p>
</div>
<a href="{{ url_for('auth.list_users') }}" class="btn btn-outline-secondary btn-sm">
<i class="bi bi-arrow-left"></i> Back to Users
</a>
</div>
{# ── Legend ── #}
<div class="legend mb-3 d-flex flex-wrap align-items-center">
<span><input type="checkbox" checked disabled class="matrix-check"> Enabled by default</span>
<span><input type="checkbox" disabled class="matrix-check"> Disabled by default</span>
<span><span class="badge-implicit">implicit</span> Always notified — not controlled here</span>
</div>
<form method="POST" action="{{ url_for('auth.notification_matrix') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="table-responsive shadow-sm rounded">
<table class="matrix-tbl">
<thead>
{# Row 1: spanning group headers #}
<tr>
<th class="event-col" rowspan="2" style="min-width:200px;">Event</th>
<th class="group-hdr" colspan="4">Internal Recipients</th>
<th class="group-hdr" colspan="1">Customer</th>
<th class="group-hdr" colspan="1">Custom Recipients</th>
</tr>
{# Row 2: individual role headers #}
<tr>
{% for role_key, role_label in matrix_roles if role_key != 'custom' and role_key != 'customer' %}
<th style="min-width:80px;">{{ role_label }}</th>
{% endfor %}
<th style="min-width:80px;">Customer</th>
<th style="min-width:200px;">Email addresses<br><span style="font-weight:400;font-size:.7rem;color:#94a3b8;">(comma-separated)</span></th>
</tr>
</thead>
<tbody>
{% for event_key, event_label in matrix_events.items() %}
{% set event_state = state.get(event_key, {}) %}
<tr>
<td class="event-name">
{{ event_label }}
{# Show implicit labels where assignee/followers are always notified #}
{% if event_key in ('issue_assigned', 'issue_reassigned', 'issue_unassigned',
'issue_status', 'issue_comment') %}
<span class="badge-implicit ms-1">assignee</span>
{% endif %}
{% if event_key == 'issue_follow_update' %}
<span class="badge-implicit ms-1">followers</span>
{% endif %}
{% if event_key == 'sla_alert' %}
<span class="badge-implicit ms-1">assignee</span>
<span class="badge-implicit ms-1">followers</span>
{% endif %}
</td>
{# Internal role checkboxes (admin, supervisor, inspector, project_manager) #}
{% for role_key, _ in matrix_roles if role_key not in ('custom', 'customer') %}
{% set row = event_state.get(role_key) %}
{% if row is not none %}
{% set checked = row.enabled %}
{% else %}
{% set checked = defaults.get((event_key, role_key), false) %}
{% endif %}
<td class="check-cell">
<input type="checkbox"
class="matrix-check"
name="matrix_{{ event_key }}_{{ role_key }}"
value="1"
{% if checked %}checked{% endif %}>
</td>
{% endfor %}
{# Customer checkbox #}
{% set cust_row = event_state.get('customer') %}
{% if cust_row is not none %}
{% set cust_checked = cust_row.enabled %}
{% else %}
{% set cust_checked = defaults.get((event_key, 'customer'), false) %}
{% endif %}
<td class="check-cell">
<input type="checkbox"
class="matrix-check"
name="matrix_{{ event_key }}_customer"
value="1"
{% if cust_checked %}checked{% endif %}>
</td>
{# Custom emails text input #}
{% set custom_row = event_state.get('custom') %}
{% if custom_row is not none %}
{% set custom_val = custom_row.get_custom_emails() | join(', ') %}
{% else %}
{% set custom_val = '' %}
{% endif %}
<td class="custom-cell">
<input type="text"
class="custom-input"
name="custom_{{ event_key }}"
value="{{ custom_val }}"
placeholder="e.g. ops@company.com, mgr@co.com">
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="mt-3 d-flex gap-2 align-items-center">
<button type="submit" class="btn btn-primary">
<i class="bi bi-check2-circle me-1"></i>Save Matrix
</button>
<a href="{{ url_for('auth.notification_matrix') }}" class="btn btn-outline-secondary">
Reset
</a>
<span class="text-muted ms-2" style="font-size:.8rem;">
<i class="bi bi-info-circle me-1"></i>
Changes take effect immediately for all subsequent notifications.
</span>
</div>
</form>
{# ── Notes card ── #}
<div class="card mt-4 border-0 bg-light">
<div class="card-body py-2 px-3">
<p class="mb-1" style="font-size:.8rem;">
<strong>Internal:</strong> Admin, Supervisor, Inspector, and Project Manager users
receive in-app notifications and emails based on their individual
<a href="{{ url_for('notifications.preferences') }}">preference settings</a>.
</p>
<p class="mb-1" style="font-size:.8rem;">
<strong>Customer:</strong> Customer-portal users are notified only for facilities
they are assigned to via their project/facility assignments.
</p>
<p class="mb-0" style="font-size:.8rem;">
<strong>Custom Recipients:</strong> Additional email addresses (e.g. external managers)
receive a plain email. They do not get in-app notifications and are not affected
by individual user preference settings.
</p>
</div>
</div>
</div>
{% endblock %}
+113 -42
View File
@@ -52,6 +52,47 @@
<a class="navbar-brand" href="{{ url_for('dashboard.index') }}"> <a class="navbar-brand" href="{{ url_for('dashboard.index') }}">
<i class="bi bi-clipboard-check"></i> Janitorial QC <i class="bi bi-clipboard-check"></i> Janitorial QC
</a> </a>
<!-- ── Bell + toggler always visible on mobile/tablet ── -->
<div class="d-flex align-items-center gap-2 ms-auto me-2 d-lg-none">
<!-- Notification bell (always visible) -->
<div class="dropdown">
<a class="nav-link position-relative notif-bell-wrapper text-white"
href="#"
id="notifDropdownMobile"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false"
title="Notifications">
<i class="bi bi-bell fs-5"></i>
{% if unread_notification_count > 0 %}
<span class="badge bg-danger notif-badge" id="notif-count-badge-mobile">
{{ unread_notification_count if unread_notification_count <= 99 else '99+' }}
</span>
{% else %}
<span class="badge bg-danger notif-badge d-none" id="notif-count-badge-mobile"></span>
{% endif %}
</a>
<div class="dropdown-menu dropdown-menu-end notif-dropdown shadow"
id="notif-dropdown-menu-mobile">
<div class="d-flex justify-content-between align-items-center px-3 py-2 border-bottom">
<span class="fw-semibold" style="font-size:.9rem;">Notifications</span>
<button class="btn btn-link btn-sm p-0 text-muted text-decoration-none mark-all-read-btn"
style="font-size:.75rem;">Mark all as read</button>
</div>
<div class="notif-list-mobile">
<div class="notif-empty">Loading…</div>
</div>
<div class="border-top d-flex justify-content-between px-3 py-2" style="font-size:.8rem;">
<a href="{{ url_for('notifications.index') }}" class="text-decoration-none">
<i class="bi bi-list-ul me-1"></i>View all
</a>
<a href="{{ url_for('notifications.preferences') }}" class="text-decoration-none text-muted">
<i class="bi bi-gear me-1"></i>Preferences
</a>
</div>
</div>
</div>
</div>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav"> <button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
@@ -111,12 +152,17 @@
<li class="nav-item"> <li class="nav-item">
<a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a> <a class="nav-link" href="{{ url_for('audit.index') }}">Audit Trail</a>
</li> </li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('auth.notification_matrix') }}">
<i class="bi bi-grid-3x3-gap-fill"></i> Notif. Matrix
</a>
</li>
{% endif %} {% endif %}
</ul> </ul>
<ul class="navbar-nav align-items-center"> <ul class="navbar-nav align-items-center">
<!-- ── Notification Bell ── --> <!-- ── Notification Bell (desktop lg+ only) ── -->
<li class="nav-item dropdown me-2"> <li class="nav-item dropdown me-2 d-none d-lg-block">
<a class="nav-link position-relative notif-bell-wrapper" <a class="nav-link position-relative notif-bell-wrapper"
href="#" href="#"
id="notifDropdown" id="notifDropdown"
@@ -227,11 +273,16 @@
const CSRF_TOKEN = '{{ csrf_token() }}'; const CSRF_TOKEN = '{{ csrf_token() }}';
const POLL_INTERVAL = 60000; // 60 seconds const POLL_INTERVAL = 60000; // 60 seconds
const badge = document.getElementById('notif-count-badge'); // ── Element refs — desktop bell (lg+) and mobile/tablet bell (<lg) ──
const listEl = document.getElementById('notif-list'); const badgeDesktop = document.getElementById('notif-count-badge');
const markAllBtn = document.getElementById('mark-all-read-btn'); const badgeMobile = document.getElementById('notif-count-badge-mobile');
const listDesktop = document.getElementById('notif-list');
const listMobile = document.querySelector('.notif-list-mobile');
// ── Update both badge instances ────────────────────────────────────────
function updateBadge(count) { function updateBadge(count) {
[badgeDesktop, badgeMobile].forEach(function(badge) {
if (!badge) return;
if (count > 0) { if (count > 0) {
badge.textContent = count > 99 ? '99+' : count; badge.textContent = count > 99 ? '99+' : count;
badge.classList.remove('d-none'); badge.classList.remove('d-none');
@@ -239,38 +290,44 @@
badge.textContent = ''; badge.textContent = '';
badge.classList.add('d-none'); badge.classList.add('d-none');
} }
});
} }
function renderNotifications(notifications) { // ── Render notification items into a given container ───────────────────
function renderInto(container, notifications) {
if (!container) return;
if (!notifications.length) { if (!notifications.length) {
listEl.innerHTML = '<div class="notif-empty">' container.innerHTML = '<div class="notif-empty">'
+ '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>'; + '<i class="bi bi-check2-circle me-1"></i>You\'re all caught up!</div>';
return; return;
} }
container.innerHTML = notifications.map(function(n) {
listEl.innerHTML = notifications.map(n => ` return '<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom '
<div class="d-block text-decoration-none text-dark notif-item px-3 py-2 border-bottom + (n.is_read ? '' : 'unread') + '"'
${n.is_read ? '' : 'unread'}" + ' data-notif-id="' + n.id + '"'
data-notif-id="${n.id}" + ' data-link="' + escapeAttr(n.link || '') + '">'
data-link="${escapeAttr(n.link || '')}"> + '<div class="notif-title">' + escapeHtml(n.title) + '</div>'
<div class="notif-title">${escapeHtml(n.title)}</div> + '<div class="notif-body">' + escapeHtml(n.body) + '</div>'
<div class="notif-body">${escapeHtml(n.body)}</div> + '<div class="notif-time">' + escapeHtml(n.created_at) + '</div>'
<div class="notif-time">${escapeHtml(n.created_at)}</div> + '</div>';
</div> }).join('');
`).join(''); container.querySelectorAll('.notif-item').forEach(function(el) {
el.addEventListener('click', function() {
listEl.querySelectorAll('.notif-item').forEach(el => { var id = this.dataset.notifId;
el.addEventListener('click', function () { var link = this.dataset.link;
const id = this.dataset.notifId; markRead(id, function() {
const link = this.dataset.link; el.classList.remove('unread');
markRead(id, () => {
this.classList.remove('unread');
if (link) window.location.href = link; if (link) window.location.href = link;
}); });
}); });
}); });
} }
function renderNotifications(notifications) {
renderInto(listDesktop, notifications);
renderInto(listMobile, notifications);
}
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;') return str.replace(/&/g,'&amp;').replace(/</g,'&lt;')
@@ -278,55 +335,69 @@
} }
function escapeAttr(str) { return escapeHtml(str); } function escapeAttr(str) { return escapeHtml(str); }
// ── Fetch + update ─────────────────────────────────────────────────────
window.fetchNotifications = function fetchNotifications() { window.fetchNotifications = function fetchNotifications() {
fetch(FEED_URL, { credentials: 'same-origin' }) fetch(FEED_URL, { credentials: 'same-origin' })
.then(r => r.json()) .then(function(r) { return r.json(); })
.then(data => { .then(function(data) {
updateBadge(data.unread_count); updateBadge(data.unread_count);
window._jqcNotifications = data.notifications; window._jqcNotifications = data.notifications;
const dropdownEl = document.getElementById('notifDropdown'); var deskEl = document.getElementById('notifDropdown');
if (dropdownEl.getAttribute('aria-expanded') === 'true') { var mobileEl = document.getElementById('notifDropdownMobile');
var deskOpen = deskEl && deskEl.getAttribute('aria-expanded') === 'true';
var mobileOpen = mobileEl && mobileEl.getAttribute('aria-expanded') === 'true';
if (deskOpen || mobileOpen) {
renderNotifications(data.notifications); renderNotifications(data.notifications);
} }
}) })
.catch(() => {}); .catch(function() {});
} };
function markRead(id, callback) { function markRead(id, callback) {
fetch(`${MARK_READ_BASE}${id}/mark-read`, { fetch(MARK_READ_BASE + id + '/mark-read', {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' }, headers: { 'X-CSRFToken': CSRF_TOKEN, 'Content-Type': 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
}) })
.then(r => r.json()) .then(function(r) { return r.json(); })
.then(() => { if (callback) callback(); fetchNotifications(); }) .then(function() { if (callback) callback(); fetchNotifications(); })
.catch(() => { if (callback) callback(); }); .catch(function() { if (callback) callback(); });
} }
document.getElementById('notifDropdown').addEventListener('show.bs.dropdown', function () { // ── Show dropdown → render cached data immediately ─────────────────────
['notifDropdown', 'notifDropdownMobile'].forEach(function(id) {
var el = document.getElementById(id);
if (!el) return;
el.addEventListener('show.bs.dropdown', function() {
if (window._jqcNotifications) { if (window._jqcNotifications) {
renderNotifications(window._jqcNotifications); renderNotifications(window._jqcNotifications);
} else { } else {
fetchNotifications(); fetchNotifications();
} }
}); });
});
markAllBtn.addEventListener('click', function (e) { // ── Mark all read — works from either bell ─────────────────────────────
document.querySelectorAll('#mark-all-read-btn, .mark-all-read-btn').forEach(function(btn) {
btn.addEventListener('click', function(e) {
e.stopPropagation(); e.stopPropagation();
fetch(MARK_ALL_URL, { fetch(MARK_ALL_URL, {
method: 'POST', method: 'POST',
headers: { 'X-CSRFToken': CSRF_TOKEN }, headers: { 'X-CSRFToken': CSRF_TOKEN },
credentials: 'same-origin', credentials: 'same-origin',
}) })
.then(r => r.json()) .then(function(r) { return r.json(); })
.then(() => { .then(function() {
updateBadge(0); updateBadge(0);
listEl.querySelectorAll('.notif-item.unread').forEach(el => el.classList.remove('unread')); document.querySelectorAll('.notif-item.unread').forEach(function(el) {
el.classList.remove('unread');
});
if (window._jqcNotifications) { if (window._jqcNotifications) {
window._jqcNotifications.forEach(n => n.is_read = true); window._jqcNotifications.forEach(function(n) { n.is_read = true; });
} }
}) })
.catch(() => {}); .catch(function() {});
});
}); });
fetchNotifications(); fetchNotifications();
+140
View File
@@ -468,3 +468,143 @@ def send_pending_digests(frequency: str = 'daily'):
) )
return sent_count return sent_count
# ── Matrix-driven broadcast helpers ───────────────────────────────────────────
def notify_by_matrix(
event_type: str,
title: str,
body: str,
link: str = None,
issue_id: int = None,
inspection_id: int = None,
facility_id: int = None,
exclude_user_ids: set = None,
):
"""
Dispatch in-app + email notifications for a broadcast event according
to the admin-configured notification matrix.
For each enabled role in the matrix, all active users with that role
are notified (optionally scoped to facility via CustomerAssignment for
the 'customer' role). Custom email addresses are sent a plain email
without creating an in-app Notification record.
Parameters
----------
event_type : One of the MATRIX_EVENTS keys from notification_matrix.
title : Short notification headline.
body : Full notification body.
link : Relative URL for 'View Details'.
issue_id : FK to issues.id (optional).
inspection_id : FK to inspections.id (optional).
facility_id : Used to scope 'customer' role to assigned facility.
exclude_user_ids : Set of user IDs to skip (e.g. the actor themselves).
"""
from app.models.notification_matrix import (
is_enabled, get_custom_emails_for, MATRIX_ROLES,
)
from app.models.user import User
exclude = set(exclude_user_ids or [])
notified = set() # deduplicate across roles
role_to_db = {
'admin': 'admin',
'supervisor': 'supervisor',
'inspector': 'inspector',
'project_manager': 'project_manager',
'customer': 'customer',
}
for role_key, _ in MATRIX_ROLES:
if role_key == 'custom':
continue # handled separately below
if not is_enabled(event_type, role_key):
continue
db_role = role_to_db.get(role_key)
if not db_role:
continue
users = User.query.filter_by(role=db_role, active=True).all()
# Scope customer role to facility if provided
if role_key == 'customer' and facility_id:
from app.utils.notifications import notify_customers_for_facility
notify_customers_for_facility(
facility_id = facility_id,
event_type = event_type,
title = title,
body = body,
link = link,
issue_id = issue_id,
inspection_id = inspection_id,
)
continue # notify_customers_for_facility handles dedup internally
for user in users:
if user.id in exclude or user.id in notified:
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,
)
notified.add(user.id)
# ── Custom email recipients ───────────────────────────────────────────
custom_emails = get_custom_emails_for(event_type)
for email in custom_emails:
_send_custom_email(email, title, body, link)
logger.info(
'MATRIX NOTIFY | event=%s | notified=%s | custom_emails=%s',
event_type, len(notified), len(custom_emails),
)
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:
base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/')
sender = current_app.config.get(
'MAIL_DEFAULT_SENDER',
current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'),
)
html_body = render_template_string(
_EMAIL_HTML_SINGLE, title=title, body=body,
link=link, base_url=base_url,
)
text_body = render_template_string(
_EMAIL_TEXT_SINGLE, title=title, body=body,
link=link, base_url=base_url,
)
msg = Message(
subject = f'[JQC] {title}',
sender = sender,
recipients = [to_email],
body = text_body,
html = html_body,
)
except Exception as exc:
logger.error('CUSTOM EMAIL BUILD FAILED | to=%s | error=%s', to_email, exc)
return
app = current_app._get_current_object()
def _send():
with app.app_context():
try:
mail.send(msg)
logger.info('CUSTOM EMAIL SENT | to=%s', to_email)
except Exception as exc:
logger.error('CUSTOM EMAIL FAILED | to=%s | error=%s', to_email, exc)
import threading
threading.Thread(target=_send, daemon=True).start()
+34 -25
View File
@@ -105,8 +105,7 @@ def send_sla_alerts():
from app import db from app import db
from app.models.issue import Issue from app.models.issue import Issue
from app.models.user import User from app.models.user import User
from app.utils.notifications import notify from app.utils.notifications import notify, notify_by_matrix
from app.models.notification import EVENT_SLA_ALERT
import logging import logging
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -115,8 +114,6 @@ def send_sla_alerts():
Issue.status.in_(['open', 'in_progress', 'pending_verification']) Issue.status.in_(['open', 'in_progress', 'pending_verification'])
).all() ).all()
admins = User.query.filter_by(role='admin').all()
total_sent = 0 total_sent = 0
for issue in open_issues: for issue in open_issues:
@@ -133,22 +130,6 @@ def send_sla_alerts():
if already == 'at_risk' and status == 'at_risk': if already == 'at_risk' and status == 'at_risk':
continue # at_risk already sent, not yet breached continue # at_risk already sent, not yet breached
# Build recipient set — deduplicated by user.id
recipients = {}
for admin in admins:
recipients[admin.id] = admin
if issue.assigned_to and issue.assigned_user:
recipients[issue.assigned_user.id] = issue.assigned_user
for follower_link in issue.followers.all():
user = follower_link.user
recipients[user.id] = user
if not recipients:
continue
# Compose message # Compose message
hrs = sla_hours_remaining(issue) hrs = sla_hours_remaining(issue)
deadline = sla_deadline(issue) deadline = sla_deadline(issue)
@@ -178,23 +159,51 @@ def send_sla_alerts():
except RuntimeError: except RuntimeError:
link = f'/issues/{issue.id}' link = f'/issues/{issue.id}'
for user in recipients.values(): # Always notify the assignee and followers (implicit, not matrix-controlled)
implicit_notified = set()
if issue.assigned_to and issue.assigned_user:
notify( notify(
recipient = user, recipient = issue.assigned_user,
title = title, title = title,
body = body, body = body,
link = link, link = link,
issue_id = issue.id, issue_id = issue.id,
event_type = EVENT_SLA_ALERT, event_type = 'sla_alert',
send_email = True, send_email = True,
) )
implicit_notified.add(issue.assigned_user.id)
total_sent += 1 total_sent += 1
for follower_link in issue.followers.all():
if follower_link.user_id not in implicit_notified:
notify(
recipient = follower_link.user,
title = title,
body = body,
link = link,
issue_id = issue.id,
event_type = 'sla_alert',
send_email = True,
)
implicit_notified.add(follower_link.user_id)
total_sent += 1
# Matrix-controlled broadcast (admin, supervisor, etc.)
notify_by_matrix(
event_type = 'sla_alert',
title = title,
body = body,
link = link,
issue_id = issue.id,
exclude_user_ids = implicit_notified,
)
total_sent += 1 # approximate — matrix count not returned
# Mark this issue as notified at the current level # Mark this issue as notified at the current level
issue.sla_notified = status issue.sla_notified = status
logger.info( logger.info(
'SLA ALERT SENT | issue_id=%s | status=%s | recipients=%s', 'SLA ALERT SENT | issue_id=%s | status=%s',
issue.id, status, list(recipients.keys()), issue.id, status,
) )
if total_sent: if total_sent:
@@ -0,0 +1,37 @@
"""Phase 8: Notification matrix — admin-controlled per-event recipient settings
Revision ID: phase8_notification_matrix
Revises: phase7_mobile_api
Create Date: 2026-04-02
"""
from alembic import op
import sqlalchemy as sa
revision = 'phase8_notification_matrix'
down_revision = 'phase7_mobile_api'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
tables = inspector.get_table_names()
if 'notification_matrix' not in tables:
op.create_table(
'notification_matrix',
sa.Column('id', sa.Integer, primary_key=True),
sa.Column('event_type', sa.String(50), nullable=False),
sa.Column('role_key', sa.String(30), nullable=False),
# enabled: whether this role receives notifications for this event
sa.Column('enabled', sa.Boolean, nullable=False, server_default='1'),
# custom_emails: JSON list of extra email addresses (role_key='custom')
sa.Column('custom_emails', sa.Text, nullable=True),
sa.UniqueConstraint('event_type', 'role_key',
name='uq_notif_matrix_event_role'),
)
def downgrade():
op.drop_table('notification_matrix')