import logging
import os
import shutil
import json
import re
import hashlib
import base64
import html
import math
from pathlib import Path
from datetime import datetime, timedelta, timezone
from typing import Any, List, Optional, Dict
from uuid import uuid4
from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request, Form, Response, Body, Depends
from fastapi.responses import FileResponse, HTMLResponse
from pydantic import BaseModel, Field
from app.core.database import execute_query, execute_query_single, execute_update, table_has_column, get_db_connection, release_db_connection
from psycopg2.extras import RealDictCursor
from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis
from app.core.config import settings
from app.core.auth_dependencies import get_current_user, require_any_permission
from app.services.email_service import EmailService
from app.services.case_analysis_service import CaseAnalysisService
from app.services.ollama_service import ollama_service
from app.services.brother_label_print_service import BrotherLabelPrintService, LabelJob
try:
import extract_msg
except ImportError:
extract_msg = None
import email
from email.header import decode_header
logger = logging.getLogger(__name__)
case_read_access = require_any_permission("cases.view", "tickets.view")
case_create_access = require_any_permission("cases.create", "tickets.create")
case_edit_access = require_any_permission("cases.edit", "tickets.edit")
case_delete_access = require_any_permission("cases.delete", "tickets.delete")
async def case_route_access(request: Request, current_user: dict = Depends(get_current_user)) -> dict:
"""Apply read/create/edit/delete access consistently to every case route."""
if current_user.get("is_superadmin"):
return current_user
method = request.method.upper()
normalized_path = request.url.path.rstrip("/")
if method in {"GET", "HEAD", "OPTIONS"}:
required = {"cases.view", "tickets.view"}
elif method == "POST" and normalized_path == "/api/v1/sag":
required = {"cases.create", "tickets.create"}
elif method == "DELETE" and re.fullmatch(r"/api/v1/sag/\d+", normalized_path):
required = {"cases.delete", "tickets.delete"}
else:
required = {"cases.edit", "tickets.edit"}
available = set(current_user.get("permissions") or [])
if available.intersection(required):
return current_user
raise HTTPException(
status_code=403,
detail=f"Missing required permission. Need one of: {', '.join(sorted(required))}",
)
router = APIRouter(dependencies=[Depends(case_route_access)])
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"))
def _get_user_id_from_request(request: Request) -> int:
user_id = getattr(request.state, "user_id", None)
if user_id is not None:
try:
return int(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user_id format")
user_id = request.query_params.get("user_id")
if user_id:
try:
return int(user_id)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid user_id format")
raise HTTPException(status_code=401, detail="User not authenticated - provide user_id query parameter")
def _ensure_case_internet_connection_tag(sag_id: int) -> None:
"""Mark a case as internet-related without creating duplicate tags."""
existing = execute_query_single(
"""SELECT id FROM sag_tags
WHERE sag_id = %s AND deleted_at IS NULL
AND LOWER(TRIM(tag_navn)) = 'internet forbindelse'
LIMIT 1""",
(sag_id,),
)
if not existing:
execute_query(
"INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, 'internet forbindelse')",
(sag_id,),
fetch=False,
)
def _normalize_case_status(status_value: Optional[str]) -> str:
canonical = {
"ny": "Ny",
"tildelt": "Tildelt",
"igangværende": "Igangværende",
"afventer": "Afventer",
"løst": "Løst",
"kunde svaret": "Kunde svaret",
"lukket": "Lukket",
}
legacy_aliases = {
"åben": "Ny",
"open": "Ny",
"under behandling": "Igangværende",
"i_gang": "Igangværende",
"in progress": "Igangværende",
"on_hold": "Afventer",
"resolved": "Løst",
"closed": "Lukket",
"afsluttet": "Lukket",
"done": "Lukket",
}
normalized = str(status_value or "Ny").strip().lower()
if normalized in canonical:
return canonical[normalized]
if normalized in legacy_aliases:
return legacy_aliases[normalized]
raise HTTPException(status_code=400, detail="Ugyldig status. Vælg en af de faste sagsstatuser.")
def _normalize_optional_timestamp(value: Optional[str], field_name: str) -> Optional[str]:
if value is None:
return None
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
text = str(value).strip()
if not text:
return None
try:
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
if parsed.tzinfo is not None:
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed.strftime("%Y-%m-%d %H:%M:%S")
except ValueError:
raise HTTPException(status_code=400, detail=f"Invalid datetime format for {field_name}")
def _coerce_optional_int(value: Optional[object], field_name: str) -> Optional[int]:
if value is None or value == "":
return None
try:
return int(value)
except (TypeError, ValueError):
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _validate_user_id(user_id: Optional[int], field_name: str = "ansvarlig_bruger_id") -> None:
if user_id is None:
return
exists = execute_query("SELECT 1 FROM users WHERE user_id = %s", (user_id,))
if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _validate_group_id(group_id: Optional[int], field_name: str = "assigned_group_id") -> None:
if group_id is None:
return
exists = execute_query("SELECT 1 FROM groups WHERE id = %s", (group_id,))
if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _validate_customer_id(customer_id: Optional[int], field_name: str = "customer_id") -> None:
if customer_id is None:
return
exists = execute_query("SELECT 1 FROM customers WHERE id = %s", (customer_id,))
if not exists:
raise HTTPException(status_code=400, detail=f"Invalid {field_name}")
def _normalize_deferred_statuses(value: Optional[object]) -> Optional[str]:
if value is None:
return None
raw_items: List[str] = []
if isinstance(value, (list, tuple, set)):
raw_items = [str(v or "") for v in value]
else:
raw_items = re.split(r"[,;\n]", str(value or ""))
cleaned: List[str] = []
seen = set()
for item in raw_items:
token = str(item or "").strip()
if not token:
continue
key = token.lower()
if key in seen:
continue
seen.add(key)
cleaned.append(token)
if not cleaned:
return None
return ", ".join(cleaned)
def _normalize_relation_input(sag_id: int, data: dict) -> tuple[int, str]:
try:
target_id = int(data.get("målsag_id"))
except (AttributeError, TypeError, ValueError):
raise HTTPException(status_code=400, detail="målsag_id must be an integer")
raw_relation_type = str(data.get("relationstype") or "").strip()
relation_aliases = {
"relateret til": "Relateret til",
"relateret_til": "Relateret til",
"afledt af": "Afledt af",
"afledt_af": "Afledt af",
"årsag til": "Årsag til",
"årsag_til": "Årsag til",
"blokkerer": "Blokkerer",
"afhænger af": "afhænger af",
"afhænger_af": "afhænger af",
"undersag": "undersag",
"duplikat": "duplikat",
"forælder": "forælder",
"barn": "barn",
"udfører for": "udfører_for",
"udfører_for": "udfører_for",
}
relation_type = relation_aliases.get(raw_relation_type.casefold())
if not relation_type:
raise HTTPException(status_code=400, detail="Invalid relationstype")
if sag_id == target_id:
raise HTTPException(status_code=400, detail="A case cannot be related to itself")
return target_id, relation_type
def _deferred_status_matches(
deferred_until_status: Optional[str],
previous_status: Optional[str],
new_status: Optional[str],
) -> bool:
normalized_new = str(new_status or "").strip().lower()
normalized_prev = str(previous_status or "").strip().lower()
if not normalized_new:
return False
status_values = [
str(part or "").strip().lower()
for part in re.split(r"[,;\n]", str(deferred_until_status or ""))
if str(part or "").strip()
]
if not status_values:
return False
if "__any_change__" in status_values and normalized_prev and normalized_prev != normalized_new:
return True
return normalized_new in status_values
def _activate_waiting_cases_by_status(
trigger_case_id: int,
previous_status: Optional[str],
new_status: Optional[str],
) -> None:
"""Set start_date for waiting cases when their status dependency is met."""
normalized_status = str(new_status or "").strip()
if not normalized_status:
return
try:
waiting_rows = execute_query(
"""
SELECT id, deferred_until_status
FROM sag_sager
WHERE deleted_at IS NULL
AND deferred_until_case_id = %s
AND deferred_until_status IS NOT NULL
AND start_date IS NULL
""",
(trigger_case_id,),
) or []
matching_ids = [
int(row["id"])
for row in waiting_rows
if _deferred_status_matches(row.get("deferred_until_status"), previous_status, normalized_status)
]
if not matching_ids:
return
placeholders = ",".join(["%s"] * len(matching_ids))
updated_rows = execute_query(
f"""
UPDATE sag_sager
SET start_date = NOW(), updated_at = NOW()
WHERE id IN ({placeholders})
RETURNING id
""",
tuple(matching_ids),
) or []
if updated_rows:
logger.info(
"✅ Activated %s waiting case(s) from trigger case %s with status '%s'",
len(updated_rows),
trigger_case_id,
normalized_status,
)
except Exception as exc:
logger.warning(
"⚠️ Could not activate waiting cases for trigger case %s and status '%s': %s",
trigger_case_id,
normalized_status,
exc,
)
# ============================================================================
# QUICKCREATE AI ANALYSIS
# ============================================================================
class QuickCreateRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=5000)
user_id: int
class RewriteTextRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=10000)
context: Optional[str] = Field(default="case")
class RewriteTextResponse(BaseModel):
rewritten_text: str
model: Optional[str] = None
endpoint: Optional[str] = None
context: Optional[str] = None
class CaseCreateRewriteRequest(BaseModel):
title: str = Field(default="", max_length=500)
description: str = Field(..., min_length=1, max_length=10000)
class CaseCreateRewriteResponse(BaseModel):
title: str
description: str
model: Optional[str] = None
class SagSendEmailRequest(BaseModel):
to: List[str]
subject: str = Field(..., min_length=1, max_length=998)
body_text: str = Field(..., min_length=1)
cc: List[str] = Field(default_factory=list)
bcc: List[str] = Field(default_factory=list)
body_html: Optional[str] = None
attachment_file_ids: List[int] = Field(default_factory=list)
thread_email_id: Optional[int] = None
thread_key: Optional[str] = None
class SignatureCanvasRequest(BaseModel):
data_url: str = Field(..., min_length=32)
class DirectPrintOverrideRequest(BaseModel):
printer_host: Optional[str] = None
printer_port: Optional[int] = None
printer_model: Optional[str] = None
label_size: Optional[str] = None
hardware_id: Optional[int] = None
class SagBuzzwordCreateRequest(BaseModel):
buzzword: str = Field(..., min_length=1, max_length=120)
class SagBuzzwordSelectionRequest(BaseModel):
selected_text: str = Field(..., min_length=1, max_length=2000)
class SagListPreferencesUpdate(BaseModel):
type_filters: Optional[List[str]] = None
column_order: Optional[List[str]] = None
hidden_columns: Optional[List[str]] = None
class CaseAssignmentsUpdate(BaseModel):
group_id: Optional[int] = None
user_ids: List[int] = Field(default_factory=list)
primary_user_id: Optional[int] = None
class CaseAssignmentWithReason(BaseModel):
group_id: int = Field(gt=0)
primary_user_id: Optional[int] = Field(default=None, gt=0)
reason: str = Field(min_length=1, max_length=2000)
content: str = Field(min_length=1, max_length=20000)
SAG_LIST_COLUMN_KEYS = (
"id", "company", "contact", "description", "type", "priority", "status",
"owner", "group", "next_todo", "created", "start", "deferred", "deadline",
)
def _normalize_email_list(values: List[str], field_name: str) -> List[str]:
cleaned: List[str] = []
for value in values or []:
candidate = str(value or "").strip()
if not candidate:
continue
if "@" not in candidate or "." not in candidate.split("@")[-1]:
raise HTTPException(status_code=400, detail=f"Invalid email in {field_name}: {candidate}")
cleaned.append(candidate)
return list(dict.fromkeys(cleaned))
def _normalize_message_id_token(value: Optional[str]) -> Optional[str]:
if not value:
return None
normalized = str(value).strip().strip("<>").lower()
normalized = "".join(normalized.split())
return normalized or None
def _assert_sag_exists(sag_id: int) -> None:
exists = execute_query_single(
"SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
(sag_id,),
)
if not exists:
raise HTTPException(status_code=404, detail="Case not found")
def _normalize_buzzword_text(value: Optional[str]) -> str:
raw = str(value or "")
normalized = " ".join(raw.strip().lower().split())
if not normalized:
raise HTTPException(status_code=400, detail="buzzword is required")
if len(normalized) > 120:
raise HTTPException(status_code=400, detail="buzzword is too long")
return normalized
def _ensure_buzzword(normalized_word: str) -> dict:
existing = execute_query_single(
"SELECT id, word FROM buzzwords WHERE word = %s AND deleted_at IS NULL",
(normalized_word,),
)
if existing:
return existing
revived = execute_query(
"""
UPDATE buzzwords
SET deleted_at = NULL, created_at = NOW()
WHERE id = (
SELECT id FROM buzzwords
WHERE word = %s AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC NULLS LAST, id DESC
LIMIT 1
)
RETURNING id, word
""",
(normalized_word,),
)
if revived:
return revived[0]
inserted = execute_query(
"INSERT INTO buzzwords (word) VALUES (%s) RETURNING id, word",
(normalized_word,),
)
if not inserted:
raise HTTPException(status_code=500, detail="Failed to create buzzword")
return inserted[0]
def _ensure_sag_buzzword_link(sag_id: int, buzzword_id: int) -> tuple[dict, str]:
active = execute_query_single(
"""
SELECT id, sag_id, buzzword_id, created_at
FROM sag_buzzwords
WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NULL
""",
(sag_id, buzzword_id),
)
if active:
return active, "existing"
revived = execute_query(
"""
UPDATE sag_buzzwords
SET deleted_at = NULL, created_at = NOW()
WHERE id = (
SELECT id FROM sag_buzzwords
WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NOT NULL
ORDER BY deleted_at DESC NULLS LAST, id DESC
LIMIT 1
)
RETURNING id, sag_id, buzzword_id, created_at
""",
(sag_id, buzzword_id),
)
if revived:
return revived[0], "reactivated"
created = execute_query(
"""
INSERT INTO sag_buzzwords (sag_id, buzzword_id)
VALUES (%s, %s)
RETURNING id, sag_id, buzzword_id, created_at
""",
(sag_id, buzzword_id),
)
if not created:
raise HTTPException(status_code=500, detail="Failed to link buzzword")
return created[0], "created"
def _derive_thread_key_for_outbound(
payload_thread_key: Optional[str],
in_reply_to_header: Optional[str],
references_header: Optional[str],
generated_message_id: Optional[str],
) -> Optional[str]:
provided = _normalize_message_id_token(payload_thread_key)
if provided:
return provided
if references_header:
for token in re.split(r"[\s,]+", references_header.strip()):
normalized = _normalize_message_id_token(token)
if normalized:
return normalized
reply_to = _normalize_message_id_token(in_reply_to_header)
if reply_to:
return reply_to
return _normalize_message_id_token(generated_message_id)
def _generate_local_thread_key_for_new_outbound(sag_id: int) -> str:
"""Generate a stable local thread key for brand-new case emails.
This prevents fallback BMCid tags like sXXt001 that cannot be mapped back
to a concrete thread later.
"""
nonce = uuid4().hex[:10]
return f"sag{sag_id}-{int(datetime.now().timestamp())}-{nonce}@bmchub.local"
def _build_scan_token(sag_id: int, token_type: str) -> str:
if token_type == "work_order":
return f"BMCSCAN-WO-S{sag_id}-{uuid4().hex[:10].upper()}"
# Keep hardware label tokens shorter so Code39 labels stay physically compact.
return f"BMCSCAN-HW-{sag_id}-{uuid4().hex[:6].upper()}"
def _create_document_token(
sag_id: int,
token_type: str,
user_id: Optional[int] = None,
hardware_id: Optional[int] = None,
) -> str:
token = _build_scan_token(sag_id, token_type)
expires_at = datetime.now() + timedelta(days=30)
execute_query(
"""
INSERT INTO sag_document_tokens (
sag_id,
token,
token_type,
hardware_id,
created_by_user_id,
expires_at
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(sag_id, token, token_type, hardware_id, user_id, expires_at),
)
return token
def _get_setting_value(key: str, fallback: Optional[str] = None) -> Optional[str]:
row = execute_query_single("SELECT value FROM settings WHERE key = %s", (key,))
if not row:
return fallback
value = row.get("value")
if value is None:
return fallback
return str(value)
_CODE39_PATTERNS: Dict[str, str] = {
"0": "nnnwwnwnn", "1": "wnnwnnnnw", "2": "nnwwnnnnw", "3": "wnwwnnnnn",
"4": "nnnwwnnnw", "5": "wnnwwnnnn", "6": "nnwwwnnnn", "7": "nnnwnnwnw",
"8": "wnnwnnwnn", "9": "nnwwnnwnn", "A": "wnnnnwnnw", "B": "nnwnnwnnw",
"C": "wnwnnwnnn", "D": "nnnnwwnnw", "E": "wnnnwwnnn", "F": "nnwnwwnnn",
"G": "nnnnnwwnw", "H": "wnnnnwwnn", "I": "nnwnnwwnn", "J": "nnnnwwwnn",
"K": "wnnnnnnww", "L": "nnwnnnnww", "M": "wnwnnnnwn", "N": "nnnnwnnww",
"O": "wnnnwnnwn", "P": "nnwnwnnwn", "Q": "nnnnnnwww", "R": "wnnnnnwwn",
"S": "nnwnnnwwn", "T": "nnnnwnwwn", "U": "wwnnnnnnw", "V": "nwwnnnnnw",
"W": "wwwnnnnnn", "X": "nwnnwnnnw", "Y": "wwnnwnnnn", "Z": "nwwnwnnnn",
"-": "nwnnnnwnw", ".": "wwnnnnwnn", " ": "nwwnnnwnn", "$": "nwnwnwnnn",
"/": "nwnwnnnwn", "+": "nwnnnwnwn", "%": "nnnwnwnwn", "*": "nwnnwnwnn",
}
def _render_code39_svg(
value: str,
height: int = 48,
narrow: int = 2,
wide: int = 5,
gap: int = 2,
font_size: int = 11,
include_text: bool = True,
) -> str:
safe_value = "".join(ch for ch in (value or "").upper() if ch in _CODE39_PATTERNS and ch != "*")
if not safe_value:
safe_value = "EMPTY"
sequence = f"*{safe_value}*"
total_width = 12
for ch in sequence:
pattern = _CODE39_PATTERNS[ch]
for idx, code in enumerate(pattern):
stroke = wide if code == "w" else narrow
total_width += stroke
if idx < len(pattern) - 1:
total_width += gap
total_width += gap
x = 6
bars = []
for ch in sequence:
pattern = _CODE39_PATTERNS[ch]
for idx, code in enumerate(pattern):
stroke = wide if code == "w" else narrow
if idx % 2 == 0:
bars.append(f'
")
signature_block = (
"
{safe_addition}
" new_beskrivelse = f"{existing_beskrivelse}\n{append_fragment}" if existing_beskrivelse else append_fragment else: new_beskrivelse = body.beskrivelse # Write history entry execute_query( """INSERT INTO sag_beskrivelse_history (sag_id, beskrivelse_before, beskrivelse_after, changed_by_user_id, changed_by_name) VALUES (%s, %s, %s, %s, %s)""", (sag_id, old_beskrivelse, new_beskrivelse, user_id, changed_by_name) ) # Update the case execute_query( "UPDATE sag_sager SET beskrivelse = %s, updated_at = NOW() WHERE id = %s", (new_beskrivelse, sag_id) ) logger.info("✅ Beskrivelse updated for sag %s by user %s", sag_id, user_id) return {"ok": True, "beskrivelse": new_beskrivelse} except HTTPException: raise except Exception as e: logger.error("❌ Error updating beskrivelse for sag %s: %s", sag_id, e) raise HTTPException(status_code=500, detail="Failed to update description") @router.get("/sag/{sag_id}/beskrivelse/history") async def get_sag_beskrivelse_history(sag_id: int): """Return the change history for a case's description, newest first.""" exists = execute_query_single( "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) if not exists: raise HTTPException(status_code=404, detail="Case not found") rows = execute_query( """SELECT id, beskrivelse_before, beskrivelse_after, changed_by_name, changed_at FROM sag_beskrivelse_history WHERE sag_id = %s ORDER BY changed_at DESC LIMIT 50""", (sag_id,) ) or [] return rows class PipelineUpdate(BaseModel): amount: Optional[float] = None probability: Optional[int] = Field(default=None, ge=0, le=100) stage_id: Optional[int] = None description: Optional[str] = None @router.patch("/sag/{sag_id}/pipeline", dependencies=[Depends(case_edit_access)]) async def update_sag_pipeline(sag_id: int, pipeline_data: PipelineUpdate): """Update pipeline fields for a case.""" try: exists = execute_query( "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) if not exists: raise HTTPException(status_code=404, detail="Case not found") provided = pipeline_data.model_dump(exclude_unset=True) if not provided: raise HTTPException(status_code=400, detail="No pipeline fields provided") if "stage_id" in provided and provided["stage_id"] is not None: stage_exists = execute_query( "SELECT id FROM pipeline_stages WHERE id = %s", (provided["stage_id"],) ) if not stage_exists: raise HTTPException(status_code=400, detail="Invalid pipeline stage") set_clauses = [] params = [] if "amount" in provided: set_clauses.append("pipeline_amount = %s") params.append(provided["amount"]) if "probability" in provided: set_clauses.append("pipeline_probability = %s") params.append(provided["probability"]) if "stage_id" in provided: set_clauses.append("pipeline_stage_id = %s") params.append(provided["stage_id"]) if "description" in provided: set_clauses.append("pipeline_description = %s") params.append(provided["description"]) params.append(sag_id) query = f"UPDATE sag_sager SET {', '.join(set_clauses)} WHERE id = %s RETURNING *" result = execute_query(query, tuple(params)) if not result: raise HTTPException(status_code=500, detail="Failed to update pipeline") logger.info("✅ Pipeline updated for case: %s", sag_id) return result[0] except HTTPException: raise except Exception as e: logger.error("❌ Error updating pipeline for case %s: %s", sag_id, e) raise HTTPException(status_code=500, detail="Failed to update pipeline") @router.delete("/sag/{sag_id:int}", dependencies=[Depends(case_delete_access)]) async def delete_sag(sag_id: int): """Soft-delete a case.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") query = "UPDATE sag_sager SET deleted_at = NOW() WHERE id = %s RETURNING id" result = execute_query(query, (sag_id,)) if result: logger.info("✅ Case soft-deleted: %s", sag_id) return {"status": "deleted", "id": sag_id} raise HTTPException(status_code=500, detail="Failed to delete case") except HTTPException: raise except Exception as e: logger.error("❌ Error deleting case: %s", e) raise HTTPException(status_code=500, detail="Failed to delete case") # ============================================================================ # RELATIONER - Case Relations # ============================================================================ @router.get("/sag/{sag_id}/relationer") async def get_relationer(sag_id: int): """Get all relations for a case.""" try: # Check if case exists check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") query = """ SELECT sr.*, ss_kilde.titel as kilde_titel, ss_mål.titel as mål_titel FROM sag_relationer sr JOIN sag_sager ss_kilde ON sr.kilde_sag_id = ss_kilde.id JOIN sag_sager ss_mål ON sr.målsag_id = ss_mål.id WHERE (sr.kilde_sag_id = %s OR sr.målsag_id = %s) AND sr.deleted_at IS NULL ORDER BY sr.created_at DESC """ result = execute_query(query, (sag_id, sag_id)) return result except HTTPException: raise except Exception as e: logger.error("❌ Error getting relations: %s", e) raise HTTPException(status_code=500, detail="Failed to get relations") @router.post("/sag/{sag_id}/relationer", dependencies=[Depends(case_edit_access)]) async def create_relation(sag_id: int, data: dict): """Add a relation to another case.""" try: if not data.get("målsag_id") or not data.get("relationstype"): raise HTTPException(status_code=400, detail="målsag_id and relationstype required") målsag_id, relationstype = _normalize_relation_input(sag_id, data) # Validate both cases exist check1 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) check2 = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (målsag_id,)) if not check1 or not check2: raise HTTPException(status_code=404, detail="One or both cases not found") duplicate = execute_query( """ SELECT id FROM sag_relationer WHERE kilde_sag_id = %s AND målsag_id = %s AND relationstype = %s AND deleted_at IS NULL """, (sag_id, målsag_id, relationstype), ) if duplicate: raise HTTPException(status_code=409, detail="Relation already exists") query = """ INSERT INTO sag_relationer (kilde_sag_id, målsag_id, relationstype) VALUES (%s, %s, %s) RETURNING * """ result = execute_query(query, (sag_id, målsag_id, relationstype)) if result: logger.info("✅ Relation created: %s -> %s (%s)", sag_id, målsag_id, relationstype) return result[0] raise HTTPException(status_code=500, detail="Failed to create relation") except HTTPException: raise except Exception as e: logger.error("❌ Error creating relation: %s", e) raise HTTPException(status_code=500, detail="Failed to create relation") @router.delete("/sag/{sag_id}/relationer/{relation_id}", dependencies=[Depends(case_edit_access)]) async def delete_relation(sag_id: int, relation_id: int): """Soft-delete a relation.""" try: check = execute_query( "SELECT id FROM sag_relationer WHERE id = %s AND deleted_at IS NULL AND (kilde_sag_id = %s OR målsag_id = %s)", (relation_id, sag_id, sag_id) ) if not check: raise HTTPException(status_code=404, detail="Relation not found") query = "UPDATE sag_relationer SET deleted_at = NOW() WHERE id = %s RETURNING id" result = execute_query(query, (relation_id,)) if result: logger.info("✅ Relation soft-deleted: %s", relation_id) return {"status": "deleted", "id": relation_id} raise HTTPException(status_code=500, detail="Failed to delete relation") except HTTPException: raise except Exception as e: logger.error("❌ Error deleting relation: %s", e) raise HTTPException(status_code=500, detail="Failed to delete relation") # ============================================================================ # TAGS - Case Tags # ============================================================================ @router.get("/sag/tags/all") async def get_all_tags(): """Return all distinct tag names across all cases (for autocomplete).""" rows = execute_query( "SELECT DISTINCT tag_navn FROM sag_tags WHERE deleted_at IS NULL ORDER BY tag_navn ASC LIMIT 200" ) or [] return rows @router.get("/sag/{sag_id}/tags") async def get_tags(sag_id: int): """Get all tags for a case.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") query = "SELECT * FROM sag_tags WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" result = execute_query(query, (sag_id,)) return result except HTTPException: raise except Exception as e: logger.error("❌ Error getting tags: %s", e) raise HTTPException(status_code=500, detail="Failed to get tags") @router.post("/sag/{sag_id}/tags", dependencies=[Depends(case_edit_access)]) async def add_tag(sag_id: int, data: dict): """Add a tag to a case.""" try: if not data.get("tag_navn"): raise HTTPException(status_code=400, detail="tag_navn is required") check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") query = """ INSERT INTO sag_tags (sag_id, tag_navn) VALUES (%s, %s) RETURNING * """ result = execute_query(query, (sag_id, data.get("tag_navn"))) if result: logger.info("✅ Tag added: %s -> %s", sag_id, data.get("tag_navn")) return result[0] raise HTTPException(status_code=500, detail="Failed to add tag") except HTTPException: raise except Exception as e: logger.error("❌ Error adding tag: %s", e) raise HTTPException(status_code=500, detail="Failed to add tag") @router.delete("/sag/{sag_id}/tags/{tag_id}", dependencies=[Depends(case_edit_access)]) async def delete_tag(sag_id: int, tag_id: int): """Soft-delete a tag.""" try: check = execute_query( "SELECT id FROM sag_tags WHERE id = %s AND sag_id = %s AND deleted_at IS NULL", (tag_id, sag_id) ) if not check: raise HTTPException(status_code=404, detail="Tag not found") query = "UPDATE sag_tags SET deleted_at = NOW() WHERE id = %s RETURNING id" result = execute_query(query, (tag_id,)) if result: logger.info("✅ Tag soft-deleted: %s", tag_id) return {"status": "deleted", "id": tag_id} raise HTTPException(status_code=500, detail="Failed to delete tag") except HTTPException: raise except Exception as e: logger.error("❌ Error deleting tag: %s", e) raise HTTPException(status_code=500, detail="Failed to delete tag") # ============================================================================ # BUZZWORDS - Case Buzzwords (Free Text) # ============================================================================ @router.get("/sag/buzzwords/all") async def get_all_buzzwords(limit: int = Query(200, ge=1, le=500)): """Return active buzzwords for autocomplete.""" try: rows = execute_query( """ SELECT id, word, created_at FROM buzzwords WHERE deleted_at IS NULL ORDER BY word ASC LIMIT %s """, (limit,), ) or [] return rows except Exception as e: logger.error("❌ Error loading buzzwords: %s", e) raise HTTPException(status_code=500, detail="Failed to load buzzwords") @router.get("/sag/{sag_id}/buzzwords") async def get_case_buzzwords(sag_id: int): """Get buzzwords linked to a case.""" try: _assert_sag_exists(sag_id) rows = execute_query( """ SELECT sb.id AS relation_id, sb.sag_id, b.id AS buzzword_id, b.word, sb.created_at FROM sag_buzzwords sb JOIN buzzwords b ON b.id = sb.buzzword_id WHERE sb.sag_id = %s AND sb.deleted_at IS NULL AND b.deleted_at IS NULL ORDER BY b.word ASC """, (sag_id,), ) or [] return rows except HTTPException: raise except Exception as e: logger.error("❌ Error loading case buzzwords: %s", e) raise HTTPException(status_code=500, detail="Failed to load case buzzwords") @router.post("/sag/{sag_id}/buzzwords") async def add_case_buzzword(sag_id: int, payload: SagBuzzwordCreateRequest): """Create or link a buzzword to a case using normalized free text.""" try: _assert_sag_exists(sag_id) normalized_word = _normalize_buzzword_text(payload.buzzword) buzzword = _ensure_buzzword(normalized_word) relation, link_status = _ensure_sag_buzzword_link(sag_id, int(buzzword["id"])) return { "status": link_status, "relation_id": relation.get("id"), "sag_id": sag_id, "buzzword_id": buzzword.get("id"), "word": buzzword.get("word"), "created_at": relation.get("created_at"), } except HTTPException: raise except Exception as e: logger.error("❌ Error adding case buzzword: %s", e) raise HTTPException(status_code=500, detail="Failed to add case buzzword") @router.post("/sag/{sag_id}/buzzwords/from-selection") async def add_case_buzzword_from_selection(sag_id: int, payload: SagBuzzwordSelectionRequest): """Create a buzzword from selected text and link it to a case.""" return await add_case_buzzword(sag_id, SagBuzzwordCreateRequest(buzzword=payload.selected_text)) @router.delete("/sag/{sag_id}/buzzwords/{buzzword_id}") async def delete_case_buzzword(sag_id: int, buzzword_id: int): """Soft-delete buzzword relation from a case.""" try: _assert_sag_exists(sag_id) relation = execute_query_single( """ SELECT id FROM sag_buzzwords WHERE sag_id = %s AND buzzword_id = %s AND deleted_at IS NULL """, (sag_id, buzzword_id), ) if not relation: raise HTTPException(status_code=404, detail="Buzzword relation not found") execute_query( "UPDATE sag_buzzwords SET deleted_at = NOW() WHERE id = %s", (relation["id"],), ) return { "status": "deleted", "sag_id": sag_id, "buzzword_id": buzzword_id, "relation_id": relation["id"], } except HTTPException: raise except Exception as e: logger.error("❌ Error deleting case buzzword: %s", e) raise HTTPException(status_code=500, detail="Failed to delete case buzzword") # ============================================================================ # CUSTOMERS - Case Customers (Many-to-Many) # ============================================================================ @router.get("/sag/{sag_id}/customers") async def list_case_customers(sag_id: int): """List customers associated with a case.""" try: query = """ SELECT sk.*, c.name as customer_name, c.email as customer_email FROM sag_kunder sk JOIN customers c ON sk.customer_id = c.id WHERE sk.sag_id = %s AND sk.deleted_at IS NULL """ result = execute_query(query, (sag_id,)) return result except Exception as e: logger.error("❌ Error listing case customers: %s", e) raise HTTPException(status_code=500, detail="Failed to list case customers") @router.post("/sag/{sag_id}/customer/replace", dependencies=[Depends(case_edit_access)]) async def replace_case_customer(sag_id: int, payload: dict, request: Request): """Replace the primary customer on a case and keep relations synchronized.""" try: existing_case = execute_query_single( "SELECT id, customer_id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,), ) if not existing_case: raise HTTPException(status_code=404, detail="Case not found") customer_id = _coerce_optional_int((payload or {}).get("customer_id"), "customer_id") if customer_id is None: raise HTTPException(status_code=400, detail="customer_id is required") _validate_customer_id(customer_id) old_customer_id = existing_case.get("customer_id") old_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (old_customer_id,)) if old_customer_id else None new_customer = execute_query_single("SELECT name FROM customers WHERE id = %s", (customer_id,)) if table_has_column("sag_sager", "customer_id"): execute_query( "UPDATE sag_sager SET customer_id = %s WHERE id = %s", (customer_id, sag_id), ) if _table_exists("sag_kunder"): execute_query( """ UPDATE sag_kunder SET deleted_at = NOW() WHERE sag_id = %s AND customer_id <> %s AND deleted_at IS NULL AND LOWER(COALESCE(role, '')) = 'kunde' """, (sag_id, customer_id), ) existing_link = execute_query_single( """ SELECT id FROM sag_kunder WHERE sag_id = %s AND customer_id = %s AND deleted_at IS NULL LIMIT 1 """, (sag_id, customer_id), ) if existing_link: execute_query( "UPDATE sag_kunder SET role = %s WHERE id = %s", ("Kunde", existing_link["id"]), ) else: execute_query( "INSERT INTO sag_kunder (sag_id, customer_id, role) VALUES (%s, %s, %s)", (sag_id, customer_id, "Kunde"), ) if old_customer_id != customer_id: try: changed_by = "System" try: user_id = _get_user_id_from_request(request) user = execute_query_single( "SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), ''), CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s", (user_id,), ) changed_by = str((user or {}).get("name") or changed_by) except Exception: pass execute_query( """ INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) VALUES (%s, %s, %s, true) """, ( sag_id, changed_by, f"🏢 Firma ændret: {(old_customer or {}).get('name') or 'Ingen kunde'} → {(new_customer or {}).get('name') or f'Kunde #{customer_id}'}", ), ) except Exception as exc: logger.warning("Could not write customer change history for case %s: %s", sag_id, exc) logger.info("✅ Primary customer replaced for case %s -> customer %s", sag_id, customer_id) return {"status": "ok", "sag_id": sag_id, "customer_id": customer_id} except HTTPException: raise except Exception as e: logger.error("❌ Error replacing case customer: %s", e) raise HTTPException(status_code=500, detail="Failed to replace case customer") @router.post("/sag/{sag_id}/customers") async def add_case_customer(sag_id: int, data: dict): """Add a customer to a case.""" try: if not data.get("customer_id"): raise HTTPException(status_code=400, detail="customer_id is required") role = data.get("role", "Kunde") # Check if already exists check = execute_query( "SELECT id FROM sag_kunder WHERE sag_id = %s AND customer_id = %s AND deleted_at IS NULL", (sag_id, data["customer_id"]) ) if check: return check[0] # Already linked query = """ INSERT INTO sag_kunder (sag_id, customer_id, role) VALUES (%s, %s, %s) RETURNING * """ result = execute_query(query, (sag_id, data["customer_id"], role)) if result: logger.info("✅ Customer %s added to case %s", data["customer_id"], sag_id) return result[0] raise HTTPException(status_code=500, detail="Failed to add case customer") except HTTPException: raise except Exception as e: logger.error("❌ Error adding case customer: %s", e) raise HTTPException(status_code=500, detail="Failed to add case customer") @router.delete("/sag/{sag_id}/customers/{customer_id}") async def remove_case_customer(sag_id: int, customer_id: int): """Remove customer from case.""" try: query = "UPDATE sag_kunder SET deleted_at = NOW() WHERE sag_id = %s AND customer_id = %s RETURNING id" result = execute_query(query, (sag_id, customer_id)) if result: logger.info("✅ Customer %s removed from case %s", customer_id, sag_id) return {"status": "deleted"} raise HTTPException(status_code=404, detail="Customer link not found") except HTTPException: raise except Exception as e: logger.error("❌ Error removing case customer: %s", e) raise HTTPException(status_code=500, detail="Failed to remove case customer") # ============================================================================ # CONTACTS - Case Contacts # ============================================================================ @router.get("/sag/{sag_id}/contacts") async def list_case_contacts(sag_id: int): """List contacts associated with a case.""" try: query = """ SELECT sk.*, c.first_name, c.last_name, c.email, c.phone, c.mobile, c.title, company.customer_name FROM sag_kontakter sk JOIN contacts c ON sk.contact_id = c.id LEFT JOIN LATERAL ( SELECT cu.name AS customer_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, cu.name LIMIT 1 ) company ON TRUE WHERE sk.sag_id = %s AND sk.deleted_at IS NULL """ result = execute_query(query, (sag_id,)) return result except Exception as e: logger.error("❌ Error listing case contacts: %s", e) raise HTTPException(status_code=500, detail="Failed to list case contacts") @router.get("/sag/{sag_id}/reply-recipients") async def list_case_reply_recipients(sag_id: int, q: str = ""): """Email-capable case contacts first, followed by contacts from the case customer.""" rows = execute_query( """ WITH target AS (SELECT customer_id FROM sag_sager WHERE id=%s), candidates AS ( SELECT c.id, c.first_name, c.last_name, c.email, c.title, true AS linked FROM sag_kontakter sk JOIN contacts c ON c.id=sk.contact_id WHERE sk.sag_id=%s AND sk.deleted_at IS NULL UNION SELECT c.id, c.first_name, c.last_name, c.email, c.title, false AS linked FROM contact_companies cc JOIN contacts c ON c.id=cc.contact_id JOIN target t ON t.customer_id=cc.customer_id ) SELECT DISTINCT ON (id) * FROM candidates WHERE NULLIF(TRIM(email),'') IS NOT NULL AND (%s='' OR CONCAT_WS(' ',first_name,last_name,email,title) ILIKE '%%' || %s || '%%') ORDER BY id, linked DESC """, (sag_id, sag_id, q.strip(), q.strip()), ) or [] return rows @router.post("/sag/{sag_id}/contacts") async def add_case_contact(sag_id: int, data: dict): """Add a contact to a case.""" try: if not data.get("contact_id"): raise HTTPException(status_code=400, detail="contact_id is required") role = data.get("role", "Kontakt") is_primary = bool(data.get("is_primary", False)) # Check if already exists check = execute_query( "SELECT id FROM sag_kontakter WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL", (sag_id, data["contact_id"]) ) if check: return check[0] # Already linked if is_primary: execute_update( "UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (sag_id,) ) query = """ INSERT INTO sag_kontakter (sag_id, contact_id, role, is_primary) VALUES (%s, %s, %s, %s) RETURNING * """ result = execute_query(query, (sag_id, data["contact_id"], role, is_primary)) if result: logger.info("✅ Contact added to case %s: %s", sag_id, data["contact_id"]) return result[0] raise HTTPException(status_code=500, detail="Failed to add contact") except HTTPException: raise except Exception as e: logger.error("❌ Error adding case contact: %s", e) raise HTTPException(status_code=500, detail="Failed to add contact") @router.delete("/sag/{sag_id}/contacts/{contact_id}") async def remove_case_contact(sag_id: int, contact_id: int): """Remove contact from case.""" try: query = "UPDATE sag_kontakter SET deleted_at = NOW() WHERE sag_id = %s AND contact_id = %s RETURNING id" result = execute_query(query, (sag_id, contact_id)) if result: logger.info("✅ Contact %s removed from case %s", contact_id, sag_id) return {"status": "deleted"} raise HTTPException(status_code=404, detail="Contact link not found") except HTTPException: raise except Exception as e: logger.error("❌ Error removing case contact: %s", e) raise HTTPException(status_code=500, detail="Failed to remove case contact") @router.patch("/sag/{sag_id}/contacts/{contact_id}") async def update_case_contact(sag_id: int, contact_id: int, data: dict): """Update role or primary status for a case contact.""" try: existing = execute_query( "SELECT id FROM sag_kontakter WHERE sag_id = %s AND contact_id = %s AND deleted_at IS NULL", (sag_id, contact_id) ) if not existing: raise HTTPException(status_code=404, detail="Contact link not found") role = data.get("role") is_primary = data.get("is_primary") updates = [] params = [] if role is not None: updates.append("role = %s") params.append(role) if is_primary is not None: if bool(is_primary): execute_update( "UPDATE sag_kontakter SET is_primary = FALSE WHERE sag_id = %s", (sag_id,) ) updates.append("is_primary = %s") params.append(bool(is_primary)) if not updates: raise HTTPException(status_code=400, detail="No fields to update") params.extend([sag_id, contact_id]) query = f""" UPDATE sag_kontakter SET {', '.join(updates)} WHERE sag_id = %s AND contact_id = %s RETURNING * """ result = execute_query(query, tuple(params)) if result: return result[0] raise HTTPException(status_code=500, detail="Failed to update contact") except HTTPException: raise except Exception as e: logger.error("❌ Error updating case contact: %s", e) raise HTTPException(status_code=500, detail="Failed to update contact") # ============================================================================ # HARDWARE - Placeholder endpoints for frontend compatibility # ============================================================================ @router.get("/sag/{sag_id}/hardware") async def list_case_hardware(sag_id: int): """List hardware associated with a case.""" try: query = """ SELECT sh.id as relation_id, h.* FROM sag_hardware sh JOIN hardware_assets h ON sh.hardware_id = h.id WHERE sh.sag_id = %s AND sh.deleted_at IS NULL ORDER BY sh.created_at DESC """ result = execute_query(query, (sag_id,)) return result except Exception as e: logger.error("❌ Error listing case hardware: %s", e) raise HTTPException(status_code=500, detail="Failed to list case hardware") @router.get("/sag/{sag_id}/locations/{location_id}/hardware") async def list_case_location_hardware(sag_id: int, location_id: int): """List hardware physically assigned to a case location and its descendants.""" try: case = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not case: raise HTTPException(status_code=404, detail="Case not found") linked_location = execute_query( "SELECT id FROM sag_lokationer WHERE sag_id = %s AND location_id = %s AND deleted_at IS NULL", (sag_id, location_id), ) if not linked_location: raise HTTPException(status_code=404, detail="Location is not linked to this case") return execute_query( """ WITH RECURSIVE location_tree AS ( SELECT id, name, parent_location_id, ARRAY[id] AS path FROM locations_locations WHERE id = %s AND deleted_at IS NULL UNION ALL SELECT child.id, child.name, child.parent_location_id, parent.path || child.id FROM locations_locations child JOIN location_tree parent ON child.parent_location_id = parent.id WHERE child.deleted_at IS NULL AND NOT child.id = ANY(parent.path) ) SELECT h.id, h.asset_type, h.brand, h.model, h.serial_number, h.customer_asset_id, h.internal_asset_id, h.status, tree.id AS location_id, tree.name AS location_name FROM location_tree tree JOIN hardware_assets h ON h.current_location_id = tree.id AND h.deleted_at IS NULL ORDER BY tree.name, h.asset_type, h.brand, h.model, h.serial_number """, (location_id,), ) or [] except HTTPException: raise except Exception as exc: logger.error("❌ Error listing hardware for case %s location %s: %s", sag_id, location_id, exc) raise HTTPException(status_code=500, detail="Failed to list location hardware") @router.post("/sag/{sag_id}/hardware") async def add_case_hardware(sag_id: int, data: dict): """Add hardware to case.""" try: if not data.get("hardware_id"): raise HTTPException(status_code=400, detail="hardware_id is required") hardware_id = data.get("hardware_id") # Check if already linked check = execute_query( "SELECT id FROM sag_hardware WHERE sag_id = %s AND hardware_id = %s AND deleted_at IS NULL", (sag_id, hardware_id) ) if check: return check[0] # Already linked query = """ INSERT INTO sag_hardware (sag_id, hardware_id, note) VALUES (%s, %s, %s) ON CONFLICT (sag_id, hardware_id) DO UPDATE SET deleted_at = NULL, note = EXCLUDED.note RETURNING * """ result = execute_query(query, (sag_id, hardware_id, data.get("note"))) if result: logger.info("✅ Hardware %s added to case %s", hardware_id, sag_id) return result[0] raise HTTPException(status_code=500, detail="Failed to add hardware") except HTTPException: raise except Exception as e: logger.error("❌ Error adding case hardware: %s", e) raise HTTPException(status_code=500, detail="Failed to add hardware") @router.delete("/sag/{sag_id}/hardware/{hardware_id}") async def remove_case_hardware(sag_id: int, hardware_id: int): """Remove hardware from case.""" try: query = "UPDATE sag_hardware SET deleted_at = NOW() WHERE sag_id = %s AND hardware_id = %s RETURNING id" result = execute_query(query, (sag_id, hardware_id)) if result: logger.info("✅ Hardware %s removed from case %s", hardware_id, sag_id) return {"status": "deleted"} raise HTTPException(status_code=404, detail="Hardware link not found") except HTTPException: raise except Exception as e: logger.error("❌ Error removing case hardware: %s", e) raise HTTPException(status_code=500, detail="Failed to remove hardware") # ============================================================================ # LOCATIONS - Placeholder endpoints for frontend compatibility # ============================================================================ @router.get("/sag/{sag_id}/locations") async def list_case_locations(sag_id: int): """List locations associated with a case.""" try: query = """ SELECT sl.id as relation_id, l.* FROM sag_lokationer sl JOIN locations_locations l ON sl.location_id = l.id WHERE sl.sag_id = %s AND sl.deleted_at IS NULL ORDER BY sl.created_at DESC """ result = execute_query(query, (sag_id,)) return result except Exception as e: logger.error("❌ Error listing case locations: %s", e) raise HTTPException(status_code=500, detail="Failed to list case locations") @router.post("/sag/{sag_id}/locations") async def add_case_location(sag_id: int, data: dict): """Add location to case.""" try: if not data.get("location_id"): raise HTTPException(status_code=400, detail="location_id is required") location_id = data.get("location_id") # Check if already linked check = execute_query( "SELECT id FROM sag_lokationer WHERE sag_id = %s AND location_id = %s AND deleted_at IS NULL", (sag_id, location_id) ) if check: return check[0] # Already linked query = """ INSERT INTO sag_lokationer (sag_id, location_id, note) VALUES (%s, %s, %s) ON CONFLICT (sag_id, location_id) DO UPDATE SET deleted_at = NULL, note = EXCLUDED.note RETURNING * """ result = execute_query(query, (sag_id, location_id, data.get("note"))) if result: logger.info("✅ Location %s added to case %s", location_id, sag_id) return result[0] raise HTTPException(status_code=500, detail="Failed to add location") except HTTPException: raise except Exception as e: logger.error("❌ Error adding case location: %s", e) raise HTTPException(status_code=500, detail="Failed to add location") @router.delete("/sag/{sag_id}/locations/{location_id}") async def remove_case_location(sag_id: int, location_id: int): """Remove location from case.""" try: query = """ UPDATE sag_lokationer SET deleted_at = NOW() WHERE sag_id = %s AND deleted_at IS NULL AND (location_id = %s OR id = %s) RETURNING id """ result = execute_query(query, (sag_id, location_id, location_id)) if result: logger.info("✅ Location %s removed from case %s", location_id, sag_id) return {"status": "deleted"} raise HTTPException(status_code=404, detail="Location link not found") except HTTPException: raise except Exception as e: logger.error("❌ Error removing case location: %s", e) raise HTTPException(status_code=500, detail="Failed to remove location") # ============================================================================ # VAREKØB & SALG - Aggregation (Case Tree) # ============================================================================ @router.get("/sag/{sag_id}/varekob-salg") async def get_varekob_salg(sag_id: int, include_subcases: bool = True): """Aggregate time entries (and future sales items) for a case tree.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") has_sale_items_table = _table_exists("sag_salgsvarer") if not has_sale_items_table: logger.warning("⚠️ sag_salgsvarer table missing - sale item aggregation skipped for sag_id=%s", sag_id) if include_subcases: case_tree_query = """ WITH RECURSIVE normalized_relations AS ( SELECT CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id ELSE kilde_sag_id END AS parent_id, CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id ELSE målsag_id END AS child_id FROM sag_relationer WHERE deleted_at IS NULL ), case_tree AS ( SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL UNION SELECT nr.child_id FROM normalized_relations nr JOIN case_tree ct ON nr.parent_id = ct.id ) SELECT s.id, s.titel FROM sag_sager s JOIN case_tree ct ON s.id = ct.id WHERE s.deleted_at IS NULL ORDER BY s.id """ case_tree = execute_query(case_tree_query, (sag_id,)) time_query = """ WITH RECURSIVE normalized_relations AS ( SELECT CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id ELSE kilde_sag_id END AS parent_id, CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id ELSE målsag_id END AS child_id FROM sag_relationer WHERE deleted_at IS NULL ), case_tree AS ( SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL UNION SELECT nr.child_id FROM normalized_relations nr JOIN case_tree ct ON nr.parent_id = ct.id ) SELECT t.*, s.titel AS source_sag_titel FROM tmodule_times t JOIN case_tree ct ON t.sag_id = ct.id LEFT JOIN sag_sager s ON s.id = t.sag_id ORDER BY t.worked_date DESC NULLS LAST, t.id DESC """ time_entries = execute_query(time_query, (sag_id,)) if has_sale_items_table: sale_items_query = """ WITH RECURSIVE normalized_relations AS ( SELECT CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id ELSE kilde_sag_id END AS parent_id, CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id ELSE målsag_id END AS child_id FROM sag_relationer WHERE deleted_at IS NULL ), case_tree AS ( SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL UNION SELECT nr.child_id FROM normalized_relations nr JOIN case_tree ct ON nr.parent_id = ct.id ) SELECT si.*, s.titel AS source_sag_titel FROM sag_salgsvarer si JOIN case_tree ct ON si.sag_id = ct.id LEFT JOIN sag_sager s ON s.id = si.sag_id ORDER BY si.line_date DESC NULLS LAST, si.id DESC """ sale_items = execute_query(sale_items_query, (sag_id,)) else: sale_items = [] else: case_tree = execute_query( "SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) time_query = """ SELECT t.*, s.titel AS source_sag_titel FROM tmodule_times t LEFT JOIN sag_sager s ON s.id = t.sag_id WHERE t.sag_id = %s ORDER BY t.worked_date DESC NULLS LAST, t.id DESC """ time_entries = execute_query(time_query, (sag_id,)) if has_sale_items_table: sale_items_query = """ SELECT si.*, s.titel AS source_sag_titel FROM sag_salgsvarer si LEFT JOIN sag_sager s ON s.id = si.sag_id WHERE si.sag_id = %s ORDER BY si.line_date DESC NULLS LAST, si.id DESC """ sale_items = execute_query(sale_items_query, (sag_id,)) else: sale_items = [] total_entries = len(time_entries or []) total_hours = 0 billable_hours = 0 sale_total = 0 purchase_total = 0 for entry in time_entries or []: hours = entry.get("approved_hours") or entry.get("original_hours") or 0 total_hours += float(hours) if entry.get("billable"): billable_hours += float(hours) for item in sale_items or []: amount = item.get("amount") or 0 if (item.get("type") or "").lower() == "purchase": purchase_total += float(amount) else: sale_total += float(amount) return { "sag_id": sag_id, "include_subcases": include_subcases, "case_tree": case_tree or [], "time_entries": time_entries or [], "sale_items": sale_items or [], "totals": { "total_entries": total_entries, "total_hours": round(total_hours, 2), "billable_hours": round(billable_hours, 2), "sale_total": round(sale_total, 2), "purchase_total": round(purchase_total, 2), "net_total": round(sale_total - purchase_total, 2) } } except HTTPException: raise except Exception as e: logger.error("❌ Error aggregating case data: %s", e) raise HTTPException(status_code=500, detail="Failed to aggregate case data") @router.get("/sag/{sag_id}/calendar-events") async def get_case_calendar_events(sag_id: int, include_children: bool = True): """Return calendar events for a case and optionally its child cases.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") if include_children: case_tree_query = """ WITH RECURSIVE normalized_relations AS ( SELECT CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id ELSE kilde_sag_id END AS parent_id, CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id ELSE målsag_id END AS child_id FROM sag_relationer WHERE deleted_at IS NULL ), case_tree AS ( SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL UNION SELECT nr.child_id FROM normalized_relations nr JOIN case_tree ct ON nr.parent_id = ct.id ) SELECT s.id, s.titel FROM sag_sager s JOIN case_tree ct ON s.id = ct.id WHERE s.deleted_at IS NULL ORDER BY s.id """ case_rows = execute_query(case_tree_query, (sag_id,)) or [] else: case_rows = execute_query( "SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) or [] case_ids = [row["id"] for row in case_rows] case_titles = {row["id"]: row.get("titel") for row in case_rows} if not case_ids: return {"current": [], "children": []} placeholders = ",".join(["%s"] * len(case_ids)) reminder_query = f""" SELECT r.id, r.sag_id, r.title, r.message, r.event_type, r.priority, r.next_check_at, r.scheduled_at FROM sag_reminders r WHERE r.deleted_at IS NULL AND r.is_active = true AND r.sag_id IN ({placeholders}) """ reminders = execute_query(reminder_query, tuple(case_ids)) or [] case_query = f""" SELECT id, titel, deadline, deferred_until FROM sag_sager WHERE deleted_at IS NULL AND id IN ({placeholders}) """ case_dates = execute_query(case_query, tuple(case_ids)) or [] events_by_case: dict[int, list] = {cid: [] for cid in case_ids} for row in reminders: start_value = row.get("next_check_at") or row.get("scheduled_at") if not start_value: continue events_by_case[row["sag_id"]].append({ "id": f"reminder:{row['id']}", "title": row.get("title"), "message": row.get("message"), "event_kind": row.get("event_type") or "reminder", "start": start_value.isoformat(), "url": f"/sag/{row['sag_id']}/v3" }) for row in case_dates: if row.get("deadline"): events_by_case[row["id"]].append({ "id": f"deadline:{row['id']}", "title": f"Deadline: {row.get('titel')}", "event_kind": "deadline", "start": row["deadline"].isoformat(), "url": f"/sag/{row['id']}/v3" }) if row.get("deferred_until"): events_by_case[row["id"]].append({ "id": f"deferred:{row['id']}", "title": f"Deferred: {row.get('titel')}", "event_kind": "deferred", "start": row["deferred_until"].isoformat(), "url": f"/sag/{row['id']}/v3" }) current_events = events_by_case.get(sag_id, []) children = [] for cid in case_ids: if cid == sag_id: continue children.append({ "case_id": cid, "case_title": case_titles.get(cid) or f"Sag #{cid}", "events": events_by_case.get(cid, []) }) return {"current": current_events, "children": children} except HTTPException: raise except Exception as e: logger.error("❌ Error loading case calendar events: %s", e) raise HTTPException(status_code=500, detail="Failed to load calendar events") # ============================================================================ # VAREKØB & SALG - CRUD (Case-linked sale items) # ============================================================================ _PURCHASE_PURPOSE_VALUES = { "salg", "lager", "asset", "intern_brug", "retur_reklamation", "projekt_omkostning", } def _normalize_purchase_purpose(value: Optional[object], item_type: str) -> Optional[str]: if item_type != "purchase": return None if value is None: return None normalized = str(value).strip().lower() if not normalized: return None if normalized not in _PURCHASE_PURPOSE_VALUES: allowed = ", ".join(sorted(_PURCHASE_PURPOSE_VALUES)) raise HTTPException(status_code=400, detail=f"purchase_purpose must be one of: {allowed}") return normalized def _resolve_purchase_traceability( supplier_invoice_id_value: Optional[object], supplier_invoice_line_id_value: Optional[object], ) -> tuple[Optional[int], Optional[int]]: supplier_invoice_id = _coerce_optional_int(supplier_invoice_id_value, "supplier_invoice_id") supplier_invoice_line_id = _coerce_optional_int(supplier_invoice_line_id_value, "supplier_invoice_line_id") if supplier_invoice_line_id is not None and supplier_invoice_id is None: line_row = execute_query_single( "SELECT supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s", (supplier_invoice_line_id,) ) if not line_row: raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id") supplier_invoice_id = int(line_row["supplier_invoice_id"]) if supplier_invoice_id is not None: invoice_exists = execute_query_single( "SELECT id FROM supplier_invoices WHERE id = %s", (supplier_invoice_id,) ) if not invoice_exists: raise HTTPException(status_code=400, detail="Invalid supplier_invoice_id") if supplier_invoice_line_id is not None: line_exists = execute_query_single( "SELECT id, supplier_invoice_id FROM supplier_invoice_lines WHERE id = %s", (supplier_invoice_line_id,) ) if not line_exists: raise HTTPException(status_code=400, detail="Invalid supplier_invoice_line_id") if supplier_invoice_id is not None and int(line_exists["supplier_invoice_id"]) != supplier_invoice_id: raise HTTPException( status_code=400, detail="supplier_invoice_line_id does not belong to supplier_invoice_id", ) return supplier_invoice_id, supplier_invoice_line_id @router.get("/sag/{sag_id}/sale-items") async def list_sale_items(sag_id: int): """List sale items for a case.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") if not _table_exists("sag_salgsvarer"): logger.warning("⚠️ sag_salgsvarer table missing - returning empty sale items list for sag_id=%s", sag_id) return [] query = """ SELECT si.*, s.titel AS source_sag_titel FROM sag_salgsvarer si LEFT JOIN sag_sager s ON s.id = si.sag_id WHERE si.sag_id = %s ORDER BY si.line_date DESC NULLS LAST, si.id DESC """ return execute_query(query, (sag_id,)) or [] except HTTPException: raise except Exception as e: logger.error("❌ Error listing sale items: %s", e) raise HTTPException(status_code=500, detail="Failed to list sale items") @router.get("/procurement/overview") async def procurement_overview(): """Operational queue for purchase lines awaiting order, receipt or delivery.""" rows = execute_query( """ SELECT p.id, p.sag_id, p.description, p.quantity, p.unit, p.unit_price, p.amount, p.status, p.line_date, p.external_ref, p.purchase_purpose, p.supplier_invoice_id, p.supplier_invoice_line_id, s.titel AS case_title, c.name AS customer_name, EXISTS ( SELECT 1 FROM sag_salgsvarer sale WHERE sale.sag_id = p.sag_id AND sale.type = 'sale' AND sale.status <> 'cancelled' ) AS has_sales_line, CASE WHEN p.supplier_invoice_line_id IS NOT NULL THEN 'received' WHEN p.status = 'confirmed' THEN 'ordered' ELSE 'to_order' END AS fulfilment_state FROM sag_salgsvarer p JOIN sag_sager s ON s.id = p.sag_id AND s.deleted_at IS NULL LEFT JOIN customers c ON c.id = s.customer_id WHERE p.type = 'purchase' AND p.status <> 'cancelled' ORDER BY CASE WHEN p.supplier_invoice_line_id IS NOT NULL THEN 2 WHEN p.status = 'confirmed' THEN 1 ELSE 0 END, p.line_date NULLS LAST, p.id DESC """ ) or [] counts = {"to_order": 0, "ordered": 0, "received": 0, "missing_sales_order": 0} for row in rows: state = row.get("fulfilment_state") or "to_order" counts[state] = counts.get(state, 0) + 1 if not row.get("has_sales_line"): counts["missing_sales_order"] += 1 return {"items": rows, "counts": counts} @router.post("/sag/{sag_id}/sale-items") async def create_sale_item(sag_id: int, data: dict): """Create a sale item for a case.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") description = data.get("description") amount = data.get("amount") if not description: raise HTTPException(status_code=400, detail="description is required") if amount is None: raise HTTPException(status_code=400, detail="amount is required") item_type = (data.get("type") or "sale").lower() if item_type not in ("sale", "purchase"): raise HTTPException(status_code=400, detail="type must be sale or purchase") status = (data.get("status") or "draft").lower() if status not in ("draft", "confirmed", "cancelled"): raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") purchase_purpose = None supplier_invoice_id = None supplier_invoice_line_id = None if has_purchase_columns: purchase_purpose = _normalize_purchase_purpose(data.get("purchase_purpose"), item_type) supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability( data.get("supplier_invoice_id"), data.get("supplier_invoice_line_id"), ) if item_type != "purchase": supplier_invoice_id = None supplier_invoice_line_id = None if has_purchase_columns: query = """ INSERT INTO sag_salgsvarer (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id, purchase_purpose, supplier_invoice_id, supplier_invoice_line_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( sag_id, item_type, description, data.get("quantity"), data.get("unit"), data.get("unit_price"), amount, data.get("currency", "DKK"), status, data.get("line_date"), data.get("external_ref"), data.get("product_id"), purchase_purpose, supplier_invoice_id, supplier_invoice_line_id, ) else: query = """ INSERT INTO sag_salgsvarer (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( sag_id, item_type, description, data.get("quantity"), data.get("unit"), data.get("unit_price"), amount, data.get("currency", "DKK"), status, data.get("line_date"), data.get("external_ref"), data.get("product_id"), ) result = execute_query(query, params) if result: logger.info("✅ Sale item created for case %s", sag_id) return result[0] raise HTTPException(status_code=500, detail="Failed to create sale item") except HTTPException: raise except Exception as e: logger.error("❌ Error creating sale item: %s", e) raise HTTPException(status_code=500, detail="Failed to create sale item") @router.post("/sag/{sag_id}/sale-items/batch") async def create_sale_items_batch(sag_id: int, data: dict): """Create purchase and/or sale lines from one selected product atomically. A product lookup is one business action, but it can have two accounting consequences: an expected supplier purchase and a customer sale. Keeping both inserts in one transaction prevents half-created product flows. """ lines = data.get("lines") if isinstance(data, dict) else None if not isinstance(lines, list) or not lines: raise HTTPException(status_code=400, detail="lines must contain at least one item") if len(lines) > 2: raise HTTPException(status_code=400, detail="A product action can contain at most one purchase and one sale line") try: case_exists = execute_query_single( "SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,), ) if not case_exists: raise HTTPException(status_code=404, detail="Case not found") if not _table_exists("sag_salgsvarer"): raise HTTPException(status_code=503, detail="Varelinjer er ikke klar endnu") has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") normalized_lines = [] seen_types = set() for raw_line in lines: if not isinstance(raw_line, dict): raise HTTPException(status_code=400, detail="Every line must be an object") item_type = str(raw_line.get("type") or "").strip().lower() if item_type not in {"sale", "purchase"}: raise HTTPException(status_code=400, detail="type must be sale or purchase") if item_type in seen_types: raise HTTPException(status_code=400, detail="Only one line of each type is allowed") seen_types.add(item_type) description = str(raw_line.get("description") or "").strip() if not description: raise HTTPException(status_code=400, detail="description is required") try: amount = float(raw_line.get("amount")) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="amount must be a number") if amount < 0: raise HTTPException(status_code=400, detail="amount cannot be negative") status = str(raw_line.get("status") or "draft").strip().lower() if status not in {"draft", "confirmed", "cancelled"}: raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") purchase_purpose = _normalize_purchase_purpose(raw_line.get("purchase_purpose"), item_type) supplier_invoice_id = supplier_invoice_line_id = None if has_purchase_columns and item_type == "purchase": supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability( raw_line.get("supplier_invoice_id"), raw_line.get("supplier_invoice_line_id"), ) normalized_lines.append({ "type": item_type, "description": description, "quantity": raw_line.get("quantity"), "unit": raw_line.get("unit"), "unit_price": raw_line.get("unit_price"), "amount": amount, "currency": str(raw_line.get("currency") or "DKK").upper(), "status": status, "line_date": raw_line.get("line_date"), "external_ref": raw_line.get("external_ref"), "product_id": raw_line.get("product_id"), "purchase_purpose": purchase_purpose, "supplier_invoice_id": supplier_invoice_id, "supplier_invoice_line_id": supplier_invoice_line_id, }) conn = get_db_connection() try: with conn.cursor(cursor_factory=RealDictCursor) as cursor: created = [] for line in normalized_lines: if has_purchase_columns: cursor.execute( """ INSERT INTO sag_salgsvarer (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id, purchase_purpose, supplier_invoice_id, supplier_invoice_line_id) VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s, %(purchase_purpose)s, %(supplier_invoice_id)s, %(supplier_invoice_line_id)s) RETURNING * """, {"sag_id": sag_id, **line}, ) else: cursor.execute( """ INSERT INTO sag_salgsvarer (sag_id, type, description, quantity, unit, unit_price, amount, currency, status, line_date, external_ref, product_id) VALUES (%(sag_id)s, %(type)s, %(description)s, %(quantity)s, %(unit)s, %(unit_price)s, %(amount)s, %(currency)s, %(status)s, %(line_date)s, %(external_ref)s, %(product_id)s) RETURNING * """, {"sag_id": sag_id, **line}, ) created.append(dict(cursor.fetchone())) conn.commit() logger.info("✅ Created %s linked product lines for case %s", len(created), sag_id) return {"created": created} except Exception: conn.rollback() raise finally: release_db_connection(conn) except HTTPException: raise except Exception as e: logger.error("❌ Error creating product lines for case %s: %s", sag_id, e, exc_info=True) raise HTTPException(status_code=500, detail="Failed to create product lines") @router.get("/sag/{sag_id}/sale-items/{item_id}") async def get_sale_item(sag_id: int, item_id: int): """Get a single sale item for a case.""" try: query = """ SELECT si.*, s.titel AS source_sag_titel FROM sag_salgsvarer si LEFT JOIN sag_sager s ON s.id = si.sag_id WHERE si.sag_id = %s AND si.id = %s """ result = execute_query(query, (sag_id, item_id)) if not result: raise HTTPException(status_code=404, detail="Sale item not found") return result[0] except HTTPException: raise except Exception as e: logger.error("❌ Error getting sale item: %s", e) raise HTTPException(status_code=500, detail="Failed to get sale item") @router.patch("/sag/{sag_id}/sale-items/{item_id}") async def update_sale_item(sag_id: int, item_id: int, updates: dict = Body(...)): """Update a sale item for a case.""" try: check = execute_query( "SELECT id, type FROM sag_salgsvarer WHERE id = %s AND sag_id = %s", (item_id, sag_id) ) if not check: raise HTTPException(status_code=404, detail="Sale item not found") allowed_fields = [ "type", "description", "quantity", "unit", "unit_price", "amount", "currency", "status", "line_date", "external_ref", "product_id", "purchase_purpose", "supplier_invoice_id", "supplier_invoice_line_id", ] set_clauses = [] params = [] has_purchase_columns = table_has_column("sag_salgsvarer", "purchase_purpose") current_type = (check[0].get("type") or "sale").lower() next_type = current_type if "type" in updates: next_type = (updates.get("type") or "").lower() for field in allowed_fields: if field in updates: if field == "type": value = (updates[field] or "").lower() if value not in ("sale", "purchase"): raise HTTPException(status_code=400, detail="type must be sale or purchase") set_clauses.append("type = %s") params.append(value) elif field == "status": value = (updates[field] or "").lower() if value not in ("draft", "confirmed", "cancelled"): raise HTTPException(status_code=400, detail="status must be draft, confirmed, or cancelled") set_clauses.append("status = %s") params.append(value) elif field == "purchase_purpose": if not has_purchase_columns: continue value = _normalize_purchase_purpose(updates.get(field), next_type) set_clauses.append("purchase_purpose = %s") params.append(value) elif field in ("supplier_invoice_id", "supplier_invoice_line_id"): # handled together below to keep consistency between IDs continue else: set_clauses.append(f"{field} = %s") params.append(updates[field]) if has_purchase_columns and ( "supplier_invoice_id" in updates or "supplier_invoice_line_id" in updates or next_type != current_type ): raw_supplier_invoice_id = updates.get("supplier_invoice_id") if "supplier_invoice_id" in updates else None raw_supplier_invoice_line_id = updates.get("supplier_invoice_line_id") if "supplier_invoice_line_id" in updates else None if "supplier_invoice_id" not in updates or "supplier_invoice_line_id" not in updates: current_refs = execute_query_single( "SELECT supplier_invoice_id, supplier_invoice_line_id FROM sag_salgsvarer WHERE id = %s AND sag_id = %s", (item_id, sag_id) ) or {} if "supplier_invoice_id" not in updates: raw_supplier_invoice_id = current_refs.get("supplier_invoice_id") if "supplier_invoice_line_id" not in updates: raw_supplier_invoice_line_id = current_refs.get("supplier_invoice_line_id") supplier_invoice_id, supplier_invoice_line_id = _resolve_purchase_traceability( raw_supplier_invoice_id, raw_supplier_invoice_line_id, ) if next_type != "purchase": supplier_invoice_id = None supplier_invoice_line_id = None set_clauses.append("supplier_invoice_id = %s") params.append(supplier_invoice_id) set_clauses.append("supplier_invoice_line_id = %s") params.append(supplier_invoice_line_id) if has_purchase_columns and next_type != "purchase": if "purchase_purpose" not in updates: set_clauses.append("purchase_purpose = %s") params.append(None) if not set_clauses: raise HTTPException(status_code=400, detail="No valid fields to update") params.extend([item_id, sag_id]) query = f"UPDATE sag_salgsvarer SET {', '.join(set_clauses)} WHERE id = %s AND sag_id = %s RETURNING *" result = execute_query(query, tuple(params)) if result: logger.info("✅ Sale item updated: %s", item_id) return result[0] raise HTTPException(status_code=500, detail="Failed to update sale item") except HTTPException: raise except Exception as e: logger.error("❌ Error updating sale item: %s", e) raise HTTPException(status_code=500, detail="Failed to update sale item") @router.delete("/sag/{sag_id}/sale-items/{item_id}") async def delete_sale_item(sag_id: int, item_id: int): """Delete a sale item for a case.""" try: query = "DELETE FROM sag_salgsvarer WHERE id = %s AND sag_id = %s RETURNING id" result = execute_query(query, (item_id, sag_id)) if result: logger.info("✅ Sale item deleted: %s", item_id) return {"status": "deleted", "id": item_id} raise HTTPException(status_code=404, detail="Sale item not found") except HTTPException: raise except Exception as e: logger.error("❌ Error deleting sale item: %s", e) raise HTTPException(status_code=500, detail="Failed to delete sale item") # ============================================================================ # KOMMENTARER - Case Comments # ============================================================================ @router.get("/sag/{sag_id}/kommentarer") async def get_kommentarer(sag_id: int): """Get all comments for a case.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") query = "SELECT * FROM sag_kommentarer WHERE sag_id = %s AND deleted_at IS NULL ORDER BY created_at DESC" result = execute_query(query, (sag_id,)) return result except HTTPException: raise except Exception as e: logger.error("❌ Error getting comments: %s", e) raise HTTPException(status_code=500, detail="Failed to get comments") def _get_case_linked_activities(case_ids: List[int]) -> List[Dict[str, Any]]: """Return non-billable source activities linked directly to cases.""" if not case_ids: return [] placeholders = ",".join(["%s"] * len(case_ids)) activities: List[Dict[str, Any]] = [] if _table_exists("telefoni_opkald"): call_query = f""" SELECT t.id, t.sag_id, t.direction, t.ekstern_nummer, t.intern_extension, t.started_at, t.ended_at, t.duration_sec, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name, NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name FROM telefoni_opkald t LEFT JOIN users u ON u.user_id = t.bruger_id LEFT JOIN contacts c ON c.id = t.kontakt_id WHERE t.sag_id IN ({placeholders}) ORDER BY t.started_at DESC, t.id DESC LIMIT 600 """ for row in execute_query(call_query, tuple(case_ids)) or []: started_at = row.get("started_at") if not started_at: continue direction = str(row.get("direction") or "inbound").lower() duration_seconds = row.get("duration_sec") if duration_seconds is not None and not 0 <= float(duration_seconds) <= 43200: duration_seconds = None if duration_seconds is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime): calculated_seconds = int((row["ended_at"] - started_at).total_seconds()) duration_seconds = calculated_seconds if 0 <= calculated_seconds <= 43200 else None activities.append({ "id": f"call:{row.get('id')}", "source_id": row.get("id"), "activity_type": "call", "sag_id": row.get("sag_id"), "timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at), "ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"), "duration_minutes": round(float(duration_seconds or 0) / 60, 1), "actor_name": str(row.get("actor_name") or "Ukendt medarbejder"), "contact_name": row.get("contact_name"), "number": row.get("ekstern_nummer"), "direction": direction, "title": "Udgående opkald" if direction == "outbound" else "Indgående opkald", "status": "completed" if row.get("ended_at") else "started", }) if _table_exists("anydesk_sessions"): anydesk_query = f""" SELECT a.id, a.anydesk_session_id, a.sag_id, a.started_at, a.ended_at, a.duration_minutes, a.status, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'Ukendt medarbejder') AS actor_name, NULLIF(TRIM(CONCAT(COALESCE(c.first_name, ''), ' ', COALESCE(c.last_name, ''))), '') AS contact_name FROM anydesk_sessions a LEFT JOIN users u ON u.user_id = a.created_by_user_id LEFT JOIN contacts c ON c.id = a.contact_id WHERE a.sag_id IN ({placeholders}) ORDER BY a.started_at DESC, a.id DESC LIMIT 600 """ for row in execute_query(anydesk_query, tuple(case_ids)) or []: started_at = row.get("started_at") if not started_at: continue duration_minutes = row.get("duration_minutes") if duration_minutes is None and row.get("ended_at") and isinstance(started_at, datetime) and isinstance(row.get("ended_at"), datetime): duration_minutes = max(0, round((row["ended_at"] - started_at).total_seconds() / 60, 1)) activities.append({ "id": f"anydesk:{row.get('id')}", "source_id": row.get("id"), "external_id": row.get("anydesk_session_id"), "activity_type": "anydesk", "sag_id": row.get("sag_id"), "timestamp": started_at.isoformat() if isinstance(started_at, datetime) else str(started_at), "ended_at": row.get("ended_at").isoformat() if isinstance(row.get("ended_at"), datetime) else row.get("ended_at"), "duration_minutes": float(duration_minutes or 0), "actor_name": str(row.get("actor_name") or "Ukendt medarbejder"), "contact_name": row.get("contact_name"), "title": "AnyDesk-session", "status": str(row.get("status") or "unknown"), }) activities.sort(key=lambda item: str(item.get("timestamp") or ""), reverse=True) return activities @router.get("/sag/{sag_id}/linked-activities") async def get_case_linked_activities(sag_id: int): _assert_sag_exists(sag_id) activities = _get_case_linked_activities([sag_id]) return {"activities": activities, "total": len(activities)} def _normalized_case_relation_sql() -> str: return """SELECT CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id ELSE kilde_sag_id END AS parent_id, CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id ELSE målsag_id END AS child_id, relationstype FROM sag_relationer WHERE deleted_at IS NULL""" def _load_case_tree(sag_id: int, include_all: bool = True) -> tuple[list[dict], bool]: """Return the focused case's whole connected parent/child tree, cycle safe.""" relations = execute_query( f"""WITH RECURSIVE normalized AS ({_normalized_case_relation_sql()}), connected(case_id) AS ( SELECT %s UNION SELECT CASE WHEN n.parent_id = c.case_id THEN n.child_id ELSE n.parent_id END FROM connected c JOIN normalized n ON n.parent_id = c.case_id OR n.child_id = c.case_id ) SELECT DISTINCT s.id, s.titel, s.status, s.type FROM connected c JOIN sag_sager s ON s.id = c.case_id WHERE s.deleted_at IS NULL ORDER BY s.id""", (sag_id,), ) or [] if not include_all: relations = [row for row in relations if row.get("id") == sag_id] ids = {row.get("id") for row in relations} if not ids: return [], False marks = ",".join(["%s"] * len(ids)) edges = execute_query( f"""WITH normalized AS ({_normalized_case_relation_sql()}) SELECT n.parent_id, n.child_id, n.relationstype, p.titel AS parent_title, c.titel AS child_title FROM normalized n JOIN sag_sager p ON p.id = n.parent_id AND p.deleted_at IS NULL JOIN sag_sager c ON c.id = n.child_id AND c.deleted_at IS NULL WHERE n.parent_id IN ({marks}) AND n.child_id IN ({marks}) ORDER BY n.parent_id, n.child_id""", tuple(ids) + tuple(ids), ) or [] nodes = {row["id"]: {"id": row["id"], "title": row.get("titel") or f"Sag #{row['id']}", "children": []} for row in relations} parents: dict[int, list[int]] = {case_id: [] for case_id in ids} for edge in edges: parent_id, child_id = edge.get("parent_id"), edge.get("child_id") if parent_id not in nodes or child_id not in nodes: continue parents[child_id].append(parent_id) nodes[parent_id]["children"].append({ "id": child_id, "title": nodes[child_id]["title"], "relation_type": edge.get("relationstype") or "", }) def prune(node_id: int, path: set[int]) -> dict: node = dict(nodes[node_id]) node["is_current"] = node_id == sag_id node["relation"] = "current" if node_id == sag_id else ("parent" if sag_id in _descendant_ids(node_id, nodes) else "child") children = [] for child in node["children"]: child_id = child["id"] if child_id in path: continue children.append({**prune(child_id, path | {child_id}), "relation_type": child.get("relation_type")}) node["children"] = children return node # Render each root so multiple parent branches remain visible. A pure cycle # has no root; start at the focused case and let the visited path break it. root_ids = sorted(case_id for case_id in ids if not parents.get(case_id)) if not root_ids: root_ids = [sag_id] return [prune(root_id, {root_id}) for root_id in root_ids], False def _descendant_ids(node_id: int, nodes: dict[int, dict]) -> set[int]: found: set[int] = set() stack = list(nodes.get(node_id, {}).get("children", [])) while stack: item = stack.pop() child_id = item.get("id") if child_id in found: continue found.add(child_id) stack.extend(nodes.get(child_id, {}).get("children", [])) return found def _ancestors_for_case(case_id: int, parents_by_child: dict[int, set[int]]) -> set[int]: found: set[int] = set() pending = list(parents_by_child.get(case_id, set())) while pending: parent_id = pending.pop() if parent_id == case_id or parent_id in found: continue found.add(parent_id) pending.extend(parents_by_child.get(parent_id, set())) return found @router.get("/sag/{sag_id}/case-tree") async def get_sag_case_tree(sag_id: int): check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") tree, truncated = _load_case_tree(sag_id) return {"tree": tree, "truncated": truncated, "max_depth": None} @router.get("/sag/{sag_id}/timeline") async def get_sag_timeline( sag_id: int, include_subcases: bool = Query(False), whole_tree: bool = Query(False), relation_types: Optional[str] = Query(None), event_types: Optional[str] = Query(None), search: Optional[str] = Query(None, max_length=200), limit: Optional[int] = Query(None, ge=1, le=200), cursor: Optional[str] = Query(None, max_length=1000), ): """Return a unified timeline for the case with optional child cases.""" try: check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") if whole_tree: tree_roots, tree_truncated = _load_case_tree(sag_id) case_rows = [] def collect_tree_cases(nodes: list[dict]) -> None: for node in nodes: case_rows.append({"id": node["id"], "titel": node.get("title")}) collect_tree_cases(node.get("children") or []) collect_tree_cases(tree_roots) elif include_subcases: case_tree_query = """ WITH RECURSIVE normalized_relations AS ( SELECT CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN målsag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN kilde_sag_id ELSE kilde_sag_id END AS parent_id, CASE WHEN LOWER(relationstype) IN ('afledt af', 'afledt_af') THEN kilde_sag_id WHEN LOWER(relationstype) IN ('årsag til', 'årsag_til') THEN målsag_id ELSE målsag_id END AS child_id FROM sag_relationer WHERE deleted_at IS NULL ), case_tree AS ( SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL UNION SELECT nr.child_id FROM normalized_relations nr JOIN case_tree ct ON nr.parent_id = ct.id ) SELECT s.id, s.titel FROM sag_sager s JOIN case_tree ct ON s.id = ct.id WHERE s.deleted_at IS NULL ORDER BY s.id """ case_rows = execute_query(case_tree_query, (sag_id,)) or [] else: tree_truncated = False case_rows = execute_query( "SELECT id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) or [] case_ids = [row.get("id") for row in case_rows if row.get("id") is not None] if not case_ids: return {"case_tree": [], "events": [], "total": 0} placeholders = ",".join(["%s"] * len(case_ids)) comments_query = f""" SELECT c.*, s.titel AS source_sag_titel FROM sag_kommentarer c LEFT JOIN sag_sager s ON s.id = c.sag_id WHERE c.deleted_at IS NULL AND c.sag_id IN ({placeholders}) ORDER BY c.created_at DESC LIMIT 800 """ comments = execute_query(comments_query, tuple(case_ids)) or [] beskrivelse_history = [] if _table_exists("sag_beskrivelse_history"): history_query = f""" SELECT h.id, h.sag_id, h.beskrivelse_before, h.beskrivelse_after, h.changed_by_name, h.changed_at, s.titel AS source_sag_titel FROM sag_beskrivelse_history h LEFT JOIN sag_sager s ON s.id = h.sag_id WHERE h.sag_id IN ({placeholders}) ORDER BY h.changed_at DESC LIMIT 400 """ beskrivelse_history = execute_query(history_query, tuple(case_ids)) or [] def _detect_source(text: Optional[str]) -> str: value = str(text or "").lower() if re.search(r"\bemail\b|\be-mail\b|✉️|📧|email-kladde|mail\b|indgaaende email|udgaaende email|indgående email|udgående email", value): return "email" if re.search(r"\bsms\b|💬", value): return "sms" if re.search(r"\bopkald\b|\bring\b|\bringet\b|📞|click-to-call|yealink", value): return "call" return "module" def _detect_field_change_subtype(text: Optional[str]) -> Optional[str]: value = str(text or "").lower() if re.search(r"\bstatus\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret status\b|\bstatus:\s*.*->|\bstatus\s*->", value): return "status" if re.search(r"\b(type|sagstype|template_key|template key)\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret type\b|\btype:\s*.*->|\btemplate_key\b", value): return "type" if re.search(r"\b(prioritet|priority)\b.*\b(ændret|ændring|skiftet|opdateret|sat til)\b|\bændret prioritet\b|\bprioritet:\s*.*->", value): return "priority" if re.search(r"\b(ansvarlig|assignee|owner|ansvarlig_bruger_id|ansvarlig bruger)\b.*\b(ændret|ændring|skiftet|opdateret|sat til|tildelt)\b|\bændret ansvarlig\b|\bansvarlig:\s*.*->|\bassign(ed)? to\b", value): return "assignee" if re.search(r"\b(gruppe|team|assigned_group_id|assigned group|gruppe-id)\b.*\b(ændret|ændring|skiftet|opdateret|sat til|tildelt)\b|\bændret gruppe\b|\bgruppe:\s*.*->", value): return "group" return None events = [] for activity in _get_case_linked_activities(case_ids): duration = float(activity.get("duration_minutes") or 0) duration_text = f"{duration:g} min" if duration > 0 else "Varighed ikke registreret" contact = str(activity.get("contact_name") or activity.get("number") or "Ukendt kontakt") activity_type = str(activity.get("activity_type") or "activity") description = f"{contact}\n{duration_text}" if activity_type == "call" and activity.get("number") and activity.get("contact_name"): description = f"{contact} · {activity.get('number')}\n{duration_text}" events.append({ "id": activity.get("id"), "event_type": activity_type, "event_subtype": activity.get("direction") or activity.get("status"), "source": "call" if activity_type == "call" else "anydesk", "timestamp": activity.get("timestamp"), "sag_id": activity.get("sag_id"), "sag_titel": next((row.get("titel") for row in case_rows if row.get("id") == activity.get("sag_id")), None), "forfatter": activity.get("actor_name") or "System", "title": activity.get("title"), "description": description, }) case_details_query = f""" SELECT s.id, s.titel, s.created_at, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name FROM sag_sager s LEFT JOIN users u ON u.user_id = s.created_by_user_id WHERE s.id IN ({placeholders}) """ for row in execute_query(case_details_query, tuple(case_ids)) or []: created_at = row.get("created_at") if not created_at: continue events.append({ "id": f"case:{row.get('id')}:created", "event_type": "case", "event_subtype": "created", "source": "case", "timestamp": created_at.isoformat() if isinstance(created_at, datetime) else str(created_at), "sag_id": row.get("id"), "sag_titel": row.get("titel"), "forfatter": str(row.get("created_by_name") or "System"), "title": "Sag oprettet", "description": str(row.get("titel") or ""), }) for row in comments: created_at = row.get("created_at") if not created_at: continue if isinstance(created_at, datetime): ts = created_at.isoformat() else: ts = str(created_at) is_system = bool(row.get("er_system_besked")) is_internal = bool(row.get("er_intern")) subtype = "system" if is_system else ("internal" if is_internal else "external") content = str(row.get("indhold") or "") author = str(row.get("forfatter") or "Bruger") field_change_subtype = _detect_field_change_subtype(content) comment_id = row.get("id") or row.get("kommentar_id") or len(events) + 1 events.append({ "id": f"comment:{comment_id}", "event_type": "field_change" if field_change_subtype else "comment", "event_subtype": field_change_subtype or subtype, "source": "case" if field_change_subtype else _detect_source(content), "timestamp": ts, "sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": author, "title": f"Feltændring · {author}" if field_change_subtype else f"Kommentar · {author}", "description": content, }) for row in beskrivelse_history: changed_at = row.get("changed_at") if not changed_at: continue if isinstance(changed_at, datetime): ts = changed_at.isoformat() else: ts = str(changed_at) changed_by = str(row.get("changed_by_name") or "Ukendt") before = str(row.get("beskrivelse_before") or "") after = str(row.get("beskrivelse_after") or "") before_short = (before[:240] + "...") if len(before) > 240 else before after_short = (after[:240] + "...") if len(after) > 240 else after description = f"Før: {before_short or 'tom'}\nEfter: {after_short or 'tom'}" events.append({ "id": f"description:{row.get('id') or len(events) + 1}", "event_type": "description", "event_subtype": "edit", "source": "module", "timestamp": ts, "sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": changed_by, "title": f"Beskrivelse opdateret · {changed_by}", "description": description, }) if _table_exists("sag_reminders"): reminders_query = f""" SELECT r.id, r.sag_id, r.title, r.message, r.event_type, r.next_check_at, r.scheduled_at, r.created_at, COALESCE(NULLIF(TRIM(u.full_name), ''), NULLIF(TRIM(u.username), ''), 'System') AS created_by_name, s.titel AS source_sag_titel FROM sag_reminders r LEFT JOIN sag_sager s ON s.id = r.sag_id LEFT JOIN users u ON u.user_id = r.created_by_user_id WHERE r.deleted_at IS NULL AND r.sag_id IN ({placeholders}) ORDER BY COALESCE(r.next_check_at, r.scheduled_at) DESC LIMIT 400 """ reminders = execute_query(reminders_query, tuple(case_ids)) or [] for row in reminders: start_value = row.get("created_at") or row.get("next_check_at") or row.get("scheduled_at") if not start_value: continue ts = start_value.isoformat() if isinstance(start_value, datetime) else str(start_value) title = str(row.get("title") or "Påmindelse") message = str(row.get("message") or "") scheduled_value = row.get("scheduled_at") or row.get("next_check_at") if scheduled_value: scheduled_text = scheduled_value.isoformat() if isinstance(scheduled_value, datetime) else str(scheduled_value) message = f"{message}\nPlanlagt til: {scheduled_text}".strip() event_kind = str(row.get("event_type") or "reminder") events.append({ "id": f"reminder:{row.get('id') or len(events) + 1}", "event_type": "reminder", "event_subtype": event_kind, "source": "reminder", "timestamp": ts, "sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": str(row.get("created_by_name") or "System"), "title": f"Påmindelse · {title}", "description": message, }) if _table_exists("tmodule_times"): time_query = f""" SELECT t.id, t.sag_id, t.description, t.entry_type, t.worked_date, t.created_at, t.approved_hours, t.original_hours, COALESCE(NULLIF(TRIM(recorder.full_name), ''), NULLIF(TRIM(recorder.username), ''), NULLIF(TRIM(t.user_name), ''), 'System') AS actor_name, COALESCE(NULLIF(TRIM(employee.full_name), ''), NULLIF(TRIM(employee.username), ''), NULLIF(TRIM(t.user_name), ''), CONCAT('Medarbejder #', t.medarbejder_id::text)) AS employee_name, s.titel AS source_sag_titel FROM tmodule_times t LEFT JOIN sag_sager s ON s.id = t.sag_id LEFT JOIN users employee ON employee.user_id = t.medarbejder_id LEFT JOIN users recorder ON LOWER(recorder.username) = LOWER(t.user_name) WHERE t.sag_id IN ({placeholders}) ORDER BY COALESCE(t.worked_date::timestamp, t.created_at) DESC, t.id DESC LIMIT 600 """ time_entries = execute_query(time_query, tuple(case_ids)) or [] for row in time_entries: raw_ts = row.get("created_at") or row.get("worked_date") if not raw_ts: continue ts = raw_ts.isoformat() if isinstance(raw_ts, datetime) else str(raw_ts) hours = row.get("approved_hours") if hours is None: hours = row.get("original_hours") hours_text = f"{float(hours):.2f} t" if hours is not None else "ukendt tid" description = str(row.get("description") or "") employee_name = str(row.get("employee_name") or "Ukendt medarbejder") description = f"Registreret for: {employee_name}\n{description}".strip() entry_type = str(row.get("entry_type") or "arbejde") events.append({ "id": f"time:{row.get('id') or len(events) + 1}", "event_type": "time", "event_subtype": entry_type, "source": "time", "timestamp": ts, "sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": str(row.get("actor_name") or "System"), "title": f"Tidsregistrering · {hours_text}", "description": description, }) if _table_exists("sag_tags"): tag_query = f""" SELECT t.id, t.sag_id, t.tag_navn, t.created_at, t.closed_at, s.titel AS source_sag_titel FROM sag_tags t LEFT JOIN sag_sager s ON s.id = t.sag_id WHERE t.sag_id IN ({placeholders}) LIMIT 600 """ for row in execute_query(tag_query, tuple(case_ids)) or []: for action, timestamp in (("tilføjet", row.get("created_at")), ("lukket", row.get("closed_at"))): if timestamp: events.append({ "id": f"tag:{row.get('id')}:{action}", "event_type": "tag", "event_subtype": action, "source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp), "sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": "System", "title": f"Tag {action}", "description": str(row.get("tag_navn") or ""), }) if _table_exists("sag_todo_steps"): todo_query = f""" SELECT t.*, s.titel AS source_sag_titel, COALESCE(NULLIF(TRIM(uc.full_name), ''), NULLIF(TRIM(uc.username), ''), 'System') AS created_by_name, COALESCE(NULLIF(TRIM(ud.full_name), ''), NULLIF(TRIM(ud.username), ''), 'System') AS completed_by_name FROM sag_todo_steps t LEFT JOIN sag_sager s ON s.id = t.sag_id LEFT JOIN users uc ON uc.user_id = t.created_by_user_id LEFT JOIN users ud ON ud.user_id = t.completed_by_user_id WHERE t.sag_id IN ({placeholders}) LIMIT 800 """ for row in execute_query(todo_query, tuple(case_ids)) or []: todo_events = (("oprettet", row.get("created_at"), row.get("created_by_name")), ("færdiggjort", row.get("completed_at"), row.get("completed_by_name")), ("slettet", row.get("deleted_at"), "System")) for action, timestamp, actor in todo_events: if timestamp: events.append({ "id": f"todo:{row.get('id')}:{action}", "event_type": "todo", "event_subtype": action, "source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp), "sag_id": row.get("sag_id"), "sag_titel": row.get("source_sag_titel"), "forfatter": str(actor or "System"), "title": f"Tjeklistepunkt {action}", "description": str(row.get("title") or ""), }) if _table_exists("sag_relationer"): relation_query = f""" SELECT id, kilde_sag_id, målsag_id, relationstype, created_at, deleted_at FROM sag_relationer WHERE kilde_sag_id IN ({placeholders}) OR målsag_id IN ({placeholders}) LIMIT 600 """ for row in execute_query(relation_query, tuple(case_ids) + tuple(case_ids)) or []: current_id = row.get("kilde_sag_id") if row.get("kilde_sag_id") in case_ids else row.get("målsag_id") other_id = row.get("målsag_id") if current_id == row.get("kilde_sag_id") else row.get("kilde_sag_id") for action, timestamp in (("oprettet", row.get("created_at")), ("fjernet", row.get("deleted_at"))): if timestamp: events.append({ "id": f"relation:{row.get('id')}:{action}", "event_type": "relation", "event_subtype": action, "source": "case", "timestamp": timestamp.isoformat() if isinstance(timestamp, datetime) else str(timestamp), "sag_id": current_id, "sag_titel": next((r.get("titel") for r in case_rows if r.get("id") == current_id), None), "forfatter": "System", "title": f"Relation {action}", "description": f"{row.get('relationstype') or 'Relateret til'} · Sag #{other_id}", }) def _sort_key(item: Dict) -> datetime: raw = str(item.get("timestamp") or "") try: parsed = datetime.fromisoformat(raw.replace("Z", "+00:00").replace(" ", "T")) if parsed.tzinfo is not None: parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None) return parsed except Exception: return datetime.min relation_edges = execute_query( f"""WITH normalized AS ({_normalized_case_relation_sql()}) SELECT parent_id, child_id FROM normalized WHERE parent_id IN ({placeholders}) OR child_id IN ({placeholders})""", tuple(case_ids) + tuple(case_ids), ) or [] parents_by_child: dict[int, set[int]] = {} for edge in relation_edges: parents_by_child.setdefault(edge["child_id"], set()).add(edge["parent_id"]) current_ancestors = _ancestors_for_case(sag_id, parents_by_child) for event in events: event_case_id = event.get("sag_id") event["relation_type"] = "current" if event_case_id == sag_id else ( "parent" if event_case_id in current_ancestors else "child" ) events = [event for event in events if event.get("timestamp")] if relation_types: selected_relations = {part.strip().lower() for part in relation_types.split(",") if part.strip()} events = [event for event in events if event.get("relation_type") in selected_relations] if event_types: selected_types = {part.strip().lower() for part in event_types.split(",") if part.strip()} events = [event for event in events if str(event.get("event_type") or "").lower() in selected_types] if search: needle = search.casefold() events = [event for event in events if needle in " ".join(str(event.get(key) or "") for key in ("title", "description", "sag_titel", "forfatter", "source")).casefold()] events.sort(key=lambda item: (_sort_key(item), str(item.get("id") or "")), reverse=True) total = len(events) if limit is not None: if cursor: try: decoded = base64.urlsafe_b64decode(cursor + "=" * (-len(cursor) % 4)).decode("utf-8") cursor_ts, cursor_id = decoded.split("|", 1) cursor_time = _sort_key({"timestamp": cursor_ts}) except Exception: raise HTTPException(status_code=400, detail="Invalid timeline cursor") events = [event for event in events if (_sort_key(event), str(event.get("id") or "")) < (cursor_time, cursor_id)] page = events[:limit] has_more = len(events) > limit next_cursor = None if has_more and page: last = page[-1] raw_timestamp = str(last.get("timestamp") or "") next_cursor = base64.urlsafe_b64encode(f"{raw_timestamp}|{last.get('id') or ''}".encode()).decode().rstrip("=") events = page else: has_more = False next_cursor = None return { "case_tree": case_rows, "events": events, "total": total, "next_cursor": next_cursor, "has_more": has_more, "truncated": tree_truncated, } except HTTPException: raise except Exception as e: logger.error("❌ Error loading case timeline: %s", e) raise HTTPException(status_code=500, detail="Failed to load case timeline") @router.post("/sag/{sag_id}/kommentarer") async def add_kommentar(sag_id: int, data: dict, request: Request): """Add a comment to a case.""" try: if not data.get("indhold"): raise HTTPException(status_code=400, detail="indhold is required") check = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,)) if not check: raise HTTPException(status_code=404, detail="Case not found") er_system_besked = bool(data.get("er_system_besked", False)) er_intern = bool(data.get("er_intern", False)) has_internal_flag = table_has_column("sag_kommentarer", "er_intern") if er_system_besked: forfatter = str(data.get("forfatter") or "System").strip() or "System" else: forfatter = None try: user_id = _get_user_id_from_request(request) user_row = execute_query_single( """ SELECT COALESCE(NULLIF(TRIM(full_name), ''), NULLIF(TRIM(username), '')) AS display_name FROM users WHERE user_id = %s """, (user_id,), ) if user_row and user_row.get("display_name"): forfatter = str(user_row.get("display_name")).strip() except HTTPException: # Fallback to provided author for legacy callers without auth context. pass if not forfatter: provided_author = str(data.get("forfatter") or "").strip() if provided_author and provided_author.lower() != "bruger": forfatter = provided_author else: forfatter = "Bruger" if has_internal_flag: query = """ INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked, er_intern) VALUES (%s, %s, %s, %s, %s) RETURNING * """ result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked, er_intern)) else: query = """ INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked) VALUES (%s, %s, %s, %s) RETURNING * """ result = execute_query(query, (sag_id, forfatter, data.get("indhold"), er_system_besked)) if result: logger.info("✅ Comment added to case %s by %s", sag_id, forfatter) return result[0] raise HTTPException(status_code=500, detail="Failed to add comment") except HTTPException: raise except Exception as e: logger.error("❌ Error adding comment: %s", e) raise HTTPException(status_code=500, detail="Failed to add comment") # ============================================================================ # WORK ORDERS / LABELS # ============================================================================ def _resolve_case_row_for_documents(sag_id: int): row = execute_query_single( """ SELECT s.id, s.titel, s.status, s.created_at, s.beskrivelse, s.customer_id, c.name AS customer_name FROM sag_sager s LEFT JOIN customers c ON c.id = s.customer_id WHERE s.id = %s AND s.deleted_at IS NULL """, (sag_id,), ) if not row: raise HTTPException(status_code=404, detail="Case not found") return row def _store_generated_case_file( sag_id: int, filename: str, content_bytes: bytes, content_type: str, source_type: str, source_token: Optional[str] = None, ) -> Dict: stored_name = _generate_stored_name(filename, SAG_FILE_SUBDIR) destination = _resolve_attachment_path(stored_name) destination.parent.mkdir(parents=True, exist_ok=True) destination.write_bytes(content_bytes) query = """ INSERT INTO sag_files ( sag_id, filename, content_type, size_bytes, stored_name, source_type, source_token ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id, filename, created_at """ rows = execute_query( query, (sag_id, filename, content_type, len(content_bytes), stored_name, source_type, source_token), ) return rows[0] @router.get("/sag/{sag_id}/work-orders/print", response_class=HTMLResponse) async def print_case_work_order(sag_id: int, request: Request): """Render a printable work order with scan token + barcode.""" case = _resolve_case_row_for_documents(sag_id) user_id = None try: user_id = _get_user_id_from_request(request) except HTTPException: user_id = None token = _create_document_token(sag_id, "work_order", user_id=user_id) barcode_svg = _render_code39_svg(token) todo_steps = execute_query( """ SELECT title, description, due_date, is_done FROM sag_todo_steps WHERE sag_id = %s AND deleted_at IS NULL ORDER BY is_done ASC, due_date ASC NULLS LAST, id ASC """, (sag_id,), ) or [] todo_items_html = "".join( ( "| Enhed | Serienr. | Tag | Type |
|---|
{_html.escape(body_text)}"
# Ensure subject carries [SAG-XX] prefix for reliable subject-line matching
sag_prefix = f"[SAG-{sag_id}]"
if sag_prefix not in subject:
subject = f"{sag_prefix} {subject}"
email_service = EmailService()
success, send_message, generated_message_id, provider_thread_key = await email_service.send_email_with_attachments(
to_addresses=to_addresses,
subject=subject,
body_text=body_text,
body_html=body_html,
cc=cc_addresses,
bcc=bcc_addresses,
in_reply_to=in_reply_to_header,
references=references_header,
attachments=smtp_attachments,
respect_dry_run=False,
)
if not success:
logger.error("❌ Failed to send case email for case %s: %s", sag_id, send_message)
failure_detail = str(send_message or "Email send failed without provider detail").strip()
# Keep error user-facing but concise; frontend will display this detail directly.
failure_detail = failure_detail.replace("❌", "").strip()
if failure_detail.lower() == "failed to send email":
failure_detail = "Email send failed (provider returned generic error; check Graph app permissions and API logs)"
raise HTTPException(status_code=500, detail=f"{failure_detail} [diag:sag-send-v3]")
sender_name = email_service.smtp_config.get("from_name") or "BMC Hub"
sender_email = (
email_service.graph_config.get("user_email")
if email_service._graph_send_available()
else email_service.smtp_config.get("from_address")
) or ""
provider_thread_key_normalized = _normalize_message_id_token(provider_thread_key)
# Keep replies in the existing case thread when we already know the target thread.
# Some providers may return a new conversation id even for replies.
derived_thread_key = _derive_thread_key_for_outbound(
effective_payload_thread_key,
in_reply_to_header,
references_header,
None,
)
thread_key = derived_thread_key or provider_thread_key_normalized
if not thread_key:
thread_key = _derive_thread_key_for_outbound(
effective_payload_thread_key,
in_reply_to_header,
references_header,
generated_message_id,
)
insert_result = None
insert_error = None
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
in_reply_to, email_references, thread_key,
received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
in_reply_to = COALESCE(EXCLUDED.in_reply_to, email_messages.in_reply_to),
email_references = COALESCE(EXCLUDED.email_references, email_messages.email_references),
thread_key = COALESCE(EXCLUDED.thread_key, email_messages.thread_key),
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
status = 'sent',
import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method),
linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id),
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
body_html,
in_reply_to_header,
references_header,
thread_key,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"manual_upload",
sag_id,
),
)
except Exception as e:
insert_error = e
logger.warning("⚠️ Outbound email full insert fallback for case %s: %s", sag_id, e)
if not insert_result:
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text, body_html,
received_date, folder, has_attachments, attachment_count,
status, import_method, linked_case_id
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
body_html = EXCLUDED.body_html,
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
status = 'sent',
import_method = COALESCE(email_messages.import_method, EXCLUDED.import_method),
linked_case_id = COALESCE(email_messages.linked_case_id, EXCLUDED.linked_case_id),
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
body_html,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
"sent",
"manual_upload",
sag_id,
),
)
except Exception as e:
insert_error = e
logger.warning("⚠️ Outbound email medium insert fallback for case %s: %s", sag_id, e)
if not insert_result:
# Legacy-safe fallback: persist with minimal guaranteed columns.
try:
insert_email_query = """
INSERT INTO email_messages (
message_id, subject, sender_email, sender_name,
recipient_email, cc, body_text,
received_date, folder, has_attachments, attachment_count
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
ON CONFLICT (message_id) DO UPDATE
SET
subject = EXCLUDED.subject,
sender_email = EXCLUDED.sender_email,
sender_name = EXCLUDED.sender_name,
recipient_email = EXCLUDED.recipient_email,
cc = EXCLUDED.cc,
body_text = EXCLUDED.body_text,
folder = 'Sent',
has_attachments = EXCLUDED.has_attachments,
attachment_count = EXCLUDED.attachment_count,
updated_at = CURRENT_TIMESTAMP
RETURNING id
"""
insert_result = execute_query(
insert_email_query,
(
generated_message_id,
subject,
sender_email,
sender_name,
", ".join(to_addresses),
", ".join(cc_addresses),
body_text,
datetime.now(),
"Sent",
bool(smtp_attachments),
len(smtp_attachments),
),
)
except Exception as e:
insert_error = e
logger.error("❌ Email sent but outbound log insert failed for case %s: %s", sag_id, e)
email_id = None
if insert_result:
email_id = insert_result[0]["id"]
else:
# Last chance recovery: if row exists already, continue with that id.
existing_email = execute_query_single(
"SELECT id FROM email_messages WHERE message_id = %s",
(generated_message_id,),
) if generated_message_id else None
if existing_email:
email_id = existing_email["id"]
else:
warning_detail = str(insert_error or "email logging failed")
logger.error("❌ Email sent but no local email_id could be resolved for case %s", sag_id)
return {
"status": "sent",
"email_id": None,
"message": send_message,
"warning": f"Email sent but could not be logged locally: {warning_detail}",
}
if smtp_attachments and email_id:
from psycopg2 import Binary
for attachment in smtp_attachments:
try:
execute_query(
"""
INSERT INTO email_attachments (
email_id, filename, content_type, size_bytes, file_path, content_data
)
VALUES (%s, %s, %s, %s, %s, %s)
""",
(
email_id,
attachment["filename"],
attachment["content_type"],
attachment.get("size") or len(attachment["content"]),
attachment.get("file_path"),
Binary(attachment["content"]),
),
)
except Exception as e:
logger.warning(
"⚠️ Could not persist outbound email attachment '%s' for email_id=%s: %s",
attachment.get("filename"),
email_id,
e,
)
linked_ok = False
try:
execute_query(
"""
INSERT INTO sag_emails (sag_id, email_id)
VALUES (%s, %s)
ON CONFLICT DO NOTHING
""",
(sag_id, email_id),
)
linked_ok = True
except Exception as e:
logger.warning("⚠️ Could not insert sag_emails link for case=%s email_id=%s: %s", sag_id, email_id, e)
if table_has_column("email_messages", "linked_case_id"):
try:
execute_query(
"UPDATE email_messages SET linked_case_id = %s WHERE id = %s",
(sag_id, email_id),
)
linked_ok = True
except Exception as nested_e:
logger.warning(
"⚠️ Fallback linked_case_id update also failed for case=%s email_id=%s: %s",
sag_id,
email_id,
nested_e,
)
sent_ts = datetime.now().isoformat()
outgoing_comment = (
"📧 Udgående email\n"
f"Email-ID: {email_id}\n"
f"Fra: {sender_email or sender_name or 'BMC Hub'}\n"
f"Til: {', '.join(to_addresses)}\n"
f"Emne: {subject}\n"
f"Modtaget: {sent_ts}\n\n"
f"{body_text}"
)
comment_row = {}
try:
has_system_flag = table_has_column("sag_kommentarer", "er_system_besked")
attempted_errors = []
has_comment_id_col = table_has_column("sag_kommentarer", "kommentar_id")
has_id_col = table_has_column("sag_kommentarer", "id")
# Prefer the variant that matches the live schema to avoid noisy SQL errors in logs.
if has_comment_id_col:
returning_variants = ["kommentar_id", "id AS kommentar_id"]
elif has_id_col:
returning_variants = ["id AS kommentar_id", "kommentar_id"]
else:
returning_variants = ["kommentar_id", "id AS kommentar_id"]
if has_system_flag:
comment_variants = [
(
f"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
VALUES (%s, %s, %s, %s)
RETURNING {returning_expr}, created_at
""",
(sag_id, 'Email Bot', outgoing_comment, True),
)
for returning_expr in returning_variants
]
else:
comment_variants = [
(
f"""
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold)
VALUES (%s, %s, %s)
RETURNING {returning_expr}, created_at
""",
(sag_id, 'Email Bot', outgoing_comment),
)
for returning_expr in returning_variants
]
for variant_query, variant_params in comment_variants:
try:
comment_row = execute_query_single(variant_query, variant_params) or {}
if comment_row:
break
except Exception as variant_error:
attempted_errors.append(str(variant_error))
if not comment_row and attempted_errors:
logger.warning(
"⚠️ Outbound email sent but comment logging variants failed for case %s: %s",
sag_id,
" | ".join(attempted_errors),
)
except Exception as e:
logger.warning("⚠️ Outbound email sent but comment logging failed for case %s: %s", sag_id, e)
comment_created_at = comment_row.get("created_at")
if isinstance(comment_created_at, datetime):
comment_created_at = comment_created_at.isoformat()
elif comment_created_at is None:
comment_created_at = sent_ts
logger.info(
"✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, payload_thread_key=%s, stored_thread_key=%s, provider_thread_key=%s, recipients=%s)",
sag_id,
email_id,
payload.thread_email_id,
effective_payload_thread_key,
thread_key,
provider_thread_key_normalized,
", ".join(to_addresses),
)
return {
"status": "sent",
"email_id": email_id,
"message": send_message,
"linked_to_case": linked_ok,
"warning": None if linked_ok else "Email sent, but automatic case-thread link fallback was required",
"comment": {
"kommentar_id": comment_row.get("kommentar_id"),
"forfatter": "Email Bot",
"indhold": outgoing_comment,
"created_at": comment_created_at,
"er_system_besked": True,
},
}
# ============================================================================
# SOLUTIONS
# ============================================================================
from . import solutions
router.include_router(solutions.router)