572 lines
22 KiB
Python
572 lines
22 KiB
Python
# 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('/<int:notif_id>/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=<float> 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}) |