from typing import Optional 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 from .service import build_bottom_bar_state router = APIRouter() class BossAssignPayload(BaseModel): case_id: int assignee_user_id: int class BossAssignNextPayload(BaseModel): assignee_user_id: int 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) 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, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0 WHEN LOWER(COALESCE(priority, '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, '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), }