import json import logging import re from typing import Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from app.core.auth_dependencies import get_current_user, require_any_permission from app.core.database import execute_query, execute_query_single from app.models.schemas import Solution, SolutionCreate, SolutionUpdate from app.services.case_analysis_service import CaseAnalysisService logger = logging.getLogger(__name__) router = APIRouter() case_edit_access = require_any_permission("cases.edit", "tickets.edit") VISIBILITIES = {"internal", "general", "customer"} APPROVAL_STATUSES = {"draft", "pending", "approved", "outdated", "rejected"} RESULT_ALIASES = {"resolved": "Løst", "partial": "Delvist", "unresolved": "Ej løst", "løst": "Løst", "delvist": "Delvist", "workaround": "Workaround", "ej løst": "Ej løst"} TYPE_ALIASES = {"standard": "Support", "permanent": "Support", "external": "Ekstern", "support": "Support", "drift": "Drift", "konsulent": "Konsulent", "infrastruktur": "Infrastruktur", "workaround": "Support"} SECRET_PATTERNS = ( (re.compile(r"(?i)\b(password|passwd|kodeord|api[_ -]?key|secret|token)\s*[:=]\s*([^\s,;]+)"), r"\1: [FJERNET]"), (re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+\-/]+=*"), "Bearer [FJERNET]"), (re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----.*?-----END [A-Z ]*PRIVATE KEY-----", re.S), "[PRIVAT NØGLE FJERNET]"), (re.compile(r"(?i)(?:postgres|mysql|mongodb(?:\+srv)?)://[^\s]+"), "[DATABASEFORBINDELSE FJERNET]"), ) def _user_id(current_user: dict) -> Optional[int]: value = current_user.get("id") or current_user.get("user_id") return int(value) if value is not None else None def _clean_list(values) -> list[str]: cleaned, seen = [], set() for value in values or []: item = str(value or "").strip() key = item.casefold() if item and key not in seen: seen.add(key) cleaned.append(item[:100]) return cleaned[:30] def _normalize_payload(data: dict) -> dict: if "title" in data: data["title"] = str(data.get("title") or "").strip() if not data["title"]: raise HTTPException(status_code=422, detail="Løsningen skal have en titel") if "visibility" in data: data["visibility"] = str(data.get("visibility") or "internal").lower() if data["visibility"] not in VISIBILITIES: raise HTTPException(status_code=422, detail="Ugyldig synlighed") if "approval_status" in data: data["approval_status"] = str(data.get("approval_status") or "draft").lower() if data["approval_status"] not in APPROVAL_STATUSES: raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus") if data.get("result") is not None: raw = str(data["result"]).strip() data["result"] = RESULT_ALIASES.get(raw.casefold(), raw) if data.get("solution_type") is not None: raw = str(data["solution_type"]).strip() data["solution_type"] = TYPE_ALIASES.get(raw.casefold(), raw) for field in ("tags", "products"): if field in data: data[field] = _clean_list(data[field]) return data def _redact_sensitive(text: str) -> tuple[str, list[str]]: cleaned = str(text or "") warnings = [] for pattern, replacement in SECRET_PATTERNS: cleaned, count = pattern.subn(replacement, cleaned) if count: warnings.append(f"{count} mulig(e) hemmelighed(er) blev fjernet før AI-behandling") return cleaned, warnings def _knowledge_tokens(text: str) -> list[str]: ignored = {"eller", "ikke", "med", "den", "det", "der", "som", "for", "fra", "til", "har", "kan", "skal", "sag", "sagen", "test", "viden"} tokens, seen = [], set() for token in re.findall(r"[A-Za-zÀ-ÿ0-9_.-]{3,}", str(text or "").lower()): if token in ignored or token in seen: continue seen.add(token) tokens.append(token) return tokens[:24] def _normalize_source_refs(raw_refs, allowed_refs: set[str], sag_id: int) -> list[str]: if isinstance(raw_refs, str): raw_refs = re.split(r"[,;\n]+", raw_refs) normalized = [f"Sag {sag_id}"] for raw in raw_refs or []: value = str(raw or "").strip().strip("[]") match = re.search(r"(?i)\b(sag|kommentar|mail|tid|artikel)\s*#?\s*(\d+)\b", value) if not match: continue kind = match.group(1).capitalize() canonical = f"{kind} {int(match.group(2))}" if canonical in allowed_refs and canonical not in normalized: normalized.append(canonical) return normalized def _version_solution(solution: dict, user_id: Optional[int], change_note: Optional[str] = None) -> None: version_row = execute_query_single("SELECT COALESCE(MAX(version_number), 0) + 1 AS next_version FROM sag_solution_versions WHERE solution_id = %s", (solution["id"],)) or {"next_version": 1} snapshot = dict(solution) for key, value in list(snapshot.items()): if hasattr(value, "isoformat"): snapshot[key] = value.isoformat() execute_query( "INSERT INTO sag_solution_versions (solution_id, version_number, snapshot, changed_by_user_id, change_note) VALUES (%s,%s,%s::jsonb,%s,%s)", (solution["id"], version_row["next_version"], json.dumps(snapshot), user_id, change_note), ) def _case_knowledge_context(sag_id: int, limit: int = 6) -> tuple[dict, list[dict]]: case = execute_query_single( """SELECT s.id,s.titel,s.beskrivelse,s.status, (SELECT sk.customer_id FROM sag_kunder sk WHERE sk.sag_id=s.id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1) AS customer_id, COALESCE((SELECT string_agg(t.name,' ') FROM entity_tags et JOIN tags t ON t.id=et.tag_id WHERE et.entity_type='case' AND et.entity_id=s.id),'') AS tag_text FROM sag_sager s WHERE s.id=%s AND s.deleted_at IS NULL""", (sag_id,), ) if not case: raise HTTPException(status_code=404, detail="Sagen findes ikke") tokens = _knowledge_tokens(" ".join([str(case.get("titel") or ""), str(case.get("beskrivelse") or ""), str(case.get("tag_text") or "")])) if not tokens: return case, [] search_query = " OR ".join(tokens) articles = execute_query( """SELECT ka.id,ka.title,ka.summary,ka.problem,ka.root_cause,ka.solution,ka.workaround, ka.visibility,ka.customer_id,ka.tags,ka.products,ka.sag_id,ka.updated_at, ts_rank_cd(ka.search_document,websearch_to_tsquery('simple',%s)) AS relevance FROM knowledge_articles ka WHERE ka.status='published' AND ka.archived_at IS NULL AND (ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s)) AND ka.search_document @@ websearch_to_tsquery('simple',%s) ORDER BY relevance DESC,ka.updated_at DESC LIMIT %s""", (search_query, case.get("customer_id"), search_query, limit), ) or [] if articles: top_relevance = float(articles[0].get("relevance") or 0) relative_floor = max(0.05, top_relevance * 0.35) articles = [row for row in articles if float(row.get("relevance") or 0) >= relative_floor] return case, articles @router.get("/sag/{sag_id}/solution", response_model=Optional[Solution]) async def get_solution(sag_id: int, _current_user: dict = Depends(get_current_user)): result = execute_query("SELECT * FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,)) return result[0] if result else None @router.get("/sag/{sag_id}/solution/versions") async def get_solution_versions(sag_id: int, _current_user: dict = Depends(get_current_user)): solution = execute_query_single("SELECT id FROM sag_solutions WHERE sag_id = %s AND deleted_at IS NULL", (sag_id,)) if not solution: return {"items": [], "total": 0} items = execute_query( """SELECT v.id, v.version_number, v.change_note, v.created_at, COALESCE(u.full_name, u.username) AS changed_by FROM sag_solution_versions v LEFT JOIN users u ON u.user_id=v.changed_by_user_id WHERE v.solution_id=%s ORDER BY v.version_number DESC""", (solution["id"],), ) or [] return {"items": items, "total": len(items)} @router.post("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)]) async def create_solution(sag_id: int, solution: SolutionCreate, current_user: dict = Depends(get_current_user)): if not execute_query_single("SELECT id FROM sag_sager WHERE id=%s AND deleted_at IS NULL", (sag_id,)): raise HTTPException(status_code=404, detail="Sagen findes ikke") existing = execute_query_single("SELECT id, deleted_at FROM sag_solutions WHERE sag_id=%s", (sag_id,)) if existing: detail = "Sagen har en arkiveret løsning, som skal gendannes" if existing.get("deleted_at") else "Der findes allerede en løsning på sagen" raise HTTPException(status_code=409, detail=detail) data = _normalize_payload(solution.model_dump(exclude={"sag_id", "created_by_user_id"})) user_id = _user_id(current_user) result = execute_query( """INSERT INTO sag_solutions ( sag_id,title,description,solution_type,result,problem,root_cause,investigation,workaround, visibility,approval_status,is_final,tags,products,created_by_user_id,updated_by_user_id ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s::jsonb,%s::jsonb,%s,%s) RETURNING *""", (sag_id, data["title"], data.get("description"), data.get("solution_type"), data.get("result"), data.get("problem"), data.get("root_cause"), data.get("investigation"), data.get("workaround"), data.get("visibility", "internal"), data.get("approval_status", "draft"), data.get("is_final", True), json.dumps(data.get("tags", [])), json.dumps(data.get("products", [])), user_id, user_id), ) created = result[0] _version_solution(created, user_id, "Løsning oprettet") return created @router.patch("/sag/{sag_id}/solution", response_model=Solution, dependencies=[Depends(case_edit_access)]) async def update_solution(sag_id: int, updates: SolutionUpdate, current_user: dict = Depends(get_current_user)): if not execute_query_single("SELECT id FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)): raise HTTPException(status_code=404, detail="Løsningen findes ikke") data = _normalize_payload(updates.model_dump(exclude_unset=True)) change_note = data.pop("change_note", None) allowed = {"title", "description", "solution_type", "result", "problem", "root_cause", "investigation", "workaround", "visibility", "approval_status", "is_final", "tags", "products"} fields, params = [], [] for key, value in data.items(): if key not in allowed: continue fields.append(f"{key} = %s" + ("::jsonb" if key in {"tags", "products"} else "")) params.append(json.dumps(value) if key in {"tags", "products"} else value) if not fields: raise HTTPException(status_code=400, detail="Ingen ændringer at gemme") user_id = _user_id(current_user) fields.extend(["updated_by_user_id = %s", "updated_at = NOW()"]) params.extend([user_id, sag_id]) updated = execute_query(f"UPDATE sag_solutions SET {', '.join(fields)} WHERE sag_id=%s RETURNING *", tuple(params))[0] _version_solution(updated, user_id, change_note or "Løsning redigeret") return updated @router.post("/sag/{sag_id}/solution/workflow", dependencies=[Depends(case_edit_access)]) async def solution_workflow(sag_id: int, request: Request, current_user: dict = Depends(get_current_user)): action = str((await request.json()).get("action") or "").lower() solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)) if not solution: raise HTTPException(status_code=404, detail="Løsningen findes ikke") user_id = _user_id(current_user) if action == "submit": status = "pending" elif action == "approve": if not str(solution.get("description") or "").strip(): raise HTTPException(status_code=422, detail="Beskriv den endelige løsning før godkendelse") status = "approved" elif action in {"reject", "outdate"}: status = "rejected" if action == "reject" else "outdated" else: raise HTTPException(status_code=422, detail="Ukendt handling") if status == "approved": updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=%s,approved_at=NOW(),updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, user_id, sag_id)) else: updated = execute_query_single("UPDATE sag_solutions SET approval_status=%s,approved_by_user_id=NULL,approved_at=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE sag_id=%s RETURNING *", (status, user_id, sag_id)) _version_solution(updated, user_id, f"Status ændret til {status}") return updated @router.post("/sag/{sag_id}/solution/publish", dependencies=[Depends(case_edit_access)]) async def publish_solution(sag_id: int, current_user: dict = Depends(get_current_user)): solution = execute_query_single("SELECT * FROM sag_solutions WHERE sag_id=%s AND deleted_at IS NULL", (sag_id,)) if not solution: raise HTTPException(status_code=404, detail="Løsningen findes ikke") if solution.get("approval_status") != "approved": raise HTTPException(status_code=409, detail="Løsningen skal godkendes før udgivelse") publish_text = "\n".join(str(solution.get(field) or "") for field in ("title", "problem", "root_cause", "investigation", "description", "workaround")) _clean_publish_text, secret_warnings = _redact_sensitive(publish_text) if secret_warnings: raise HTTPException(status_code=422, detail="Løsningen indeholder muligvis password, token eller anden hemmelighed. Fjern det før udgivelse.") customer_id = None if solution.get("visibility") == "customer": customer = execute_query_single("SELECT customer_id FROM sag_kunder WHERE sag_id=%s AND deleted_at IS NULL ORDER BY id LIMIT 1", (sag_id,)) if not customer: raise HTTPException(status_code=409, detail="Kundespecifik viden kræver en kunde på sagen") customer_id = customer["customer_id"] description = str(solution.get("description") or "").strip() summary = description[:300] + ("…" if len(description) > 300 else "") article = execute_query_single( """INSERT INTO knowledge_articles ( solution_id,sag_id,customer_id,title,summary,problem,root_cause,investigation,solution,workaround, visibility,status,tags,products,published_by_user_id,reviewed_at ) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'published',%s::jsonb,%s::jsonb,%s,NOW()) ON CONFLICT (solution_id) DO UPDATE SET customer_id=EXCLUDED.customer_id,title=EXCLUDED.title, summary=EXCLUDED.summary,problem=EXCLUDED.problem,root_cause=EXCLUDED.root_cause, investigation=EXCLUDED.investigation,solution=EXCLUDED.solution,workaround=EXCLUDED.workaround, visibility=EXCLUDED.visibility,tags=EXCLUDED.tags,products=EXCLUDED.products,status='published', version_number=knowledge_articles.version_number+1,published_by_user_id=EXCLUDED.published_by_user_id, reviewed_at=NOW(),updated_at=NOW(),archived_at=NULL,archived_by_user_id=NULL RETURNING *""", (solution["id"], sag_id, customer_id, solution["title"], summary, solution.get("problem"), solution.get("root_cause"), solution.get("investigation"), description, solution.get("workaround"), solution.get("visibility", "internal"), json.dumps(solution.get("tags") or []), json.dumps(solution.get("products") or []), _user_id(current_user)), ) return article @router.get("/sag/{sag_id}/knowledge-suggestions") async def case_knowledge_suggestions( sag_id: int, limit: int = Query(6, ge=1, le=20), _current_user: dict = Depends(get_current_user), ): _case, articles = _case_knowledge_context(sag_id, limit) items = [] for article in articles: item = dict(article) item["reason"] = "Matcher sagens titel, beskrivelse eller tags" item["relevance_percent"] = min(99, max(1, round(float(item.get("relevance") or 0) * 100))) items.append(item) return {"items": items, "total": len(items), "source": "approved_knowledge_only"} @router.post("/sag/{sag_id}/solution/ai-draft", dependencies=[Depends(case_edit_access)]) async def generate_solution_ai_draft(sag_id: int, current_user: dict = Depends(get_current_user)): case, articles = _case_knowledge_context(sag_id, 5) comments = execute_query( """SELECT id,forfatter,indhold,created_at FROM sag_kommentarer WHERE sag_id=%s AND deleted_at IS NULL ORDER BY created_at DESC LIMIT 60""", (sag_id,), ) or [] emails = execute_query( """SELECT DISTINCT e.id,e.subject,e.sender_email,e.recipient_email,e.body_text,e.received_date FROM email_messages e LEFT JOIN sag_emails se ON se.email_id=e.id WHERE e.deleted_at IS NULL AND (se.sag_id=%s OR e.linked_case_id=%s) ORDER BY e.received_date DESC NULLS LAST LIMIT 25""", (sag_id, sag_id), ) or [] time_entries = execute_query( """SELECT id,description,original_hours,worked_date FROM tmodule_times WHERE sag_id=%s ORDER BY worked_date DESC NULLS LAST LIMIT 30""", (sag_id,), ) or [] sections = [f"SAG #{sag_id}\nTitel: {case.get('titel') or ''}\nBeskrivelse: {case.get('beskrivelse') or ''}"] if comments: sections.append("KOMMENTARER:\n" + "\n".join(f"[Kommentar {row['id']}] {row.get('forfatter') or 'Ukendt'}: {row.get('indhold') or ''}" for row in reversed(comments))) if emails: sections.append("MAILS:\n" + "\n".join(f"[Mail {row['id']}] {row.get('subject') or '(uden emne)'}: {row.get('body_text') or ''}" for row in reversed(emails))) if time_entries: sections.append("TIDSNOTER:\n" + "\n".join(f"[Tid {row['id']}] {row.get('description') or ''}" for row in time_entries)) if articles: sections.append("GODKENDT VIDEN:\n" + "\n".join(f"[Artikel {row['id']}] {row['title']}\nProblem: {row.get('problem') or ''}\nLøsning: {row.get('solution') or ''}" for row in articles)) context, warnings = _redact_sensitive("\n\n".join(sections)) context = context[:24000] prompt = """Du laver et UDKAST til dokumentation af en IT-supportløsning. Returner KUN gyldig JSON med felterne title, problem, root_cause, investigation, description, workaround, solution_type, result, tags, products, confidence, uncertainties og source_refs. Regler: - Brug kun fakta fra den vedlagte sag og de nummererede kilder. - Opfind aldrig en årsag eller handling. Sæt feltet tomt og beskriv manglen i uncertainties. - description er den endelige løsning. Hvis sagen endnu ikke dokumenterer en løsning, skal description være tom. - source_refs skal indeholde de præcise kilder, fx "Kommentar 12" eller "Artikel 4". - Medtag aldrig passwords, tokens, API-nøgler eller andre hemmeligheder. - Skriv kort, teknisk og professionelt på dansk. - confidence er et tal mellem 0 og 1. """ service = CaseAnalysisService() # A full case history is materially larger than QuickCreate input. Keep the # global QuickCreate timeout unchanged, but allow the local model time to # produce a source-grounded solution draft. service.ai_timeout = max(service.ai_timeout, 60) result = await service._call_ollama(prompt, context) if not result: warnings.append("Den lokale AI-tjeneste er utilgængelig; der vises en sikker grundkladde uden AI-genererede konklusioner") result = { "title": case.get("titel") or "", "problem": case.get("beskrivelse") or "", "root_cause": "", "investigation": "", "description": "", "workaround": "", "solution_type": "Support", "result": "Ej løst", "tags": _knowledge_tokens(case.get("tag_text") or "")[:10], "products": [], "confidence": 0.2, "uncertainties": ["Årsag og endelig løsning skal udfyldes af en medarbejder"], "source_refs": [f"Sag {sag_id}"], } allowed_refs = {f"Kommentar {row['id']}" for row in comments} | {f"Mail {row['id']}" for row in emails} | {f"Tid {row['id']}" for row in time_entries} | {f"Artikel {row['id']}" for row in articles} | {f"Sag {sag_id}"} source_refs = _normalize_source_refs(result.get("source_refs", []), allowed_refs, sag_id) draft = { "title": str(result.get("title") or case.get("titel") or "")[:255], "problem": str(result.get("problem") or ""), "root_cause": str(result.get("root_cause") or ""), "investigation": str(result.get("investigation") or ""), "description": str(result.get("description") or ""), "workaround": str(result.get("workaround") or ""), "solution_type": TYPE_ALIASES.get(str(result.get("solution_type") or "Support").casefold(), "Support"), "result": RESULT_ALIASES.get(str(result.get("result") or "Ej løst").casefold(), "Ej løst"), "tags": _clean_list(result.get("tags")), "products": _clean_list(result.get("products")), "confidence": max(0.0, min(1.0, float(result.get("confidence") or 0))), "uncertainties": _clean_list(result.get("uncertainties")), "source_refs": source_refs, } if not draft["description"]: warnings.append("Sagen indeholder ikke en sikkert dokumenteret endelig løsning") if not source_refs: warnings.append("AI-udkastet indeholdt ingen gyldige kildehenvisninger") return {"draft": draft, "warnings": warnings, "model": service.ollama_model, "review_required": True} @router.get("/solutions") async def list_solutions( q: str = Query("", max_length=200), approval_status: Optional[str] = None, visibility: Optional[str] = None, include_archived: bool = False, limit: int = Query(50, ge=1, le=200), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user), ): where = ["s.deleted_at IS NOT NULL" if include_archived else "s.deleted_at IS NULL"] params: list = [] if approval_status: if approval_status not in APPROVAL_STATUSES: raise HTTPException(status_code=422, detail="Ugyldig godkendelsesstatus") where.append("s.approval_status=%s") params.append(approval_status) if visibility: if visibility not in VISIBILITIES: raise HTTPException(status_code=422, detail="Ugyldig synlighed") where.append("s.visibility=%s") params.append(visibility) term = q.strip() if term: where.append("(s.title ILIKE %s OR s.description ILIKE %s OR s.problem ILIKE %s OR sg.titel ILIKE %s OR c.name ILIKE %s OR s.tags::text ILIKE %s OR s.products::text ILIKE %s)") like = f"%{term}%" params.extend([like] * 7) where_sql = " AND ".join(where) count = execute_query_single( f"""SELECT COUNT(*) AS total FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id LEFT JOIN LATERAL ( SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1 ) c ON TRUE WHERE {where_sql}""", tuple(params), ) or {"total": 0} items = execute_query( f"""SELECT s.*, sg.titel AS case_title, sg.status AS case_status, c.name AS customer_name, COALESCE(creator.full_name,creator.username) AS created_by, COALESCE(updater.full_name,updater.username) AS updated_by, ka.id AS article_id, ka.status AS article_status FROM sag_solutions s JOIN sag_sager sg ON sg.id=s.sag_id LEFT JOIN LATERAL ( SELECT cu.name FROM sag_kunder sk JOIN customers cu ON cu.id=sk.customer_id WHERE sk.sag_id=s.sag_id AND sk.deleted_at IS NULL ORDER BY sk.id LIMIT 1 ) c ON TRUE LEFT JOIN users creator ON creator.user_id=s.created_by_user_id LEFT JOIN users updater ON updater.user_id=s.updated_by_user_id LEFT JOIN knowledge_articles ka ON ka.solution_id=s.id WHERE {where_sql} ORDER BY s.updated_at DESC,s.id DESC LIMIT %s OFFSET %s""", tuple(params + [limit, offset]), ) or [] return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset, "archived": include_archived} @router.delete("/solutions/{solution_id}", dependencies=[Depends(case_edit_access)]) async def archive_solution(solution_id: int, current_user: dict = Depends(get_current_user)): solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NULL", (solution_id,)) if not solution: raise HTTPException(status_code=404, detail="Løsningen findes ikke eller er allerede arkiveret") user_id = _user_id(current_user) execute_query("UPDATE knowledge_articles SET status='archived',archived_at=NOW(),archived_by_user_id=%s,updated_at=NOW() WHERE solution_id=%s", (user_id, solution_id)) archived = execute_query_single("UPDATE sag_solutions SET deleted_at=NOW(),deleted_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id)) _version_solution(archived, user_id, "Løsning arkiveret") return {"status": "archived", "id": solution_id, "sag_id": solution["sag_id"]} @router.post("/solutions/{solution_id}/restore", dependencies=[Depends(case_edit_access)]) async def restore_solution(solution_id: int, current_user: dict = Depends(get_current_user)): solution = execute_query_single("SELECT * FROM sag_solutions WHERE id=%s AND deleted_at IS NOT NULL", (solution_id,)) if not solution: raise HTTPException(status_code=404, detail="Den arkiverede løsning findes ikke") user_id = _user_id(current_user) restored = execute_query_single("UPDATE sag_solutions SET deleted_at=NULL,deleted_by_user_id=NULL,updated_by_user_id=%s,updated_at=NOW() WHERE id=%s RETURNING *", (user_id, solution_id)) _version_solution(restored, user_id, "Løsning gendannet") return {"status": "restored", "id": solution_id, "sag_id": solution["sag_id"]} @router.get("/knowledge/articles") async def search_knowledge_articles(q: str = Query("", max_length=200), customer_id: Optional[int] = None, limit: int = Query(25, ge=1, le=100), offset: int = Query(0, ge=0), _current_user: dict = Depends(get_current_user)): term = q.strip() scope = "(ka.visibility IN ('general','internal') OR (ka.visibility='customer' AND ka.customer_id=%s))" if customer_id else "ka.visibility IN ('general','internal')" params: list = [customer_id] if customer_id else [] search = "" if term: search = " AND (ka.search_document @@ websearch_to_tsquery('simple', %s) OR ka.title ILIKE %s)" params.extend([term, f"%{term}%"]) count = execute_query_single(f"SELECT COUNT(*) AS total FROM knowledge_articles ka WHERE ka.status='published' AND {scope}{search}", tuple(params)) or {"total": 0} item_params = [] rank_expr = "ts_rank_cd(ka.search_document, websearch_to_tsquery('simple', %s))" if term else "0" if term: item_params.append(term) item_params.extend(params) item_params.extend([limit, offset]) items = execute_query( f"""SELECT ka.id,ka.title,ka.summary,ka.visibility,ka.customer_id,ka.tags,ka.products, ka.version_number,ka.updated_at,ka.sag_id,{rank_expr} AS relevance,c.name AS customer_name FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id WHERE ka.status='published' AND {scope}{search} ORDER BY relevance DESC,ka.updated_at DESC,ka.id DESC LIMIT %s OFFSET %s""", tuple(item_params), ) or [] return {"items": items, "total": int(count["total"]), "limit": limit, "offset": offset} @router.get("/knowledge/articles/{article_id}") async def get_knowledge_article(article_id: int, _current_user: dict = Depends(get_current_user)): article = execute_query_single( """SELECT ka.*,c.name AS customer_name,COALESCE(u.full_name,u.username) AS published_by FROM knowledge_articles ka LEFT JOIN customers c ON c.id=ka.customer_id LEFT JOIN users u ON u.user_id=ka.published_by_user_id WHERE ka.id=%s AND ka.status='published'""", (article_id,), ) if not article: raise HTTPException(status_code=404, detail="Vidensartiklen findes ikke") return article