from typing import Optional import logging from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from app.core.auth_service import AuthService from app.core.auth_dependencies import get_current_user from app.core.database import execute_query, execute_query_single, execute_update from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases router = APIRouter() logger = logging.getLogger(__name__) _USER_NOTES_SCHEMA_READY = False class BossAssignPayload(BaseModel): case_id: int assignee_user_id: int class BossAssignNextPayload(BaseModel): assignee_user_id: int class UserNoteCreatePayload(BaseModel): title: Optional[str] = None content: str is_pinned: bool = False class UserNoteUpdatePayload(BaseModel): title: Optional[str] = None content: Optional[str] = None is_pinned: Optional[bool] = None is_archived: Optional[bool] = None class NoteToCaseCommentPayload(BaseModel): sag_id: int excerpt: Optional[str] = None class NoteToContactPayload(BaseModel): contact_id: int field: str value: Optional[str] = None excerpt: Optional[str] = None mode: str = "append" class NoteToCustomerPayload(BaseModel): customer_id: int field: str value: Optional[str] = None excerpt: Optional[str] = None mode: str = "append" def _ensure_user_notes_schema() -> None: global _USER_NOTES_SCHEMA_READY if _USER_NOTES_SCHEMA_READY: return exists = execute_query_single("SELECT to_regclass('public.user_notes') AS table_name") or {} if exists.get("table_name"): _USER_NOTES_SCHEMA_READY = True return execute_query( """ CREATE TABLE IF NOT EXISTS user_notes ( id SERIAL PRIMARY KEY, user_id INTEGER NOT NULL REFERENCES users(user_id) ON DELETE CASCADE, title VARCHAR(200) NOT NULL DEFAULT '', content TEXT NOT NULL, is_pinned BOOLEAN NOT NULL DEFAULT FALSE, is_archived BOOLEAN NOT NULL DEFAULT FALSE, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, deleted_at TIMESTAMP NULL ) """ ) execute_query( """ CREATE INDEX IF NOT EXISTS idx_user_notes_user_active ON user_notes (user_id, is_archived, is_pinned, updated_at DESC) WHERE deleted_at IS NULL """ ) execute_query( """ CREATE INDEX IF NOT EXISTS idx_user_notes_user_updated ON user_notes (user_id, updated_at DESC) WHERE deleted_at IS NULL """ ) execute_query( """ CREATE OR REPLACE FUNCTION update_user_notes_updated_at() RETURNS TRIGGER AS $$ BEGIN NEW.updated_at = CURRENT_TIMESTAMP; RETURN NEW; END; $$ LANGUAGE plpgsql """ ) execute_query("DROP TRIGGER IF EXISTS trg_user_notes_updated_at ON user_notes") execute_query( """ CREATE TRIGGER trg_user_notes_updated_at BEFORE UPDATE ON user_notes FOR EACH ROW EXECUTE FUNCTION update_user_notes_updated_at() """ ) _USER_NOTES_SCHEMA_READY = True logger.warning("⚠️ user_notes table was missing and has been created automatically") def _resolve_current_user_display_name(current_user: dict) -> str: current_user_id = current_user.get("id") if current_user_id is None: return "System" row = execute_query_single( """ SELECT full_name, username FROM users WHERE user_id = %s """, (int(current_user_id),), ) or {} return str(row.get("full_name") or row.get("username") or f"Bruger #{current_user_id}") def _get_owned_note_or_404(note_id: int, user_id: int) -> dict: _ensure_user_notes_schema() row = execute_query_single( """ SELECT id, user_id, title, content, is_pinned, is_archived, created_at, updated_at FROM user_notes WHERE id = %s AND user_id = %s AND deleted_at IS NULL """, (int(note_id), int(user_id)), ) if not row: raise HTTPException(status_code=404, detail="Note ikke fundet") return row def _normalize_note_text(value: Optional[str]) -> str: return str(value or "").strip() def _build_merge_value(current_value: Optional[str], incoming_value: str, mode: str) -> str: incoming = _normalize_note_text(incoming_value) if not incoming: return str(current_value or "") current = str(current_value or "").strip() normalized_mode = str(mode or "append").strip().lower() if normalized_mode == "replace": return incoming if not current: return incoming if incoming in current: return current return f"{current}\n{incoming}" @router.get("/notes") @router.get("/notes/") async def list_user_notes( include_archived: bool = Query(default=False), limit: int = Query(default=50, ge=1, le=200), offset: int = Query(default=0, ge=0), current_user: dict = Depends(get_current_user), ): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") _ensure_user_notes_schema() rows = execute_query( """ SELECT id, title, content, is_pinned, is_archived, created_at, updated_at FROM user_notes WHERE user_id = %s AND deleted_at IS NULL AND (%s = TRUE OR is_archived = FALSE) ORDER BY is_pinned DESC, updated_at DESC, id DESC LIMIT %s OFFSET %s """, (int(current_user_id), bool(include_archived), int(limit), int(offset)), ) or [] total_row = execute_query_single( """ SELECT COUNT(*) AS count FROM user_notes WHERE user_id = %s AND deleted_at IS NULL AND (%s = TRUE OR is_archived = FALSE) """, (int(current_user_id), bool(include_archived)), ) or {} return { "items": rows, "count": int(total_row.get("count") or 0), } @router.post("/notes") @router.post("/notes/") async def create_user_note(payload: UserNoteCreatePayload, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") _ensure_user_notes_schema() content = _normalize_note_text(payload.content) if not content: raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt") title = _normalize_note_text(payload.title) row = execute_query_single( """ INSERT INTO user_notes (user_id, title, content, is_pinned) VALUES (%s, %s, %s, %s) RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at """, (int(current_user_id), title, content, bool(payload.is_pinned)), ) return row or {} @router.patch("/notes/{note_id}") @router.patch("/notes/{note_id}/") async def update_user_note(note_id: int, payload: UserNoteUpdatePayload, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") _ensure_user_notes_schema() _get_owned_note_or_404(note_id, int(current_user_id)) sets = [] params = [] if payload.title is not None: sets.append("title = %s") params.append(_normalize_note_text(payload.title)) if payload.content is not None: content = _normalize_note_text(payload.content) if not content: raise HTTPException(status_code=400, detail="Note-indhold kan ikke være tomt") sets.append("content = %s") params.append(content) if payload.is_pinned is not None: sets.append("is_pinned = %s") params.append(bool(payload.is_pinned)) if payload.is_archived is not None: sets.append("is_archived = %s") params.append(bool(payload.is_archived)) if not sets: return _get_owned_note_or_404(note_id, int(current_user_id)) params.extend([int(note_id), int(current_user_id)]) row = execute_query_single( f""" UPDATE user_notes SET {', '.join(sets)} WHERE id = %s AND user_id = %s AND deleted_at IS NULL RETURNING id, title, content, is_pinned, is_archived, created_at, updated_at """, tuple(params), ) if not row: raise HTTPException(status_code=404, detail="Note ikke fundet") return row @router.delete("/notes/{note_id}") @router.delete("/notes/{note_id}/") async def delete_user_note(note_id: int, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") _ensure_user_notes_schema() deleted = execute_update( """ UPDATE user_notes SET deleted_at = CURRENT_TIMESTAMP WHERE id = %s AND user_id = %s AND deleted_at IS NULL """, (int(note_id), int(current_user_id)), ) if not deleted: raise HTTPException(status_code=404, detail="Note ikke fundet") return {"status": "deleted", "note_id": int(note_id)} @router.post("/notes/{note_id}/actions/sag-comment") @router.post("/notes/{note_id}/actions/sag-comment/") async def note_to_case_comment(note_id: int, payload: NoteToCaseCommentPayload, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") note = _get_owned_note_or_404(note_id, int(current_user_id)) case_row = execute_query_single( """ SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL """, (int(payload.sag_id),), ) if not case_row: raise HTTPException(status_code=404, detail="Sag ikke fundet") text = _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content")) if not text: raise HTTPException(status_code=400, detail="Ingen tekst at indsætte") author = _resolve_current_user_display_name(current_user) created = execute_query_single( """ INSERT INTO sag_kommentarer (sag_id, indhold, forfatter) VALUES (%s, %s, %s) RETURNING id, sag_id, indhold, forfatter, created_at """, (int(payload.sag_id), text, author), ) or {} return { "status": "inserted", "target": "sag_comment", "note_id": int(note_id), "sag_id": int(payload.sag_id), "comment": created, } @router.post("/notes/{note_id}/actions/contact-update") @router.post("/notes/{note_id}/actions/contact-update/") async def note_to_contact_update(note_id: int, payload: NoteToContactPayload, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") note = _get_owned_note_or_404(note_id, int(current_user_id)) allowed_fields = {"phone", "mobile", "email", "title", "department"} field = str(payload.field or "").strip().lower() if field not in allowed_fields: raise HTTPException(status_code=400, detail="Ugyldigt kontaktfelt") contact = execute_query_single( f"SELECT id, {field} FROM contacts WHERE id = %s", (int(payload.contact_id),), ) if not contact: raise HTTPException(status_code=404, detail="Kontakt ikke fundet") incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content")) if not incoming: raise HTTPException(status_code=400, detail="Ingen tekst at indsætte") merged = _build_merge_value(contact.get(field), incoming, payload.mode) updated = execute_query_single( f""" UPDATE contacts SET {field} = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, {field} """, (merged, int(payload.contact_id)), ) or {} return { "status": "updated", "target": "contact", "note_id": int(note_id), "contact_id": int(payload.contact_id), "field": field, "value": updated.get(field), } @router.post("/notes/{note_id}/actions/customer-update") @router.post("/notes/{note_id}/actions/customer-update/") async def note_to_customer_update(note_id: int, payload: NoteToCustomerPayload, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") note = _get_owned_note_or_404(note_id, int(current_user_id)) field = str(payload.field or "").strip().lower() allowed_fields = {"phone", "mobile_phone", "email", "address", "invoice_email", "note"} if field not in allowed_fields: raise HTTPException(status_code=400, detail="Ugyldigt firmafelt") customer = execute_query_single( "SELECT id FROM customers WHERE id = %s", (int(payload.customer_id),), ) if not customer: raise HTTPException(status_code=404, detail="Firma ikke fundet") incoming = _normalize_note_text(payload.value) or _normalize_note_text(payload.excerpt) or _normalize_note_text(note.get("content")) if not incoming: raise HTTPException(status_code=400, detail="Ingen tekst at indsætte") if field == "note": author = _resolve_current_user_display_name(current_user) created = execute_query_single( """ INSERT INTO customer_notes (customer_id, note_type, note, created_by) VALUES (%s, %s, %s, %s) RETURNING id, customer_id, note_type, note, created_by, created_at """, (int(payload.customer_id), "general", incoming, author), ) or {} return { "status": "inserted", "target": "customer_note", "note_id": int(note_id), "customer_id": int(payload.customer_id), "record": created, } current = execute_query_single( f"SELECT {field} FROM customers WHERE id = %s", (int(payload.customer_id),), ) or {} merged = _build_merge_value(current.get(field), incoming, payload.mode) updated = execute_query_single( f""" UPDATE customers SET {field} = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, {field} """, (merged, int(payload.customer_id)), ) or {} return { "status": "updated", "target": "customer", "note_id": int(note_id), "customer_id": int(payload.customer_id), "field": field, "value": updated.get(field), } def _resolve_user_id_from_request(request: Request) -> Optional[int]: state_user_id = getattr(request.state, "user_id", None) if state_user_id is not None: try: return int(state_user_id) except (TypeError, ValueError): pass user_id_param = request.query_params.get("user_id") if user_id_param: try: return int(user_id_param) except (TypeError, ValueError): pass token = (request.cookies.get("access_token") or "").strip() or None payload = AuthService.verify_token(token) if token else None sub_claim = payload.get("sub") if payload else None if sub_claim is not None: try: return int(sub_claim) except (TypeError, ValueError): return None return None @router.get("/state") async def get_bottom_bar_state(request: Request, current_user: dict = Depends(get_current_user)): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") user_id = int(current_user_id) force_boss_access = bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")) context_path = request.query_params.get("context") or "" return build_bottom_bar_state(user_id, context_path=context_path, force_boss_access=force_boss_access) @router.get("/timers/own") async def get_own_timers( paused_limit: int = Query(default=10, ge=1, le=25), current_user: dict = Depends(get_current_user), ): current_user_id = current_user.get("id") if current_user_id is None: raise HTTPException(status_code=401, detail="Not authenticated") return get_own_timer_snapshot(int(current_user_id), paused_limit=paused_limit) @router.get("/boss/unassigned-cases") async def list_unassigned_open_cases( limit: int = Query(default=25, ge=1, le=100), current_user: dict = Depends(get_current_user), ): if not _has_boss_access(current_user): raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") return get_unassigned_open_cases(limit=limit) from app.services.task_routing import TaskRouter from app.services.m365_calendar import M365CalendarService def _has_boss_access(current_user: dict) -> bool: if bool(current_user.get("is_superadmin") or current_user.get("is_shadow_admin")): return True current_user_id = current_user.get("id") if current_user_id is None: return False rows = execute_query( """ SELECT LOWER(g.name) AS name FROM user_groups ug JOIN groups g ON g.id = ug.group_id WHERE ug.user_id = %s """, (int(current_user_id),), ) or [] names = [str(r.get("name") or "") for r in rows] tokens = ("admin", "manager", "leder", "chef", "teknik", "technician", "support") return any(any(token in name for token in tokens) for name in names) def _ensure_user_exists(user_id: int) -> None: user = execute_query_single("SELECT user_id FROM users WHERE user_id = %s", (user_id,)) if not user: raise HTTPException(status_code=404, detail="Bruger ikke fundet") def _get_next_unassigned_case() -> Optional[dict]: return execute_query_single( """ SELECT id, titel, priority FROM sag_sager WHERE deleted_at IS NULL AND ansvarlig_bruger_id IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') ORDER BY CASE WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1 ELSE 2 END, COALESCE(updated_at, created_at) ASC, id ASC LIMIT 1 """ ) @router.post("/next_task") async def assign_next_task( request: Request, user_id: int | None = Query(default=None), current_user: dict = Depends(get_current_user), ): # Prefer authenticated user context; allow explicit user_id for controlled testing. current_user_id = current_user.get("id") resolved_user_id = user_id if resolved_user_id is None and current_user_id is not None: resolved_user_id = int(current_user_id) if resolved_user_id is None: raise HTTPException(status_code=401, detail="Authentication required for task assignment") # Kombinerer de nye services router_svc = TaskRouter() cal = M365CalendarService() # Henter hvor meget fri tid medarbejderen har lige nu free_mins = await cal.get_user_free_time("now", 2) # Bed the engine allocate the next best task task = await router_svc.get_next_best_task(resolved_user_id) task = task or {} return { "status": "assigned", "task": task, "free_time_calculated": free_mins, "message": f"Fandt Næste Opgave (SLA: {task.get('assigned_reason')} - {task.get('estimated_minutes')}m. Du har {free_mins}m frit). " } @router.post("/boss/auto-assign-next") async def boss_auto_assign_next(current_user: dict = Depends(get_current_user)): if not _has_boss_access(current_user): raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") next_case = _get_next_unassigned_case() if not next_case: return { "status": "noop", "message": "Ingen ufordelte åbne sager at fordele.", } assignee = execute_query_single( """ SELECT u.user_id, COALESCE(NULLIF(u.full_name, ''), u.username, ('Bruger #' || u.user_id::text)) AS owner_name, COUNT(s.id)::int AS open_cases, COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases FROM users u JOIN user_groups ug ON ug.user_id = u.user_id JOIN groups g ON g.id = ug.group_id LEFT JOIN sag_sager s ON s.ansvarlig_bruger_id = u.user_id AND s.deleted_at IS NULL AND LOWER(COALESCE(s.status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') WHERE LOWER(g.name) LIKE ANY(ARRAY['%admin%', '%manager%', '%leder%', '%chef%', '%teknik%', '%technician%', '%support%']) GROUP BY u.user_id, u.full_name, u.username ORDER BY hot_cases ASC, open_cases ASC, owner_name ASC LIMIT 1 """ ) if not assignee: raise HTTPException(status_code=409, detail="Ingen kvalificeret medarbejder fundet til auto-fordeling") updated = execute_query_single( """ UPDATE sag_sager SET ansvarlig_bruger_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, titel, priority, ansvarlig_bruger_id """, (int(assignee["user_id"]), int(next_case["id"])), ) if not updated: raise HTTPException(status_code=500, detail="Kunne ikke opdatere sag") return { "status": "assigned", "message": "Sagen blev auto-fordelt.", "case": { "id": updated.get("id"), "title": updated.get("titel") or f"Sag #{updated.get('id')}", "priority": updated.get("priority") or "normal", }, "assignee": { "user_id": assignee.get("user_id"), "name": assignee.get("owner_name") or f"Bruger #{assignee.get('user_id')}", }, } @router.post("/boss/assign-case") async def boss_assign_case(payload: BossAssignPayload, current_user: dict = Depends(get_current_user)): if not _has_boss_access(current_user): raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") _ensure_user_exists(int(payload.assignee_user_id)) case_row = execute_query_single( """ SELECT id, titel, priority FROM sag_sager WHERE id = %s AND deleted_at IS NULL AND LOWER(COALESCE(status, '')) NOT IN ('lukket', 'løst', 'closed', 'resolved') """, (int(payload.case_id),), ) if not case_row: raise HTTPException(status_code=404, detail="Sag ikke fundet eller er afsluttet") updated = execute_query_single( """ UPDATE sag_sager SET ansvarlig_bruger_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, titel, priority, ansvarlig_bruger_id """, (int(payload.assignee_user_id), int(payload.case_id)), ) if not updated: raise HTTPException(status_code=500, detail="Kunne ikke tildele sag") return { "status": "assigned", "message": "Sagen blev tildelt.", "case": { "id": updated.get("id"), "title": updated.get("titel") or f"Sag #{updated.get('id')}", "priority": updated.get("priority") or "normal", }, "assignee_user_id": int(payload.assignee_user_id), } @router.post("/boss/assign-next-to-user") async def boss_assign_next_to_user(payload: BossAssignNextPayload, current_user: dict = Depends(get_current_user)): if not _has_boss_access(current_user): raise HTTPException(status_code=403, detail="Insufficient permissions for boss action") _ensure_user_exists(int(payload.assignee_user_id)) next_case = _get_next_unassigned_case() if not next_case: return { "status": "noop", "message": "Ingen ufordelte åbne sager at tildele.", } updated = execute_query_single( """ UPDATE sag_sager SET ansvarlig_bruger_id = %s, updated_at = CURRENT_TIMESTAMP WHERE id = %s RETURNING id, titel, priority, ansvarlig_bruger_id """, (int(payload.assignee_user_id), int(next_case["id"])), ) if not updated: raise HTTPException(status_code=500, detail="Kunne ikke tildele næste sag") return { "status": "assigned", "message": "Næste ufordelte sag blev tildelt.", "case": { "id": updated.get("id"), "title": updated.get("titel") or f"Sag #{updated.get('id')}", "priority": updated.get("priority") or "normal", }, "assignee_user_id": int(payload.assignee_user_id), }