""" Reminder API Endpoints for Sag Module CRUD operations, user preferences, snooze/dismiss functionality """ import logging from typing import List, Optional import json from datetime import datetime, timedelta from fastapi import APIRouter, HTTPException, status, Depends, Request from pydantic import BaseModel, Field from app.core.database import execute_query, execute_insert from app.core.config import settings from app.core.auth_dependencies import require_any_permission from app.services.reminder_notification_service import reminder_notification_service logger = logging.getLogger(__name__) router = APIRouter() case_read_access = require_any_permission("cases.view", "tickets.view") case_edit_access = require_any_permission("cases.edit", "tickets.edit") def _ensure_task_list_rules(): execute_query("""CREATE TABLE IF NOT EXISTS reminder_task_list_rules ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL, title TEXT NOT NULL, times_json JSONB NOT NULL DEFAULT '[]'::jsonb, include_groups BOOLEAN NOT NULL DEFAULT true, notify_mattermost BOOLEAN NOT NULL DEFAULT true, is_active BOOLEAN NOT NULL DEFAULT true, last_sent_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)""", fetch=False) execute_query("ALTER TABLE reminder_task_list_rules ADD COLUMN IF NOT EXISTS last_sent_at TIMESTAMP", fetch=False) def _task_list(user_id: int, include_groups: bool): group_filter = "OR s.assigned_group_id IN (SELECT group_id FROM user_groups WHERE user_id=%s)" if include_groups else "" params = (user_id, user_id) if include_groups else (user_id,) return execute_query(f"""SELECT s.id, s.titel, s.status, s.priority, s.deadline, c.name AS customer_name FROM sag_sager s LEFT JOIN customers c ON c.id=s.customer_id WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status,'')) NOT IN ('lukket','løst','closed','resolved','udsat','deferred') AND (s.ansvarlig_bruger_id=%s {group_filter}) ORDER BY s.deadline NULLS LAST, s.updated_at DESC LIMIT 50""", params) or [] async def _send_task_list(rule, user_id: int): tasks = _task_list(user_id, bool(rule.get('include_groups'))) if not tasks: return {'sent': False, 'message': 'Ingen åbne sager at sende'} base_url = str(settings.HUB_BASE_URL or "https://hub.bmcnetworks.dk").rstrip('/') def cell(value, fallback='—'): value = str(value or fallback).replace('|', '\\|').replace('\n', ' ') return value[:120] lines = [ '| Sag | Kunde | Status | Prioritet | Deadline |', '| :-- | :-- | :-- | :-- | :-- |', ] for row in tasks: title = cell(row.get('titel'), 'Uden titel') link = f"[#{row['id']} · {title}]({base_url}/sag/{row['id']}/v3)" deadline = row.get('deadline') if hasattr(deadline, 'strftime'): deadline = deadline.strftime('%d.%m.%Y') lines.append( f"| {link} | {cell(row.get('customer_name'))} | {cell(row.get('status'))} " f"| {cell(row.get('priority'))} | {cell(deadline)} |" ) result = await reminder_notification_service.send_reminder( reminder_id=0, sag_id=int(tasks[0]['id']), case_title='Opgaveliste', customer_name=None, reminder_title=rule['title'], reminder_message='\n'.join(lines), recipient_user_ids=[user_id], recipient_emails=[], priority='normal', notify_mattermost=bool(rule.get('notify_mattermost')), notify_email=False, notify_frontend=True, override_user_preferences=True) return {'sent': bool(result.get('success')), 'count': len(tasks), 'result': result} async def process_task_list_rules(): _ensure_task_list_rules() now = datetime.now() window_start = now - timedelta(minutes=5) for rule in execute_query("SELECT * FROM reminder_task_list_rules WHERE is_active=true") or []: times = rule.get('times_json') or [] if isinstance(times, str): times = json.loads(times) for scheduled_time in times: try: hour, minute = (int(value) for value in scheduled_time.split(':', 1)) scheduled_at = now.replace(hour=hour, minute=minute, second=0, microsecond=0) except (AttributeError, TypeError, ValueError): logger.warning("Ignoring invalid task-list schedule %r for rule %s", scheduled_time, rule['id']) continue last_sent = rule.get('last_sent_at') if window_start < scheduled_at <= now and (not last_sent or last_sent < scheduled_at): await _send_task_list(rule, int(rule['user_id'])) execute_query('UPDATE reminder_task_list_rules SET last_sent_at=CURRENT_TIMESTAMP,updated_at=CURRENT_TIMESTAMP WHERE id=%s',(rule['id'],),fetch=False) break @router.get('/api/v1/reminder-task-rules') async def list_task_rules(request: Request): _ensure_task_list_rules(); user_id=_get_user_id_from_request(request) return execute_query('SELECT * FROM reminder_task_list_rules WHERE user_id=%s ORDER BY id DESC',(user_id,)) or [] @router.post('/api/v1/reminder-task-rules') async def save_task_rule(request: Request, data: dict): _ensure_task_list_rules(); user_id=_get_user_id_from_request(request) title=str(data.get('title') or 'Min opgaveliste').strip(); times=[t for t in (data.get('times') or []) if isinstance(t,str)] if not times: raise HTTPException(status_code=400, detail='Vælg mindst ét tidspunkt') rows=execute_query("""INSERT INTO reminder_task_list_rules(user_id,title,times_json,include_groups,notify_mattermost) VALUES(%s,%s,%s::jsonb,%s,%s) RETURNING *""",(user_id,title,json.dumps(times),bool(data.get('include_groups',True)),bool(data.get('notify_mattermost',True)))) return rows[0] @router.post('/api/v1/reminder-task-rules/{rule_id}/send-now') async def send_task_rule_now(rule_id:int, request:Request): _ensure_task_list_rules(); user_id=_get_user_id_from_request(request) rule=execute_query("SELECT * FROM reminder_task_list_rules WHERE id=%s AND user_id=%s",(rule_id,user_id)) if not rule: raise HTTPException(status_code=404, detail='Reglen findes ikke') return await _send_task_list(rule[0],user_id) # ============================================================================ # Helper Functions # ============================================================================ def _get_user_id_from_request(request: Request) -> int: """Extract user_id from request query params or raise 401""" user_id = getattr(request.state, 'user_id', None) if user_id is not None: try: return int(user_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid user_id format") user_id = request.query_params.get('user_id') if user_id: try: return int(user_id) except ValueError: raise HTTPException(status_code=400, detail="Invalid user_id format") raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter") # ============================================================================ # Pydantic Schemas # ============================================================================ class UserNotificationPreferences(BaseModel): """User notification preferences""" notify_mattermost: bool = True notify_email: bool = True notify_frontend: bool = True email_override: Optional[str] = None mattermost_username: Optional[str] = None quiet_hours_enabled: bool = False quiet_hours_start: Optional[str] = None # HH:MM format quiet_hours_end: Optional[str] = None # HH:MM format class ReminderCreate(BaseModel): """Create reminder request""" title: str = Field(..., min_length=3, max_length=255) message: Optional[str] = None priority: str = Field(default="normal", pattern="^(low|normal|high|urgent)$") event_type: str = Field(default="reminder", pattern="^(reminder|meeting|technician_visit|obs|deadline)$") trigger_type: str = Field(pattern="^(status_change|deadline_approaching|time_based)$") trigger_config: dict # JSON config for trigger recipient_user_ids: List[int] = [] recipient_emails: List[str] = [] notify_mattermost: Optional[bool] = None notify_email: Optional[bool] = None notify_frontend: Optional[bool] = None override_user_preferences: bool = False recurrence_type: str = Field(default="once", pattern="^(once|daily|weekly|monthly)$") recurrence_day_of_week: Optional[int] = None # 0-6 for weekly recurrence_day_of_month: Optional[int] = None # 1-31 for monthly scheduled_at: Optional[datetime] = None class ReminderUpdate(BaseModel): """Update reminder request""" title: Optional[str] = None message: Optional[str] = None priority: Optional[str] = None event_type: Optional[str] = Field(default=None, pattern="^(reminder|meeting|technician_visit|obs|deadline)$") notify_mattermost: Optional[bool] = None notify_email: Optional[bool] = None notify_frontend: Optional[bool] = None override_user_preferences: Optional[bool] = None is_active: Optional[bool] = None class ReminderResponse(BaseModel): """Reminder response""" id: int sag_id: int title: str message: Optional[str] priority: str event_type: Optional[str] trigger_type: str recurrence_type: str is_active: bool next_check_at: Optional[datetime] last_sent_at: Optional[datetime] created_at: datetime class ReminderProfileResponse(BaseModel): """Reminder response for profile list""" id: int sag_id: int title: str message: Optional[str] priority: str event_type: Optional[str] trigger_type: str recurrence_type: str is_active: bool next_check_at: Optional[datetime] last_sent_at: Optional[datetime] created_at: datetime case_title: Optional[str] customer_name: Optional[str] class ReminderLogResponse(BaseModel): """Reminder execution log""" id: int reminder_id: Optional[int] sag_id: int status: str triggered_at: datetime channels_used: List[str] class SnoozeRequest(BaseModel): """Snooze reminder request""" duration_minutes: int = Field(..., ge=15, le=1440) # 15 min to 24 hours class DismissRequest(BaseModel): """Dismiss reminder request""" reason: Optional[str] = None # ============================================================================ # User Preferences Endpoints # ============================================================================ @router.get("/api/v1/users/me/notification-preferences", response_model=UserNotificationPreferences) async def get_user_notification_preferences(request: Request): """Get current user's notification preferences""" user_id = _get_user_id_from_request(request) query = """ SELECT notify_mattermost, notify_email, notify_frontend, email_override, mattermost_username, quiet_hours_enabled, quiet_hours_start, quiet_hours_end FROM user_notification_preferences WHERE user_id = %s """ result = execute_query(query, (user_id,)) if result: r = result[0] return UserNotificationPreferences( notify_mattermost=r.get('notify_mattermost', True), notify_email=r.get('notify_email', True), notify_frontend=r.get('notify_frontend', True), email_override=r.get('email_override'), mattermost_username=r.get('mattermost_username'), quiet_hours_enabled=r.get('quiet_hours_enabled', False), quiet_hours_start=r.get('quiet_hours_start'), quiet_hours_end=r.get('quiet_hours_end') ) # Return defaults return UserNotificationPreferences() @router.patch("/api/v1/users/me/notification-preferences") async def update_user_notification_preferences( request: Request, preferences: UserNotificationPreferences ): """Update user notification preferences""" user_id = _get_user_id_from_request(request) # Check if preferences exist check_query = "SELECT id FROM user_notification_preferences WHERE user_id = %s" exists = execute_query(check_query, (user_id,)) try: if exists: # Update existing query = """ UPDATE user_notification_preferences SET notify_mattermost = %s, notify_email = %s, notify_frontend = %s, email_override = %s, mattermost_username = %s, quiet_hours_enabled = %s, quiet_hours_start = %s, quiet_hours_end = %s, updated_at = CURRENT_TIMESTAMP WHERE user_id = %s RETURNING id """ execute_insert(query, ( preferences.notify_mattermost, preferences.notify_email, preferences.notify_frontend, preferences.email_override, preferences.mattermost_username, preferences.quiet_hours_enabled, preferences.quiet_hours_start, preferences.quiet_hours_end, user_id )) else: # Create new query = """ INSERT INTO user_notification_preferences ( user_id, notify_mattermost, notify_email, notify_frontend, email_override, mattermost_username, quiet_hours_enabled, quiet_hours_start, quiet_hours_end ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """ execute_insert(query, ( user_id, preferences.notify_mattermost, preferences.notify_email, preferences.notify_frontend, preferences.email_override, preferences.mattermost_username, preferences.quiet_hours_enabled, preferences.quiet_hours_start, preferences.quiet_hours_end )) logger.info(f"✅ Updated notification preferences for user {user_id}") return {"success": True, "message": "Preferences updated"} except Exception as e: logger.error(f"❌ Error updating preferences: {e}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Reminder CRUD Endpoints # ============================================================================ @router.get( "/api/v1/sag/{sag_id}/reminders", response_model=List[ReminderResponse], dependencies=[Depends(case_read_access)], ) async def list_sag_reminders(sag_id: int): """List all reminders for a case""" query = """ SELECT id, sag_id, title, message, priority, event_type, trigger_type, recurrence_type, is_active, next_check_at, last_sent_at, created_at FROM sag_reminders WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC """ results = execute_query(query, (sag_id,)) return [ ReminderResponse( id=r['id'], sag_id=r['sag_id'], title=r['title'], message=r['message'], priority=r['priority'], event_type=r.get('event_type'), trigger_type=r['trigger_type'], recurrence_type=r['recurrence_type'], is_active=r['is_active'], next_check_at=r['next_check_at'], last_sent_at=r['last_sent_at'], created_at=r['created_at'] ) for r in results ] @router.get("/api/v1/reminders/my", response_model=List[ReminderProfileResponse]) async def list_my_reminders(request: Request): """List reminders for the authenticated user""" user_id = _get_user_id_from_request(request) query = """ SELECT r.id, r.sag_id, r.title, r.message, r.priority, r.event_type, r.trigger_type, r.recurrence_type, r.is_active, r.next_check_at, r.last_sent_at, r.created_at, s.titel as case_title, c.name as customer_name FROM sag_reminders r LEFT JOIN sag_sager s ON s.id = r.sag_id LEFT JOIN customers c ON c.id = s.customer_id WHERE r.deleted_at IS NULL AND %s = ANY(r.recipient_user_ids) ORDER BY r.created_at DESC """ results = execute_query(query, (user_id,)) return [ ReminderProfileResponse( id=r['id'], sag_id=r['sag_id'], title=r['title'], message=r['message'], priority=r['priority'], event_type=r.get('event_type'), trigger_type=r['trigger_type'], recurrence_type=r['recurrence_type'], is_active=r['is_active'], next_check_at=r['next_check_at'], last_sent_at=r['last_sent_at'], created_at=r['created_at'], case_title=r.get('case_title'), customer_name=r.get('customer_name') ) for r in results ] @router.post( "/api/v1/sag/{sag_id}/reminders", response_model=ReminderResponse, dependencies=[Depends(case_edit_access)], ) async def create_sag_reminder(sag_id: int, request: Request, reminder: ReminderCreate): """Create a new reminder for a case""" user_id = _get_user_id_from_request(request) # Verify case exists case_query = "SELECT id FROM sag_sager WHERE id = %s" case = execute_query(case_query, (sag_id,)) if not case: raise HTTPException(status_code=404, detail=f"Case #{sag_id} not found") # Calculate next_check_at based on trigger type next_check_at = None if reminder.trigger_type == 'time_based' and reminder.scheduled_at: next_check_at = reminder.scheduled_at elif reminder.trigger_type == 'deadline_approaching': next_check_at = datetime.now() + timedelta(days=1) # Check daily try: import json query = """ INSERT INTO sag_reminders ( sag_id, title, message, priority, event_type, trigger_type, trigger_config, recipient_user_ids, recipient_emails, notify_mattermost, notify_email, notify_frontend, override_user_preferences, recurrence_type, recurrence_day_of_week, recurrence_day_of_month, scheduled_at, next_check_at, is_active, created_by_user_id ) VALUES ( %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, true, %s ) RETURNING id, sag_id, title, message, priority, event_type, trigger_type, recurrence_type, is_active, next_check_at, last_sent_at, created_at """ result = execute_insert(query, ( sag_id, reminder.title, reminder.message, reminder.priority, reminder.event_type, reminder.trigger_type, json.dumps(reminder.trigger_config), reminder.recipient_user_ids, reminder.recipient_emails, reminder.notify_mattermost, reminder.notify_email, reminder.notify_frontend, reminder.override_user_preferences, reminder.recurrence_type, reminder.recurrence_day_of_week, reminder.recurrence_day_of_month, reminder.scheduled_at, next_check_at, user_id )) if not result: raise HTTPException(status_code=500, detail="Failed to create reminder") raw_row = result[0] if isinstance(result, list) else result if isinstance(raw_row, dict): r = raw_row else: reminder_id = int(raw_row) fetch_query = """ SELECT id, sag_id, title, message, priority, event_type, trigger_type, recurrence_type, is_active, next_check_at, last_sent_at, created_at FROM sag_reminders WHERE id = %s """ fetched = execute_query(fetch_query, (reminder_id,)) if not fetched: raise HTTPException(status_code=500, detail="Failed to fetch reminder after creation") r = fetched[0] logger.info(f"✅ Reminder created for case #{sag_id} by user {user_id}") return ReminderResponse( id=r['id'], sag_id=r['sag_id'], title=r['title'], message=r['message'], priority=r['priority'], event_type=r.get('event_type'), trigger_type=r['trigger_type'], recurrence_type=r['recurrence_type'], is_active=r['is_active'], next_check_at=r['next_check_at'], last_sent_at=r['last_sent_at'], created_at=r['created_at'] ) except Exception as e: logger.error(f"❌ Error creating reminder: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.patch("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)]) async def update_sag_reminder(reminder_id: int, update: ReminderUpdate, request: Request): """Update a reminder""" user_id = _get_user_id_from_request(request) # Build update query dynamically updates = [] params = [] if update.title is not None: updates.append("title = %s") params.append(update.title) if update.message is not None: updates.append("message = %s") params.append(update.message) if update.priority is not None: updates.append("priority = %s") params.append(update.priority) if update.event_type is not None: updates.append("event_type = %s") params.append(update.event_type) if update.notify_mattermost is not None: updates.append("notify_mattermost = %s") params.append(update.notify_mattermost) if update.notify_email is not None: updates.append("notify_email = %s") params.append(update.notify_email) if update.notify_frontend is not None: updates.append("notify_frontend = %s") params.append(update.notify_frontend) if update.override_user_preferences is not None: updates.append("override_user_preferences = %s") params.append(update.override_user_preferences) if update.is_active is not None: updates.append("is_active = %s") params.append(update.is_active) if not updates: raise HTTPException(status_code=400, detail="No fields to update") updates.append("updated_at = CURRENT_TIMESTAMP") params.append(reminder_id) try: query = f""" UPDATE sag_reminders SET {', '.join(updates)} WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL RETURNING id """ params.append(user_id) result = execute_insert(query, tuple(params)) if not result: raise HTTPException(status_code=404, detail="Reminder not found") logger.info(f"✅ Reminder {reminder_id} updated") return {"success": True, "message": "Reminder updated"} except HTTPException: raise except Exception as e: logger.error(f"❌ Error updating reminder: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.delete("/api/v1/sag/reminders/{reminder_id}", dependencies=[Depends(case_edit_access)]) async def delete_sag_reminder(reminder_id: int, request: Request): """Soft-delete a reminder""" user_id = _get_user_id_from_request(request) try: query = """ UPDATE sag_reminders SET deleted_at = CURRENT_TIMESTAMP, is_active = false WHERE id = %s AND created_by_user_id = %s AND deleted_at IS NULL RETURNING id """ result = execute_insert(query, (reminder_id, user_id)) if not result: raise HTTPException(status_code=404, detail="Reminder not found") logger.info(f"✅ Reminder {reminder_id} deleted") return {"success": True, "message": "Reminder deleted"} except HTTPException: raise except Exception as e: logger.error(f"❌ Error deleting reminder: {e}") raise HTTPException(status_code=500, detail=str(e)) # ============================================================================ # Reminder Interaction Endpoints # ============================================================================ @router.post("/api/v1/sag/reminders/{reminder_id}/snooze") async def snooze_reminder(reminder_id: int, request: Request, snooze_request: SnoozeRequest): """Snooze a reminder for specified minutes""" user_id = _get_user_id_from_request(request) snooze_until = datetime.now() + timedelta(minutes=snooze_request.duration_minutes) try: query = """ INSERT INTO sag_reminder_logs ( reminder_id, sag_id, user_id, status, snoozed_until, snoozed_by_user_id, triggered_at ) SELECT id, sag_id, %s, 'snoozed', %s, %s, CURRENT_TIMESTAMP FROM sag_reminders WHERE id = %s RETURNING id """ result = execute_insert(query, (user_id, snooze_until, user_id, reminder_id)) if not result: raise HTTPException(status_code=404, detail="Reminder not found") logger.info(f"✅ Reminder {reminder_id} snoozed until {snooze_until}") return { "success": True, "message": f"Reminder snoozed for {snooze_request.duration_minutes} minutes", "snoozed_until": snooze_until } except Exception as e: logger.error(f"❌ Error snoozing reminder: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/api/v1/sag/reminders/{reminder_id}/dismiss") async def dismiss_reminder(reminder_id: int, request: Request, dismiss_request: DismissRequest): """Dismiss a reminder""" user_id = _get_user_id_from_request(request) try: query = """ INSERT INTO sag_reminder_logs ( reminder_id, sag_id, user_id, status, dismissed_at, dismissed_by_user_id, triggered_at ) SELECT id, sag_id, %s, 'dismissed', CURRENT_TIMESTAMP, %s, CURRENT_TIMESTAMP FROM sag_reminders WHERE id = %s RETURNING id """ result = execute_insert(query, (user_id, user_id, reminder_id)) if not result: raise HTTPException(status_code=404, detail="Reminder not found") logger.info(f"✅ Reminder {reminder_id} dismissed by user {user_id}") return {"success": True, "message": "Reminder dismissed"} except Exception as e: logger.error(f"❌ Error dismissing reminder: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/api/v1/reminders/pending/me") async def get_pending_reminders(request: Request): """Get pending reminders for current user (for frontend polling)""" user_id = _get_user_id_from_request(request) query = """ SELECT r.id, r.sag_id, r.title, r.message, r.priority, s.titel as case_title, c.name as customer_name, l.id as log_id, l.snoozed_until, l.status as log_status FROM sag_reminders r JOIN sag_sager s ON r.sag_id = s.id JOIN customers c ON s.customer_id = c.id LEFT JOIN LATERAL ( SELECT id, snoozed_until, status, triggered_at FROM sag_reminder_logs WHERE reminder_id = r.id AND user_id = %s ORDER BY triggered_at DESC LIMIT 1 ) l ON true WHERE r.is_active = true AND r.deleted_at IS NULL AND r.next_check_at <= CURRENT_TIMESTAMP AND %s = ANY(r.recipient_user_ids) AND (l.snoozed_until IS NULL OR l.snoozed_until < CURRENT_TIMESTAMP) AND (l.status IS NULL OR l.status != 'dismissed') ORDER BY r.priority DESC, r.next_check_at ASC LIMIT 5 """ try: results = execute_query(query, (user_id, user_id)) return [{ 'id': r['id'], 'sag_id': r['sag_id'], 'title': r['title'], 'message': r['message'], 'priority': r['priority'], 'case_title': r['case_title'], 'customer_name': r['customer_name'] } for r in results] except Exception as e: logger.error(f"❌ Error fetching pending reminders: {e}") raise HTTPException(status_code=500, detail=str(e))