import json import logging from typing import Any, Dict, Optional from app.core.database import execute_query, execute_query_single logger = logging.getLogger(__name__) class MissionService: MISSION_CASE_TYPES = {"ticket", "opgave", "ordre", "projekt", "service"} @staticmethod def _safe(label: str, func, default): try: return func() except Exception as exc: logger.error("❌ Mission state component failed: %s (%s)", label, exc) return default @staticmethod def _table_exists(table_name: str) -> bool: row = execute_query_single("SELECT to_regclass(%s) AS table_name", (f"public.{table_name}",)) return bool(row and row.get("table_name")) @staticmethod def get_ring_timeout_seconds() -> int: raw = MissionService.get_setting_value("mission_call_ring_timeout_seconds", "180") or "180" try: value = int(raw) except (TypeError, ValueError): value = 180 return max(30, min(value, 3600)) @staticmethod def expire_stale_ringing_calls() -> None: if not MissionService._table_exists("mission_call_state"): return timeout_seconds = MissionService.get_ring_timeout_seconds() execute_query( """ UPDATE mission_call_state SET state = 'hangup', ended_at = COALESCE(ended_at, NOW()), updated_at = NOW() WHERE state = 'ringing' AND started_at < (NOW() - make_interval(secs => %s)) """, (timeout_seconds,), ) @staticmethod def get_setting_value(key: str, default: Optional[str] = None) -> Optional[str]: row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,)) if not row: return default value = row.get("value") if value is None or value == "": return default return str(value) @staticmethod def parse_json_setting(key: str, default: Any) -> Any: raw = MissionService.get_setting_value(key, None) if raw is None: return default try: return json.loads(raw) except Exception: return default @staticmethod def build_alert_key(service_name: str, customer_name: Optional[str]) -> str: customer_part = (customer_name or "").strip().lower() or "global" return f"{service_name.strip().lower()}::{customer_part}" @staticmethod def resolve_contact_context(caller_number: Optional[str]) -> Dict[str, Optional[str]]: if not caller_number: return {"contact_name": None, "company_name": None, "customer_tag": None} query = """ SELECT c.id, c.first_name, c.last_name, ( SELECT cu.name FROM contact_companies cc JOIN customers cu ON cu.id = cc.customer_id WHERE cc.contact_id = c.id ORDER BY cc.is_primary DESC NULLS LAST, cc.id ASC LIMIT 1 ) AS company_name, ( SELECT t.name FROM entity_tags et JOIN tags t ON t.id = et.tag_id WHERE et.entity_type = 'contact' AND et.entity_id = c.id AND LOWER(t.name) IN ('vip', 'serviceaftale', 'service agreement') ORDER BY t.name LIMIT 1 ) AS customer_tag FROM contacts c WHERE RIGHT(regexp_replace(COALESCE(c.phone, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8) OR RIGHT(regexp_replace(COALESCE(c.mobile, ''), '\\D', '', 'g'), 8) = RIGHT(regexp_replace(%s, '\\D', '', 'g'), 8) LIMIT 1 """ row = execute_query_single(query, (caller_number, caller_number)) if not row: return {"contact_name": None, "company_name": None, "customer_tag": None} contact_name = f"{(row.get('first_name') or '').strip()} {(row.get('last_name') or '').strip()}".strip() or None return { "contact_name": contact_name, "company_name": row.get("company_name"), "customer_tag": row.get("customer_tag"), } @staticmethod def insert_event( *, event_type: str, title: str, severity: str = "info", source: Optional[str] = None, customer_name: Optional[str] = None, payload: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: if not MissionService._table_exists("mission_events"): logger.warning("Mission table missing: mission_events (event skipped)") return {} rows = execute_query( """ INSERT INTO mission_events (event_type, severity, title, source, customer_name, payload) VALUES (%s, %s, %s, %s, %s, %s::jsonb) RETURNING id, event_type, severity, title, source, customer_name, payload, created_at """, (event_type, severity, title, source, customer_name, json.dumps(payload or {})), ) return rows[0] if rows else {} @staticmethod def get_kpis() -> Dict[str, int]: query = """ SELECT COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet') AS open_cases, COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) = 'åben' AND ansvarlig_bruger_id IS NULL) AS new_cases, COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND ansvarlig_bruger_id IS NULL) AS unassigned_cases, COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS deadlines_today, COUNT(*) FILTER (WHERE deleted_at IS NULL AND LOWER(status) <> 'afsluttet' AND deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_deadlines FROM sag_sager """ row = execute_query_single(query) or {} return { "open_cases": int(row.get("open_cases") or 0), "new_cases": int(row.get("new_cases") or 0), "unassigned_cases": int(row.get("unassigned_cases") or 0), "deadlines_today": int(row.get("deadlines_today") or 0), "overdue_deadlines": int(row.get("overdue_deadlines") or 0), } @staticmethod def get_employee_deadlines() -> list[Dict[str, Any]]: rows = execute_query( """ SELECT COALESCE(u.full_name, u.username, 'Ukendt') AS employee_name, COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) AS deadlines_today, COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) AS overdue_deadlines FROM sag_sager s LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id WHERE s.deleted_at IS NULL AND LOWER(s.status) <> 'afsluttet' AND s.deadline IS NOT NULL GROUP BY COALESCE(u.full_name, u.username, 'Ukendt') HAVING COUNT(*) FILTER (WHERE s.deadline::date = CURRENT_DATE) > 0 OR COUNT(*) FILTER (WHERE s.deadline::date < CURRENT_DATE) > 0 ORDER BY overdue_deadlines DESC, deadlines_today DESC, employee_name ASC """ ) or [] return [ { "employee_name": row.get("employee_name"), "deadlines_today": int(row.get("deadlines_today") or 0), "overdue_deadlines": int(row.get("overdue_deadlines") or 0), } for row in rows ] @staticmethod def get_active_calls() -> list[Dict[str, Any]]: if not MissionService._table_exists("mission_call_state"): logger.warning("Mission table missing: mission_call_state (active calls unavailable)") return [] MissionService.expire_stale_ringing_calls() rows = execute_query( """ SELECT call_id, queue_name, caller_number, contact_name, company_name, customer_tag, state, started_at, answered_at, ended_at, updated_at FROM mission_call_state WHERE state = 'ringing' ORDER BY started_at DESC """ ) return rows or [] @staticmethod def get_active_alerts() -> list[Dict[str, Any]]: if not MissionService._table_exists("mission_uptime_alerts"): logger.warning("Mission table missing: mission_uptime_alerts (active alerts unavailable)") return [] rows = execute_query( """ SELECT alert_key, service_name, customer_name, status, is_active, started_at, resolved_at, updated_at FROM mission_uptime_alerts WHERE is_active = TRUE ORDER BY started_at ASC NULLS LAST """ ) return rows or [] @staticmethod def get_live_feed(limit: int = 20) -> list[Dict[str, Any]]: if not MissionService._table_exists("mission_events"): logger.warning("Mission table missing: mission_events (live feed unavailable)") return [] rows = execute_query( """ SELECT id, event_type, severity, title, source, customer_name, payload, created_at FROM mission_events ORDER BY created_at DESC LIMIT %s """, (limit,), ) return rows or [] @staticmethod def _normalize_case_type(template_key: Optional[str]) -> str: key = (template_key or "").strip().lower() if not key: return "opgave" if "ticket" in key: return "ticket" if "ordre" in key or "order" in key: return "ordre" if "projekt" in key or "project" in key or "pipeline" in key: return "projekt" if "service" in key or "support" in key: return "service" if "opgave" in key or "task" in key: return "opgave" return "opgave" @staticmethod def get_important_cases(limit: int = 80) -> list[Dict[str, Any]]: if not MissionService._table_exists("sag_sager"): logger.warning("Mission table missing: sag_sager (important cases unavailable)") return [] rows = execute_query( """ SELECT s.id, s.titel, s.status, s.priority, s.deadline, s.template_key, s.created_at, COALESCE(c.name, 'Ukendt kunde') 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, '')) <> 'afsluttet' ORDER BY CASE LOWER(COALESCE(s.priority, '')) WHEN 'kritisk' THEN 5 WHEN 'critical' THEN 5 WHEN 'høj' THEN 4 WHEN 'hoj' THEN 4 WHEN 'high' THEN 4 WHEN 'medium' THEN 3 WHEN 'normal' THEN 2 WHEN 'lav' THEN 1 WHEN 'low' THEN 1 ELSE 2 END DESC, s.deadline ASC NULLS LAST, s.created_at DESC LIMIT %s """, (limit,), ) or [] result: list[Dict[str, Any]] = [] for row in rows: item = dict(row) normalized_type = MissionService._normalize_case_type(item.get("template_key")) if normalized_type not in MissionService.MISSION_CASE_TYPES: normalized_type = "opgave" item["case_type"] = normalized_type result.append(item) return result @staticmethod def get_recent_emails(limit: int = 25) -> list[Dict[str, Any]]: if not MissionService._table_exists("email_messages"): logger.warning("Mission table missing: email_messages (recent emails unavailable)") return [] rows = execute_query( """ SELECT id, subject, sender_name, sender_email, classification, status, linked_case_id, received_date FROM email_messages WHERE deleted_at IS NULL ORDER BY received_date DESC NULLS LAST, created_at DESC LIMIT %s """, (limit,), ) or [] return [dict(row) for row in rows] @staticmethod def get_environment_readings(limit: int = 12) -> list[Dict[str, Any]]: readings = MissionService.parse_json_setting("mission_environment_readings", []) if not isinstance(readings, list): return [] result: list[Dict[str, Any]] = [] for raw in readings: if not isinstance(raw, dict): continue item = { "sensor_id": str(raw.get("sensor_id") or "").strip() or None, "sensor_name": str(raw.get("sensor_name") or "").strip() or "Sensor", "temperature": raw.get("temperature"), "unit": str(raw.get("unit") or "°C").strip() or "°C", "timestamp": raw.get("timestamp"), "payload": raw.get("payload") if isinstance(raw.get("payload"), dict) else {}, } result.append(item) return result[: max(1, int(limit))] @staticmethod def get_state() -> Dict[str, Any]: kpis_default = { "open_cases": 0, "new_cases": 0, "unassigned_cases": 0, "deadlines_today": 0, "overdue_deadlines": 0, } return { "kpis": MissionService._safe("kpis", MissionService.get_kpis, kpis_default), "active_calls": MissionService._safe("active_calls", MissionService.get_active_calls, []), "employee_deadlines": MissionService._safe("employee_deadlines", MissionService.get_employee_deadlines, []), "active_alerts": MissionService._safe("active_alerts", MissionService.get_active_alerts, []), "live_feed": MissionService._safe("live_feed", lambda: MissionService.get_live_feed(20), []), "important_cases": MissionService._safe("important_cases", lambda: MissionService.get_important_cases(80), []), "recent_emails": MissionService._safe("recent_emails", lambda: MissionService.get_recent_emails(25), []), "environment_readings": MissionService._safe("environment_readings", lambda: MissionService.get_environment_readings(12), []), "config": { "display_queues": MissionService._safe("config.display_queues", lambda: MissionService.parse_json_setting("mission_display_queues", []), []), "sound_enabled": MissionService._safe( "config.sound_enabled", lambda: str(MissionService.get_setting_value("mission_sound_enabled", "true")).lower() == "true", True, ), "sound_volume": MissionService._safe( "config.sound_volume", lambda: int(MissionService.get_setting_value("mission_sound_volume", "70") or 70), 70, ), "sound_events": MissionService._safe( "config.sound_events", lambda: MissionService.parse_json_setting("mission_sound_events", ["incoming_call", "uptime_down", "critical_event"]), ["incoming_call", "uptime_down", "critical_event"], ), "kpi_visible": MissionService._safe( "config.kpi_visible", lambda: MissionService.parse_json_setting( "mission_kpi_visible", ["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"], ), ["open_cases", "new_cases", "unassigned_cases", "deadlines_today", "overdue_deadlines"], ), "customer_filter": MissionService._safe( "config.customer_filter", lambda: MissionService.get_setting_value("mission_customer_filter", "") or "", "", ), "camera_enabled": MissionService._safe( "config.camera_enabled", lambda: str(MissionService.get_setting_value("mission_camera_enabled", "false")).lower() == "true", False, ), "camera_name": MissionService._safe( "config.camera_name", lambda: MissionService.get_setting_value("mission_camera_name", "Mission Kamera") or "Mission Kamera", "Mission Kamera", ), "camera_feed_url": MissionService._safe( "config.camera_feed_url", lambda: MissionService.get_setting_value("mission_camera_feed_url", "") or "", "", ), "camera_spotlight_seconds": MissionService._safe( "config.camera_spotlight_seconds", lambda: max(5, min(int(MissionService.get_setting_value("mission_camera_spotlight_seconds", "20") or 20), 120)), 20, ), }, }