bmc_hub/app/modules/bottom_bar/backend/service.py
Christian 3452472ba9 Add migrations for recent cases, time tracking pause/resume, and user notes
- Created `sag_recent_cases` table to persist recently opened cases per user for quick access in the bottom bar.
- Added pause/resume support in `tmodule_times` by introducing `paused_at` and `pause_total_seconds` columns.
- Established `user_notes` table for personal user notes with indexing for active and updated notes, along with a trigger to update the `updated_at` timestamp on modifications.

Co-authored-by: Copilot <copilot@github.com>
2026-04-24 11:28:12 +02:00

971 lines
32 KiB
Python

import logging
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
from app.core.database import execute_query, execute_query_single
logger = logging.getLogger(__name__)
CLOSED_CASE_STATUSES = ("lukket", "løst", "closed", "resolved")
URGENT_PRIORITIES = ("urgent", "high", "kritisk", "critical")
def _safe_count(row: Optional[dict], key: str = "count") -> int:
if not row:
return 0
try:
return int(row.get(key) or 0)
except (TypeError, ValueError):
return 0
def _format_elapsed(seconds: int) -> str:
total = max(0, int(seconds or 0))
hours = total // 3600
minutes = (total % 3600) // 60
secs = total % 60
return f"{hours:02d}:{minutes:02d}:{secs:02d}"
def _priority_rank(priority: str) -> int:
normalized = str(priority or "").strip().lower()
if normalized in {"urgent", "critical", "kritisk"}:
return 3
if normalized in {"high", "høj"}:
return 2
if normalized in {"normal", "medium", "middel"}:
return 1
return 0
def _table_exists(table_name: str) -> bool:
row = execute_query_single(
"""
SELECT EXISTS(
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = %s
) AS exists
""",
(table_name,),
)
return bool((row or {}).get("exists"))
def _table_columns(table_name: str) -> List[str]:
rows = execute_query(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_schema = 'public' AND table_name = %s
""",
(table_name,),
) or []
return [str(r.get("column_name") or "").strip().lower() for r in rows if r.get("column_name")]
def _get_user_group_names(user_id: Optional[int]) -> List[str]:
if user_id is None:
return []
rows = execute_query(
"""
SELECT LOWER(g.name) AS name
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = %s
""",
(user_id,),
) or []
return [str(r.get("name") or "").strip() for r in rows if r.get("name")]
def _can_view_boss_tab(user_id: Optional[int]) -> bool:
if user_id is None:
return False
group_names = _get_user_group_names(user_id)
if not group_names:
# Fail-open for authenticated users if group mapping is missing.
return True
leadership_tokens = (
"admin",
"manager",
"leder",
"chef",
"teknik",
"technician",
"support",
"drift",
"it",
)
return any(
any(token in group for token in leadership_tokens)
for group in group_names
)
def is_bottom_bar_enabled(user_id: Optional[int]) -> bool:
setting = execute_query_single("SELECT value FROM settings WHERE key = %s", ("bottom_bar_enabled",))
setting_value = str((setting or {}).get("value") or "").strip().lower()
if setting_value not in {"1", "true", "yes", "on"}:
return False
if user_id is None:
return True
pref = execute_query_single(
"""
SELECT enabled
FROM user_module_preferences
WHERE user_id = %s AND module_name = %s
LIMIT 1
""",
(user_id, "bottom_bar"),
)
if pref and pref.get("enabled") is not None:
return bool(pref.get("enabled"))
role = execute_query_single(
"""
SELECT mrs.enabled
FROM module_role_settings mrs
JOIN user_groups ug ON ug.group_id = mrs.group_id
WHERE ug.user_id = %s
AND mrs.module_name = %s
ORDER BY mrs.enabled DESC
LIMIT 1
""",
(user_id, "bottom_bar"),
)
if role and role.get("enabled") is not None:
return bool(role.get("enabled"))
return True
def get_dashboard_status() -> Dict[str, int]:
mails_unread = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM email_messages
WHERE deleted_at IS NULL
AND COALESCE(is_read, FALSE) = FALSE
"""
)
)
sager_open = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
"""
)
)
sager_urgent = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
"""
)
)
sager_unassigned = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND ansvarlig_bruger_id IS NULL
"""
)
)
return {
"mails_unread": mails_unread,
"sager_open": sager_open,
"sager_urgent": sager_urgent,
"sager_unassigned": sager_unassigned,
}
def get_active_timer(user_id: Optional[int]) -> Dict[str, Any]:
if user_id is None:
return {
"active": False,
"sag_id": None,
"sag_navn": None,
"start_tid": None,
"elapsed": 0,
"elapsed_hhmmss": "00:00:00",
"time_entry_id": None,
}
timer = execute_query_single(
"""
SELECT
t.id,
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
GREATEST(EXTRACT(EPOCH FROM (NOW() - t.start_tid))::int, 0) AS elapsed
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = TRUE
AND t.slut_tid IS NULL
ORDER BY t.start_tid DESC NULLS LAST, t.id DESC
LIMIT 1
""",
(user_id,),
)
if not timer:
return {
"active": False,
"sag_id": None,
"sag_navn": None,
"start_tid": None,
"elapsed": 0,
"elapsed_hhmmss": "00:00:00",
"time_entry_id": None,
}
elapsed = int(timer.get("elapsed") or 0)
return {
"active": True,
"sag_id": timer.get("sag_id"),
"sag_navn": timer.get("sag_navn"),
"start_tid": timer.get("start_tid"),
"elapsed": elapsed,
"elapsed_hhmmss": _format_elapsed(elapsed),
"time_entry_id": timer.get("id"),
}
def get_own_timer_snapshot(user_id: Optional[int], paused_limit: int = 10) -> Dict[str, Any]:
active = get_active_timer(user_id)
if user_id is None:
return {
"active": active,
"paused": [],
"counts": {"active": 0, "paused": 0, "total": 0},
}
paused_limit_safe = max(1, min(int(paused_limit or 10), 25))
paused_rows = execute_query(
"""
SELECT
t.id,
t.sag_id,
s.titel AS sag_navn,
t.start_tid,
t.slut_tid,
GREATEST(
EXTRACT(EPOCH FROM (NOW() - COALESCE(t.start_tid, NOW())))::int,
0
) AS elapsed_seconds,
COALESCE(t.pause_total_seconds, 0)::int AS pause_total_seconds
FROM tmodule_times t
LEFT JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = FALSE
AND t.paused_at IS NOT NULL
AND t.slut_tid IS NULL
ORDER BY COALESCE(t.paused_at, t.updated_at, t.created_at) DESC, t.id DESC
LIMIT %s
""",
(user_id, paused_limit_safe),
) or []
paused_count_row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM tmodule_times t
WHERE t.medarbejder_id = %s
AND t.aktiv_timer = FALSE
AND t.paused_at IS NOT NULL
AND t.slut_tid IS NULL
""",
(user_id,),
)
paused_count = _safe_count(paused_count_row)
active_count = 1 if active.get("active") else 0
return {
"active": active,
"paused": [
{
"time_entry_id": row.get("id"),
"sag_id": row.get("sag_id"),
"sag_navn": row.get("sag_navn") or f"Sag #{row.get('sag_id')}",
"start_tid": row.get("start_tid"),
"slut_tid": row.get("slut_tid"),
"faktisk_tid_min": 0,
"elapsed_hhmmss": _format_elapsed(
max(0, int(row.get("elapsed_seconds") or 0) - int(row.get("pause_total_seconds") or 0))
),
}
for row in paused_rows
],
"counts": {
"active": active_count,
"paused": paused_count,
"total": active_count + paused_count,
},
}
def get_unassigned_open_cases(limit: int = 25) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 25), 100))
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.created_at,
s.updated_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
(limit_safe,),
) or []
count_row = execute_query_single(
"""
SELECT COUNT(*)::int AS count
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND s.ansvarlig_bruger_id IS NULL
"""
)
return {
"items": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in rows
],
"count": _safe_count(count_row),
"filter_meta": {
"route": "/api/v1/bottom-bar/boss/unassigned-cases",
"query": {"limit": limit_safe, "only_open": True, "only_unassigned": True},
"sql_guarantee": [
"s.deleted_at IS NULL",
"LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')",
"s.ansvarlig_bruger_id IS NULL",
],
},
}
def _get_recent_cases(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
limit_safe = max(1, min(int(limit or 10), 20))
source = "direct_query"
rows: List[Dict[str, Any]] = []
if _table_exists("sag_recent_cases"):
columns = set(_table_columns("sag_recent_cases"))
has_required = {"sag_id", "user_id"}.issubset(columns)
if has_required:
order_column = "viewed_at" if "viewed_at" in columns else "opened_at" if "opened_at" in columns else "updated_at" if "updated_at" in columns else "created_at"
if order_column:
source = "sag_recent_cases"
rows = execute_query(
f"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
rc.{order_column} AS recent_at
FROM sag_recent_cases rc
JOIN sag_sager s ON s.id = rc.sag_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND rc.user_id = %s
ORDER BY rc.{order_column} DESC, s.id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
if not rows and user_id is not None:
source = "direct_query_user_timers"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
MAX(COALESCE(t.start_tid, t.updated_at, t.created_at)) AS recent_at
FROM tmodule_times t
JOIN sag_sager s ON s.id = t.sag_id
WHERE t.medarbejder_id = %s
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
GROUP BY s.id, s.titel, s.priority, s.status
ORDER BY recent_at DESC, s.id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
if not rows:
source = "direct_query_global"
rows = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.status,
COALESCE(s.updated_at, s.created_at) AS recent_at
FROM sag_sager s
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY COALESCE(s.updated_at, s.created_at) DESC, s.id DESC
LIMIT %s
""",
(limit_safe,),
) or []
return {
"source": source,
"items": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"status": row.get("status"),
"recent_at": row.get("recent_at"),
}
for row in rows
],
"count": len(rows),
}
def get_notifications(user_id: Optional[int], limit: int = 20) -> Dict[str, Any]:
if user_id is None:
return {"items": [], "count": 0}
limit_safe = max(1, min(int(limit or 20), 100))
reminders = execute_query(
"""
SELECT
r.id,
r.sag_id,
r.title,
r.message,
r.priority,
r.event_type,
r.next_check_at,
s.titel AS case_title,
c.name AS customer_name
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
CASE LOWER(COALESCE(r.priority::text, 'normal'))
WHEN 'urgent' THEN 1
WHEN 'high' THEN 2
WHEN 'normal' THEN 3
ELSE 4
END,
r.next_check_at ASC
LIMIT %s
""",
(user_id, user_id, limit_safe),
) or []
unread_mail_count = _safe_count(
execute_query_single(
"""
SELECT COUNT(*) AS count
FROM email_messages em
WHERE em.deleted_at IS NULL
AND COALESCE(em.is_read, FALSE) = FALSE
"""
)
)
items: List[Dict[str, Any]] = []
if unread_mail_count > 0:
items.append(
{
"id": f"mail-unread-{unread_mail_count}",
"type": "mail",
"severity": "medium" if unread_mail_count < 10 else "high",
"title": f"{unread_mail_count} ulæste mails",
"message": "Der er ulæste mails i indbakken",
"action": "/emails",
"created_at": datetime.now(timezone.utc).isoformat(),
}
)
for row in reminders:
priority = str(row.get("priority") or "normal").lower()
severity = "low"
if priority in {"high", "høj"}:
severity = "medium"
if priority in {"urgent", "critical", "kritisk"}:
severity = "high"
items.append(
{
"id": f"reminder-{row.get('id')}",
"type": row.get("event_type") or "reminder",
"severity": severity,
"title": row.get("title") or "Påmindelse",
"message": row.get("message") or row.get("case_title") or "",
"sag_id": row.get("sag_id"),
"case_title": row.get("case_title"),
"customer_name": row.get("customer_name"),
"action": f"/sag/{row.get('sag_id')}" if row.get("sag_id") else "/sag",
"created_at": row.get("next_check_at"),
}
)
items.sort(
key=lambda item: (
{"high": 0, "medium": 1, "low": 2}.get(str(item.get("severity") or "low"), 3),
str(item.get("created_at") or ""),
)
)
return {"items": items[:limit_safe], "count": len(items)}
def _context_actions_for_path(context_path: str) -> Dict[str, Any]:
normalized = str(context_path or "").strip().lower()
payload: Dict[str, Any] = {
"context_key": "global",
"global": [
{"id": "new_case", "label": "Ny sag", "action": "/sag"},
{"id": "new_mail", "label": "Ny mail", "action": "/emails"},
{"id": "start_timer", "label": "Start timer", "action": "/timetracking"},
{"id": "log_time", "label": "Log tid", "action": "/timetracking"},
{"id": "add_note", "label": "Tilføj note", "action": "/sag"},
],
"context": [],
}
if normalized.startswith("/sag"):
payload["context_key"] = "sag"
payload["context"] = [
{"id": "case_time", "label": "Tid", "action": "/timetracking"},
{"id": "case_mail", "label": "Mail", "action": "/emails"},
{"id": "case_relation", "label": "Relation", "action": "/customers"},
{"id": "case_tag", "label": "Tag", "action": "/tags"},
]
elif normalized.startswith("/hardware"):
payload["context_key"] = "hardware"
payload["context"] = [
{"id": "hardware_new", "label": "Ny enhed", "action": "/hardware"},
{"id": "hardware_history", "label": "Historik", "action": "/hardware"},
{"id": "hardware_link_case", "label": "Tilknyt sag", "action": "/sag"},
]
return payload
def get_user_notes_summary(user_id: Optional[int], limit: int = 10) -> Dict[str, Any]:
if user_id is None:
return {"count": 0, "list": []}
limit_safe = max(1, min(int(limit or 10), 50))
rows = execute_query(
"""
SELECT
id,
title,
content,
is_pinned,
is_archived,
created_at,
updated_at
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND is_archived = FALSE
ORDER BY is_pinned DESC, updated_at DESC, id DESC
LIMIT %s
""",
(user_id, limit_safe),
) or []
total_row = execute_query_single(
"""
SELECT COUNT(*) AS count
FROM user_notes
WHERE user_id = %s
AND deleted_at IS NULL
AND is_archived = FALSE
""",
(user_id,),
)
return {
"count": _safe_count(total_row),
"list": [
{
"id": row.get("id"),
"title": row.get("title") or "",
"content": row.get("content") or "",
"is_pinned": bool(row.get("is_pinned")),
"is_archived": bool(row.get("is_archived")),
"created_at": row.get("created_at"),
"updated_at": row.get("updated_at"),
}
for row in rows
],
}
def build_bottom_bar_state(
user_id: Optional[int],
context_path: str = "",
force_boss_access: bool = False,
) -> Dict[str, Any]:
enabled = is_bottom_bar_enabled(user_id)
if not enabled:
return {"enabled": False, "sections": {}}
status = get_dashboard_status()
timer = get_active_timer(user_id)
own_timers = get_own_timer_snapshot(user_id, paused_limit=10)
notifications = get_notifications(user_id, limit=10)
unassigned_open_cases = get_unassigned_open_cases(limit=8)
recent_cases = _get_recent_cases(user_id, limit=10)
notes_summary = get_user_notes_summary(user_id, limit=10)
urgent_cases = execute_query(
"""
SELECT id, titel
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
) or []
open_cases = execute_query(
"""
SELECT id, titel
FROM sag_sager
WHERE deleted_at IS NULL
AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
ORDER BY updated_at DESC NULLS LAST, id DESC
LIMIT 5
"""
) or []
timer_list: List[Dict[str, Any]] = []
if timer.get("active"):
timer_list.append(
{
"id": timer.get("time_entry_id"),
"sag_id": timer.get("sag_id"),
"desc": timer.get("sag_navn") or f"Sag #{timer.get('sag_id')}",
"elapsed": timer.get("elapsed"),
"elapsed_hhmmss": timer.get("elapsed_hhmmss"),
}
)
messages = [
{
"from": "System",
"text": f"{notifications.get('count', 0)} aktive notifikationer",
}
]
tasks = []
for n in (notifications.get("items") or [])[:5]:
tasks.append(
{
"title": n.get("title") or "Notifikation",
"deadline": n.get("severity") or "info",
"action": n.get("action") or "/",
}
)
context_actions = _context_actions_for_path(context_path)
can_view_boss = bool(force_boss_access) or _can_view_boss_tab(user_id)
team_workload: List[Dict[str, Any]] = []
technicians_today: List[Dict[str, Any]] = []
escalation_cases: List[Dict[str, Any]] = []
unassigned_cases: List[Dict[str, Any]] = []
if can_view_boss:
team_workload = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical') THEN 1 END)::int AS urgent_cases
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
GROUP BY u.user_id, u.full_name, u.username
HAVING COUNT(s.id) > 0
ORDER BY urgent_cases DESC, open_cases DESC, owner_name ASC
LIMIT 8
"""
) or []
technicians_today = execute_query(
"""
SELECT
u.user_id,
COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name,
COUNT(s.id)::int AS open_cases,
COUNT(CASE WHEN s.deadline::date = CURRENT_DATE THEN 1 END)::int AS due_today_cases,
COALESCE(
(
SELECT json_agg(
json_build_object(
'id', t.id,
'title', t.titel,
'priority', COALESCE(t.priority::text, 'normal'),
'deadline', t.deadline
)
ORDER BY
CASE WHEN t.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
COALESCE(t.deadline, t.updated_at, t.created_at) ASC,
t.id ASC
)
FROM (
SELECT s2.id, s2.titel, s2.priority, s2.deadline, s2.updated_at, s2.created_at
FROM sag_sager s2
WHERE s2.ansvarlig_bruger_id = u.user_id
AND s2.deleted_at IS NULL
AND LOWER(COALESCE(s2.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND (
s2.deadline::date = CURRENT_DATE
OR s2.created_at::date = CURRENT_DATE
)
ORDER BY
CASE WHEN s2.deadline::date = CURRENT_DATE THEN 0 ELSE 1 END,
COALESCE(s2.deadline, s2.updated_at, s2.created_at) ASC,
s2.id ASC
LIMIT 6
) t
),
'[]'::json
) AS today_tasks
FROM users u
LEFT JOIN sag_sager s
ON s.ansvarlig_bruger_id = u.user_id
AND s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
WHERE EXISTS (
SELECT 1
FROM user_groups ug
JOIN groups g ON g.id = ug.group_id
WHERE ug.user_id = u.user_id
AND LOWER(g.name) LIKE ANY(ARRAY['%teknik%', '%technician%', '%support%'])
)
GROUP BY u.user_id, u.full_name, u.username
ORDER BY due_today_cases DESC, open_cases DESC, owner_name ASC
LIMIT 10
"""
) or []
escalation_cases = execute_query(
"""
SELECT
s.id,
s.titel,
s.priority,
s.updated_at,
EXTRACT(EPOCH FROM (NOW() - COALESCE(s.updated_at, s.created_at)))::int AS age_seconds,
COALESCE(NULLIF(u.full_name, ''), u.username, 'Ikke tildelt') AS owner_name
FROM sag_sager s
LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id
WHERE s.deleted_at IS NULL
AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved')
AND LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'high', 'kritisk', 'critical')
AND NOW() - COALESCE(s.updated_at, s.created_at) > INTERVAL '24 hours'
ORDER BY COALESCE(s.updated_at, s.created_at) ASC
LIMIT 8
"""
) or []
unassigned_cases = [
{
"id": row.get("id"),
"titel": row.get("title"),
"priority": row.get("priority"),
}
for row in (unassigned_open_cases.get("items") or [])
]
sections = {
"mail": {
"unread": status.get("mails_unread", 0),
"customer_reply_needed": status.get("mails_unread", 0),
},
"cases": {
"open": status.get("sager_open", 0),
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in open_cases],
},
"urgent": {
"count": status.get("sager_urgent", 0),
"list": [{"id": row.get("id"), "title": row.get("titel") or f"Sag #{row.get('id')}"} for row in urgent_cases],
},
"unassigned": {
"count": status.get("sager_unassigned", 0),
"list": unassigned_open_cases.get("items") or [],
"filter_meta": unassigned_open_cases.get("filter_meta") or {},
},
"timer": {
"active_count": 1 if timer.get("active") else 0,
"list": timer_list,
"active": timer,
"own": own_timers,
"switch_case_hooks": {
"fetch_own_active_paused_timers": {
"route": "/api/v1/bottom-bar/timers/own",
"method": "GET",
"query": {"paused_limit": 10},
},
"switch_case_start_timer": {
"route": "/api/v1/timetracking/time/start",
"method": "POST",
"payload": {
"sag_id": "required:int",
"medarbejder_id": "optional:int",
"beskrivelse": "optional:string",
},
},
},
},
"kuma": {
"down": 0,
"list": [],
},
"eset": {
"incidents": 0,
"list": [],
},
"messages": {
"count": len(messages),
"list": messages,
},
"tasks": {
"count": len(tasks),
"list": tasks,
},
"recent_cases": recent_cases,
"notes": notes_summary,
"boss": {
"can_view": can_view_boss,
"stats": {
"unassigned": status.get("sager_unassigned", 0),
"active_employees": _safe_count(
execute_query_single(
"SELECT COUNT(*) AS count FROM tmodule_times WHERE aktiv_timer = TRUE AND slut_tid IS NULL"
)
),
"open_cases": status.get("sager_open", 0),
"urgent_cases": status.get("sager_urgent", 0),
"stale_urgent_cases": len(escalation_cases),
}
,
"team_workload": [
{
"user_id": row.get("user_id"),
"owner_name": row.get("owner_name"),
"open_cases": int(row.get("open_cases") or 0),
"urgent_cases": int(row.get("urgent_cases") or 0),
}
for row in team_workload
],
"technicians_today": [
{
"user_id": row.get("user_id"),
"owner_name": row.get("owner_name"),
"open_cases": int(row.get("open_cases") or 0),
"due_today_cases": int(row.get("due_today_cases") or 0),
"today_tasks": row.get("today_tasks") or [],
}
for row in technicians_today
],
"escalations": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
"owner_name": row.get("owner_name") or "Ikke tildelt",
"age_seconds": int(row.get("age_seconds") or 0),
}
for row in escalation_cases
],
"unassigned_cases": [
{
"id": row.get("id"),
"title": row.get("titel") or f"Sag #{row.get('id')}",
"priority": row.get("priority") or "normal",
}
for row in unassigned_cases
],
},
"context_actions": context_actions,
}
return {
"enabled": True,
"sections": sections,
"status": status,
"active_timer": timer,
"own_timers": own_timers,
"recent_cases": recent_cases,
"notifications": notifications,
}