bmc_hub/app/dashboard/backend/mission_service.py

1362 lines
53 KiB
Python
Raw Normal View History

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,
),
},
}