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) ORDER BY ( SELECT COUNT(*) FROM sag_kontakter sk JOIN sag_sager s ON s.id = sk.sag_id WHERE sk.contact_id = c.id AND sk.deleted_at IS NULL AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) <> 'lukket' ) DESC, ( SELECT MAX(t.started_at) FROM telefoni_opkald t WHERE t.kontakt_id = c.id ) DESC NULLS LAST, c.id ASC 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::text, '')) 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 _project_risk_level_from_score(score: int) -> str: if score <= 29: return "low" if score <= 69: return "medium" if score <= 119: return "high" return "critical" @staticmethod def _compute_project_risk(project_row: Dict[str, Any]) -> Dict[str, Any]: score = int(project_row.get("score") or 0) factors: list[str] = [] overdue_tasks = int(project_row.get("overdue_tasks") or 0) open_blockers = int(project_row.get("open_blockers") or 0) overdue_milestones = int(project_row.get("overdue_milestones") or 0) if overdue_tasks > 0: score += min(overdue_tasks * 8, 40) factors.append("overdue_tasks") if overdue_milestones > 0: score += min(overdue_milestones * 10, 40) factors.append("overdue_milestones") if open_blockers > 0: score += min(open_blockers * 15, 60) factors.append("open_blockers") return { "risk_score": score, "risk_level": MissionService._project_risk_level_from_score(score), "risk_factors": factors, } @staticmethod def get_project_workload(limit: int = 100) -> list[Dict[str, Any]]: if not MissionService._table_exists("mission_projects"): return [] rows = execute_query( """ SELECT p.id, p.name, COUNT(*) FILTER ( WHERE s.id IS NOT NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS open_tasks, COUNT(*) FILTER ( WHERE s.id IS NOT NULL AND s.deadline IS NOT NULL AND s.deadline::date < CURRENT_DATE AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS overdue_tasks, COUNT(*) FILTER ( WHERE s.id IS NOT NULL AND s.ansvarlig_bruger_id IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS unassigned_tasks FROM mission_projects p LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL GROUP BY p.id, p.name ORDER BY overdue_tasks DESC, unassigned_tasks DESC, open_tasks DESC, p.id DESC LIMIT %s """, (limit,), ) or [] return [dict(row) for row in rows] @staticmethod def _get_projects_from_cases(limit: int = 120) -> list[Dict[str, Any]]: if not MissionService._table_exists("sag_sager"): return [] case_rows = execute_query( """ SELECT s.id, s.titel AS name, s.beskrivelse AS description, LOWER(COALESCE(s.status, 'active')) AS status, 0 AS score, s.start_date AS started_at, s.deadline AS ended_at, s.created_at AS updated_at, 0 AS active_milestones, 0 AS overdue_milestones, 0 AS open_blockers, 1 AS open_tasks, CASE WHEN s.deadline IS NOT NULL AND s.deadline::date = CURRENT_DATE THEN 1 ELSE 0 END AS due_today_tasks, CASE WHEN s.deadline IS NOT NULL AND s.deadline::date < CURRENT_DATE AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') THEN 1 ELSE 0 END AS overdue_tasks FROM sag_sager s WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') AND ( LOWER(COALESCE(s.template_key, '')) IN ('projekt', 'project') OR LOWER(COALESCE(s.project_task_type, '')) = 'project' OR s.project_id IS NOT NULL ) ORDER BY s.deadline ASC NULLS LAST, s.created_at DESC LIMIT %s """, (limit,), ) or [] fallback: list[Dict[str, Any]] = [] for row in case_rows: item = dict(row) item.update(MissionService._compute_project_risk(item)) fallback.append(item) return fallback @staticmethod def get_projects(limit: int = 120) -> list[Dict[str, Any]]: if not MissionService._table_exists("mission_projects"): return MissionService._get_projects_from_cases(limit) rows = execute_query( """ SELECT p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at, COUNT(DISTINCT m.id) FILTER ( WHERE m.status NOT IN ('completed', 'cancelled') ) AS active_milestones, COUNT(DISTINCT m.id) FILTER ( WHERE m.target_date IS NOT NULL AND m.target_date < CURRENT_DATE AND m.status NOT IN ('completed', 'cancelled') ) AS overdue_milestones, COUNT(DISTINCT b.id) FILTER ( WHERE b.status NOT IN ('resolved', 'cancelled') ) AS open_blockers, COUNT(DISTINCT s.id) FILTER ( WHERE s.id IS NOT NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS open_tasks, COUNT(DISTINCT s.id) FILTER ( WHERE s.id IS NOT NULL AND s.deadline IS NOT NULL AND s.deadline::date = CURRENT_DATE AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS due_today_tasks, COUNT(DISTINCT s.id) FILTER ( WHERE s.id IS NOT NULL AND s.deadline IS NOT NULL AND s.deadline::date < CURRENT_DATE AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') ) AS overdue_tasks FROM mission_projects p LEFT JOIN mission_project_milestones m ON m.project_id = p.id LEFT JOIN mission_project_blockers b ON b.project_id = p.id LEFT JOIN sag_sager s ON s.project_id = p.id AND s.deleted_at IS NULL GROUP BY p.id, p.name, p.description, p.status, p.score, p.started_at, p.ended_at, p.updated_at ORDER BY p.updated_at DESC, p.id DESC LIMIT %s """, (limit,), ) or [] result: list[Dict[str, Any]] = [] for row in rows: item = dict(row) item.update(MissionService._compute_project_risk(item)) result.append(item) # Important fallback: migration may have created mission_projects but with zero rows. if not result: return MissionService._get_projects_from_cases(limit) return result @staticmethod def get_project_detail(project_id: int) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_projects"): return None rows = MissionService.get_projects(limit=200) project = next((item for item in rows if int(item.get("id") or 0) == int(project_id)), None) if not project: return None milestones = execute_query( """ SELECT id, project_id, title, description, status, target_date, created_at, updated_at FROM mission_project_milestones WHERE project_id = %s ORDER BY target_date ASC NULLS LAST, id DESC """, (project_id,), ) or [] blockers = execute_query( """ SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at FROM mission_project_blockers WHERE project_id = %s ORDER BY CASE severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, id DESC """, (project_id,), ) or [] tasks = execute_query( """ SELECT s.id, s.titel, s.status, s.priority, s.deadline, s.ansvarlig_bruger_id, s.project_milestone_id, s.is_project_blocker, s.project_task_type, s.created_at, COALESCE(ts.open_todo_count, 0) AS open_todo_count, COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles FROM sag_sager s LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER ( WHERE t.deleted_at IS NULL AND COALESCE(t.is_done, FALSE) = FALSE ) AS open_todo_count, ARRAY_REMOVE( ARRAY_AGG( CASE WHEN t.deleted_at IS NULL AND COALESCE(t.is_done, FALSE) = FALSE THEN t.title END ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC ), NULL ) AS open_todo_titles FROM sag_todo_steps t WHERE t.sag_id = s.id ) ts ON TRUE WHERE s.deleted_at IS NULL AND s.project_id = %s ORDER BY s.deadline ASC NULLS LAST, s.created_at DESC LIMIT 200 """, (project_id,), ) or [] # Fallback for case-backed projects: fetch directly related/under cases from relation table. # This is used when a project is a case of type project/projekt and tasks are linked as case relations. if not tasks and MissionService._table_exists("sag_relationer"): tasks = execute_query( """ WITH related AS ( SELECT sr.målsag_id AS task_id, sr.relationstype AS relation_type FROM sag_relationer sr WHERE sr.deleted_at IS NULL AND sr.kilde_sag_id = %s UNION ALL SELECT sr.kilde_sag_id AS task_id, sr.relationstype AS relation_type FROM sag_relationer sr WHERE sr.deleted_at IS NULL AND sr.målsag_id = %s ) SELECT s.id, s.titel, s.status, s.priority, s.deadline, s.ansvarlig_bruger_id, s.project_milestone_id, s.is_project_blocker, COALESCE(NULLIF(TRIM(s.project_task_type), ''), r.relation_type) AS project_task_type, s.created_at, COALESCE(ts.open_todo_count, 0) AS open_todo_count, COALESCE(ts.open_todo_titles, ARRAY[]::text[]) AS open_todo_titles FROM related r JOIN sag_sager s ON s.id = r.task_id LEFT JOIN LATERAL ( SELECT COUNT(*) FILTER ( WHERE t.deleted_at IS NULL AND COALESCE(t.is_done, FALSE) = FALSE ) AS open_todo_count, ARRAY_REMOVE( ARRAY_AGG( CASE WHEN t.deleted_at IS NULL AND COALESCE(t.is_done, FALSE) = FALSE THEN t.title END ORDER BY COALESCE(t.due_date, DATE '9999-12-31') ASC, t.id ASC ), NULL ) AS open_todo_titles FROM sag_todo_steps t WHERE t.sag_id = s.id ) ts ON TRUE WHERE s.deleted_at IS NULL AND s.id <> %s ORDER BY s.deadline ASC NULLS LAST, s.created_at DESC LIMIT 200 """, (project_id, project_id, project_id), ) or [] return { **project, "milestones": [dict(row) for row in milestones], "blockers": [dict(row) for row in blockers], "tasks": [dict(row) for row in tasks], } @staticmethod def get_projects_state_payload(limit: int = 120) -> Dict[str, Any]: projects = MissionService.get_projects(limit) workload = MissionService.get_project_workload(limit) details: Dict[str, Any] = {} for project in projects: project_id = int(project.get("id") or 0) if project_id <= 0: continue detail = MissionService.get_project_detail(project_id) if detail: details[str(project_id)] = detail total = len(projects) active = len([p for p in projects if str(p.get("status") or "").lower() == "active"]) high_risk = len([p for p in projects if p.get("risk_level") in {"high", "critical"}]) blocked = len([p for p in projects if int(p.get("open_blockers") or 0) > 0]) due_today = sum(int(p.get("due_today_tasks") or 0) for p in projects) return { "summary": { "total": total, "active": active, "high_risk": high_risk, "blocked": blocked, "due_today": due_today, }, "projects": projects, "workload": workload, "details": details, } @staticmethod def create_project(payload: Dict[str, Any], actor_user_id: Optional[int] = None) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_projects"): return None name = str(payload.get("name") or "").strip() if not name: return None description = str(payload.get("description") or "").strip() or None status = str(payload.get("status") or "planned").strip().lower() if status not in {"planned", "active", "paused", "completed", "cancelled"}: status = "planned" try: score = int(payload.get("score") or 0) except (TypeError, ValueError): score = 0 started_at = payload.get("started_at") ended_at = payload.get("ended_at") rows = execute_query( """ INSERT INTO mission_projects ( name, description, status, score, started_at, ended_at, created_by, payload, updated_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s::jsonb, NOW()) RETURNING id """, ( name, description, status, score, started_at, ended_at, actor_user_id, json.dumps(payload.get("payload") or {}), ), ) or [] if not rows: return None return MissionService.get_project_detail(int(rows[0].get("id"))) @staticmethod def update_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_projects"): return None updates: list[str] = [] values: list[Any] = [] if "name" in payload: name = str(payload.get("name") or "").strip() if name: updates.append("name = %s") values.append(name) if "description" in payload: updates.append("description = %s") values.append(str(payload.get("description") or "").strip() or None) if "status" in payload: status = str(payload.get("status") or "").strip().lower() if status in {"planned", "active", "paused", "completed", "cancelled"}: updates.append("status = %s") values.append(status) if "score" in payload: try: updates.append("score = %s") values.append(int(payload.get("score") or 0)) except (TypeError, ValueError): pass if "started_at" in payload: updates.append("started_at = %s") values.append(payload.get("started_at")) if "ended_at" in payload: updates.append("ended_at = %s") values.append(payload.get("ended_at")) if not updates: return MissionService.get_project_detail(project_id) updates.append("updated_at = NOW()") values.append(project_id) execute_query( f""" UPDATE mission_projects SET {', '.join(updates)} WHERE id = %s """, tuple(values), ) return MissionService.get_project_detail(project_id) @staticmethod def add_project_milestone(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_project_milestones"): return None title = str(payload.get("title") or "").strip() if not title: return None description = str(payload.get("description") or "").strip() or None status = str(payload.get("status") or "active").strip().lower() if status not in {"active", "completed", "cancelled"}: status = "active" target_date = payload.get("target_date") rows = execute_query( """ INSERT INTO mission_project_milestones (project_id, title, description, status, target_date, updated_at) VALUES (%s, %s, %s, %s, %s, NOW()) RETURNING id, project_id, title, description, status, target_date, created_at, updated_at """, (project_id, title, description, status, target_date), ) or [] return dict(rows[0]) if rows else None @staticmethod def update_project_milestone(project_id: int, milestone_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_project_milestones"): return None updates: list[str] = [] values: list[Any] = [] if "title" in payload: title = str(payload.get("title") or "").strip() if title: updates.append("title = %s") values.append(title) if "description" in payload: updates.append("description = %s") values.append(str(payload.get("description") or "").strip() or None) if "status" in payload: status = str(payload.get("status") or "").strip().lower() if status in {"active", "completed", "cancelled"}: updates.append("status = %s") values.append(status) if "target_date" in payload: updates.append("target_date = %s") values.append(payload.get("target_date")) if not updates: return execute_query_single( """ SELECT id, project_id, title, description, status, target_date, created_at, updated_at FROM mission_project_milestones WHERE id = %s AND project_id = %s """, (milestone_id, project_id), ) updates.append("updated_at = NOW()") values.extend([milestone_id, project_id]) rows = execute_query( f""" UPDATE mission_project_milestones SET {', '.join(updates)} WHERE id = %s AND project_id = %s RETURNING id, project_id, title, description, status, target_date, created_at, updated_at """, tuple(values), ) or [] return dict(rows[0]) if rows else None @staticmethod def add_project_blocker(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_project_blockers"): return None title = str(payload.get("title") or "").strip() if not title: return None description = str(payload.get("description") or "").strip() or None status = str(payload.get("status") or "open").strip().lower() if status not in {"open", "in_progress", "resolved", "cancelled"}: status = "open" severity = str(payload.get("severity") or "medium").strip().lower() if severity not in {"low", "medium", "high", "critical"}: severity = "medium" resolved_at = payload.get("resolved_at") rows = execute_query( """ INSERT INTO mission_project_blockers (project_id, title, description, status, severity, resolved_at, updated_at) VALUES (%s, %s, %s, %s, %s, %s, NOW()) RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at """, (project_id, title, description, status, severity, resolved_at), ) or [] return dict(rows[0]) if rows else None @staticmethod def update_project_blocker(project_id: int, blocker_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_project_blockers"): return None updates: list[str] = [] values: list[Any] = [] if "title" in payload: title = str(payload.get("title") or "").strip() if title: updates.append("title = %s") values.append(title) if "description" in payload: updates.append("description = %s") values.append(str(payload.get("description") or "").strip() or None) if "status" in payload: status = str(payload.get("status") or "").strip().lower() if status in {"open", "in_progress", "resolved", "cancelled"}: updates.append("status = %s") values.append(status) if status == "resolved" and "resolved_at" not in payload: updates.append("resolved_at = NOW()") if "severity" in payload: severity = str(payload.get("severity") or "").strip().lower() if severity in {"low", "medium", "high", "critical"}: updates.append("severity = %s") values.append(severity) if "resolved_at" in payload: updates.append("resolved_at = %s") values.append(payload.get("resolved_at")) if not updates: return execute_query_single( """ SELECT id, project_id, title, description, status, severity, resolved_at, created_at, updated_at FROM mission_project_blockers WHERE id = %s AND project_id = %s """, (blocker_id, project_id), ) updates.append("updated_at = NOW()") values.extend([blocker_id, project_id]) rows = execute_query( f""" UPDATE mission_project_blockers SET {', '.join(updates)} WHERE id = %s AND project_id = %s RETURNING id, project_id, title, description, status, severity, resolved_at, created_at, updated_at """, tuple(values), ) or [] return dict(rows[0]) if rows else None @staticmethod def link_case_to_project(project_id: int, payload: Dict[str, Any]) -> Optional[Dict[str, Any]]: if not MissionService._table_exists("mission_projects") or not MissionService._table_exists("sag_sager"): return None sag_id = payload.get("sag_id") if sag_id is None: return None project_exists = execute_query_single("SELECT id FROM mission_projects WHERE id = %s", (project_id,)) if not project_exists: return None milestone_id = payload.get("project_milestone_id") is_project_blocker = bool(payload.get("is_project_blocker") or False) project_task_type = str(payload.get("project_task_type") or "").strip() or None rows = execute_query( """ UPDATE sag_sager SET project_id = %s, project_milestone_id = %s, is_project_blocker = %s, project_task_type = %s WHERE id = %s AND deleted_at IS NULL RETURNING id, titel, status, project_id, project_milestone_id, is_project_blocker, project_task_type """, (project_id, milestone_id, is_project_blocker, project_task_type, sag_id), ) or [] return dict(rows[0]) if rows else None @staticmethod def get_assignment_users(limit: int = 300) -> list[Dict[str, Any]]: rows = execute_query( """ SELECT u.user_id, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', u.user_id::text)) AS display_name FROM users u WHERE COALESCE(u.is_active, TRUE) = TRUE ORDER BY display_name ASC LIMIT %s """, (limit,), ) or [] return [ { "user_id": int(row.get("user_id") or 0), "display_name": row.get("display_name") or "Ukendt", } for row in rows if row.get("user_id") is not None ] @staticmethod def get_assignment_groups(limit: int = 200) -> list[Dict[str, Any]]: if not MissionService._table_exists("groups"): return [] rows = execute_query( """ SELECT id, COALESCE(NULLIF(TRIM(name), ''), CONCAT('Gruppe #', id::text)) AS name FROM groups ORDER BY name ASC LIMIT %s """, (limit,), ) or [] return [ { "id": int(row.get("id") or 0), "name": row.get("name") or "Ukendt gruppe", } for row in rows if row.get("id") is not None ] @staticmethod def get_day_unassigned_cases(limit: int = 120) -> list[Dict[str, Any]]: if not MissionService._table_exists("sag_sager"): return [] rows = execute_query( """ SELECT s.id, s.titel, s.beskrivelse, s.status, s.priority, s.start_date, s.deadline, s.created_at, s.ansvarlig_bruger_id, s.assigned_group_id, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS ansvarlig_navn, COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS assigned_group_name, COALESCE(c.name, 'Ukendt kunde') AS customer_name FROM sag_sager s LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN groups g ON g.id = s.assigned_group_id WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('afsluttet', 'lukket', 'closed') AND (s.ansvarlig_bruger_id IS NULL OR s.assigned_group_id IS NULL) ORDER BY CASE WHEN s.ansvarlig_bruger_id IS NULL AND s.assigned_group_id IS NULL THEN 0 ELSE 1 END ASC, CASE LOWER(COALESCE(s.priority::text, '')) WHEN 'kritisk' THEN 5 WHEN 'critical' THEN 5 WHEN 'høj' THEN 4 WHEN 'hoj' THEN 4 WHEN 'high' THEN 4 WHEN 'urgent' 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 ASC LIMIT %s """, (limit,), ) or [] return [dict(row) for row in rows] @staticmethod def get_day_agent_workloads(limit_agents: int = 60, limit_cases_per_agent: int = 20) -> list[Dict[str, Any]]: if not MissionService._table_exists("sag_sager"): return [] rows = execute_query( """ WITH active_cases AS ( SELECT s.id, s.titel, s.status, s.priority, s.start_date, s.deadline, s.created_at, COALESCE(c.name, 'Ukendt kunde') AS customer_name, s.ansvarlig_bruger_id, s.assigned_group_id, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), CONCAT('Bruger #', s.ansvarlig_bruger_id::text)) AS assignee_name, COALESCE(NULLIF(TRIM(g.name), ''), CONCAT('Gruppe #', s.assigned_group_id::text)) AS group_name FROM sag_sager s LEFT JOIN customers c ON c.id = s.customer_id LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN groups g ON g.id = s.assigned_group_id WHERE s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) <> 'afsluttet' AND ( (s.start_date IS NOT NULL AND s.start_date::date <= CURRENT_DATE) OR (s.deadline IS NOT NULL AND s.deadline::date <= CURRENT_DATE) ) ), grouped AS ( SELECT COALESCE( CASE WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text) WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text) ELSE 'unassigned' END, 'unassigned' ) AS assignee_key, COALESCE(assignee_name, group_name, 'Ufordelt') AS assignee_name, COUNT(*) AS total_cases, COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date < CURRENT_DATE) AS overdue_cases, COUNT(*) FILTER (WHERE deadline IS NOT NULL AND deadline::date = CURRENT_DATE) AS due_today_cases, COUNT(*) FILTER (WHERE start_date IS NOT NULL AND start_date::date <= CURRENT_DATE) AS started_cases, JSONB_AGG( JSONB_BUILD_OBJECT( 'id', id, 'titel', titel, 'status', status, 'priority', priority, 'customer_name', customer_name, 'start_date', start_date, 'deadline', deadline, 'created_at', created_at ) ORDER BY CASE WHEN deadline IS NOT NULL AND deadline::date < CURRENT_DATE THEN 0 WHEN deadline IS NOT NULL AND deadline::date = CURRENT_DATE THEN 1 ELSE 2 END, deadline ASC NULLS LAST, created_at ASC ) AS case_list FROM active_cases GROUP BY COALESCE( CASE WHEN ansvarlig_bruger_id IS NOT NULL THEN CONCAT('user:', ansvarlig_bruger_id::text) WHEN assigned_group_id IS NOT NULL THEN CONCAT('group:', assigned_group_id::text) ELSE 'unassigned' END, 'unassigned' ), COALESCE(assignee_name, group_name, 'Ufordelt') ) SELECT assignee_key, assignee_name, total_cases, overdue_cases, due_today_cases, started_cases, CASE WHEN case_list IS NULL THEN '[]'::jsonb ELSE case_list END AS case_list FROM grouped ORDER BY overdue_cases DESC, due_today_cases DESC, total_cases DESC, assignee_name ASC LIMIT %s """, (limit_agents,), ) or [] result: list[Dict[str, Any]] = [] for row in rows: case_list = row.get("case_list") if isinstance(case_list, list): trimmed_cases = case_list[: max(1, int(limit_cases_per_agent))] else: trimmed_cases = [] result.append( { "assignee_key": row.get("assignee_key") or "unassigned", "assignee_name": row.get("assignee_name") or "Ufordelt", "total_cases": int(row.get("total_cases") or 0), "overdue_cases": int(row.get("overdue_cases") or 0), "due_today_cases": int(row.get("due_today_cases") or 0), "started_cases": int(row.get("started_cases") or 0), "case_list": trimmed_cases, } ) 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), []), "projects": MissionService._safe("projects", lambda: MissionService.get_projects_state_payload(120), {"summary": {}, "projects": [], "workload": []}), "day_unassigned_cases": MissionService._safe("day_unassigned_cases", lambda: MissionService.get_day_unassigned_cases(120), []), "day_agent_workloads": MissionService._safe("day_agent_workloads", lambda: MissionService.get_day_agent_workloads(60, 20), []), "assignment_users": MissionService._safe("assignment_users", lambda: MissionService.get_assignment_users(300), []), "assignment_groups": MissionService._safe("assignment_groups", lambda: MissionService.get_assignment_groups(200), []), "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, ), }, }