From 698c1bbff233cdd16f7479b283d131398f38206b Mon Sep 17 00:00:00 2001 From: NguyenND Date: Sun, 1 Mar 2026 20:32:24 -0500 Subject: [PATCH] Feb 02 2026: implement notification system 3 --- app/models/notification.py | 28 +++++++ app/utils/notifications.py | 168 +++++++++++++++++++++++-------------- 2 files changed, 135 insertions(+), 61 deletions(-) create mode 100644 app/models/notification.py diff --git a/app/models/notification.py b/app/models/notification.py new file mode 100644 index 0000000..1e156a6 --- /dev/null +++ b/app/models/notification.py @@ -0,0 +1,28 @@ +from app import db +from app.utils.time_utils import now_eastern + + +class Notification(db.Model): + """Stores in-app notifications for users. + + Each notification is tied to a single recipient and optionally linked to + either an Issue or an Inspection so the UI can build a direct link. + """ + __tablename__ = 'notifications' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + title = db.Column(db.String(255), nullable=False) + body = db.Column(db.Text, nullable=False) + link = db.Column(db.String(512)) # URL the bell-click should navigate to + is_read = db.Column(db.Boolean, default=False, nullable=False) + created_at = db.Column(db.DateTime, default=now_eastern, nullable=False) + + # Optional FK references — only one will be populated at a time + issue_id = db.Column(db.Integer, db.ForeignKey('issues.id', ondelete='CASCADE'), nullable=True) + inspection_id = db.Column(db.Integer, db.ForeignKey('inspections.id', ondelete='CASCADE'), nullable=True) + + recipient = db.relationship('User', foreign_keys=[user_id], backref='notifications') + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/app/utils/notifications.py b/app/utils/notifications.py index e98fd9b..6db572e 100644 --- a/app/utils/notifications.py +++ b/app/utils/notifications.py @@ -1,74 +1,120 @@ -# app/routes/notifications.py +""" +app/utils/notifications.py +~~~~~~~~~~~~~~~~~~~~~~~~~~ +Central helper for creating in-app notifications and dispatching email alerts. +""" + import logging -from flask import Blueprint, jsonify, request, abort -from flask_login import login_required, current_user -from app import db +from flask import current_app, render_template_string +from flask_mail import Message +from app import db, mail from app.models.notification import Notification logger = logging.getLogger(__name__) -bp = Blueprint('notifications', __name__, url_prefix='/notifications') +_EMAIL_HTML = """\ + + + +

{{ title }}

+

{{ body }}

+ {% if link %} +

+ + View Details + +

+ {% endif %} +
+

+ Janitorial QC System — automated notification. Do not reply to this email. +

+ + +""" + +_EMAIL_TEXT = """\ +{{ title }} + +{{ body }} +{% if link %} +View: {{ base_url }}{{ link }} +{% endif %} + +-- +Janitorial QC System — automated notification. +""" -@bp.route('/feed') -@login_required -def feed(): - """Return the 20 most recent notifications for the current user as JSON. - Used by the navbar bell icon to populate the dropdown. - """ - notifs = ( - Notification.query - .filter_by(user_id=current_user.id) - .order_by(Notification.created_at.desc()) - .limit(20) - .all() +def notify( + recipient, + title: str, + body: str, + link: str = None, + issue_id: int = None, + inspection_id: int = None, + send_email: bool = True, +): + """Create an in-app Notification record and optionally send an email.""" + # ── 1. Persist in-app notification ───────────────────────────────────── + notif = Notification( + user_id = recipient.id, + title = title, + body = body, + link = link, + issue_id = issue_id, + inspection_id = inspection_id, + is_read = False, ) - unread_count = Notification.query.filter_by( - user_id=current_user.id, is_read=False - ).count() + db.session.add(notif) + # NOTE: The caller is responsible for calling db.session.commit(). - items = [] - for n in notifs: - items.append({ - 'id': n.id, - 'title': n.title, - 'body': n.body, - 'link': n.link, - 'is_read': n.is_read, - 'created_at': n.created_at.strftime('%b %d, %Y %I:%M %p'), - }) - - return jsonify({'notifications': items, 'unread_count': unread_count}) - - -@bp.route('//mark-read', methods=['POST']) -@login_required -def mark_read(notif_id): - """Mark a single notification as read.""" - notif = Notification.query.get_or_404(notif_id) - if notif.user_id != current_user.id: - abort(403) - notif.is_read = True - db.session.commit() logger.info( - 'NOTIFICATION READ | id=%s | user=%s', - notif_id, current_user.username, + 'NOTIFICATION CREATED | user=%s | title=%s | issue_id=%s | inspection_id=%s', + recipient.username, title, issue_id, inspection_id, ) - return jsonify({'ok': True}) + # ── 2. Send email (best-effort) ───────────────────────────────────────── + if send_email and recipient.email and current_app.config.get('MAIL_SERVER'): + try: + base_url = current_app.config.get('APP_BASE_URL', '').rstrip('/') -@bp.route('/mark-all-read', methods=['POST']) -@login_required -def mark_all_read(): - """Mark all unread notifications for the current user as read.""" - updated = ( - Notification.query - .filter_by(user_id=current_user.id, is_read=False) - .update({'is_read': True}) - ) - db.session.commit() - logger.info( - 'NOTIFICATIONS ALL READ | user=%s | count=%s', - current_user.username, updated, - ) - return jsonify({'ok': True, 'marked': updated}) \ No newline at end of file + html_body = render_template_string( + _EMAIL_HTML, + title=title, + body=body, + link=link, + base_url=base_url, + ) + text_body = render_template_string( + _EMAIL_TEXT, + title=title, + body=body, + link=link, + base_url=base_url, + ) + + sender = current_app.config.get( + 'MAIL_DEFAULT_SENDER', + current_app.config.get('MAIL_USERNAME', 'noreply@janitorialqc.local'), + ) + + msg = Message( + subject = f'[JQC] {title}', + sender = sender, + recipients = [recipient.email], + body = text_body, + html = html_body, + ) + mail.send(msg) + logger.info( + 'NOTIFICATION EMAIL SENT | to=%s | subject=%s', + recipient.email, msg.subject, + ) + except Exception as exc: + logger.error( + 'NOTIFICATION EMAIL FAILED | to=%s | error=%s', + recipient.email, exc, + ) \ No newline at end of file