2026-04-12 02:27:01 +02:00
|
|
|
from typing import Optional
|
2026-04-24 11:28:12 +02:00
|
|
|
import logging
|
2026-04-12 02:27:01 +02:00
|
|
|
|
|
|
|
|
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
|
2026-04-24 11:28:12 +02:00
|
|
|
from app.core.database import execute_query, execute_query_single, execute_update
|
2026-04-12 02:27:01 +02:00
|
|
|
|
2026-04-24 11:28:12 +02:00
|
|
|
from .service import build_bottom_bar_state, get_own_timer_snapshot, get_unassigned_open_cases
|
2026-04-12 02:27:01 +02:00
|
|
|
|
|
|
|
|
router = APIRouter()
|
2026-04-24 11:28:12 +02:00
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
_USER_NOTES_SCHEMA_READY = False
|
2026-04-12 02:27:01 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
class BossAssignPayload(BaseModel):
|
|
|
|
|
case_id: int
|
|
|
|
|
assignee_user_id: int
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class BossAssignNextPayload(BaseModel):
|
|
|
|
|
assignee_user_id: int
|
|
|
|
|
|
|
|
|
|
|
2026-04-24 11:28:12 +02:00
|
|
|
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),
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
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)
|
|
|
|
|
|
2026-04-24 11:28:12 +02:00
|
|
|
|
|
|
|
|
@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)
|
|
|
|
|
|
2026-04-12 02:27:01 +02:00
|
|
|
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
|
2026-04-24 11:28:12 +02:00
|
|
|
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk') THEN 0
|
|
|
|
|
WHEN LOWER(COALESCE(priority::text, 'normal')) IN ('high', 'høj') THEN 1
|
2026-04-12 02:27:01 +02:00
|
|
|
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,
|
2026-04-24 11:28:12 +02:00
|
|
|
COUNT(CASE WHEN LOWER(COALESCE(s.priority::text, 'normal')) IN ('urgent', 'critical', 'kritisk', 'high', 'høj') THEN 1 END)::int AS hot_cases
|
2026-04-12 02:27:01 +02:00
|
|
|
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),
|
|
|
|
|
}
|