import logging import os import shutil from pathlib import Path from datetime import datetime from typing import List, Optional from uuid import uuid4 from fastapi import APIRouter, HTTPException, Query, UploadFile, File, Request from fastapi.responses import FileResponse from pydantic import BaseModel, Field from app.core.database import execute_query, execute_query_single from app.models.schemas import TodoStep, TodoStepCreate, TodoStepUpdate, QuickCreateAnalysis from app.core.config import settings from app.services.email_service import EmailService from app.services.case_analysis_service import CaseAnalysisService try: import extract_msg except ImportError: extract_msg = None import email from email.header import decode_header logger = logging.getLogger(__name__) router = APIRouter() 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 _normalize_case_status(status_value: Optional[str]) -> str: if not status_value: return "åben" normalized = str(status_value).strip().lower() if normalized == "afventer": return "åben" if normalized in {"åben", "lukket"}: return normalized return "åben" 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.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}") # ============================================================================ # QUICKCREATE AI ANALYSIS # ============================================================================ class QuickCreateRequest(BaseModel): text: str = Field(..., min_length=1, max_length=5000) user_id: int 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 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)) @router.post("/sag/analyze-quick-create", response_model=QuickCreateAnalysis) async def analyze_quick_create(request: QuickCreateRequest): """ Analyze case description text using AI for QuickCreate feature. Returns structured suggestions for customer, technician, priority, tags, etc. """ try: logger.info(f"🔍 QuickCreate analysis requested by user {request.user_id}, text length: {len(request.text)}") # Initialize service and analyze service = CaseAnalysisService() analysis = await service.analyze_case_text(request.text, request.user_id) logger.info(f"✅ QuickCreate analysis complete: confidence={analysis.confidence}, priority={analysis.suggested_priority}") return analysis except Exception as e: logger.error(f"❌ QuickCreate analysis failed: {str(e)}", exc_info=True) raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}") # ============================================================================ # SAGER - CRUD Operations # ============================================================================ @router.get("/sag") async def list_sager( status: Optional[str] = Query(None), tag: Optional[str] = Query(None), customer_id: Optional[int] = Query(None), ansvarlig_bruger_id: Optional[int] = Query(None), assigned_group_id: Optional[int] = Query(None), include_deferred: bool = Query(False), q: Optional[str] = Query(None), limit: Optional[int] = Query(None, ge=1, le=200), offset: int = Query(0, ge=0), ): """List all cases with optional filtering.""" try: query = """ SELECT s.*, COALESCE(u.full_name, u.username) AS ansvarlig_navn, g.name AS assigned_group_name FROM sag_sager s LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN groups g ON g.id = s.assigned_group_id WHERE s.deleted_at IS NULL """ params = [] if not include_deferred: query += " AND (deferred_until IS NULL OR deferred_until <= NOW())" if status: query += " AND s.status = %s" params.append(status) if customer_id: query += " AND s.customer_id = %s" params.append(customer_id) if ansvarlig_bruger_id: query += " AND s.ansvarlig_bruger_id = %s" params.append(ansvarlig_bruger_id) if assigned_group_id: query += " AND s.assigned_group_id = %s" params.append(assigned_group_id) if q: query += " AND (LOWER(s.titel) LIKE %s OR CAST(s.id AS TEXT) LIKE %s)" q_like = f"%{q.lower()}%" params.extend([q_like, q_like]) query += " ORDER BY s.created_at DESC" if limit is not None: query += " LIMIT %s OFFSET %s" params.extend([limit, offset]) cases = execute_query(query, tuple(params)) # If tag filter, filter in Python after fetch if tag: case_ids = [case["id"] for case in cases] if case_ids: tag_query = "SELECT sag_id FROM sag_tags WHERE tag_navn = %s AND deleted_at IS NULL" tagged_cases = execute_query(tag_query, (tag,)) tagged_ids = set(t["sag_id"] for t in tagged_cases) cases = [c for c in cases if c["id"] in tagged_ids] return cases except Exception as e: logger.error("❌ Error listing cases: %s", e) raise HTTPException(status_code=500, detail="Failed to list cases") @router.get("/sag/sale-items/all") async def list_all_sale_items( type: Optional[str] = Query(None), status: Optional[str] = Query(None), q: Optional[str] = Query(None), customer_id: Optional[int] = Query(None), sag_id: Optional[int] = Query(None), date_from: Optional[str] = Query(None), date_to: Optional[str] = Query(None), ): """List all sale items across cases (orders overview).""" try: if not _table_exists("sag_salgsvarer"): logger.warning("⚠️ sag_salgsvarer table missing - returning empty sale items list") return [] query = """ SELECT si.*, s.titel AS sag_titel, s.customer_id, c.name AS customer_name FROM sag_salgsvarer si JOIN sag_sager s ON s.id = si.sag_id LEFT JOIN customers c ON c.id = s.customer_id WHERE s.deleted_at IS NULL """ params = [] if type: query += " AND LOWER(si.type) = %s" params.append(type.lower()) if status: query += " AND LOWER(si.status) = %s" params.append(status.lower()) if q: query += " AND (LOWER(si.description) LIKE %s OR LOWER(si.external_ref) LIKE %s OR LOWER(s.titel) LIKE %s OR LOWER(c.name) LIKE %s)" like = f"%{q.lower()}%" params.extend([like, like, like, like]) if customer_id: query += " AND s.customer_id = %s" params.append(customer_id) if sag_id: query += " AND s.id = %s" params.append(sag_id) if date_from: query += " AND si.line_date >= %s" params.append(date_from) if date_to: query += " AND si.line_date <= %s" params.append(date_to) query += " ORDER BY si.line_date DESC NULLS LAST, si.id DESC" return execute_query(query, tuple(params)) or [] except Exception as e: logger.error("❌ Error listing sale items: %s", e) raise HTTPException(status_code=500, detail="Failed to list sale items") @router.post("/sag") async def create_sag(data: dict): """Create a new case.""" try: if not data.get("titel"): raise HTTPException(status_code=400, detail="titel is required") if not data.get("customer_id"): raise HTTPException(status_code=400, detail="customer_id is required") status = _normalize_case_status(data.get("status")) deadline = _normalize_optional_timestamp(data.get("deadline"), "deadline") deferred_until = _normalize_optional_timestamp(data.get("deferred_until"), "deferred_until") ansvarlig_bruger_id = _coerce_optional_int(data.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id") assigned_group_id = _coerce_optional_int(data.get("assigned_group_id"), "assigned_group_id") _validate_user_id(ansvarlig_bruger_id) _validate_group_id(assigned_group_id) query = """ INSERT INTO sag_sager (titel, beskrivelse, template_key, status, customer_id, ansvarlig_bruger_id, assigned_group_id, created_by_user_id, deadline, deferred_until, deferred_until_case_id, deferred_until_status) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING * """ params = ( data.get("titel"), data.get("beskrivelse", ""), data.get("template_key") or data.get("type", "ticket"), status, data.get("customer_id"), ansvarlig_bruger_id, assigned_group_id, data.get("created_by_user_id", 1), deadline, deferred_until, data.get("deferred_until_case_id"), data.get("deferred_until_status"), ) result = execute_query(query, params) if result: logger.info("✅ Case created: %s", result[0]["id"]) return result[0] raise HTTPException(status_code=500, detail="Failed to create case") except Exception as e: logger.error("❌ Error creating case: %s", e) raise HTTPException(status_code=500, detail="Failed to create case") @router.get("/sag/{sag_id}") async def get_sag(sag_id: int): """Get a specific case.""" try: query = """ SELECT s.*, COALESCE(u.full_name, u.username) AS ansvarlig_navn, g.name AS assigned_group_name FROM sag_sager s LEFT JOIN users u ON u.user_id = s.ansvarlig_bruger_id LEFT JOIN groups g ON g.id = s.assigned_group_id WHERE s.id = %s AND s.deleted_at IS NULL """ result = execute_query(query, (sag_id,)) if not result: raise HTTPException(status_code=404, detail="Case not found") return result[0] except HTTPException: raise except Exception as e: logger.error("❌ Error getting case: %s", e) raise HTTPException(status_code=500, detail="Failed to get case") @router.get("/sag/{sag_id}/modules") async def get_case_module_prefs(sag_id: int): """Get module visibility preferences for a case.""" try: query = "SELECT module_key, is_enabled FROM sag_module_prefs WHERE sag_id = %s" prefs = execute_query(query, (sag_id,)) return prefs or [] except Exception as e: logger.error("❌ Error getting module prefs: %s", e) raise HTTPException(status_code=500, detail="Failed to get module prefs") @router.post("/sag/{sag_id}/modules") async def set_case_module_pref(sag_id: int, data: dict): """Set module visibility preference for a case.""" try: module_key = data.get("module_key") is_enabled = data.get("is_enabled") if not module_key or is_enabled is None: raise HTTPException(status_code=400, detail="module_key and is_enabled are required") query = """ INSERT INTO sag_module_prefs (sag_id, module_key, is_enabled) VALUES (%s, %s, %s) ON CONFLICT (sag_id, module_key) DO UPDATE SET is_enabled = EXCLUDED.is_enabled RETURNING module_key, is_enabled """ result = execute_query(query, (sag_id, module_key, bool(is_enabled))) return result[0] if result else {"module_key": module_key, "is_enabled": bool(is_enabled)} except HTTPException: raise except Exception as e: logger.error("❌ Error setting module pref: %s", e) raise HTTPException(status_code=500, detail="Failed to set module pref") # ========================================================================= # SAG TODO STEPS # ========================================================================= @router.get("/sag/{sag_id}/todo-steps", response_model=List[TodoStep]) async def list_todo_steps(sag_id: int): try: query = """ SELECT t.*, COALESCE(u_created.full_name, u_created.username) AS created_by_name, COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name FROM sag_todo_steps t LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id WHERE t.sag_id = %s AND t.deleted_at IS NULL ORDER BY t.is_done ASC, t.due_date NULLS LAST, t.created_at DESC """ return execute_query(query, (sag_id,)) or [] except Exception as e: logger.error("❌ Error listing todo steps: %s", e) raise HTTPException(status_code=500, detail="Failed to list todo steps") @router.post("/sag/{sag_id}/todo-steps", response_model=TodoStep) async def create_todo_step(sag_id: int, request: Request, data: TodoStepCreate): try: user_id = _get_user_id_from_request(request) if not data.title: raise HTTPException(status_code=400, detail="title is required") insert_query = """ INSERT INTO sag_todo_steps (sag_id, title, description, due_date, created_by_user_id) VALUES (%s, %s, %s, %s, %s) RETURNING id """ result = execute_query(insert_query, ( sag_id, data.title, data.description, data.due_date, user_id )) if not result: raise HTTPException(status_code=500, detail="Failed to create todo step") step_id = result[0]["id"] return execute_query( """ SELECT t.*, COALESCE(u_created.full_name, u_created.username) AS created_by_name, COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name FROM sag_todo_steps t LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id WHERE t.id = %s """, (step_id,) )[0] except HTTPException: raise except Exception as e: logger.error("❌ Error creating todo step: %s", e) raise HTTPException(status_code=500, detail="Failed to create todo step") @router.patch("/sag/todo-steps/{step_id}", response_model=TodoStep) async def update_todo_step(step_id: int, request: Request, data: TodoStepUpdate): try: if data.is_done is None: raise HTTPException(status_code=400, detail="is_done is required") user_id = _get_user_id_from_request(request) if data.is_done: update_query = """ UPDATE sag_todo_steps SET is_done = TRUE, completed_by_user_id = %s, completed_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(update_query, (user_id, step_id)) else: update_query = """ UPDATE sag_todo_steps SET is_done = FALSE, completed_by_user_id = NULL, completed_at = NULL WHERE id = %s AND deleted_at IS NULL RETURNING id """ result = execute_query(update_query, (step_id,)) if not result: raise HTTPException(status_code=404, detail="Todo step not found") return execute_query( """ SELECT t.*, COALESCE(u_created.full_name, u_created.username) AS created_by_name, COALESCE(u_completed.full_name, u_completed.username) AS completed_by_name FROM sag_todo_steps t LEFT JOIN users u_created ON u_created.user_id = t.created_by_user_id LEFT JOIN users u_completed ON u_completed.user_id = t.completed_by_user_id WHERE t.id = %s """, (step_id,) )[0] except HTTPException: raise except Exception as e: logger.error("❌ Error updating todo step: %s", e) raise HTTPException(status_code=500, detail="Failed to update todo step") @router.delete("/sag/todo-steps/{step_id}") async def delete_todo_step(step_id: int): try: result = execute_query( """ UPDATE sag_todo_steps SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s AND deleted_at IS NULL RETURNING id """, (step_id,) ) if not result: raise HTTPException(status_code=404, detail="Todo step not found") return {"status": "deleted"} except HTTPException: raise except Exception as e: logger.error("❌ Error deleting todo step: %s", e) raise HTTPException(status_code=500, detail="Failed to delete todo step") @router.patch("/sag/{sag_id}") async def update_sag(sag_id: int, updates: dict): """Update 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") # Backwards compatibility: frontend sends "type", DB stores "template_key" if "type" in updates and "template_key" not in updates: updates["template_key"] = updates.get("type") if "status" in updates: updates["status"] = _normalize_case_status(updates.get("status")) if "deadline" in updates: updates["deadline"] = _normalize_optional_timestamp(updates.get("deadline"), "deadline") if "deferred_until" in updates: updates["deferred_until"] = _normalize_optional_timestamp(updates.get("deferred_until"), "deferred_until") if "ansvarlig_bruger_id" in updates: updates["ansvarlig_bruger_id"] = _coerce_optional_int(updates.get("ansvarlig_bruger_id"), "ansvarlig_bruger_id") _validate_user_id(updates["ansvarlig_bruger_id"]) if "assigned_group_id" in updates: updates["assigned_group_id"] = _coerce_optional_int(updates.get("assigned_group_id"), "assigned_group_id") _validate_group_id(updates["assigned_group_id"]) # Build dynamic update query allowed_fields = [ "titel", "beskrivelse", "template_key", "status", "ansvarlig_bruger_id", "assigned_group_id", "deadline", "deferred_until", "deferred_until_case_id", "deferred_until_status", ] set_clauses = [] params = [] for field in allowed_fields: if field in updates: set_clauses.append(f"{field} = %s") params.append(updates[field]) if not set_clauses: raise HTTPException(status_code=400, detail="No valid fields to update") params.append(sag_id) set_sql = ", ".join(set_clauses) query = f"UPDATE sag_sager SET {set_sql} WHERE id = %s RETURNING *" result = execute_query(query, tuple(params)) if result: logger.info("✅ Case updated: %s", sag_id) return result[0] raise HTTPException(status_code=500, detail="Failed to update case") except HTTPException: raise except Exception as e: logger.error("❌ Error updating case: %s", e) raise HTTPException(status_code=500, detail="Failed to update case") # --------------------------------------------------------------------------- # Beskrivelse inline editing with history # --------------------------------------------------------------------------- class BeskrivelsePatch(BaseModel): beskrivelse: str @router.patch("/sag/{sag_id}/beskrivelse") async def update_sag_beskrivelse(sag_id: int, body: BeskrivelsePatch, request: Request): """Update case description and store a change history entry.""" try: row = execute_query_single( "SELECT id, beskrivelse FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,) ) if not row: raise HTTPException(status_code=404, detail="Case not found") old_beskrivelse = row.get("beskrivelse") new_beskrivelse = body.beskrivelse # Resolve acting user (may be None for anonymous) user_id = _get_user_id_from_request(request) changed_by_name = None if user_id: u = execute_query_single( "SELECT COALESCE(full_name, username, CONCAT('Bruger #', user_id::text)) AS name FROM users WHERE user_id = %s", (user_id,) ) if u: changed_by_name = u["name"] # 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") 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}") 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") 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 = data.get("målsag_id") relationstype = data.get("relationstype") # 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") 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}") 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") 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}") 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") # ============================================================================ # 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}/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.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.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']}" }) 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']}" }) 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']}" }) 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) # ============================================================================ @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.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") 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.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): """Update a sale item for a case.""" try: check = execute_query( "SELECT id 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", ] set_clauses = [] params = [] 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) else: set_clauses.append(f"{field} = %s") params.append(updates[field]) 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 ASC" 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") @router.post("/sag/{sag_id}/kommentarer") async def add_kommentar(sag_id: int, data: dict): """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") # Default author to current user or provided in body (if system) # simplistic auth for now forfatter = data.get("forfatter", "Bruger") er_system_besked = data.get("er_system_besked", False) 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") # ============================================================================ # FILES - Case Files # ============================================================================ UPLOAD_BASE_PATH = Path(settings.UPLOAD_DIR).resolve() SAG_FILE_SUBDIR = "sag_files" (UPLOAD_BASE_PATH / SAG_FILE_SUBDIR).mkdir(parents=True, exist_ok=True) ALLOWED_EXTENSIONS = {ext.lower() for ext in settings.ALLOWED_EXTENSIONS} MAX_ATTACHMENT_SIZE = settings.EMAIL_MAX_UPLOAD_SIZE_MB * 1024 * 1024 def _generate_stored_name(filename: str, subdir: str) -> str: cleaned = Path(filename).name unique = f"{uuid4().hex}_{cleaned}" return f"{subdir}/{unique}" def _resolve_attachment_path(stored_name: str) -> Path: return UPLOAD_BASE_PATH / stored_name def _store_upload_file(upload_file: UploadFile, subdir: str): if not upload_file.filename: raise HTTPException(400, detail="Filename missing") ext = Path(upload_file.filename).suffix.lower().lstrip(".") # Basic check - allow more types for generic files? # if ext not in ALLOWED_EXTENSIONS: ... upload_file.file.seek(0, os.SEEK_END) size = upload_file.file.tell() upload_file.file.seek(0) if size > MAX_ATTACHMENT_SIZE: raise HTTPException(400, detail=f"File too large (> {settings.EMAIL_MAX_UPLOAD_SIZE_MB}MB)") stored_name = _generate_stored_name(upload_file.filename, subdir) destination = _resolve_attachment_path(stored_name) destination.parent.mkdir(parents=True, exist_ok=True) try: with destination.open("wb") as buffer: shutil.copyfileobj(upload_file.file, buffer) except Exception as e: logger.error(f"Upload failed: {e}") raise HTTPException(500, detail="Server upload failed") return stored_name, size @router.get("/sag/{sag_id}/files") async def list_sag_files(sag_id: int): """List files attached to a case.""" try: query = """ SELECT * FROM sag_files WHERE sag_id = %s ORDER BY created_at DESC """ files = execute_query(query, (sag_id,)) # Add download URL if files: for f in files: f["download_url"] = f"/api/v1/sag/{sag_id}/files/{f['id']}" return files or [] except Exception as e: logger.error("❌ Error listing files: %s", e) raise HTTPException(status_code=500, detail="Failed to list files") @router.post("/sag/{sag_id}/files") async def upload_sag_files(sag_id: int, files: List[UploadFile] = File(...)): """Upload files to a case.""" 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") saved_files = [] for file in files: try: stored_name, size = _store_upload_file(file, SAG_FILE_SUBDIR) query = """ INSERT INTO sag_files (sag_id, filename, content_type, size_bytes, stored_name) VALUES (%s, %s, %s, %s, %s) RETURNING id, filename, created_at """ result = execute_query(query, (sag_id, file.filename, file.content_type, size, stored_name)) if result: saved = result[0] saved["download_url"] = f"/api/v1/sag/{sag_id}/files/{saved['id']}" saved_files.append(saved) except HTTPException: continue # Skip invalid except Exception as e: logger.error(f"Error saving file {file.filename}: {e}") continue return saved_files @router.get("/sag/{sag_id}/files/{file_id}") async def download_sag_file(sag_id: int, file_id: int, download: bool = False): """Download or preview a specific file. Args: download: If True, force download. If False (default), display inline in browser. """ query = "SELECT * FROM sag_files WHERE id = %s AND sag_id = %s" result = execute_query(query, (file_id, sag_id)) if not result: raise HTTPException(status_code=404, detail="File not found") file_data = result[0] path = _resolve_attachment_path(file_data["stored_name"]) if not path.exists(): raise HTTPException(status_code=404, detail="File lost on server") # Determine content disposition headers = {} if download: headers["Content-Disposition"] = f'attachment; filename="{file_data["filename"]}"' else: headers["Content-Disposition"] = f'inline; filename="{file_data["filename"]}"' return FileResponse( path=path, filename=file_data["filename"], media_type=file_data.get("content_type", "application/octet-stream"), headers=headers ) @router.delete("/sag/{sag_id}/files/{file_id}") async def delete_sag_file(sag_id: int, file_id: int): """Delete a file.""" query = "DELETE FROM sag_files WHERE id = %s AND sag_id = %s RETURNING stored_name" result = execute_query(query, (file_id, sag_id)) if result: # Clean up disk path = _resolve_attachment_path(result[0]["stored_name"]) if path.exists(): try: os.remove(path) except: pass return {"status": "deleted"} raise HTTPException(status_code=404, detail="File not found") # ============================================================================ # EMAILS - Case Emails (Linked) # ============================================================================ @router.post("/sag/{sag_id}/email-links") async def add_sag_email_link(sag_id: int, payload: dict): """Link an existing email to a case.""" email_id = payload.get("email_id") if not email_id: raise HTTPException(status_code=400, detail="email_id required") query = """ INSERT INTO sag_emails (sag_id, email_id) VALUES (%s, %s) ON CONFLICT DO NOTHING RETURNING * """ execute_query(query, (sag_id, email_id)) return {"status": "linked"} @router.get("/sag/{sag_id}/email-links") async def get_sag_emails(sag_id: int): """Get emails linked to a case.""" query = """ WITH linked_emails AS ( SELECT e.*, COALESCE( NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.in_reply_to, '')), '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE((REGEXP_SPLIT_TO_ARRAY(COALESCE(e.email_references, ''), E'[\\s,]+'))[1], '[<>\\s]', '', 'g'), ''), NULLIF(REGEXP_REPLACE(TRIM(COALESCE(e.message_id, '')), '[<>\\s]', '', 'g'), ''), CONCAT('email-', e.id::text) ) AS thread_key FROM email_messages e JOIN sag_emails se ON e.id = se.email_id WHERE se.sag_id = %s ) SELECT linked_emails.*, COUNT(*) OVER (PARTITION BY linked_emails.thread_key) AS thread_message_count, MAX(linked_emails.received_date) OVER (PARTITION BY linked_emails.thread_key) AS thread_last_received_date FROM linked_emails ORDER BY thread_last_received_date DESC NULLS LAST, received_date DESC """ return execute_query(query, (sag_id,)) or [] @router.delete("/sag/{sag_id}/email-links/{email_id}") async def remove_sag_email_link(sag_id: int, email_id: int): """Unlink an email.""" query = "DELETE FROM sag_emails WHERE sag_id = %s AND email_id = %s" execute_query(query, (sag_id, email_id)) return {"status": "unlinked"} def _decode_header_str(header_val): if not header_val: return "" try: decoded_list = decode_header(header_val) result = "" for content, encoding in decoded_list: if isinstance(content, bytes): if encoding: try: result += content.decode(encoding) except: result += content.decode('utf-8', errors='ignore') else: result += content.decode('utf-8', errors='ignore') else: result += str(content) return result except: return str(header_val) @router.post("/sag/{sag_id}/upload-email") async def upload_sag_email(sag_id: int, file: UploadFile = File(...)): """Upload .eml/.msg, parse it, save to email_messages, and link to case.""" content = await file.read() filename = file.filename.lower() email_data = {} temp_id = uuid4().hex # 1. Parse File if filename.endswith('.msg'): if not extract_msg: raise HTTPException(500, "extract-msg missing") import io msg = extract_msg.Message(io.BytesIO(content)) email_data = { 'message_id': msg.messageId or f"msg-{temp_id}", 'subject': msg.subject or "No Subject", 'sender_email': msg.sender or "", 'sender_name': msg.sender or "", 'recipient_email': msg.to or "", 'cc': msg.cc or "", 'body_text': msg.body, 'body_html': msg.htmlBody, 'received_date': msg.date or datetime.now(), 'folder': 'Imported', 'attachments': [], 'has_attachments': False, 'attachment_count': 0 } elif filename.endswith('.eml'): msg = email.message_from_bytes(content) body_text = "" if msg.is_multipart(): for part in msg.walk(): if part.get_content_type() == "text/plain": body_text = part.get_payload(decode=True).decode('utf-8', errors='ignore') break else: body_text = msg.get_payload(decode=True).decode('utf-8', errors='ignore') email_data = { 'message_id': msg.get('Message-ID', f"eml-{temp_id}"), 'in_reply_to': _decode_header_str(msg.get('In-Reply-To', '')), 'email_references': _decode_header_str(msg.get('References', '')), 'subject': _decode_header_str(msg.get('Subject', 'No Subject')), 'sender_email': _decode_header_str(msg.get('From', '')), 'sender_name': _decode_header_str(msg.get('From', '')), 'recipient_email': _decode_header_str(msg.get('To', '')), 'cc': _decode_header_str(msg.get('Cc', '')), 'received_date': datetime.now(), 'body_text': body_text, 'body_html': "", 'folder': 'Imported', 'has_attachments': False, 'attachment_count': 0, 'attachments': [] } else: raise HTTPException(400, "Only .msg or .eml files allowed") # 2. Save Email via Service (deduplicate) svc = EmailService() existing = execute_query_single("SELECT id FROM email_messages WHERE message_id = %s", (email_data['message_id'],)) if existing: email_id = existing['id'] else: # We try to strict save, keeping it simple email_id = await svc.save_email(email_data) # 3. Link await add_sag_email_link(sag_id, {"email_id": email_id}) return {"status": "imported", "email_id": email_id} @router.post("/sag/{sag_id}/emails/send") async def send_sag_email(sag_id: int, payload: SagSendEmailRequest): """Send outbound email directly from case email tab and link it to case.""" case_exists = execute_query("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") to_addresses = _normalize_email_list(payload.to, "to") cc_addresses = _normalize_email_list(payload.cc, "cc") bcc_addresses = _normalize_email_list(payload.bcc, "bcc") if not to_addresses: raise HTTPException(status_code=400, detail="At least one recipient in 'to' is required") subject = (payload.subject or "").strip() body_text = (payload.body_text or "").strip() if not subject: raise HTTPException(status_code=400, detail="subject is required") if not body_text: raise HTTPException(status_code=400, detail="body_text is required") attachment_rows = [] attachment_ids = list(dict.fromkeys(payload.attachment_file_ids or [])) if attachment_ids: placeholders = ",".join(["%s"] * len(attachment_ids)) attachment_query = f""" SELECT id, filename, content_type, size_bytes, stored_name FROM sag_files WHERE sag_id = %s AND id IN ({placeholders}) """ attachment_rows = execute_query(attachment_query, (sag_id, *attachment_ids)) if len(attachment_rows) != len(attachment_ids): raise HTTPException(status_code=400, detail="One or more selected attachments were not found on this case") smtp_attachments = [] for row in attachment_rows: path = _resolve_attachment_path(row["stored_name"]) if not path.exists(): raise HTTPException(status_code=404, detail=f"Attachment file is missing on server: {row['filename']}") smtp_attachments.append({ "filename": row["filename"], "content_type": row.get("content_type") or "application/octet-stream", "content": path.read_bytes(), "size": row.get("size_bytes") or 0, "file_path": str(path), }) in_reply_to_header = None references_header = None if payload.thread_email_id: thread_row = None try: thread_row = execute_query_single( """ SELECT id, message_id, in_reply_to, email_references FROM email_messages WHERE id = %s """, (payload.thread_email_id,), ) except Exception: # Backward compatibility for DBs without in_reply_to/email_references columns. thread_row = execute_query_single( """ SELECT id, message_id FROM email_messages WHERE id = %s """, (payload.thread_email_id,), ) if thread_row: base_message_id = str(thread_row.get("message_id") or "").strip() if base_message_id and not base_message_id.startswith("<"): base_message_id = f"<{base_message_id}>" if base_message_id: in_reply_to_header = base_message_id existing_refs = str(thread_row.get("email_references") or "").strip() if existing_refs: references_header = f"{existing_refs} {base_message_id}".strip() else: references_header = base_message_id email_service = EmailService() success, send_message, generated_message_id = await email_service.send_email_with_attachments( to_addresses=to_addresses, subject=subject, body_text=body_text, body_html=payload.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) raise HTTPException(status_code=500, detail="Failed to send email") sender_name = settings.EMAIL_SMTP_FROM_NAME or "BMC Hub" sender_email = settings.EMAIL_SMTP_FROM_ADDRESS or "" insert_result = 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, 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) 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, payload.body_html, in_reply_to_header, references_header, datetime.now(), "Sent", bool(smtp_attachments), len(smtp_attachments), "sent", "sag_outbound", sag_id, ), ) except Exception: 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) 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, payload.body_html, datetime.now(), "Sent", bool(smtp_attachments), len(smtp_attachments), "sent", "sag_outbound", sag_id, ), ) if not insert_result: logger.error("❌ Email sent but outbound log insert failed for case %s", sag_id) raise HTTPException(status_code=500, detail="Email sent but logging failed") email_id = insert_result[0]["id"] if smtp_attachments: from psycopg2 import Binary for attachment in smtp_attachments: 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"]), ), ) execute_query( """ INSERT INTO sag_emails (sag_id, email_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (sag_id, email_id), ) logger.info( "✅ Outbound case email sent and linked (case=%s, email_id=%s, thread_email_id=%s, thread_key=%s, recipients=%s)", sag_id, email_id, payload.thread_email_id, payload.thread_key, ", ".join(to_addresses), ) return { "status": "sent", "email_id": email_id, "message": send_message, } # ============================================================================ # SOLUTIONS # ============================================================================ from . import solutions router.include_router(solutions.router)