bmc_hub/app/modules/bottom_bar/backend/router.py

887 lines
30 KiB
Python
Raw Normal View History

from typing import Optional, Literal
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
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
2026-07-03 20:20:22 +02:00
from .service import (
acknowledge_message,
build_bottom_bar_state,
ensure_bottom_bar_messages_schema,
get_own_timer_snapshot,
get_unassigned_open_cases,
mark_user_messages_read,
)
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"
2026-07-03 20:20:22 +02:00
class BottomBarMessageCreatePayload(BaseModel):
message: str
recipient_user_id: Optional[int] = None
requires_manual_ack: bool = False
message_kind: Literal['message', 'phone'] = 'message'
contact_id: Optional[int] = Field(default=None, gt=0)
caller_name: str = Field(default='', max_length=200)
callback_phone: str = Field(default='', max_length=80)
2026-07-03 20:20:22 +02:00
class BottomBarMessageReadPayload(BaseModel):
partner_user_id: Optional[int] = None
class BottomBarMessageAcknowledgePayload(BaseModel):
message_id: int
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)
2026-07-03 20:20:22 +02:00
@router.post("/messages")
async def send_bottom_bar_message(
payload: BottomBarMessageCreatePayload,
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_bottom_bar_messages_schema()
message_text = str(payload.message or "").strip()
if not message_text:
raise HTTPException(status_code=400, detail="Besked må ikke være tom")
if len(message_text) > 2000:
raise HTTPException(status_code=400, detail="Besked er for lang")
recipient_user_id = payload.recipient_user_id
if recipient_user_id is not None:
recipient_user_id = int(recipient_user_id)
_ensure_user_exists(recipient_user_id)
if recipient_user_id == int(current_user_id):
raise HTTPException(status_code=400, detail="Du kan ikke sende en besked til dig selv")
if payload.contact_id is not None:
if not execute_query_single('SELECT id FROM contacts WHERE id=%s', (payload.contact_id,)):
raise HTTPException(status_code=400, detail='Kontaktpersonen findes ikke')
2026-07-03 20:20:22 +02:00
row = execute_query_single(
"""
INSERT INTO bottom_bar_messages (sender_user_id, recipient_user_id, message_text, requires_manual_ack, message_kind, contact_id, caller_name, callback_phone)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
2026-07-03 20:20:22 +02:00
RETURNING id, sender_user_id, recipient_user_id, message_text, requires_manual_ack, created_at
""",
(int(current_user_id), recipient_user_id, message_text, bool(payload.requires_manual_ack), payload.message_kind, payload.contact_id, payload.caller_name.strip(), payload.callback_phone.strip()),
2026-07-03 20:20:22 +02:00
) or {}
return {
"message": "Besked sendt",
"item": {
"id": row.get("id"),
"from": _resolve_current_user_display_name(current_user),
"to": "Alle på vagt" if recipient_user_id is None else f"Bruger #{recipient_user_id}",
"text": row.get("message_text") or message_text,
"message_kind": payload.message_kind,
"contact_id": payload.contact_id,
"caller_name": payload.caller_name.strip(),
"callback_phone": payload.callback_phone.strip(),
2026-07-03 20:20:22 +02:00
"requires_manual_ack": bool(row.get("requires_manual_ack")),
"created_at": row.get("created_at"),
"is_own": True,
"is_unread": False,
"is_acknowledged": False,
},
}
@router.post("/messages/read")
async def mark_bottom_bar_messages_read(
payload: Optional[BottomBarMessageReadPayload] = None,
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 {
"ok": True,
"updated": mark_user_messages_read(
int(current_user_id),
partner_user_id=payload.partner_user_id if payload else None,
),
}
@router.post("/messages/acknowledge")
async def acknowledge_bottom_bar_message(
payload: BottomBarMessageAcknowledgePayload,
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")
if not acknowledge_message(int(current_user_id), int(payload.message_id)):
raise HTTPException(status_code=404, detail="Besked ikke fundet eller allerede bekræftet")
return {"ok": True, "message_id": int(payload.message_id)}
@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")
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')
AND (
LOWER(COALESCE(template_key, '')) IN ('ticket', 'support')
OR EXISTS (
SELECT 1
FROM groups support_group
WHERE support_group.id = sag_sager.assigned_group_id
AND LOWER(support_group.name) LIKE ANY(ARRAY['%support%', '%teknik%', '%technician%'])
)
)
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),
}