- Implemented user notification preferences table for managing default notification settings. - Created sag_reminders table to define reminder rules with various trigger types and recipient configurations. - Developed sag_reminder_queue for processing reminder events triggered by status changes or scheduled times. - Added sag_reminder_logs to track reminder notifications and user interactions. - Introduced frontend notification system using Bootstrap 5 Toast for displaying reminders. - Created email template for sending reminders with case details and action links. - Implemented rate limiting for user notifications to prevent spamming. - Added triggers and functions for automatic updates and reminder processing.
278 lines
11 KiB
Python
278 lines
11 KiB
Python
"""
|
|
Reminder Scheduler Job
|
|
Processes pending time-based reminders and queue-based trigger events
|
|
Runs every 5 minutes (configurable)
|
|
"""
|
|
|
|
import logging
|
|
from datetime import datetime, timedelta
|
|
import json
|
|
|
|
from app.core.config import settings
|
|
from app.core.database import execute_query, execute_insert
|
|
from app.services.reminder_notification_service import reminder_notification_service
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
async def check_reminders():
|
|
"""
|
|
Main job: Check for pending reminders and trigger notifications
|
|
- Process time-based reminders (scheduled_at or next_check_at <= NOW())
|
|
- Process queued trigger events from database triggers
|
|
- Handle recurring reminders (calculate next_check_at)
|
|
- Respect rate limiting (max 5 per user per hour)
|
|
"""
|
|
|
|
if not settings.REMINDERS_ENABLED:
|
|
return
|
|
|
|
try:
|
|
logger.info("🔔 Checking for pending reminders...")
|
|
|
|
# Step 1: Process queued trigger events (status changes)
|
|
queue_count = await _process_reminder_queue()
|
|
|
|
# Step 2: Process time-based reminders
|
|
time_based_count = await _process_time_based_reminders()
|
|
|
|
logger.info(f"✅ Reminder check complete: {queue_count} queue events, {time_based_count} time-based")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Reminder check failed: {e}")
|
|
|
|
|
|
async def _process_reminder_queue():
|
|
"""Process queued reminder events from status change triggers"""
|
|
|
|
count = 0
|
|
batch_size = settings.REMINDERS_QUEUE_BATCH_SIZE
|
|
|
|
try:
|
|
# Get pending queue events
|
|
query = """
|
|
SELECT
|
|
q.id, q.reminder_id, q.sag_id, q.event_data,
|
|
r.title, r.message, r.priority,
|
|
r.recipient_user_ids, r.recipient_emails,
|
|
r.notify_mattermost, r.notify_email, r.notify_frontend,
|
|
r.override_user_preferences,
|
|
s.titel as case_title, c.name as customer_name,
|
|
s.status as case_status, s.deadline, s.ansvarlig_bruger_id
|
|
FROM v_pending_reminder_queue q
|
|
JOIN sag_reminders r ON q.reminder_id = r.id
|
|
JOIN sag_sager s ON q.sag_id = s.id
|
|
JOIN customers c ON s.customer_id = c.id
|
|
LIMIT %s
|
|
"""
|
|
|
|
events = execute_query(query, (batch_size,))
|
|
|
|
for event in events:
|
|
try:
|
|
# Update queue status to processing
|
|
update_query = "UPDATE sag_reminder_queue SET status = 'processing' WHERE id = %s"
|
|
execute_insert(update_query, (event['id'],))
|
|
|
|
# Get assigned user name
|
|
assigned_user = None
|
|
if event['ansvarlig_bruger_id']:
|
|
user_query = "SELECT full_name FROM users WHERE id = %s"
|
|
user = execute_query(user_query, (event['ansvarlig_bruger_id'],))
|
|
assigned_user = user[0]['full_name'] if user else None
|
|
|
|
# Send reminder
|
|
result = await reminder_notification_service.send_reminder(
|
|
reminder_id=event['reminder_id'],
|
|
sag_id=event['sag_id'],
|
|
case_title=event['case_title'],
|
|
customer_name=event['customer_name'],
|
|
reminder_title=event['title'],
|
|
reminder_message=event['message'],
|
|
recipient_user_ids=event['recipient_user_ids'] or [],
|
|
recipient_emails=event['recipient_emails'] or [],
|
|
priority=event['priority'],
|
|
notify_mattermost=event['notify_mattermost'],
|
|
notify_email=event['notify_email'],
|
|
notify_frontend=event['notify_frontend'],
|
|
override_user_preferences=event['override_user_preferences'],
|
|
case_status=event['case_status'],
|
|
deadline=event['deadline'].isoformat() if event['deadline'] else None,
|
|
assigned_user=assigned_user
|
|
)
|
|
|
|
# Update queue status
|
|
if result['success']:
|
|
status = 'sent'
|
|
log_msg = None
|
|
elif result['rate_limited_users']:
|
|
status = 'rate_limited'
|
|
log_msg = f"Rate limited: {len(result['rate_limited_users'])} users"
|
|
else:
|
|
status = 'failed'
|
|
log_msg = ', '.join(result['errors'])[:500]
|
|
|
|
update_query = """
|
|
UPDATE sag_reminder_queue
|
|
SET status = %s, processed_at = CURRENT_TIMESTAMP, error_message = %s
|
|
WHERE id = %s
|
|
"""
|
|
execute_insert(update_query, (status, log_msg, event['id']))
|
|
|
|
count += 1
|
|
logger.info(f"✅ Processed queue event {event['id']} (reminder {event['reminder_id']})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to process queue event {event['id']}: {e}")
|
|
update_query = """
|
|
UPDATE sag_reminder_queue
|
|
SET status = 'failed', error_message = %s, processed_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
"""
|
|
execute_insert(update_query, (str(e)[:500], event['id']))
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Error processing reminder queue: {e}")
|
|
|
|
return count
|
|
|
|
|
|
async def _process_time_based_reminders():
|
|
"""Process time-based reminders with scheduling"""
|
|
|
|
count = 0
|
|
batch_size = settings.REMINDERS_QUEUE_BATCH_SIZE
|
|
|
|
try:
|
|
# Get pending time-based reminders
|
|
query = """
|
|
SELECT
|
|
r.id, r.sag_id, r.title, r.message, r.priority,
|
|
r.recipient_user_ids, r.recipient_emails,
|
|
r.notify_mattermost, r.notify_email, r.notify_frontend,
|
|
r.override_user_preferences,
|
|
r.recurrence_type, r.recurrence_day_of_week, r.recurrence_day_of_month,
|
|
r.next_check_at,
|
|
s.titel as case_title, c.name as customer_name,
|
|
s.status as case_status, s.deadline, s.ansvarlig_bruger_id
|
|
FROM sag_reminders r
|
|
JOIN sag_sager s ON r.sag_id = s.id
|
|
JOIN customers c ON s.customer_id = c.id
|
|
WHERE r.is_active = true
|
|
AND r.deleted_at IS NULL
|
|
AND r.trigger_type = 'time_based'
|
|
AND r.next_check_at IS NOT NULL
|
|
AND r.next_check_at <= CURRENT_TIMESTAMP
|
|
ORDER BY r.priority DESC, r.next_check_at ASC
|
|
LIMIT %s
|
|
"""
|
|
|
|
reminders = execute_query(query, (batch_size,))
|
|
|
|
for reminder in reminders:
|
|
try:
|
|
# Get assigned user name
|
|
assigned_user = None
|
|
if reminder['ansvarlig_bruger_id']:
|
|
user_query = "SELECT full_name FROM users WHERE id = %s"
|
|
user = execute_query(user_query, (reminder['ansvarlig_bruger_id'],))
|
|
assigned_user = user[0]['full_name'] if user else None
|
|
|
|
# Send reminder
|
|
result = await reminder_notification_service.send_reminder(
|
|
reminder_id=reminder['id'],
|
|
sag_id=reminder['sag_id'],
|
|
case_title=reminder['case_title'],
|
|
customer_name=reminder['customer_name'],
|
|
reminder_title=reminder['title'],
|
|
reminder_message=reminder['message'],
|
|
recipient_user_ids=reminder['recipient_user_ids'] or [],
|
|
recipient_emails=reminder['recipient_emails'] or [],
|
|
priority=reminder['priority'],
|
|
notify_mattermost=reminder['notify_mattermost'],
|
|
notify_email=reminder['notify_email'],
|
|
notify_frontend=reminder['notify_frontend'],
|
|
override_user_preferences=reminder['override_user_preferences'],
|
|
case_status=reminder['case_status'],
|
|
deadline=reminder['deadline'].isoformat() if reminder['deadline'] else None,
|
|
assigned_user=assigned_user
|
|
)
|
|
|
|
# Calculate next check time for recurring reminders
|
|
next_check_at = _calculate_next_check(
|
|
reminder['recurrence_type'],
|
|
reminder['recurrence_day_of_week'],
|
|
reminder['recurrence_day_of_month']
|
|
)
|
|
|
|
# Update reminder
|
|
update_query = """
|
|
UPDATE sag_reminders
|
|
SET last_sent_at = CURRENT_TIMESTAMP,
|
|
next_check_at = %s,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = %s
|
|
"""
|
|
execute_insert(update_query, (next_check_at, reminder['id']))
|
|
|
|
count += 1
|
|
logger.info(f"✅ Processed reminder {reminder['id']} (next: {next_check_at})")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Failed to process reminder {reminder['id']}: {e}")
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Error processing time-based reminders: {e}")
|
|
|
|
return count
|
|
|
|
|
|
def _calculate_next_check(recurrence_type: str, day_of_week: int = None, day_of_month: int = None):
|
|
"""Calculate when reminder should be checked next"""
|
|
|
|
now = datetime.now()
|
|
|
|
if recurrence_type == 'once':
|
|
# One-time reminder - no next check
|
|
return None
|
|
|
|
elif recurrence_type == 'daily':
|
|
# Next day at same time
|
|
return now + timedelta(days=1)
|
|
|
|
elif recurrence_type == 'weekly':
|
|
# Same day next week
|
|
if day_of_week is not None:
|
|
# If specific day set, calculate days until that day
|
|
days_ahead = day_of_week - now.weekday()
|
|
if days_ahead <= 0: # Target day already happened this week
|
|
days_ahead += 7
|
|
return now + timedelta(days=days_ahead)
|
|
else:
|
|
# Next week same day
|
|
return now + timedelta(days=7)
|
|
|
|
elif recurrence_type == 'monthly':
|
|
# Same day next month
|
|
if day_of_month is not None:
|
|
try:
|
|
# Try to set day in next month
|
|
if now.month == 12:
|
|
next_month = now.replace(year=now.year + 1, month=1, day=min(day_of_month, 28))
|
|
else:
|
|
next_month = now.replace(month=now.month + 1, day=min(day_of_month, 28))
|
|
|
|
if next_month <= now:
|
|
# Already passed this month, go to next
|
|
next_month = next_month + timedelta(days=28)
|
|
|
|
return next_month
|
|
except ValueError:
|
|
# Invalid date (e.g., Feb 30), use last day of month
|
|
pass
|
|
|
|
# Fallback: 30 days from now
|
|
return now + timedelta(days=30)
|
|
|
|
return None
|