# app/routes/notifications.py import logging from flask import (Blueprint, jsonify, request, abort, render_template, redirect, url_for, flash, current_app) from flask_login import login_required, current_user from app import db, csrf from app.models.notification import ( Notification, NotificationPreference, ALL_EVENT_TYPES ) logger = logging.getLogger(__name__) bp = Blueprint('notifications', __name__, url_prefix='/notifications') # ── Bell feed (navbar dropdown) ─────────────────────────────────────────────── @bp.route('/feed') @login_required def feed(): """Return the 20 most recent notifications for the current user as JSON.""" notifs = ( Notification.query .filter_by(user_id=current_user.id) .order_by(Notification.created_at.desc()) .limit(20) .all() ) unread_count = Notification.query.filter_by( user_id=current_user.id, is_read=False ).count() 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}) # ── Full notification history page ──────────────────────────────────────────── @bp.route('/') @login_required def index(): """Full paginated notification history with read/unread filter.""" page = request.args.get('page', 1, type=int) filter_read = request.args.get('filter', 'all') # 'all' | 'unread' | 'read' q = Notification.query.filter_by(user_id=current_user.id) if filter_read == 'unread': q = q.filter_by(is_read=False) elif filter_read == 'read': q = q.filter_by(is_read=True) notifications = q.order_by(Notification.created_at.desc()).paginate( page=page, per_page=25, error_out=False ) unread_count = Notification.query.filter_by( user_id=current_user.id, is_read=False ).count() return render_template( 'notifications/index.html', notifications=notifications, filter_read=filter_read, unread_count=unread_count, ) # ── Mark single notification read ───────────────────────────────────────────── @bp.route('//mark-read', methods=['POST']) @login_required def mark_read(notif_id): notif = db.session.get(Notification, notif_id) if notif is None: abort(404) 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, ) return jsonify({'ok': True}) # ── Mark all read ───────────────────────────────────────────────────────────── @bp.route('/mark-all-read', methods=['POST']) @login_required def mark_all_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, ) # Support both AJAX (returns JSON) and form POST (redirects to index) if request.headers.get('X-Requested-With') == 'XMLHttpRequest' or \ request.content_type == 'application/json': return jsonify({'ok': True, 'marked': updated}) return redirect(url_for('notifications.index')) # ── Notification preferences ────────────────────────────────────────────────── @bp.route('/preferences', methods=['GET', 'POST']) @login_required def preferences(): """Display and save per-event notification preferences.""" if request.method == 'POST': for event_type in ALL_EVENT_TYPES: pref = NotificationPreference.query.filter_by( user_id=current_user.id, event_type=event_type, ).first() if pref is None: pref = NotificationPreference( user_id=current_user.id, event_type=event_type, ) db.session.add(pref) pref.email_enabled = bool(request.form.get(f'email_{event_type}')) pref.digest_mode = bool(request.form.get(f'digest_{event_type}')) pref.digest_frequency = request.form.get(f'freq_{event_type}', 'daily') # Guard: digest_mode only meaningful when email is enabled if not pref.email_enabled: pref.digest_mode = False db.session.commit() logger.info( 'NOTIFICATION PREFERENCES SAVED | user=%s', current_user.username, ) flash('Notification preferences saved.', 'success') return redirect(url_for('notifications.preferences')) # Build a dict keyed by event_type for easy template access prefs_map = {} for pref in NotificationPreference.query.filter_by(user_id=current_user.id).all(): prefs_map[pref.event_type] = pref return render_template( 'notifications/preferences.html', event_types=ALL_EVENT_TYPES, prefs_map=prefs_map, ) # ── Digest trigger (called by cron) ─────────────────────────────────────────── @bp.route('/send-digest', methods=['POST']) @csrf.exempt def send_digest(): """Trigger digest email delivery. Protected by a shared secret token. Called by a cron job, e.g.: # Hourly digest 0 * * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \ -d "token=YOUR_DIGEST_SECRET&frequency=hourly" # Daily digest at 07:00 0 7 * * * curl -s -X POST https://yourdomain.com/notifications/send-digest \ -d "token=YOUR_DIGEST_SECRET&frequency=daily" """ token = request.form.get('token') or request.args.get('token') frequency = request.form.get('frequency', 'daily') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('DIGEST TRIGGER REJECTED | bad or missing token') abort(403) if frequency not in ('hourly', 'daily'): abort(400) from app.utils.notifications import send_pending_digests sent = send_pending_digests(frequency=frequency) logger.info('DIGEST TRIGGERED | frequency=%s | sent=%s', frequency, sent) return jsonify({'ok': True, 'sent': sent, 'frequency': frequency}) # ── SLA alert trigger (called by cron) ──────────────────────────────────────── @bp.route('/check-sla', methods=['POST']) @csrf.exempt def check_sla(): """Scan all open issues for SLA breaches and dispatch alerts. Protected by the same DIGEST_SECRET token used for digest delivery. Recommended cron schedule — every 30 minutes is sufficient for most deployments; adjust based on your shortest SLA threshold (critical = 4h): */30 * * * * curl -s -X POST https://yourdomain.com/notifications/check-sla \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('SLA CHECK REJECTED | bad or missing token') abort(403) from app.utils.sla import send_sla_alerts sent = send_sla_alerts() logger.info('SLA CHECK TRIGGERED | notifications_sent=%s', sent) return jsonify({'ok': True, 'notifications_sent': sent}) # ── Expired token cleanup (called by cron) ──────────────────────────────────── @bp.route('/cleanup-tokens', methods=['POST']) @csrf.exempt def cleanup_tokens(): """Purge expired and revoked refresh tokens from api_refresh_tokens. Safe to run frequently — only deletes rows where expires_at has passed OR revoked=True. Keeps the table lean without touching live sessions. Recommended cron schedule — nightly is sufficient: 0 3 * * * curl -s -X POST https://yourdomain.com/notifications/cleanup-tokens \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('TOKEN CLEANUP REJECTED | bad or missing token') abort(403) from app.models.api_token import RefreshToken from app.utils.time_utils import now_eastern now = now_eastern() deleted = ( RefreshToken.query .filter( db.or_( RefreshToken.expires_at < now, RefreshToken.revoked == True, # noqa: E712 ) ) .delete(synchronize_session=False) ) db.session.commit() logger.info('TOKEN CLEANUP | deleted=%s expired/revoked rows', deleted) return jsonify({'ok': True, 'deleted': deleted}) # ── Score trend alert trigger (called by cron) ──────────────────────────────── @bp.route('/check-score-trends', methods=['POST']) @csrf.exempt def check_score_trends(): """Scan facility score trends and dispatch alerts for significant drops. Compares each active facility's avg inspection score for the last 30 days against the prior 30-day period. Alerts fire when the drop exceeds the configured threshold (default: 5 percentage points). Protected by the same DIGEST_SECRET token used by the other cron endpoints. Recommended cron schedule — once per day is sufficient: 0 8 * * * curl -s -X POST https://yourdomain.com/notifications/check-score-trends \\ -d "token=YOUR_DIGEST_SECRET" Optional param: threshold= Override the default 5.0-point drop threshold. """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('SCORE TREND CHECK REJECTED | bad or missing token') abort(403) threshold = request.form.get('threshold', type=float) or None from app.utils.sla import send_score_alerts kwargs = {} if threshold is not None: kwargs['threshold'] = threshold sent = send_score_alerts(**kwargs) logger.info('SCORE TREND CHECK TRIGGERED | alerts_sent=%s', sent) return jsonify({'ok': True, 'alerts_sent': sent}) # ── Trial-ending reminder (called by cron) ──────────────────────────────────── @bp.route('/trial-reminders', methods=['POST']) @csrf.exempt def trial_reminders(): """Send trial-ending warning emails for tenants whose trial expires within 3 days. Cross-tenant: queries the control DB and iterates every qualifying tenant. Exempt from tenant middleware — must be callable without a tenant Host header. Protected by DIGEST_SECRET. Sets trial_reminder_sent_at on the Tenant row so re-runs within 22 hours are skipped (handles cron timing jitter). Recommended cron schedule — once per day at 09:00 is sufficient: 0 9 * * * curl -s -X POST https://yourdomain.com/notifications/trial-reminders \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('TRIAL REMINDERS REJECTED | bad or missing token') abort(403) if not current_app.config.get('BILLING_ENABLED', False): return jsonify({'ok': True, 'skipped': 'billing_disabled', 'sent': 0}) if not current_app.config.get('MULTI_TENANT_ENABLED', False): return jsonify({'ok': True, 'skipped': 'multi_tenant_disabled', 'sent': 0}) from datetime import timedelta from app.utils.time_utils import now_eastern from app.billing.emails import send_billing_email try: from control.base import control_session from control.models import Tenant as ControlTenant, TenantDomain except ImportError as exc: logger.error('TRIAL REMINDERS | control import failed: %s', exc) return jsonify({'ok': False, 'error': str(exc)}), 500 now = now_eastern() window_end = now + timedelta(days=3) resend_gap = timedelta(hours=22) sent = 0 skipped = 0 errors = 0 with control_session() as s: candidates = ( s.query(ControlTenant) .filter( ControlTenant.subscription_status == 'trial', ControlTenant.trial_ends_at.isnot(None), ControlTenant.trial_ends_at > now, # not yet expired ControlTenant.trial_ends_at <= window_end, # expires within 3 days ) .all() ) for t in candidates: # Skip if we already sent a reminder recently. if t.trial_reminder_sent_at and (now - t.trial_reminder_sent_at) < resend_gap: skipped += 1 continue # Resolve recipient email: billing_email first, fall back to tenant DB admin. email = t.billing_email if not email: try: from sqlalchemy import create_engine, text as sa_text eng = create_engine(t.db_uri, pool_pre_ping=True, connect_args={'connect_timeout': 5}) with eng.connect() as conn: row = conn.execute(sa_text( "SELECT email FROM users " "WHERE role='admin' AND active=1 " "ORDER BY id LIMIT 1" )).fetchone() if row: email = row[0] eng.dispose() except Exception as exc: logger.error('TRIAL REMINDERS | admin email lookup failed | ' 'tenant=%s err=%s', t.slug, exc) if not email: logger.warning('TRIAL REMINDERS | no email | tenant=%s', t.slug) skipped += 1 continue # Build subscribe URL from tenant's primary verified domain. primary = next( (d.domain for d in t.domains if d.is_primary and d.verified), None, ) subscribe_url = ( f'https://{primary}/billing/subscribe' if primary else current_app.config.get('APP_BASE_URL', '') + '/billing/subscribe' ) days_left = (t.trial_ends_at - now).days expiry_str = t.trial_ends_at.strftime('%B %d, %Y') try: send_billing_email(email, 'trial_ending', { 'trial_ends_at': expiry_str, 'subscribe_url': subscribe_url, 'days_left': days_left, 'tenant_name': t.name, }) t.trial_reminder_sent_at = now sent += 1 logger.info('TRIAL REMINDERS | sent | tenant=%s to=%s days_left=%s', t.slug, email, days_left) except Exception as exc: logger.error('TRIAL REMINDERS | send failed | tenant=%s err=%s', t.slug, exc) errors += 1 logger.info('TRIAL REMINDERS COMPLETE | sent=%s skipped=%s errors=%s', sent, skipped, errors) return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': errors}) # ── Dunning reminder (called by cron) ──────────────────────────────────────── @bp.route('/dunning-reminders', methods=['POST']) @csrf.exempt def dunning_reminders(): """Send escalating payment-failure reminder emails to past_due tenants. Stage logic (based on time since first payment failure): Stage 0 → 1 (day 3): Friendly reminder Stage 1 → 2 (day 7): Urgent reminder Stage 2 → 3 (day 14): Final notice (account cancellation imminent) Cross-tenant: queries the control DB and iterates every qualifying tenant. Exempt from tenant middleware — callable without a tenant Host header. Protected by DIGEST_SECRET. Recommended cron schedule — once per day at 10:00 is sufficient: 0 10 * * * curl -s -X POST https://yourdomain.com/notifications/dunning-reminders \\ -d "token=YOUR_DIGEST_SECRET" """ token = request.form.get('token') or request.args.get('token') expected = current_app.config.get('DIGEST_SECRET') if not expected or token != expected: logger.warning('DUNNING REMINDERS REJECTED | bad or missing token') abort(403) if not current_app.config.get('BILLING_ENABLED', False): return jsonify({'ok': True, 'skipped': 'billing_disabled', 'sent': 0}) if not current_app.config.get('MULTI_TENANT_ENABLED', False): return jsonify({'ok': True, 'skipped': 'multi_tenant_disabled', 'sent': 0}) from datetime import timedelta from app.utils.time_utils import now_eastern from app.billing.emails import send_billing_email try: from control.base import control_session from control.models import Tenant as ControlTenant except ImportError as exc: logger.error('DUNNING REMINDERS | control import failed: %s', exc) return jsonify({'ok': False, 'error': str(exc)}), 500 now = now_eastern() sent = 0 skipped = 0 errors = 0 # Stage thresholds (days since past_due_since) _STAGES = [ (3, 1, 'payment_reminder'), (7, 2, 'payment_reminder'), (14, 3, 'payment_final'), ] with control_session() as s: past_due_tenants = ( s.query(ControlTenant) .filter( ControlTenant.subscription_status == 'past_due', ControlTenant.past_due_since.isnot(None), ) .all() ) for t in past_due_tenants: elapsed_days = (now - t.past_due_since).days # Determine the target stage based on elapsed time target_stage = 0 target_event = None for days_threshold, stage_num, event_type in _STAGES: if elapsed_days >= days_threshold: target_stage = stage_num target_event = event_type if target_stage == 0 or t.dunning_stage >= target_stage: skipped += 1 continue email = t.billing_email if not email: try: from sqlalchemy import create_engine, text as sa_text eng = create_engine(t.db_uri, pool_pre_ping=True, connect_args={'connect_timeout': 5}) with eng.connect() as conn: row = conn.execute(sa_text( "SELECT email FROM users " "WHERE role='admin' AND active=1 " "ORDER BY id LIMIT 1" )).fetchone() if row: email = row[0] eng.dispose() except Exception as exc: logger.error('DUNNING REMINDERS | admin email lookup failed | ' 'tenant=%s err=%s', t.slug, exc) if not email: logger.warning('DUNNING REMINDERS | no email | tenant=%s', t.slug) skipped += 1 continue # Build billing portal URL from tenant's primary verified domain. primary = next( (d.domain for d in t.domains if d.is_primary and d.verified), None, ) portal_url = ( f'https://{primary}/billing/portal' if primary else current_app.config.get('APP_BASE_URL', '') + '/billing/portal' ) try: send_billing_email(email, target_event, { 'portal_url': portal_url, 'tenant_name': t.name, 'days_overdue': elapsed_days, }) t.dunning_stage = target_stage t.dunning_sent_at = now sent += 1 logger.info('DUNNING REMINDERS | sent | tenant=%s stage=%s to=%s', t.slug, target_stage, email) except Exception as exc: logger.error('DUNNING REMINDERS | send failed | tenant=%s err=%s', t.slug, exc) errors += 1 logger.info('DUNNING REMINDERS COMPLETE | sent=%s skipped=%s errors=%s', sent, skipped, errors) return jsonify({'ok': True, 'sent': sent, 'skipped': skipped, 'errors': errors})