2026-02-10 14:40:38 +01:00
|
|
|
"""
|
|
|
|
|
AnyDesk Remote Support Router
|
|
|
|
|
REST API endpoints for managing remote support sessions
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import logging
|
2026-03-30 07:50:15 +02:00
|
|
|
import json
|
|
|
|
|
from uuid import uuid4
|
2026-02-10 14:40:38 +01:00
|
|
|
from typing import Optional
|
|
|
|
|
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()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# =====================================================
|
|
|
|
|
# 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,
|
2026-03-30 07:50:15 +02:00
|
|
|
sag.titel as sag_title,
|
2026-02-10 14:40:38 +01:00
|
|
|
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
|
2026-03-30 07:50:15 +02:00
|
|
|
LEFT JOIN users u ON s.created_by_user_id = u.user_id
|
2026-02-10 14:40:38 +01:00
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|
2026-03-30 07:50:15 +02:00
|
|
|
@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))
|
|
|
|
|
|
|
|
|
|
|
2026-02-10 14:40:38 +01:00
|
|
|
@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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 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
|
|
|
|
|
"""
|
2026-03-30 07:50:15 +02:00
|
|
|
creds = anydesk_service._get_credentials()
|
2026-02-10 14:40:38 +01:00
|
|
|
return JSONResponse(content={
|
|
|
|
|
"service": "AnyDesk Remote Support",
|
|
|
|
|
"status": "operational",
|
2026-03-30 07:50:15 +02:00
|
|
|
"configured": bool(creds["api_token"] and creds["license_id"]),
|
|
|
|
|
"dry_run_mode": creds["dry_run"],
|
|
|
|
|
"read_only_mode": creds["read_only"],
|
2026-02-10 14:40:38 +01:00
|
|
|
"auto_start_enabled": anydesk_service.auto_start
|
|
|
|
|
})
|
2026-03-30 07:50:15 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
@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"]
|
|
|
|
|
|
|
|
|
|
sessions = []
|
|
|
|
|
for r in (rows or []):
|
|
|
|
|
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 JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset})
|
|
|
|
|
|
|
|
|
|
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))
|
|
|
|
|
|
|
|
|
|
|