""" AnyDesk Remote Support Router REST API endpoints for managing remote support sessions """ import logging import json from uuid import uuid4 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, 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/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 """ 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"] 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))