From 59f46f211086645bd5e80c54e66bca341b4e6456 Mon Sep 17 00:00:00 2001 From: NguyenND Date: Wed, 4 Mar 2026 13:31:11 -0500 Subject: [PATCH] Mar 04 2026: Implement customer's view functionalities - Phase 4 --- app/models/notification.py | 10 ++ app/routes/inspections.py | 37 +++++++- app/routes/issues.py | 36 ++++++- app/templates/notifications/preferences.html | 98 ++++++++++++++++++- app/utils/notifications.py | 99 ++++++++++++++++++++ 5 files changed, 276 insertions(+), 4 deletions(-) diff --git a/app/models/notification.py b/app/models/notification.py index 2216811..7b011b7 100644 --- a/app/models/notification.py +++ b/app/models/notification.py @@ -13,6 +13,13 @@ EVENT_ISSUE_FOLLOW = 'issue_follow_update' EVENT_INSPECTION_DONE = 'inspection_completed' EVENT_SLA_ALERT = 'sla_alert' +# ── Customer portal events ───────────────────────────────────────────────── +# Fired when an inspection completes or an issue is created/updated at a +# facility the customer is assigned to. Separate constants allow customers +# to manage these preferences independently from internal staff events. +EVENT_CUSTOMER_INSPECTION_DONE = 'customer_inspection_completed' +EVENT_CUSTOMER_ISSUE_UPDATED = 'customer_issue_updated' + ALL_EVENT_TYPES = { EVENT_ISSUE_ASSIGNED: 'Issue assigned to me', EVENT_ISSUE_STATUS: 'Issue status changed', @@ -20,6 +27,9 @@ ALL_EVENT_TYPES = { EVENT_ISSUE_FOLLOW: 'Updates on followed issues', EVENT_INSPECTION_DONE: 'Inspection completed', EVENT_SLA_ALERT: 'SLA at-risk / breached alerts', + # Customer-facing — only relevant for customer role accounts + EVENT_CUSTOMER_INSPECTION_DONE: 'Inspection completed at my facility (portal)', + EVENT_CUSTOMER_ISSUE_UPDATED: 'Issue created or updated at my facility (portal)', } diff --git a/app/routes/inspections.py b/app/routes/inspections.py index 82290c7..b65ee12 100644 --- a/app/routes/inspections.py +++ b/app/routes/inspections.py @@ -15,8 +15,11 @@ from app.models.user import User from app.utils.forms import StartInspectionForm, IssueForm from app.utils.decorators import supervisor_required from app.utils.pdf_export import generate_inspection_pdf -from app.utils.notifications import notify -from app.models.notification import EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED +from app.utils.notifications import notify, notify_customers_for_facility +from app.models.notification import ( + EVENT_INSPECTION_DONE, EVENT_ISSUE_ASSIGNED, + EVENT_CUSTOMER_INSPECTION_DONE, EVENT_CUSTOMER_ISSUE_UPDATED, +) from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE, ACTION_EXPORT from app.utils.scope import get_customer_scope @@ -347,6 +350,19 @@ def execute(inspection_id): event_type = EVENT_INSPECTION_DONE, send_email = True, ) + # ── Notify customer portal users for this facility ────────── + notify_customers_for_facility( + facility_id = inspection.facility_id, + event_type = EVENT_CUSTOMER_INSPECTION_DONE, + 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() # Commit notifications log_action(ACTION_UPDATE, 'Inspection', inspection.id, f'{inspection.template.name} @ {inspection.facility.name}', @@ -530,6 +546,23 @@ def flag_issue(inspection_id): ) db.session.commit() # Commit notification + # ── Notify customer portal users for this facility ────────── + notify_customers_for_facility( + facility_id = inspection.facility_id, + event_type = EVENT_CUSTOMER_ISSUE_UPDATED, + title = f'New Issue #{issue.id} at {inspection.facility.name}', + body = ( + f'A new {issue.severity.title()}-severity issue has been logged ' + f'in {issue.area.name} at {inspection.facility.name} ' + f'during inspection #{inspection_id}. ' + f'Description: {issue.description[:120]}' + f'{"…" if len(issue.description) > 120 else ""}' + ), + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + ) + db.session.commit() + flash('Issue logged successfully.', 'success') return redirect(url_for('inspections.execute', inspection_id=inspection_id)) diff --git a/app/routes/issues.py b/app/routes/issues.py index 220071b..6714da5 100644 --- a/app/routes/issues.py +++ b/app/routes/issues.py @@ -9,10 +9,11 @@ from app.models.user import User from app.models.notification import ( EVENT_ISSUE_ASSIGNED, EVENT_ISSUE_STATUS, EVENT_ISSUE_COMMENT, EVENT_ISSUE_FOLLOW, + EVENT_CUSTOMER_ISSUE_UPDATED, ) from app.utils.forms import IssueForm, IssueUpdateForm from app.utils.decorators import supervisor_required -from app.utils.notifications import notify +from app.utils.notifications import notify, notify_customers_for_facility from app.utils.audit import log_action, ACTION_CREATE, ACTION_UPDATE, ACTION_DELETE from app.utils.scope import get_customer_scope from app.utils.sla import sla_status @@ -265,6 +266,22 @@ def view(issue_id): exclude_user_ids = exclude_ids, ) + # ── Notify customer portal users for this facility ────────── + facility_id = issue.area.facility_id if issue.area else None + if facility_id: + changes_summary = '; '.join(changes) if changes else 'updated' + notify_customers_for_facility( + facility_id = facility_id, + event_type = EVENT_CUSTOMER_ISSUE_UPDATED, + title = f'Issue #{issue.id} Updated at {issue.area.facility.name}', + body = ( + f'Issue #{issue.id} ({issue.severity.title()} severity) ' + f'in {issue.area.name} was updated: {changes_summary}. ' + f'Current status: {issue.status.replace("_", " ").title()}.' + ), + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + ) db.session.commit() # Commit all notifications log_action(ACTION_UPDATE, 'Issue', issue.id, f'#{issue.id} in {issue.area.name}', @@ -379,6 +396,23 @@ def create(): ) db.session.commit() + # ── Notify customer portal users for this facility ────────── + area = Area.query.get(issue.area_id) + if area: + notify_customers_for_facility( + facility_id = area.facility_id, + event_type = EVENT_CUSTOMER_ISSUE_UPDATED, + title = f'New Issue #{issue.id} at {area.facility.name}', + body = ( + f'A new {issue.severity.title()}-severity issue has been logged ' + f'in {area.name} at {area.facility.name}. ' + f'Description: {issue.description[:120]}' + f'{"…" if len(issue.description) > 120 else ""}' + ), + link = url_for('issues.view', issue_id=issue.id), + issue_id = issue.id, + ) + db.session.commit() flash('Issue created.', 'success') return redirect(url_for('issues.index')) diff --git a/app/templates/notifications/preferences.html b/app/templates/notifications/preferences.html index 60c36fb..754083e 100644 --- a/app/templates/notifications/preferences.html +++ b/app/templates/notifications/preferences.html @@ -32,8 +32,23 @@ + {# ── Internal staff events ── #} + {% set internal_events = [ + 'issue_assigned', 'issue_status', 'issue_comment', + 'issue_follow_update', 'inspection_completed', 'sla_alert' + ] %} + {# ── Customer portal events ── #} + {% set customer_events = [ + 'customer_inspection_completed', 'customer_issue_updated' + ] %} + diff --git a/app/utils/notifications.py b/app/utils/notifications.py index e1c69b9..e49a8cf 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -273,6 +273,105 @@ def _send_single_email(recipient, title, body, link): # ── Digest delivery ──────────────────────────────────────────────────────────── + +# ── Customer portal notifications ───────────────────────────────────────────── + +def notify_customers_for_facility( + facility_id: int, + event_type: str, + title: str, + body: str, + link: str = None, + issue_id: int = None, + inspection_id: int = None, +): + """Dispatch in-app + email notifications to all customer users assigned + to the given facility. + + Resolves assignments via CustomerAssignment rows: + - facility-scoped assignment (facility_id matches exactly) + - project-scoped assignment (facility belongs to the project, no facility_id set) + + Respects each customer's NotificationPreference for the supplied event_type. + Best-effort: a failure on one recipient does not block others. + + Parameters + ---------- + facility_id : The facility where the event occurred. + event_type : EVENT_CUSTOMER_INSPECTION_DONE or EVENT_CUSTOMER_ISSUE_UPDATED. + title : Short notification headline. + body : Full notification message. + link : Relative URL for 'View Details'. + issue_id : FK to issues.id (optional). + inspection_id : FK to inspections.id (optional). + """ + try: + from app.models.project import CustomerAssignment + from app.models.facility import Facility + from app.models.user import User + + facility = Facility.query.get(facility_id) + if not facility: + logger.warning( + 'notify_customers_for_facility | facility_id=%s not found', facility_id + ) + return + + # Collect distinct customer user IDs that have access to this facility + notified_user_ids = set() + + # 1. Direct facility-scoped assignments + direct = CustomerAssignment.query.filter_by(facility_id=facility_id).all() + for a in direct: + notified_user_ids.add(a.user_id) + + # 2. Project-scoped assignments (no facility_id) — if facility belongs to a project + if facility.project_id: + project_wide = CustomerAssignment.query.filter_by( + project_id=facility.project_id, + facility_id=None, + ).all() + for a in project_wide: + notified_user_ids.add(a.user_id) + + if not notified_user_ids: + logger.debug( + 'notify_customers_for_facility | facility_id=%s | no customer assignments found', + facility_id, + ) + return + + for user_id in notified_user_ids: + user = User.query.get(user_id) + if not user or not user.active or user.role != 'customer': + continue + try: + notify( + recipient = user, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + event_type = event_type, + send_email = True, + ) + logger.info( + 'CUSTOMER NOTIFY | user=%s | facility_id=%s | event=%s', + user.username, facility_id, event_type, + ) + except Exception as exc: + logger.error( + 'CUSTOMER NOTIFY FAILED | user=%s | facility_id=%s | event=%s | error=%s', + user_id, facility_id, event_type, exc, + ) + + except Exception as exc: + logger.error( + 'notify_customers_for_facility | unexpected error | facility_id=%s | error=%s', + facility_id, exc, + ) + def send_pending_digests(frequency: str = 'daily'): """Send digest emails for all users who have pending digest notifications.