- Added endpoints to list, upsert, and delete AnyDesk IDs associated with cases. - Introduced normalization for AnyDesk IDs and ensured case existence checks. - Enhanced session management with quick-connect functionality and local session synchronization. - Created a new job for syncing AnyDesk sessions from a local endpoint. - Added database migration for the new `sag_anydesk_ids` table to store AnyDesk IDs per case.
1415 lines
52 KiB
Python
1415 lines
52 KiB
Python
"""
|
||
AnyDesk Remote Support Router
|
||
REST API endpoints for managing remote support sessions
|
||
"""
|
||
|
||
import logging
|
||
import json
|
||
import re
|
||
from uuid import uuid4
|
||
from typing import Optional
|
||
from datetime import timedelta
|
||
from fastapi import APIRouter, HTTPException
|
||
from fastapi.responses import JSONResponse
|
||
|
||
from app.models.schemas import (
|
||
AnyDeskSessionCreate,
|
||
AnyDeskSession,
|
||
AnyDeskSessionDetail,
|
||
AnyDeskSessionHistory,
|
||
AnyDeskSessionWithWorklog
|
||
)
|
||
from app.services.anydesk import AnyDeskService
|
||
from app.core.database import execute_query
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
router = APIRouter()
|
||
anydesk_service = AnyDeskService()
|
||
|
||
|
||
def _normalize_anydesk_id(raw_value: Optional[str]) -> str:
|
||
"""Normalize AnyDesk ID by stripping non-digits when possible."""
|
||
raw_text = str(raw_value or "").strip()
|
||
digits_only = re.sub(r"\D", "", raw_text)
|
||
return digits_only or raw_text
|
||
|
||
|
||
def _ensure_case_exists(sag_id: int) -> dict:
|
||
case_row = execute_query(
|
||
"SELECT id, customer_id, titel FROM sag_sager WHERE id = %s AND deleted_at IS NULL",
|
||
(sag_id,),
|
||
)
|
||
if not case_row:
|
||
raise HTTPException(status_code=404, detail="Case not found")
|
||
return case_row[0]
|
||
|
||
|
||
# =====================================================
|
||
# Session Management Endpoints
|
||
# =====================================================
|
||
|
||
@router.post("/anydesk/start-session", response_model=AnyDeskSession, tags=["Remote Support"])
|
||
async def start_remote_session(session_data: AnyDeskSessionCreate):
|
||
"""
|
||
Start a new AnyDesk remote support session
|
||
|
||
- **customer_id**: Required - Customer to provide support to
|
||
- **contact_id**: Optional - Specific contact person
|
||
- **sag_id**: Optional - Link to case/ticket for time tracking
|
||
- **description**: Optional - Purpose of session
|
||
- **created_by_user_id**: Optional - User initiating session
|
||
|
||
Returns session details with access link for sharing with customer
|
||
"""
|
||
try:
|
||
logger.info(f"🔗 Starting AnyDesk session for customer {session_data.customer_id}")
|
||
|
||
# Verify customer exists
|
||
cust_query = "SELECT id FROM customers WHERE id = %s"
|
||
customer = execute_query(cust_query, (session_data.customer_id,))
|
||
if not customer:
|
||
raise HTTPException(status_code=404, detail="Customer not found")
|
||
|
||
# Verify contact exists if provided
|
||
if session_data.contact_id:
|
||
contact_query = "SELECT id FROM contacts WHERE id = %s"
|
||
contact = execute_query(contact_query, (session_data.contact_id,))
|
||
if not contact:
|
||
raise HTTPException(status_code=404, detail="Contact not found")
|
||
|
||
# Verify sag exists if provided
|
||
if session_data.sag_id:
|
||
sag_query = "SELECT id FROM sag_sager WHERE id = %s"
|
||
sag = execute_query(sag_query, (session_data.sag_id,))
|
||
if not sag:
|
||
raise HTTPException(status_code=404, detail="Case not found")
|
||
|
||
# Create session via AnyDesk service
|
||
result = await anydesk_service.create_session(
|
||
customer_id=session_data.customer_id,
|
||
contact_id=session_data.contact_id,
|
||
sag_id=session_data.sag_id,
|
||
description=session_data.description,
|
||
created_by_user_id=session_data.created_by_user_id
|
||
)
|
||
|
||
if "error" in result:
|
||
raise HTTPException(status_code=400, detail=result["error"])
|
||
|
||
return result
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error starting session: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/anydesk/sessions/{session_id}", response_model=AnyDeskSessionDetail, tags=["Remote Support"])
|
||
async def get_session_details(session_id: int):
|
||
"""
|
||
Get details of a specific AnyDesk session
|
||
|
||
Includes current status, duration, and linked entities (contact, customer, case)
|
||
"""
|
||
try:
|
||
query = """
|
||
SELECT
|
||
s.id, s.anydesk_session_id, s.customer_id, s.contact_id, s.sag_id,
|
||
s.session_link, s.status, s.started_at, s.ended_at, s.duration_minutes,
|
||
s.created_by_user_id, s.created_at, s.updated_at,
|
||
c.first_name || ' ' || c.last_name as contact_name,
|
||
cust.name as customer_name,
|
||
sag.titel as sag_title,
|
||
u.full_name as created_by_user_name,
|
||
s.device_info, s.metadata
|
||
FROM anydesk_sessions s
|
||
LEFT JOIN contacts c ON s.contact_id = c.id
|
||
LEFT JOIN customers cust ON s.customer_id = cust.id
|
||
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
|
||
LEFT JOIN users u ON s.created_by_user_id = u.user_id
|
||
WHERE s.id = %s
|
||
"""
|
||
|
||
result = execute_query(query, (session_id,))
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
|
||
session = result[0]
|
||
return AnyDeskSessionDetail(**session)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error fetching session: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/anydesk/sessions/{session_id}/end", tags=["Remote Support"])
|
||
async def end_remote_session(session_id: int):
|
||
"""
|
||
End a remote support session and calculate duration
|
||
|
||
Returns completed session with duration in minutes and hours
|
||
"""
|
||
try:
|
||
logger.info(f"🛑 Ending AnyDesk session {session_id}")
|
||
|
||
result = await anydesk_service.end_session(session_id)
|
||
|
||
if "error" in result:
|
||
raise HTTPException(status_code=400, detail=result["error"])
|
||
|
||
return JSONResponse(content=result)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error ending session: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.patch("/anydesk/sessions/{session_id}", tags=["Remote Support"])
|
||
async def update_session(session_id: int, data: dict):
|
||
"""
|
||
Update a session — assign/re-assign to a sag, contact, or add notes.
|
||
|
||
Accepted fields: sag_id, contact_id, customer_id, notes, status
|
||
"""
|
||
try:
|
||
allowed = {"sag_id", "contact_id", "customer_id", "notes", "status"}
|
||
updates = {k: v for k, v in data.items() if k in allowed}
|
||
if not updates:
|
||
raise HTTPException(status_code=400, detail="No valid fields to update")
|
||
|
||
# Verify session exists
|
||
existing = execute_query("SELECT id FROM anydesk_sessions WHERE id = %s", (session_id,))
|
||
if not existing:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
|
||
# Verify sag exists if provided
|
||
if "sag_id" in updates and updates["sag_id"] is not None:
|
||
sag = execute_query("SELECT id FROM sag_sager WHERE id = %s", (updates["sag_id"],))
|
||
if not sag:
|
||
raise HTTPException(status_code=404, detail="Case not found")
|
||
|
||
set_clauses = ", ".join([f"{k} = %s" for k in updates])
|
||
params = list(updates.values()) + [session_id]
|
||
|
||
query = f"""
|
||
UPDATE anydesk_sessions
|
||
SET {set_clauses}, updated_at = NOW()
|
||
WHERE id = %s
|
||
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id,
|
||
session_link, status, started_at, ended_at, duration_minutes,
|
||
created_by_user_id, created_at, updated_at
|
||
"""
|
||
result = execute_query(query, tuple(params))
|
||
if not result:
|
||
raise HTTPException(status_code=500, detail="Update failed")
|
||
|
||
logger.info(f"✅ Updated AnyDesk session {session_id}: {list(updates.keys())}")
|
||
return result[0]
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error updating session: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/anydesk/register-manual-session", tags=["Remote Support"])
|
||
async def register_manual_session(data: dict):
|
||
"""
|
||
Register a manual AnyDesk support session directly on a case.
|
||
|
||
Expected payload:
|
||
- customer_id (required)
|
||
- sag_id (required)
|
||
- anydesk_id (required)
|
||
- assisted_device (required)
|
||
- device_type (required, e.g. placebo/desktop/server)
|
||
- contact_id (optional)
|
||
- notes (optional)
|
||
- created_by_user_id (optional)
|
||
"""
|
||
try:
|
||
customer_id = data.get("customer_id")
|
||
sag_id = data.get("sag_id")
|
||
contact_id = data.get("contact_id")
|
||
created_by_user_id = data.get("created_by_user_id")
|
||
|
||
anydesk_id = str(data.get("anydesk_id") or "").strip()
|
||
assisted_device = str(data.get("assisted_device") or "").strip()
|
||
device_type = str(data.get("device_type") or "placebo").strip().lower()
|
||
notes = str(data.get("notes") or "").strip()
|
||
|
||
if not customer_id:
|
||
raise HTTPException(status_code=400, detail="customer_id is required")
|
||
if not sag_id:
|
||
raise HTTPException(status_code=400, detail="sag_id is required")
|
||
if not anydesk_id:
|
||
raise HTTPException(status_code=400, detail="anydesk_id is required")
|
||
if not assisted_device:
|
||
raise HTTPException(status_code=400, detail="assisted_device is required")
|
||
|
||
customer = execute_query("SELECT id FROM customers WHERE id = %s", (customer_id,))
|
||
if not customer:
|
||
raise HTTPException(status_code=404, detail="Customer not found")
|
||
|
||
sag = execute_query("SELECT id FROM sag_sager WHERE id = %s AND deleted_at IS NULL", (sag_id,))
|
||
if not sag:
|
||
raise HTTPException(status_code=404, detail="Case not found")
|
||
|
||
if contact_id:
|
||
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||
if not contact:
|
||
raise HTTPException(status_code=404, detail="Contact not found")
|
||
|
||
manual_external_id = f"manual-{uuid4().hex[:12]}"
|
||
is_placeholder = device_type in {"placebo", "placeholder", "ukendt", "unknown"}
|
||
|
||
device_info = {
|
||
"to_id": anydesk_id,
|
||
"customer_machine_id": anydesk_id,
|
||
"assisted_device_name": assisted_device,
|
||
"assisted_device_type": device_type,
|
||
"is_placeholder_device": is_placeholder,
|
||
"source": "manual_case_registration"
|
||
}
|
||
metadata = {
|
||
"notes": notes,
|
||
"source": "manual_case_registration"
|
||
}
|
||
|
||
insert_q = """
|
||
INSERT INTO anydesk_sessions (
|
||
anydesk_session_id,
|
||
contact_id,
|
||
customer_id,
|
||
sag_id,
|
||
session_link,
|
||
device_info,
|
||
created_by_user_id,
|
||
started_at,
|
||
ended_at,
|
||
duration_minutes,
|
||
status,
|
||
metadata,
|
||
created_at,
|
||
updated_at
|
||
)
|
||
VALUES (
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
NULL,
|
||
%s::jsonb,
|
||
%s,
|
||
NOW(),
|
||
NOW(),
|
||
0,
|
||
'completed',
|
||
%s::jsonb,
|
||
NOW(),
|
||
NOW()
|
||
)
|
||
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at
|
||
"""
|
||
created = execute_query(
|
||
insert_q,
|
||
(
|
||
manual_external_id,
|
||
contact_id,
|
||
customer_id,
|
||
sag_id,
|
||
json.dumps(device_info),
|
||
created_by_user_id,
|
||
json.dumps(metadata),
|
||
),
|
||
)
|
||
if not created:
|
||
raise HTTPException(status_code=500, detail="Failed to register session")
|
||
|
||
comment_lines = [
|
||
f"🖥️ AnyDesk session registreret manuelt (AnyDesk ID: {anydesk_id})",
|
||
f"Enhed: {assisted_device}",
|
||
f"Type: {device_type}",
|
||
]
|
||
if notes:
|
||
comment_lines.append(f"Notat: {notes}")
|
||
if is_placeholder:
|
||
comment_lines.append("Info: Enhedstype er placeholder og kan linkes til hardware senere.")
|
||
|
||
execute_query(
|
||
"""
|
||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||
VALUES (%s, %s, %s, %s)
|
||
""",
|
||
(sag_id, "System", "\n".join(comment_lines), True),
|
||
)
|
||
|
||
logger.info("✅ Manual AnyDesk session registered for case %s", sag_id)
|
||
return {"ok": True, "session": created[0]}
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Error registering manual AnyDesk session: %s", str(e))
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
|
||
async def list_case_anydesk_ids(sag_id: int):
|
||
"""List saved AnyDesk IDs for a case (multi-ID support)."""
|
||
try:
|
||
_ensure_case_exists(sag_id)
|
||
|
||
rows = execute_query(
|
||
"""
|
||
SELECT
|
||
sai.id,
|
||
sai.sag_id,
|
||
sai.anydesk_id,
|
||
sai.hardware_asset_id,
|
||
sai.is_primary,
|
||
sai.note,
|
||
sai.created_by_user_id,
|
||
sai.created_at,
|
||
sai.updated_at,
|
||
h.brand,
|
||
h.model,
|
||
h.serial_number,
|
||
h.anydesk_id AS hardware_anydesk_id
|
||
FROM sag_anydesk_ids sai
|
||
LEFT JOIN hardware_assets h ON h.id = sai.hardware_asset_id
|
||
WHERE sai.sag_id = %s
|
||
AND sai.deleted_at IS NULL
|
||
ORDER BY sai.is_primary DESC, sai.updated_at DESC, sai.id DESC
|
||
""",
|
||
(sag_id,),
|
||
) or []
|
||
|
||
for row in rows:
|
||
brand = (row.get("brand") or "").strip()
|
||
model = (row.get("model") or "").strip()
|
||
serial = (row.get("serial_number") or "").strip()
|
||
fragments = [f for f in [brand, model] if f]
|
||
if serial:
|
||
fragments.append(f"SN: {serial}")
|
||
row["hardware_label"] = " - ".join(fragments) if fragments else None
|
||
|
||
return {"ids": rows}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Error listing case AnyDesk IDs: %s", e)
|
||
raise HTTPException(status_code=500, detail="Could not load case AnyDesk IDs")
|
||
|
||
|
||
@router.post("/anydesk/cases/{sag_id}/ids", tags=["Remote Support"])
|
||
async def upsert_case_anydesk_id(sag_id: int, data: dict):
|
||
"""Create or update a saved AnyDesk ID on a case."""
|
||
try:
|
||
_ensure_case_exists(sag_id)
|
||
|
||
anydesk_id = _normalize_anydesk_id(data.get("anydesk_id"))
|
||
if not anydesk_id:
|
||
raise HTTPException(status_code=400, detail="anydesk_id is required")
|
||
|
||
hardware_asset_id = data.get("hardware_asset_id")
|
||
created_by_user_id = data.get("created_by_user_id")
|
||
note = (data.get("note") or "").strip() or None
|
||
is_primary = bool(data.get("is_primary"))
|
||
|
||
if hardware_asset_id is not None:
|
||
asset = execute_query(
|
||
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
|
||
(hardware_asset_id,),
|
||
)
|
||
if not asset:
|
||
raise HTTPException(status_code=404, detail="Hardware asset not found")
|
||
|
||
row = execute_query(
|
||
"""
|
||
INSERT INTO sag_anydesk_ids (
|
||
sag_id,
|
||
anydesk_id,
|
||
hardware_asset_id,
|
||
is_primary,
|
||
note,
|
||
created_by_user_id,
|
||
deleted_at
|
||
)
|
||
VALUES (%s, %s, %s, %s, %s, %s, NULL)
|
||
ON CONFLICT (sag_id, anydesk_id)
|
||
DO UPDATE SET
|
||
hardware_asset_id = EXCLUDED.hardware_asset_id,
|
||
is_primary = EXCLUDED.is_primary,
|
||
note = COALESCE(EXCLUDED.note, sag_anydesk_ids.note),
|
||
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, sag_anydesk_ids.created_by_user_id),
|
||
deleted_at = NULL,
|
||
updated_at = NOW()
|
||
RETURNING id, sag_id, anydesk_id, hardware_asset_id, is_primary, note, created_by_user_id, created_at, updated_at
|
||
""",
|
||
(sag_id, anydesk_id, hardware_asset_id, is_primary, note, created_by_user_id),
|
||
)
|
||
|
||
if not row:
|
||
raise HTTPException(status_code=500, detail="Could not save AnyDesk ID")
|
||
|
||
if is_primary:
|
||
execute_query(
|
||
"""
|
||
UPDATE sag_anydesk_ids
|
||
SET is_primary = FALSE, updated_at = NOW()
|
||
WHERE sag_id = %s AND id != %s AND deleted_at IS NULL
|
||
""",
|
||
(sag_id, row[0]["id"]),
|
||
)
|
||
|
||
return {"ok": True, "entry": row[0]}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Error upserting case AnyDesk ID: %s", e)
|
||
raise HTTPException(status_code=500, detail="Could not save case AnyDesk ID")
|
||
|
||
|
||
@router.delete("/anydesk/cases/{sag_id}/ids/{entry_id}", tags=["Remote Support"])
|
||
async def delete_case_anydesk_id(sag_id: int, entry_id: int):
|
||
"""Soft-delete a saved AnyDesk ID from a case."""
|
||
try:
|
||
_ensure_case_exists(sag_id)
|
||
result = execute_query(
|
||
"""
|
||
UPDATE sag_anydesk_ids
|
||
SET deleted_at = NOW(), updated_at = NOW(), is_primary = FALSE
|
||
WHERE id = %s AND sag_id = %s AND deleted_at IS NULL
|
||
RETURNING id
|
||
""",
|
||
(entry_id, sag_id),
|
||
)
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="AnyDesk ID entry not found")
|
||
return {"ok": True}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Error deleting case AnyDesk ID: %s", e)
|
||
raise HTTPException(status_code=500, detail="Could not delete case AnyDesk ID")
|
||
|
||
|
||
@router.get("/anydesk/cases/{sag_id}/hardware-options", tags=["Remote Support"])
|
||
async def get_case_anydesk_hardware_options(sag_id: int):
|
||
"""Get hardware options: case-linked assets first, then customer fallback assets."""
|
||
try:
|
||
case_row = _ensure_case_exists(sag_id)
|
||
customer_id = case_row.get("customer_id")
|
||
|
||
case_hardware = execute_query(
|
||
"""
|
||
SELECT
|
||
h.id,
|
||
h.brand,
|
||
h.model,
|
||
h.serial_number,
|
||
h.anydesk_id,
|
||
h.current_owner_customer_id
|
||
FROM sag_hardware sh
|
||
JOIN hardware_assets h ON h.id = sh.hardware_id
|
||
WHERE sh.sag_id = %s
|
||
AND sh.deleted_at IS NULL
|
||
AND h.deleted_at IS NULL
|
||
ORDER BY h.brand, h.model, h.id
|
||
""",
|
||
(sag_id,),
|
||
) or []
|
||
|
||
case_ids = {row["id"] for row in case_hardware}
|
||
customer_hardware = []
|
||
|
||
if customer_id:
|
||
customer_hardware = execute_query(
|
||
"""
|
||
SELECT
|
||
h.id,
|
||
h.brand,
|
||
h.model,
|
||
h.serial_number,
|
||
h.anydesk_id,
|
||
h.current_owner_customer_id
|
||
FROM hardware_assets h
|
||
WHERE h.deleted_at IS NULL
|
||
AND h.current_owner_customer_id = %s
|
||
ORDER BY h.brand, h.model, h.id
|
||
""",
|
||
(customer_id,),
|
||
) or []
|
||
customer_hardware = [row for row in customer_hardware if row["id"] not in case_ids]
|
||
|
||
def _with_label(row: dict) -> dict:
|
||
brand = (row.get("brand") or "").strip()
|
||
model = (row.get("model") or "").strip()
|
||
serial = (row.get("serial_number") or "").strip()
|
||
aid = (row.get("anydesk_id") or "").strip()
|
||
parts = [p for p in [brand, model] if p]
|
||
if serial:
|
||
parts.append(f"SN: {serial}")
|
||
if aid:
|
||
parts.append(f"AD: {aid}")
|
||
row["label"] = " - ".join(parts) if parts else f"Hardware #{row.get('id')}"
|
||
return row
|
||
|
||
return {
|
||
"case_hardware": [_with_label(row) for row in case_hardware],
|
||
"customer_hardware": [_with_label(row) for row in customer_hardware],
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Error loading AnyDesk hardware options: %s", e)
|
||
raise HTTPException(status_code=500, detail="Could not load hardware options")
|
||
|
||
|
||
@router.post("/anydesk/cases/{sag_id}/connect", tags=["Remote Support"])
|
||
async def connect_case_anydesk(sag_id: int, data: dict):
|
||
"""
|
||
Quick connect flow for a case:
|
||
- Save AnyDesk ID on case
|
||
- Create local session row for enrichment
|
||
- Return AnyDesk deep link for app open
|
||
"""
|
||
try:
|
||
case_row = _ensure_case_exists(sag_id)
|
||
customer_id = case_row.get("customer_id")
|
||
if not customer_id:
|
||
raise HTTPException(status_code=400, detail="Case has no customer")
|
||
|
||
anydesk_id = _normalize_anydesk_id(data.get("anydesk_id"))
|
||
if not anydesk_id:
|
||
raise HTTPException(status_code=400, detail="anydesk_id is required")
|
||
|
||
created_by_user_id = data.get("created_by_user_id")
|
||
contact_id = data.get("contact_id")
|
||
hardware_asset_id = data.get("hardware_asset_id")
|
||
note = (data.get("note") or "").strip() or None
|
||
make_primary = bool(data.get("make_primary", True))
|
||
|
||
if contact_id is not None:
|
||
contact = execute_query("SELECT id FROM contacts WHERE id = %s", (contact_id,))
|
||
if not contact:
|
||
raise HTTPException(status_code=404, detail="Contact not found")
|
||
|
||
if hardware_asset_id is not None:
|
||
asset = execute_query(
|
||
"SELECT id FROM hardware_assets WHERE id = %s AND deleted_at IS NULL",
|
||
(hardware_asset_id,),
|
||
)
|
||
if not asset:
|
||
raise HTTPException(status_code=404, detail="Hardware asset not found")
|
||
|
||
case_anydesk_row = execute_query(
|
||
"""
|
||
INSERT INTO sag_anydesk_ids (
|
||
sag_id,
|
||
anydesk_id,
|
||
hardware_asset_id,
|
||
is_primary,
|
||
note,
|
||
created_by_user_id,
|
||
deleted_at
|
||
)
|
||
VALUES (%s, %s, %s, %s, %s, %s, NULL)
|
||
ON CONFLICT (sag_id, anydesk_id)
|
||
DO UPDATE SET
|
||
hardware_asset_id = EXCLUDED.hardware_asset_id,
|
||
is_primary = EXCLUDED.is_primary,
|
||
note = COALESCE(EXCLUDED.note, sag_anydesk_ids.note),
|
||
created_by_user_id = COALESCE(EXCLUDED.created_by_user_id, sag_anydesk_ids.created_by_user_id),
|
||
deleted_at = NULL,
|
||
updated_at = NOW()
|
||
RETURNING id
|
||
""",
|
||
(sag_id, anydesk_id, hardware_asset_id, make_primary, note, created_by_user_id),
|
||
)
|
||
case_anydesk_id = case_anydesk_row[0]["id"] if case_anydesk_row else None
|
||
|
||
if make_primary and case_anydesk_id:
|
||
execute_query(
|
||
"""
|
||
UPDATE sag_anydesk_ids
|
||
SET is_primary = FALSE, updated_at = NOW()
|
||
WHERE sag_id = %s AND id != %s AND deleted_at IS NULL
|
||
""",
|
||
(sag_id, case_anydesk_id),
|
||
)
|
||
|
||
manual_external_id = f"case-{uuid4().hex[:12]}"
|
||
deep_link = f"anydesk:{anydesk_id}"
|
||
|
||
# Idempotency guard: avoid creating duplicate rows when users double-click connect.
|
||
recent_existing = execute_query(
|
||
"""
|
||
SELECT id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at, session_link
|
||
FROM anydesk_sessions
|
||
WHERE sag_id = %s
|
||
AND status IN ('active', 'pending')
|
||
AND (
|
||
COALESCE(device_info->>'to_id', '') = %s
|
||
OR COALESCE(device_info->>'customer_machine_id', '') = %s
|
||
)
|
||
AND started_at >= NOW() - INTERVAL '10 minutes'
|
||
ORDER BY started_at DESC
|
||
LIMIT 1
|
||
""",
|
||
(sag_id, anydesk_id, anydesk_id),
|
||
)
|
||
if recent_existing:
|
||
existing = recent_existing[0]
|
||
logger.info("ℹ️ Reusing in-flight AnyDesk session for case %s (session %s)", sag_id, existing.get("id"))
|
||
return {
|
||
"ok": True,
|
||
"already_registering": True,
|
||
"deep_link": existing.get("session_link") or deep_link,
|
||
"anydesk_id": anydesk_id,
|
||
"session": {
|
||
"id": existing.get("id"),
|
||
"anydesk_session_id": existing.get("anydesk_session_id"),
|
||
"customer_id": existing.get("customer_id"),
|
||
"contact_id": existing.get("contact_id"),
|
||
"sag_id": existing.get("sag_id"),
|
||
"status": existing.get("status"),
|
||
"started_at": existing.get("started_at"),
|
||
},
|
||
"case_anydesk_id": case_anydesk_id,
|
||
}
|
||
|
||
device_info = {
|
||
"to_id": anydesk_id,
|
||
"customer_machine_id": anydesk_id,
|
||
"hardware_asset_id": hardware_asset_id,
|
||
"case_anydesk_id": case_anydesk_id,
|
||
"source": "case_quick_connect",
|
||
}
|
||
metadata = {
|
||
"note": note,
|
||
"source": "case_quick_connect",
|
||
"needs_local_sync_enrichment": True,
|
||
}
|
||
|
||
created = execute_query(
|
||
"""
|
||
INSERT INTO anydesk_sessions (
|
||
anydesk_session_id,
|
||
contact_id,
|
||
customer_id,
|
||
sag_id,
|
||
session_link,
|
||
device_info,
|
||
created_by_user_id,
|
||
started_at,
|
||
ended_at,
|
||
duration_minutes,
|
||
status,
|
||
metadata,
|
||
created_at,
|
||
updated_at
|
||
)
|
||
VALUES (
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s,
|
||
%s::jsonb,
|
||
%s,
|
||
NOW(),
|
||
NULL,
|
||
NULL,
|
||
'active',
|
||
%s::jsonb,
|
||
NOW(),
|
||
NOW()
|
||
)
|
||
RETURNING id, anydesk_session_id, customer_id, contact_id, sag_id, status, started_at
|
||
""",
|
||
(
|
||
manual_external_id,
|
||
contact_id,
|
||
customer_id,
|
||
sag_id,
|
||
deep_link,
|
||
json.dumps(device_info),
|
||
created_by_user_id,
|
||
json.dumps(metadata),
|
||
),
|
||
)
|
||
|
||
if not created:
|
||
raise HTTPException(status_code=500, detail="Failed to create AnyDesk session row")
|
||
|
||
execute_query(
|
||
"""
|
||
INSERT INTO sag_kommentarer (sag_id, forfatter, indhold, er_system_besked)
|
||
VALUES (%s, %s, %s, %s)
|
||
""",
|
||
(
|
||
sag_id,
|
||
"System",
|
||
f"🖥️ AnyDesk quick-connect startet (ID: {anydesk_id})",
|
||
True,
|
||
),
|
||
)
|
||
|
||
logger.info("✅ AnyDesk quick-connect prepared for case %s", sag_id)
|
||
return {
|
||
"ok": True,
|
||
"deep_link": deep_link,
|
||
"anydesk_id": anydesk_id,
|
||
"session": created[0],
|
||
"case_anydesk_id": case_anydesk_id,
|
||
}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error("Error in AnyDesk quick-connect: %s", e)
|
||
raise HTTPException(status_code=500, detail="Could not start AnyDesk quick-connect")
|
||
|
||
|
||
@router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
|
||
async def get_session_history(
|
||
contact_id: Optional[int] = None,
|
||
customer_id: Optional[int] = None,
|
||
sag_id: Optional[int] = None,
|
||
limit: int = 50,
|
||
offset: int = 0
|
||
):
|
||
"""
|
||
Get session history filtered by contact, customer, or case
|
||
|
||
At least one filter parameter should be provided.
|
||
Results are paginated and sorted by date (newest first).
|
||
|
||
- **contact_id**: Get all sessions for a specific contact
|
||
- **customer_id**: Get all sessions for a company
|
||
- **sag_id**: Get sessions linked to a specific case
|
||
- **limit**: Number of sessions per page (default 50, max 100)
|
||
- **offset**: Pagination offset
|
||
"""
|
||
try:
|
||
if limit > 100:
|
||
limit = 100
|
||
|
||
result = await anydesk_service.get_session_history(
|
||
contact_id=contact_id,
|
||
customer_id=customer_id,
|
||
sag_id=sag_id,
|
||
limit=limit,
|
||
offset=offset
|
||
)
|
||
|
||
if "error" in result:
|
||
raise HTTPException(status_code=400, detail=result["error"])
|
||
|
||
# Enrich session data with contact/customer/user names
|
||
enriched_sessions = []
|
||
for session in result.get("sessions", []):
|
||
enriched_sessions.append(AnyDeskSessionDetail(**session))
|
||
|
||
return AnyDeskSessionHistory(
|
||
sessions=enriched_sessions,
|
||
total=result["total"],
|
||
limit=limit,
|
||
offset=offset
|
||
)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error fetching session history: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# =====================================================
|
||
# Worklog Integration Endpoints
|
||
# =====================================================
|
||
|
||
@router.get("/anydesk/sessions/{session_id}/worklog-suggestion", tags=["Remote Support", "Time Tracking"])
|
||
async def suggest_worklog_from_session(session_id: int):
|
||
"""
|
||
Get suggested worklog entry from a completed session
|
||
|
||
Calculates billable hours from session duration and provides
|
||
a pre-filled worklog suggestion for review/approval.
|
||
|
||
The worklog still needs to be created separately via the
|
||
timetracking/worklog endpoints after user approval.
|
||
"""
|
||
try:
|
||
# Get session
|
||
query = """
|
||
SELECT id, duration_minutes, customer_id, sag_id, contact_id,
|
||
started_at, ended_at, status
|
||
FROM anydesk_sessions
|
||
WHERE id = %s
|
||
"""
|
||
|
||
result = execute_query(query, (session_id,))
|
||
if not result:
|
||
raise HTTPException(status_code=404, detail="Session not found")
|
||
|
||
session = result[0]
|
||
|
||
if session["status"] != "completed":
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail=f"Cannot suggest worklog for non-completed session (status: {session['status']})"
|
||
)
|
||
|
||
if not session["duration_minutes"]:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Session duration not calculated yet"
|
||
)
|
||
|
||
# Build worklog suggestion
|
||
duration_hours = round(session["duration_minutes"] / 60, 2)
|
||
|
||
suggestion = {
|
||
"session_id": session_id,
|
||
"duration_minutes": session["duration_minutes"],
|
||
"duration_hours": duration_hours,
|
||
"start_time": str(session["started_at"]),
|
||
"end_time": str(session["ended_at"]),
|
||
"description": f"Remote support session via AnyDesk",
|
||
"work_type": "remote_support",
|
||
"billable": True,
|
||
"linked_to": {
|
||
"customer_id": session["customer_id"],
|
||
"contact_id": session["contact_id"],
|
||
"sag_id": session["sag_id"]
|
||
}
|
||
}
|
||
|
||
logger.info(f"Generated worklog suggestion for session {session_id}: {duration_hours}h")
|
||
return JSONResponse(content=suggestion)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error generating worklog suggestion: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# =====================================================
|
||
# Status & Analytics Endpoints
|
||
# =====================================================
|
||
|
||
@router.get("/anydesk/stats", tags=["Remote Support", "Analytics"])
|
||
async def get_anydesk_stats():
|
||
"""
|
||
Get AnyDesk integration statistics
|
||
|
||
- Total sessions today/this week/this month
|
||
- Active sessions count
|
||
- Average session duration
|
||
- Most supported customers
|
||
"""
|
||
try:
|
||
stats = {
|
||
"sessions_today": 0,
|
||
"sessions_this_week": 0,
|
||
"sessions_this_month": 0,
|
||
"active_sessions": 0,
|
||
"average_duration_minutes": 0,
|
||
"total_support_hours": 0.0
|
||
}
|
||
|
||
# Get today's sessions
|
||
query = """
|
||
SELECT COUNT(*) as count FROM anydesk_sessions
|
||
WHERE DATE(started_at) = CURRENT_DATE
|
||
"""
|
||
result = execute_query(query)
|
||
stats["sessions_today"] = result[0]["count"] if result else 0
|
||
|
||
# Get this week's sessions
|
||
query = """
|
||
SELECT COUNT(*) as count FROM anydesk_sessions
|
||
WHERE started_at >= CURRENT_DATE - INTERVAL '7 days'
|
||
"""
|
||
result = execute_query(query)
|
||
stats["sessions_this_week"] = result[0]["count"] if result else 0
|
||
|
||
# Get this month's sessions
|
||
query = """
|
||
SELECT COUNT(*) as count FROM anydesk_sessions
|
||
WHERE started_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||
"""
|
||
result = execute_query(query)
|
||
stats["sessions_this_month"] = result[0]["count"] if result else 0
|
||
|
||
# Get active sessions
|
||
query = """
|
||
SELECT COUNT(*) as count FROM anydesk_sessions
|
||
WHERE status = 'active'
|
||
"""
|
||
result = execute_query(query)
|
||
stats["active_sessions"] = result[0]["count"] if result else 0
|
||
|
||
# Get average duration
|
||
query = """
|
||
SELECT AVG(duration_minutes) as avg_duration FROM anydesk_sessions
|
||
WHERE status = 'completed' AND duration_minutes IS NOT NULL
|
||
"""
|
||
result = execute_query(query)
|
||
stats["average_duration_minutes"] = round(result[0]["avg_duration"], 1) if result and result[0]["avg_duration"] else 0
|
||
|
||
# Get total support hours this month
|
||
query = """
|
||
SELECT SUM(duration_minutes) as total_minutes FROM anydesk_sessions
|
||
WHERE status = 'completed'
|
||
AND started_at >= DATE_TRUNC('month', CURRENT_DATE)
|
||
"""
|
||
result = execute_query(query)
|
||
total_minutes = result[0]["total_minutes"] if result and result[0]["total_minutes"] else 0
|
||
stats["total_support_hours"] = round(total_minutes / 60, 2)
|
||
|
||
return JSONResponse(content=stats)
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error calculating stats: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/anydesk/health", tags=["Remote Support", "Health"])
|
||
async def anydesk_health_check():
|
||
"""
|
||
Health check for AnyDesk integration
|
||
|
||
Returns configuration status, API connectivity, and last sync time
|
||
"""
|
||
creds = anydesk_service._get_credentials()
|
||
return JSONResponse(content={
|
||
"service": "AnyDesk Remote Support",
|
||
"status": "operational",
|
||
"configured": bool(creds["api_token"] and creds["license_id"]),
|
||
"dry_run_mode": creds["dry_run"],
|
||
"read_only_mode": creds["read_only"],
|
||
"auto_start_enabled": anydesk_service.auto_start
|
||
})
|
||
|
||
|
||
@router.post("/anydesk/fetch-from-api", tags=["Remote Support"])
|
||
async def fetch_sessions_from_anydesk(
|
||
days: int = 30,
|
||
limit: int = 1000,
|
||
after: Optional[str] = None,
|
||
before: Optional[str] = None,
|
||
):
|
||
"""
|
||
Pull session log from the live AnyDesk REST API and import into local DB.
|
||
|
||
- **days**: How many days back to fetch (default 30)
|
||
- **limit**: Max entries to fetch (default 1000)
|
||
- **after**: Override start as ISO-8601 timestamp (e.g. 2024-01-01T00:00:00Z)
|
||
- **before**: Override end as ISO-8601 timestamp
|
||
|
||
Requires `dry_run=false` in AnyDesk settings.
|
||
Auth uses HMAC-SHA1 (AnyDesk native format), not Bearer token.
|
||
Returns count of newly imported and updated sessions.
|
||
"""
|
||
try:
|
||
result = await anydesk_service.fetch_sessions_from_api(
|
||
days=days,
|
||
limit=limit,
|
||
after=after,
|
||
before=before,
|
||
)
|
||
if "error" in result:
|
||
raise HTTPException(status_code=400, detail=result["error"])
|
||
return JSONResponse(content=result)
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Error fetching from AnyDesk API: {str(e)}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# =====================================================
|
||
# Sessions Dashboard Endpoints
|
||
# =====================================================
|
||
|
||
@router.get("/anydesk/sessions-overview", tags=["Remote Support"])
|
||
async def sessions_overview(
|
||
days: int = 90,
|
||
unregistered_only: bool = False,
|
||
limit: int = 200,
|
||
offset: int = 0,
|
||
):
|
||
"""
|
||
Enriched session list for the dashboard page.
|
||
Joins hardware_assets (via remote_id/anydesk_id), contacts, customers, sag.
|
||
"""
|
||
try:
|
||
where = "WHERE s.started_at >= NOW() - INTERVAL '%s days'" % int(days)
|
||
if unregistered_only:
|
||
where += " AND s.sag_id IS NULL AND s.contact_id IS NULL AND s.hardware_asset_id IS NULL"
|
||
|
||
query = f"""
|
||
SELECT
|
||
s.id,
|
||
s.anydesk_session_id,
|
||
s.started_at,
|
||
s.ended_at,
|
||
s.duration_minutes,
|
||
s.status,
|
||
s.notes,
|
||
-- linked hardware
|
||
s.hardware_asset_id,
|
||
ha.brand AS hw_brand,
|
||
ha.model AS hw_model,
|
||
ha.anydesk_id AS hw_anydesk_id,
|
||
ha.current_owner_customer_id AS hw_customer_id,
|
||
-- linked contact
|
||
s.contact_id,
|
||
c.first_name || ' ' || c.last_name AS contact_name,
|
||
c.email AS contact_email,
|
||
-- linked customer
|
||
s.customer_id,
|
||
cust.name AS customer_name,
|
||
-- linked sag
|
||
s.sag_id,
|
||
sag.titel AS sag_titel,
|
||
sag.status AS sag_status,
|
||
-- raw remote_id from import
|
||
(s.device_info->>'remote_id')::TEXT AS remote_id,
|
||
(s.device_info->>'remote_alias')::TEXT AS remote_alias,
|
||
(s.device_info->>'from_id')::TEXT AS technician_id,
|
||
(s.device_info->>'to_id')::TEXT AS customer_machine_id,
|
||
(s.device_info->>'local_alias')::TEXT AS customer_alias,
|
||
-- technician resolved from user_anydesk_ids
|
||
tech_u.user_id AS tech_user_id,
|
||
COALESCE(tech_u.full_name, tech_u.username) AS tech_name
|
||
FROM anydesk_sessions s
|
||
LEFT JOIN hardware_assets ha ON s.hardware_asset_id = ha.id
|
||
LEFT JOIN contacts c ON s.contact_id = c.id
|
||
LEFT JOIN customers cust ON s.customer_id = cust.id
|
||
LEFT JOIN sag_sager sag ON s.sag_id = sag.id
|
||
LEFT JOIN user_anydesk_ids uad
|
||
ON regexp_replace(COALESCE(uad.anydesk_id, ''), '[^0-9]', '', 'g') =
|
||
regexp_replace(COALESCE((s.device_info->>'from_id')::TEXT, ''), '[^0-9]', '', 'g')
|
||
LEFT JOIN users tech_u ON tech_u.user_id = uad.user_id
|
||
{where}
|
||
ORDER BY s.started_at DESC
|
||
LIMIT {int(limit)} OFFSET {int(offset)}
|
||
"""
|
||
rows = execute_query(query)
|
||
|
||
# count
|
||
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
|
||
total = (execute_query(count_q) or [{"total": 0}])[0]["total"]
|
||
|
||
def _is_synthetic_session_id(session_id: Optional[str]) -> bool:
|
||
sid = str(session_id or "")
|
||
return sid.startswith("case-") or sid.startswith("manual-") or sid.startswith("local-")
|
||
|
||
def _dedupe_key(row: dict) -> str:
|
||
# Prefer stable external AnyDesk session IDs when they are not synthetic.
|
||
sid = str(row.get("anydesk_session_id") or "").strip()
|
||
if sid and not _is_synthetic_session_id(sid):
|
||
return f"sid:{sid}"
|
||
|
||
machine_id = str(row.get("customer_machine_id") or row.get("remote_id") or "").strip()
|
||
tech_id = str(row.get("technician_id") or "").strip()
|
||
# Use 15-minute buckets to collapse short-lived duplicates from hub/import/local sync.
|
||
started = row.get("started_at")
|
||
if started:
|
||
minutes = int(started.timestamp() // (15 * 60))
|
||
bucket = str(minutes)
|
||
else:
|
||
bucket = "unknown"
|
||
return f"heur:{machine_id}:{tech_id}:{bucket}"
|
||
|
||
def _row_score(row: dict) -> int:
|
||
score = 0
|
||
if row.get("sag_id"):
|
||
score += 200
|
||
if row.get("customer_id"):
|
||
score += 120
|
||
if row.get("contact_id"):
|
||
score += 100
|
||
if row.get("hardware_asset_id"):
|
||
score += 80
|
||
duration = row.get("duration_minutes")
|
||
if duration is not None:
|
||
try:
|
||
if float(duration) > 0:
|
||
score += 60
|
||
else:
|
||
score += 20
|
||
except Exception:
|
||
score += 10
|
||
if row.get("ended_at"):
|
||
score += 25
|
||
if not _is_synthetic_session_id(row.get("anydesk_session_id")):
|
||
score += 40
|
||
return score
|
||
|
||
def _backfill_enrichment(winner: dict, loser: dict) -> None:
|
||
"""Copy missing enriched fields from loser into winner."""
|
||
for field in (
|
||
"tech_name",
|
||
"technician_id",
|
||
"remote_alias",
|
||
"remote_id",
|
||
"customer_machine_id",
|
||
"customer_alias",
|
||
"contact_id",
|
||
"contact_name",
|
||
"contact_email",
|
||
"customer_id",
|
||
"customer_name",
|
||
"sag_id",
|
||
"sag_titel",
|
||
"sag_status",
|
||
"hardware_asset_id",
|
||
"hw_brand",
|
||
"hw_model",
|
||
"hw_anydesk_id",
|
||
"hw_customer_id",
|
||
"notes",
|
||
):
|
||
if not winner.get(field) and loser.get(field):
|
||
winner[field] = loser.get(field)
|
||
|
||
deduped_rows: dict[str, dict] = {}
|
||
for row in (rows or []):
|
||
key = _dedupe_key(row)
|
||
if key not in deduped_rows:
|
||
deduped_rows[key] = row
|
||
continue
|
||
|
||
current = deduped_rows[key]
|
||
current_score = _row_score(current)
|
||
incoming_score = _row_score(row)
|
||
|
||
if incoming_score > current_score:
|
||
winner = row
|
||
other = current
|
||
else:
|
||
winner = current
|
||
other = row
|
||
|
||
# Merge useful enrichment from the losing row into the winner.
|
||
if not winner.get("duration_minutes") and other.get("duration_minutes") is not None:
|
||
winner["duration_minutes"] = other.get("duration_minutes")
|
||
if not winner.get("ended_at") and other.get("ended_at"):
|
||
winner["ended_at"] = other.get("ended_at")
|
||
if (winner.get("status") in (None, "", "active", "pending")) and other.get("status"):
|
||
if other.get("status") in ("completed", "failed", "cancelled"):
|
||
winner["status"] = other.get("status")
|
||
if not winner.get("notes") and other.get("notes"):
|
||
winner["notes"] = other.get("notes")
|
||
_backfill_enrichment(winner, other)
|
||
|
||
deduped_rows[key] = winner
|
||
|
||
# Second pass: merge neighboring buckets for same machine ID when timestamps are close.
|
||
# This catches duplicates where one row lands in an adjacent 15-minute bucket.
|
||
second_pass_rows = list(deduped_rows.values())
|
||
second_pass_rows.sort(key=lambda item: item.get("started_at") or "", reverse=True)
|
||
|
||
clustered: list[dict] = []
|
||
for row in second_pass_rows:
|
||
row_machine = str(row.get("customer_machine_id") or row.get("remote_id") or "").strip()
|
||
row_started = row.get("started_at")
|
||
attached = False
|
||
|
||
if row_machine and row_started:
|
||
for target in clustered:
|
||
target_machine = str(target.get("customer_machine_id") or target.get("remote_id") or "").strip()
|
||
target_started = target.get("started_at")
|
||
if not target_machine or not target_started:
|
||
continue
|
||
if row_machine != target_machine:
|
||
continue
|
||
|
||
if abs(target_started - row_started) <= timedelta(minutes=20):
|
||
target_score = _row_score(target)
|
||
row_score = _row_score(row)
|
||
winner = row if row_score > target_score else target
|
||
loser = target if winner is row else row
|
||
|
||
if not winner.get("duration_minutes") and loser.get("duration_minutes") is not None:
|
||
winner["duration_minutes"] = loser.get("duration_minutes")
|
||
if not winner.get("ended_at") and loser.get("ended_at"):
|
||
winner["ended_at"] = loser.get("ended_at")
|
||
if (winner.get("status") in (None, "", "active", "pending")) and loser.get("status"):
|
||
if loser.get("status") in ("completed", "failed", "cancelled", "registered"):
|
||
winner["status"] = loser.get("status")
|
||
if not winner.get("notes") and loser.get("notes"):
|
||
winner["notes"] = loser.get("notes")
|
||
_backfill_enrichment(winner, loser)
|
||
|
||
if winner is row:
|
||
clustered.remove(target)
|
||
clustered.append(winner)
|
||
attached = True
|
||
break
|
||
|
||
if not attached:
|
||
clustered.append(row)
|
||
|
||
merged_rows = clustered
|
||
merged_rows.sort(key=lambda item: item.get("started_at") or "", reverse=True)
|
||
|
||
sessions = []
|
||
for r in merged_rows:
|
||
sessions.append({
|
||
"id": r["id"],
|
||
"anydesk_session_id": r["anydesk_session_id"],
|
||
"started_at": str(r["started_at"]) if r["started_at"] else None,
|
||
"ended_at": str(r["ended_at"]) if r["ended_at"] else None,
|
||
"duration_minutes": r["duration_minutes"],
|
||
"status": r["status"],
|
||
"notes": r["notes"],
|
||
"remote_id": r["remote_id"],
|
||
"remote_alias": r["remote_alias"],
|
||
"technician_id": r["technician_id"], # from.cid — teknikkerens maskine
|
||
"technician_name": r["tech_name"], # resolved from user_anydesk_ids
|
||
"customer_machine_id": r["customer_machine_id"], # to.cid — kundens maskine
|
||
"customer_alias": r["customer_alias"],
|
||
"hardware": {
|
||
"id": r["hardware_asset_id"],
|
||
"brand": r["hw_brand"],
|
||
"model": r["hw_model"],
|
||
"anydesk_id": r["hw_anydesk_id"],
|
||
"customer_id": r["hw_customer_id"],
|
||
} if r["hardware_asset_id"] else None,
|
||
"contact": {
|
||
"id": r["contact_id"],
|
||
"name": r["contact_name"],
|
||
"email": r["contact_email"],
|
||
} if r["contact_id"] else None,
|
||
"customer": {
|
||
"id": r["customer_id"],
|
||
"name": r["customer_name"],
|
||
} if r["customer_id"] else None,
|
||
"sag": {
|
||
"id": r["sag_id"],
|
||
"titel": r["sag_titel"],
|
||
"status": r["sag_status"],
|
||
} if r["sag_id"] else None,
|
||
})
|
||
|
||
# Return deduplicated total for UI consistency on overview endpoint.
|
||
return JSONResponse(content={"sessions": sessions, "total": len(sessions), "limit": limit, "offset": offset, "raw_total": total})
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in sessions_overview: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.post("/anydesk/auto-link", tags=["Remote Support"])
|
||
async def auto_link_sessions():
|
||
"""
|
||
Auto-link unlinked sessions to hardware_assets via anydesk_id match,
|
||
and carry over contact/customer from the hardware asset.
|
||
Returns count of newly linked sessions.
|
||
"""
|
||
try:
|
||
linked = 0
|
||
|
||
# Match sessions to hardware_assets where to_id (customer machine) = anydesk_id
|
||
# NOTE: from.cid is the TECHNICIAN's machine, to.cid is the CUSTOMER's machine
|
||
result = execute_query("""
|
||
UPDATE anydesk_sessions s
|
||
SET
|
||
hardware_asset_id = ha.id,
|
||
customer_id = COALESCE(s.customer_id, ha.current_owner_customer_id),
|
||
updated_at = NOW()
|
||
FROM hardware_assets ha
|
||
WHERE ha.anydesk_id IS NOT NULL
|
||
AND ha.anydesk_id != ''
|
||
AND (
|
||
(s.device_info->>'to_id') = ha.anydesk_id
|
||
OR
|
||
-- fallback: older imports without to_id — try remote_id only if it differs from technicians' known IDs
|
||
(s.device_info->>'to_id' IS NULL AND (s.device_info->>'remote_id') = ha.anydesk_id)
|
||
)
|
||
AND s.hardware_asset_id IS NULL
|
||
RETURNING s.id
|
||
""")
|
||
linked = len(result) if result else 0
|
||
|
||
logger.info(f"✅ Auto-linked {linked} AnyDesk sessions to hardware assets")
|
||
return JSONResponse(content={"linked": linked})
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error in auto_link_sessions: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.patch("/anydesk/sessions/{session_id}/link", tags=["Remote Support"])
|
||
async def link_session(
|
||
session_id: int,
|
||
sag_id: Optional[int] = None,
|
||
contact_id: Optional[int] = None,
|
||
customer_id: Optional[int] = None,
|
||
hardware_asset_id: Optional[int] = None,
|
||
notes: Optional[str] = None,
|
||
):
|
||
"""
|
||
Manually link a session to sag, contact, customer, hardware, or add notes.
|
||
"""
|
||
try:
|
||
sets = ["updated_at = NOW()"]
|
||
params = []
|
||
if sag_id is not None:
|
||
sets.append("sag_id = %s"); params.append(sag_id)
|
||
if contact_id is not None:
|
||
sets.append("contact_id = %s"); params.append(contact_id)
|
||
if customer_id is not None:
|
||
sets.append("customer_id = %s"); params.append(customer_id)
|
||
if hardware_asset_id is not None:
|
||
sets.append("hardware_asset_id = %s"); params.append(hardware_asset_id)
|
||
if notes is not None:
|
||
sets.append("notes = %s"); params.append(notes)
|
||
|
||
if len(sets) == 1:
|
||
return JSONResponse(content={"message": "no changes"})
|
||
|
||
params.append(session_id)
|
||
execute_query(
|
||
f"UPDATE anydesk_sessions SET {', '.join(sets)} WHERE id = %s",
|
||
tuple(params)
|
||
)
|
||
return JSONResponse(content={"ok": True})
|
||
|
||
except Exception as e:
|
||
logger.error(f"Error linking session {session_id}: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@router.get("/anydesk/hardware-assets", tags=["Remote Support"])
|
||
async def anydesk_hardware_list():
|
||
"""List all hardware assets that have an anydesk_id (for linking dropdown)"""
|
||
try:
|
||
rows = execute_query("""
|
||
SELECT ha.id, ha.brand, ha.model, ha.anydesk_id, ha.serial_number,
|
||
ha.current_owner_customer_id AS customer_id, cust.name AS customer_name
|
||
FROM hardware_assets ha
|
||
LEFT JOIN customers cust ON ha.current_owner_customer_id = cust.id
|
||
WHERE ha.deleted_at IS NULL
|
||
ORDER BY ha.brand, ha.model
|
||
""")
|
||
return JSONResponse(content={"assets": rows or []})
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|