feat(anydesk): Implement multi-ID support for AnyDesk cases

- 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.
This commit is contained in:
Christian 2026-04-06 12:46:04 +02:00
parent ee8c517acc
commit 270af0e277
7 changed files with 1294 additions and 166 deletions

View File

@ -253,6 +253,11 @@ class Settings(BaseSettings):
ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls ANYDESK_DRY_RUN: bool = True # SAFETY: Log without executing API calls
ANYDESK_TIMEOUT_SECONDS: int = 30 ANYDESK_TIMEOUT_SECONDS: int = 30
ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested ANYDESK_AUTO_START_SESSION: bool = True # Auto-start session when requested
ANYDESK_LOCAL_SESSIONS_URL: str = "http://localhost:8001/anydesk/sessions"
ANYDESK_LOCAL_SYNC_ENABLED: bool = True
ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES: int = 15
ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS: int = 20
ANYDESK_LOCAL_SYNC_DRY_RUN: bool = False
# Telefoni (Yealink) Integration # Telefoni (Yealink) Integration
TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=... TELEFONI_SHARED_SECRET: str = "" # If set, required as ?token=...

View File

@ -0,0 +1,41 @@
"""
AnyDesk local sessions sync job.
Polls local AnyDesk bridge endpoint and enriches local session rows.
"""
import logging
from app.core.config import settings
from app.services.anydesk import AnyDeskService
logger = logging.getLogger(__name__)
anydesk_service = AnyDeskService()
async def sync_anydesk_local_sessions():
"""Sync AnyDesk sessions from local endpoint every N minutes."""
if not settings.ANYDESK_LOCAL_SYNC_ENABLED:
return
try:
logger.info("🔄 AnyDesk local sync started")
result = await anydesk_service.fetch_sessions_from_local_endpoint(
endpoint_url=settings.ANYDESK_LOCAL_SESSIONS_URL,
timeout_seconds=settings.ANYDESK_LOCAL_SYNC_TIMEOUT_SECONDS,
dry_run=settings.ANYDESK_LOCAL_SYNC_DRY_RUN,
)
if result.get("error"):
logger.error("❌ AnyDesk local sync failed: %s", result["error"])
return
logger.info(
"✅ AnyDesk local sync completed: total=%s imported=%s updated=%s matched=%s errors=%s",
result.get("total", 0),
result.get("imported", 0),
result.get("updated", 0),
result.get("matched", 0),
len(result.get("errors") or []),
)
except Exception as exc:
logger.error("❌ Unexpected AnyDesk local sync error: %s", exc)

View File

@ -1842,7 +1842,16 @@
} }
.case-tabs-topbar.topbar-secondary { .case-tabs-topbar.topbar-secondary {
grid-template-columns: repeat(8, minmax(150px, 1fr)); /* Vægt kolonner så dato-felter (som har indlejrede ikoner) får mere plads */
grid-template-columns:
minmax(110px, 0.75fr) /* Type */
minmax(110px, 0.8fr) /* Prioritet */
minmax(105px, 0.75fr) /* Oprettet */
minmax(195px, 1.3fr) /* Arbejdsstart (2 knapper) */
minmax(195px, 1.3fr) /* Start senest (2 knapper) */
minmax(150px, 1.1fr) /* Deadline (1 knap) */
minmax(120px, 0.85fr) /* AnyDesk */
minmax(140px, 1fr) /* Dokumenter */;
} }
.case-tabs-topbar-item { .case-tabs-topbar-item {
@ -1966,12 +1975,6 @@
text-align: left; text-align: left;
} }
.topbar-deferred-shortcuts {
display: flex;
gap: 0.35rem;
margin-top: 0.35rem;
flex-wrap: wrap;
}
.topbar-deferred-current { .topbar-deferred-current {
margin-top: 0.4rem; margin-top: 0.4rem;
@ -1994,24 +1997,7 @@
background: rgba(15, 76, 117, 0.09); background: rgba(15, 76, 117, 0.09);
} }
.topbar-mini-trigger {
border: 1px solid rgba(0,0,0,0.14);
background: rgba(255,255,255,0.75);
color: var(--text-primary);
border-radius: 999px;
font-size: 0.7rem;
font-weight: 700;
line-height: 1;
padding: 0.34rem 0.58rem;
min-height: 30px;
letter-spacing: 0.01em;
}
.topbar-mini-trigger:hover {
border-color: var(--accent);
color: var(--accent);
background: rgba(255,255,255,0.95);
}
.topbar-secondary-action:hover { .topbar-secondary-action:hover {
border-color: var(--accent); border-color: var(--accent);
@ -2019,31 +2005,6 @@
background: rgba(255,255,255,0.95); background: rgba(255,255,255,0.95);
} }
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-add {
background: rgba(32, 120, 72, 0.24);
border-color: rgba(92, 194, 132, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-type {
background: rgba(80, 120, 200, 0.18);
border-color: rgba(140, 180, 255, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-created {
background: rgba(40, 120, 80, 0.22);
border-color: rgba(90, 200, 140, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-priority {
background: rgba(110, 80, 170, 0.24);
border-color: rgba(180, 145, 255, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start {
background: rgba(150, 120, 40, 0.24);
border-color: rgba(230, 190, 90, 0.35);
}
[data-bs-theme="dark"] .topbar-deferred-current { [data-bs-theme="dark"] .topbar-deferred-current {
color: rgba(236, 242, 255, 0.82); color: rgba(236, 242, 255, 0.82);
background: rgba(255, 255, 255, 0.06); background: rgba(255, 255, 255, 0.06);
@ -2056,16 +2017,6 @@
background: rgba(19, 100, 154, 0.24); background: rgba(19, 100, 154, 0.24);
} }
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-start-before {
background: rgba(150, 90, 30, 0.24);
border-color: rgba(230, 160, 90, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary .case-tabs-topbar-item.field-deadline {
background: rgba(150, 40, 40, 0.24);
border-color: rgba(240, 120, 120, 0.35);
}
[data-bs-theme="dark"] .topbar-secondary-action { [data-bs-theme="dark"] .topbar-secondary-action {
background: rgba(20, 27, 38, 0.72); background: rgba(20, 27, 38, 0.72);
border-color: rgba(170, 190, 216, 0.35); border-color: rgba(170, 190, 216, 0.35);
@ -2081,16 +2032,7 @@
background: rgba(20, 27, 38, 0.78); background: rgba(20, 27, 38, 0.78);
} }
[data-bs-theme="dark"] .topbar-mini-trigger {
background: rgba(20, 27, 38, 0.78);
border-color: rgba(170, 190, 216, 0.4);
color: #dce8f4;
}
[data-bs-theme="dark"] .topbar-mini-trigger:hover {
border-color: #9fc4e8;
color: #9fc4e8;
}
.case-add-side-backdrop { .case-add-side-backdrop {
position: fixed; position: fixed;
@ -2399,43 +2341,56 @@
</div> </div>
<div class="case-tabs-topbar-item field-start"> <div class="case-tabs-topbar-item field-start">
<div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div> <div class="case-tabs-topbar-label"><i class="bi bi-play-circle"></i>Arbejdsstart</div>
<div class="topbar-secondary-inline"> <div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input <input
id="topbarStartDateInput" id="topbarStartDateInput"
type="date" type="date"
class="case-inline-select" class="case-inline-select"
value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}" value="{{ case.start_date.strftime('%Y-%m-%d') if case.start_date else '' }}"
onchange="saveCaseStartDateFromTopbar()" onchange="saveCaseStartDateFromTopbar()"
style="padding-left: 0.35rem; padding-right: 0.1rem;"
> >
<button type="button" class="topbar-secondary-action is-icon" onclick="clearCaseStartDateFromTopbar()" title="Fjern startdato"> <div class="dropdown">
<i class="bi bi-x-lg"></i> <button class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Menu">
</button> <i class="bi bi-three-dots-vertical"></i>
</div> </button>
<div class="topbar-deferred-shortcuts"> <ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(0)">I dag</button> <li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); clearCaseStartDateFromTopbar();"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(1)">+1 dag</button> <li><hr class="dropdown-divider"></li>
<button type="button" class="topbar-mini-trigger" onclick="setStartDateAndSave(7)">+1 uge</button> <li><h6 class="dropdown-header">Udskyd til...</h6></li>
<button type="button" class="topbar-mini-trigger" onclick="openStartDateModal()">Flere valg</button> <li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(0);"><i class="bi bi-calendar-event me-2 text-muted"></i>I dag</a></li>
</div> <li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(1);"><i class="bi bi-calendar-plus me-2 text-muted"></i>I morgen (+1 dag)</a></li>
<div class="topbar-deferred-shortcuts"> <li><a class="dropdown-item" href="#" onclick="event.preventDefault(); setStartDateAndSave(7);"><i class="bi bi-calendar-week me-2 text-muted"></i>Næste uge (+1 uge)</a></li>
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModal()">Trigger</button> <li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openStartDateModal();"><i class="bi bi-gear me-2 text-muted"></i>Flere valg...</a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModal();"><i class="bi bi-lightning-charge me-2 text-muted"></i>Sæt afhængighed (Trigger)...</a></li>
</ul>
</div>
</div> </div>
</div> </div>
<div class="case-tabs-topbar-item field-start-before"> <div class="case-tabs-topbar-item field-start-before">
<div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div> <div class="case-tabs-topbar-label"><i class="bi bi-hourglass-split"></i>Start senest</div>
<div class="topbar-secondary-inline"> <div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input <input
id="topbarDeferredInput" id="topbarDeferredInput"
type="date" type="date"
class="case-inline-select" class="case-inline-select"
value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}" value="{{ case.deferred_until.strftime('%Y-%m-%d') if case.deferred_until else '' }}"
onchange="updateDeferredUntil(this.value || null)" onchange="updateDeferredUntil(this.value || null)"
style="padding-left: 0.35rem; padding-right: 0.1rem;"
> >
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value=''" title="Fjern dato"><i class="bi bi-x-lg"></i></button> <div class="dropdown">
</div> <button class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" type="button" data-bs-toggle="dropdown" aria-expanded="false" title="Menu">
<div class="topbar-deferred-shortcuts"> <i class="bi bi-three-dots-vertical"></i>
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('lukket')">Lukket</button> </button>
<button type="button" class="topbar-mini-trigger" onclick="openDeferredModalWithPresetStatus('løst')">Løst</button> <ul class="dropdown-menu dropdown-menu-end shadow-sm" style="font-size: 0.85rem;">
<li><a class="dropdown-item text-danger" href="#" onclick="event.preventDefault(); updateDeferredUntil(null); document.getElementById('topbarDeferredInput').value='';"><i class="bi bi-x-circle me-2"></i>Ryd dato</a></li>
<li><hr class="dropdown-divider"></li>
<li><h6 class="dropdown-header">Venter på sag...</h6></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('lukket');"><i class="bi bi-check2-circle me-2 text-success"></i>Trigger: Sag Lukket</a></li>
<li><a class="dropdown-item" href="#" onclick="event.preventDefault(); openDeferredModalWithPresetStatus('løst');"><i class="bi bi-check-lg me-2 text-primary"></i>Trigger: Sag Løst</a></li>
</ul>
</div>
</div> </div>
{% set deferred_case_ns = namespace(title='') %} {% set deferred_case_ns = namespace(title='') %}
{% if case.deferred_until_case_id %} {% if case.deferred_until_case_id %}
@ -2459,21 +2414,22 @@
</div> </div>
<div class="case-tabs-topbar-item field-deadline"> <div class="case-tabs-topbar-item field-deadline">
<div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div> <div class="case-tabs-topbar-label"><i class="bi bi-clock"></i>Deadline dato</div>
<div class="topbar-secondary-inline"> <div class="topbar-secondary-inline" style="gap: 0.25rem;">
<input <input
id="topbarDeadlineInput" id="topbarDeadlineInput"
type="date" type="date"
class="case-inline-select" class="case-inline-select"
value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}" value="{{ case.deadline.strftime('%Y-%m-%d') if case.deadline else '' }}"
onchange="updateDeadline(this.value || null)" onchange="updateDeadline(this.value || null)"
style="padding-left: 0.35rem; padding-right: 0.1rem;"
> >
<button type="button" class="topbar-secondary-action is-icon" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x-lg"></i></button> <button type="button" class="topbar-secondary-action is-icon" style="padding: 0.35rem 0.2rem; min-width: 32px; flex: 0 0 32px;" onclick="updateDeadline(null); document.getElementById('topbarDeadlineInput').value=''" title="Fjern deadline"><i class="bi bi-x-lg"></i></button>
</div> </div>
</div> </div>
<div class="case-tabs-topbar-item field-anydesk"> <div class="case-tabs-topbar-item field-anydesk">
<div class="case-tabs-topbar-label"><i class="bi bi-display"></i>AnyDesk</div> <div class="case-tabs-topbar-label"><i class="bi bi-display"></i>AnyDesk</div>
<button type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Registrer AnyDesk session for denne sag"> <button id="caseAnyDeskOpenBtn" type="button" class="topbar-secondary-action is-wide" onclick="openCaseAnyDeskModal()" title="Start AnyDesk quick connect for denne sag">
<i class="bi bi-plus-circle"></i> Registrer session <i class="bi bi-plug"></i> Quick connect
</button> </button>
</div> </div>
<div class="case-tabs-topbar-item field-documents"> <div class="case-tabs-topbar-item field-documents">
@ -3487,36 +3443,41 @@
</div> </div>
</div> </div>
<!-- AnyDesk manual registration modal --> <!-- AnyDesk quick-connect modal -->
<div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true"> <div class="modal fade" id="caseAnyDeskModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered"> <div class="modal-dialog modal-dialog-centered">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title"><i class="bi bi-display me-2"></i>Registrer AnyDesk session</h5> <h5 class="modal-title"><i class="bi bi-display me-2"></i>AnyDesk quick connect</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3">
<label class="form-label">AnyDesk ID</label> <label class="form-label">AnyDesk ID</label>
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" /> <div class="input-group">
<input type="text" class="form-control" id="caseAnydeskIdInput" placeholder="fx 123 456 789" oninput="onCaseAnyDeskIdInputChange()" />
<a href="#" class="btn btn-outline-primary" id="caseAnydeskOpenLinkBtn" target="_self" style="display:none;">
<i class="bi bi-box-arrow-up-right me-1"></i>Åbn AnyDesk
</a>
</div>
<div class="form-text">ID gemmes automatisk på sagen når du klikker forbind.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Hvilken enhed hjælper du med?</label> <label class="form-label">Gemte IDs på sagen</label>
<input type="text" class="form-control" id="caseAnydeskDeviceNameInput" placeholder="fx Reception-PC, Lager printer, Router i teknikrum" /> <div id="caseAnyDeskSavedIds" class="d-flex flex-wrap gap-2">
<span class="text-muted small">Indlæser...</span>
</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Enhedstype</label> <label class="form-label">Relatér til hardware (valgfri)</label>
<select class="form-select" id="caseAnydeskDeviceTypeInput"> <select class="form-select" id="caseAnydeskHardwareSelect">
<option value="placebo" selected>Placebo / ukendt (kan linkes senere)</option> <option value="">Ingen hardware valgt</option>
<option value="desktop">Desktop / PC</option>
<option value="laptop">Laptop</option>
<option value="server">Server</option>
<option value="network">Netværksudstyr</option>
<option value="printer">Printer</option>
<option value="phone">Telefon / mobil</option>
<option value="other">Andet</option>
</select> </select>
<div class="form-text">Sagens hardware vises først. Derefter hardware hos kunden.</div>
</div> </div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label">Kontakt (valgfri)</label> <label class="form-label">Kontakt (valgfri)</label>
<select class="form-select" id="caseAnydeskContactSelect"> <select class="form-select" id="caseAnydeskContactSelect">
@ -3531,12 +3492,12 @@
<textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea> <textarea class="form-control" id="caseAnydeskNoteInput" rows="3" placeholder="Kort notat om supporten"></textarea>
</div> </div>
<div class="small text-muted mt-2"> <div class="small text-muted mt-2">
Sessionen gemmes direkte på sagen. Hvis du vælger type "Placebo", kan den senere linkes til rigtig hardware. Når du klikker forbind oprettes sessionen på sagen med det samme, så varighed/status kan beriges via lokal AnyDesk sync.
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button> <button type="button" class="btn btn-light" data-bs-dismiss="modal">Annuller</button>
<button type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()">Gem registrering</button> <button id="caseAnyDeskConnectBtn" type="button" class="btn btn-primary" onclick="registerCaseAnyDeskSession()"><i class="bi bi-plug me-1"></i>Forbind og gem</button>
</div> </div>
</div> </div>
</div> </div>
@ -9463,6 +9424,7 @@
<script> <script>
let currentSearchType = null; let currentSearchType = null;
let searchDebounceIds = null; let searchDebounceIds = null;
let caseAnyDeskConnectInFlight = false;
const caseIds = {{ case.id }}; const caseIds = {{ case.id }};
const currentCaseTitle = {{ (case.titel or '') | tojson }}; const currentCaseTitle = {{ (case.titel or '') | tojson }};
let caseAddPanelInitialized = false; let caseAddPanelInitialized = false;
@ -9482,60 +9444,192 @@
{ action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' } { action: 'email', label: 'Send email', icon: 'bi-envelope', moduleKey: 'emails', relFn: 'openRelEmailModal' }
]; ];
function openCaseAnyDeskModal() { function normalizeAnyDeskIdClient(rawValue) {
const raw = String(rawValue || '').trim();
const digits = raw.replace(/\D/g, '');
return digits || raw;
}
function onCaseAnyDeskIdInputChange() {
const input = document.getElementById('caseAnydeskIdInput');
const linkBtn = document.getElementById('caseAnydeskOpenLinkBtn');
if (!input || !linkBtn) return;
const id = normalizeAnyDeskIdClient(input.value);
if (!id) {
linkBtn.style.display = 'none';
linkBtn.setAttribute('href', '#');
return;
}
input.value = id;
linkBtn.style.display = '';
linkBtn.setAttribute('href', `anydesk:${id}`);
}
function setCaseAnyDeskInputFromSaved(anydeskId) {
const input = document.getElementById('caseAnydeskIdInput');
if (!input) return;
input.value = normalizeAnyDeskIdClient(anydeskId);
onCaseAnyDeskIdInputChange();
}
function renderCaseAnyDeskSavedIds(entries) {
const container = document.getElementById('caseAnyDeskSavedIds');
if (!container) return;
if (!entries?.length) {
container.innerHTML = '<span class="text-muted small">Ingen gemte AnyDesk IDs på sagen endnu.</span>';
return;
}
container.innerHTML = entries.map((entry) => {
const primary = entry?.is_primary ? ' border-primary text-primary' : '';
const hardware = entry?.hardware_label ? ` <span class="text-muted">(${entry.hardware_label})</span>` : '';
const badge = entry?.is_primary ? ' <span class="badge bg-primary-subtle text-primary-emphasis border border-primary-subtle">Primær</span>' : '';
return `<button type="button" class="btn btn-sm btn-outline-secondary${primary}" onclick="setCaseAnyDeskInputFromSaved('${String(entry.anydesk_id || '').replace(/'/g, "\\'")}')">${entry.anydesk_id}${badge}${hardware}</button>`;
}).join('');
}
function renderCaseAnyDeskHardwareOptions(caseHardware, customerHardware) {
const select = document.getElementById('caseAnydeskHardwareSelect');
if (!select) return;
let html = '<option value="">Ingen hardware valgt</option>';
if (Array.isArray(caseHardware) && caseHardware.length) {
html += '<optgroup label="Hardware på sagen">';
html += caseHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
html += '</optgroup>';
}
if (Array.isArray(customerHardware) && customerHardware.length) {
html += '<optgroup label="Andet hardware hos kunden">';
html += customerHardware.map((row) => `<option value="${row.id}">${row.label || ('Hardware #' + row.id)}</option>`).join('');
html += '</optgroup>';
}
select.innerHTML = html;
}
async function loadCaseAnyDeskContext() {
const [savedIdsRes, hardwareRes] = await Promise.all([
fetch(`/api/v1/anydesk/cases/${caseIds}/ids`, { credentials: 'include' }),
fetch(`/api/v1/anydesk/cases/${caseIds}/hardware-options`, { credentials: 'include' })
]);
if (!savedIdsRes.ok) {
throw new Error('Kunne ikke hente gemte AnyDesk IDs');
}
if (!hardwareRes.ok) {
throw new Error('Kunne ikke hente hardwarevalg');
}
const savedIdsPayload = await savedIdsRes.json();
const hardwarePayload = await hardwareRes.json();
renderCaseAnyDeskSavedIds(savedIdsPayload?.ids || []);
renderCaseAnyDeskHardwareOptions(hardwarePayload?.case_hardware || [], hardwarePayload?.customer_hardware || []);
const primary = (savedIdsPayload?.ids || []).find((item) => item?.is_primary);
if (primary?.anydesk_id) {
setCaseAnyDeskInputFromSaved(primary.anydesk_id);
}
}
async function openCaseAnyDeskModal() {
if (caseAnyDeskConnectInFlight) {
return;
}
if (!caseAnyDeskModal) { if (!caseAnyDeskModal) {
const modalEl = document.getElementById('caseAnyDeskModal'); const modalEl = document.getElementById('caseAnyDeskModal');
if (!modalEl) return; if (!modalEl) return;
caseAnyDeskModal = new bootstrap.Modal(modalEl); caseAnyDeskModal = new bootstrap.Modal(modalEl);
} }
const anydeskInput = document.getElementById('caseAnydeskIdInput');
const deviceInput = document.getElementById('caseAnydeskDeviceNameInput');
const typeInput = document.getElementById('caseAnydeskDeviceTypeInput');
const noteInput = document.getElementById('caseAnydeskNoteInput'); const noteInput = document.getElementById('caseAnydeskNoteInput');
if (anydeskInput) anydeskInput.value = '';
if (deviceInput) deviceInput.value = '';
if (typeInput) typeInput.value = 'placebo';
if (noteInput) noteInput.value = ''; if (noteInput) noteInput.value = '';
const saved = document.getElementById('caseAnyDeskSavedIds');
if (saved) {
saved.innerHTML = '<span class="text-muted small">Indlæser...</span>';
}
const hardwareSelect = document.getElementById('caseAnydeskHardwareSelect');
if (hardwareSelect) {
hardwareSelect.innerHTML = '<option value="">Indlæser hardware...</option>';
}
const input = document.getElementById('caseAnydeskIdInput');
if (input && !input.value) {
input.value = '';
}
onCaseAnyDeskIdInputChange();
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
if (connectBtn) {
connectBtn.disabled = false;
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
}
try {
await loadCaseAnyDeskContext();
} catch (error) {
const message = error?.message || 'Kunne ikke hente AnyDesk data';
if (saved) {
saved.innerHTML = `<span class="text-danger small">${message}</span>`;
}
}
caseAnyDeskModal.show(); caseAnyDeskModal.show();
} }
async function registerCaseAnyDeskSession() { async function registerCaseAnyDeskSession() {
const anydeskId = (document.getElementById('caseAnydeskIdInput')?.value || '').trim(); if (caseAnyDeskConnectInFlight) {
const assistedDevice = (document.getElementById('caseAnydeskDeviceNameInput')?.value || '').trim(); return;
const deviceType = (document.getElementById('caseAnydeskDeviceTypeInput')?.value || 'placebo').trim(); }
const anydeskId = normalizeAnyDeskIdClient(document.getElementById('caseAnydeskIdInput')?.value || '');
const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || ''; const contactIdRaw = document.getElementById('caseAnydeskContactSelect')?.value || '';
const hardwareIdRaw = document.getElementById('caseAnydeskHardwareSelect')?.value || '';
const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim(); const notes = (document.getElementById('caseAnydeskNoteInput')?.value || '').trim();
if (!anydeskId) { if (!anydeskId) {
alert('Udfyld AnyDesk ID'); alert('Udfyld AnyDesk ID');
return; return;
} }
if (!assistedDevice) {
alert('Udfyld hvilken enhed du hjælper med');
return;
}
const customerId = {{ customer.id if customer else 'null' }}; const customerId = {{ customer.id if customer else 'null' }};
if (!customerId) { if (!customerId) {
alert('Sagen har ingen kunde - kan ikke registrere AnyDesk session'); alert('Sagen har ingen kunde - kan ikke starte AnyDesk session');
return; return;
} }
const createdByUserId = await ensureCaseCurrentUserId(); const createdByUserId = await ensureCaseCurrentUserId();
const payload = { const payload = {
sag_id: caseIds,
customer_id: customerId,
contact_id: contactIdRaw ? Number(contactIdRaw) : null,
anydesk_id: anydeskId, anydesk_id: anydeskId,
assisted_device: assistedDevice, contact_id: contactIdRaw ? Number(contactIdRaw) : null,
device_type: deviceType, hardware_asset_id: hardwareIdRaw ? Number(hardwareIdRaw) : null,
notes, note: notes,
make_primary: true,
created_by_user_id: createdByUserId || null created_by_user_id: createdByUserId || null
}; };
caseAnyDeskConnectInFlight = true;
const connectBtn = document.getElementById('caseAnyDeskConnectBtn');
const openBtn = document.getElementById('caseAnyDeskOpenBtn');
if (connectBtn) {
connectBtn.disabled = true;
connectBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2" role="status" aria-hidden="true"></span>Registrerer...';
}
if (openBtn) {
openBtn.disabled = true;
}
try { try {
const res = await fetch('/api/v1/anydesk/register-manual-session', { const res = await fetch(`/api/v1/anydesk/cases/${caseIds}/connect`, {
method: 'POST', method: 'POST',
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
credentials: 'include', credentials: 'include',
@ -9544,17 +9638,31 @@
if (!res.ok) { if (!res.ok) {
const txt = await res.text(); const txt = await res.text();
throw new Error(txt || 'Kunne ikke registrere session'); throw new Error(txt || 'Kunne ikke starte AnyDesk quick connect');
} }
const result = await res.json(); const result = await res.json();
if (caseAnyDeskModal) caseAnyDeskModal.hide(); if (caseAnyDeskModal) caseAnyDeskModal.hide();
alert(`AnyDesk session registreret (ID: ${result?.session?.id || '-'})`);
if (result?.deep_link) {
window.location.href = result.deep_link;
}
alert(`AnyDesk forbindelse startet (session: ${result?.session?.id || '-'})`);
if (typeof loadComments === 'function') { if (typeof loadComments === 'function') {
loadComments(); loadComments();
} }
} catch (e) { } catch (e) {
alert('Fejl ved registrering: ' + (e.message || 'Ukendt fejl')); alert('Fejl ved AnyDesk quick connect: ' + (e.message || 'Ukendt fejl'));
} finally {
caseAnyDeskConnectInFlight = false;
if (connectBtn) {
connectBtn.disabled = false;
connectBtn.innerHTML = '<i class="bi bi-plug me-1"></i>Forbind og gem';
}
if (openBtn) {
openBtn.disabled = false;
}
} }
} }
@ -9578,6 +9686,10 @@
window._showRelModal = renderCaseAddWorkspaceModal; window._showRelModal = renderCaseAddWorkspaceModal;
} }
// Remove legacy modal instance to avoid duplicate relQaModalEl IDs
// when side-panel rendering is active.
document.querySelectorAll('body > #relQaModalEl').forEach((el) => el.remove());
renderCaseAddActionList(caseAddActiveAction); renderCaseAddActionList(caseAddActiveAction);
caseAddPanelInitialized = true; caseAddPanelInitialized = true;
} }
@ -9594,15 +9706,28 @@
if (typeof caseAddOriginalShowRelModal === 'function') { if (typeof caseAddOriginalShowRelModal === 'function') {
window._showRelModal = caseAddOriginalShowRelModal; window._showRelModal = caseAddOriginalShowRelModal;
} }
const workspace = document.getElementById('caseAddSideWorkspace');
if (workspace) {
workspace.innerHTML = '<div class="text-muted small">Vaelg en handling i venstre side.</div>';
}
} }
function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) { function renderCaseAddWorkspaceModal(title, bodyHtml, footerBtns) {
const workspace = document.getElementById('caseAddSideWorkspace'); const workspace = document.getElementById('caseAddSideWorkspace');
if (!workspace) return; if (!workspace) return;
// Ensure only one relQaModalEl exists. Duplicate IDs can break button
// handlers and value lookups across add forms.
document.querySelectorAll('#relQaModalEl').forEach((el) => {
if (!workspace.contains(el)) {
el.remove();
}
});
workspace.innerHTML = ` workspace.innerHTML = `
<div id="relQaModalEl" class="d-flex flex-column gap-2"> <div id="relQaModalEl" class="d-flex flex-column gap-2">
<div class="section-title">${title}</div> <div id="relQaModalTitle" class="section-title">${title}</div>
<div id="relQaModalBody">${bodyHtml}</div> <div id="relQaModalBody">${bodyHtml}</div>
<div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2"> <div id="relQaModalFooter" class="d-flex justify-content-end gap-2 border-top pt-2">
<button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button> <button class="btn btn-sm btn-outline-secondary" type="button" onclick="closeCaseModuleAddPanel()">Luk</button>
@ -9681,14 +9806,15 @@
return; return;
} }
const existingRelQaEl = document.getElementById('relQaModalEl'); document.querySelectorAll('#relQaModalEl').forEach((existingRelQaEl) => {
if (existingRelQaEl && !workspace.contains(existingRelQaEl)) { if (!workspace.contains(existingRelQaEl)) {
const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl); const existingModalInstance = window.bootstrap?.Modal?.getInstance(existingRelQaEl);
if (existingModalInstance) { if (existingModalInstance) {
existingModalInstance.hide(); existingModalInstance.hide();
}
existingRelQaEl.remove();
} }
existingRelQaEl.remove(); });
}
try { try {
await Promise.resolve(relFn(caseIds, currentCaseTitle)); await Promise.resolve(relFn(caseIds, currentCaseTitle));
@ -13020,9 +13146,9 @@
function getRelQaPrimaryButton() { function getRelQaPrimaryButton() {
const sidePanel = document.getElementById('caseAddSidePanel'); const sidePanel = document.getElementById('caseAddSidePanel');
if (sidePanel && sidePanel.classList.contains('open')) { if (sidePanel && sidePanel.classList.contains('open')) {
return sidePanel.querySelector('#relQaModalFooter .btn-primary'); return document.querySelector('#caseAddSideWorkspace #relQaModalFooter .btn-primary');
} }
return document.querySelector('#relQaModalEl .btn-primary'); return document.querySelector('body > #relQaModalEl .btn-primary');
} }
function closeRelQaSurfaceAfterSave() { function closeRelQaSurfaceAfterSave() {
@ -13145,21 +13271,47 @@
if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; } if (!fileInput.files.length) { if (typeof showNotification === 'function') showNotification('Vælg mindst én fil', 'warning'); return; }
const saveBtn = getRelQaPrimaryButton(); const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; } if (saveBtn) { saveBtn.disabled = true; saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm"></span> Uploader…'; }
let success = 0; let failed = 0; try {
for (const file of fileInput.files) { const fd = new FormData();
try { for (const file of fileInput.files) {
const fd = new FormData(); fd.append('files', file);
fd.append('file', file); }
const desc = document.getElementById('rqf_desc').value;
if (desc) fd.append('description', desc); const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd });
const r = await fetch(`/api/v1/sag/${caseId}/files`, { method: 'POST', credentials: 'include', body: fd }); if (!r.ok) {
if (r.ok) success++; else failed++; const d = await r.json().catch(() => ({}));
} catch { failed++; } if (typeof showNotification === 'function') showNotification(d.detail || 'Upload fejlede', 'error');
} if (saveBtn) {
closeRelQaSurfaceAfterSave(); saveBtn.disabled = false;
if (typeof showNotification === 'function') { saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
if (failed === 0) showNotification(`${success} fil(er) uploadet ✓`, 'success'); }
else showNotification(`${success} ok, ${failed} fejlede`, 'warning'); return;
}
const saved = await r.json().catch(() => []);
const uploadedCount = Array.isArray(saved) ? saved.length : 0;
if (uploadedCount === 0) {
if (typeof showNotification === 'function') showNotification('Filer blev ikke gemt. Prøv igen.', 'warning');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
}
return;
}
if (typeof loadSagFiles === 'function') {
await loadSagFiles();
}
closeRelQaSurfaceAfterSave();
if (typeof showNotification === 'function') {
showNotification(`${uploadedCount} fil(er) uploadet ✓`, 'success');
}
} catch {
if (typeof showNotification === 'function') showNotification('Upload fejlede', 'error');
if (saveBtn) {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-upload me-1"></i>Upload';
}
} }
}; };
@ -13669,7 +13821,7 @@
window._submitRelNote = async function(caseId) { window._submitRelNote = async function(caseId) {
const text = document.getElementById('rqn_text').value.trim(); const text = document.getElementById('rqn_text').value.trim();
if (!text) return; if (!text) return;
const saveBtn = document.querySelector('#relQaModalEl .btn-primary'); const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; } if (saveBtn) { saveBtn.disabled = true; }
try { try {
const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, { const r = await fetch(`/api/v1/sag/${caseId}/kommentarer`, {
@ -13782,11 +13934,52 @@
}; };
window._submitRelReminder = async function(caseId) { window._submitRelReminder = async function(caseId) {
const payload = { sag_id: caseId, remind_at: document.getElementById('rqr_at').value, message: document.getElementById('rqr_msg').value }; const whenInput = document.getElementById('rqr_at')?.value || '';
const message = (document.getElementById('rqr_msg')?.value || '').trim();
if (!whenInput) {
if (typeof showNotification === 'function') showNotification('Vælg tidspunkt', 'warning');
return;
}
let reminderUserId = null;
try {
if (typeof ensureReminderUserId === 'function') {
reminderUserId = await ensureReminderUserId();
} else if (typeof getReminderUserId === 'function') {
reminderUserId = getReminderUserId();
}
} catch {}
if (!reminderUserId) {
if (typeof showNotification === 'function') showNotification('Mangler bruger-id til reminder', 'error');
return;
}
const scheduledAtIso = new Date(whenInput).toISOString();
const baseTitle = (message || 'Husk opfoelgning').trim();
const safeTitle = (baseTitle.length >= 3 ? baseTitle : 'Reminder').slice(0, 255);
const payload = {
title: safeTitle,
message: message || null,
priority: 'normal',
event_type: 'reminder',
trigger_type: 'time_based',
trigger_config: {},
recipient_user_ids: [Number(reminderUserId)],
recipient_emails: [],
notify_mattermost: false,
notify_email: false,
notify_frontend: true,
override_user_preferences: false,
recurrence_type: 'once',
recurrence_day_of_week: null,
recurrence_day_of_month: null,
scheduled_at: scheduledAtIso,
};
const saveBtn = getRelQaPrimaryButton(); const saveBtn = getRelQaPrimaryButton();
if (saveBtn) { saveBtn.disabled = true; } if (saveBtn) { saveBtn.disabled = true; }
try { try {
const r = await fetch('/api/v1/reminders', { const r = await fetch(`/api/v1/sag/${caseId}/reminders?user_id=${encodeURIComponent(reminderUserId)}`, {
method: 'POST', credentials: 'include', method: 'POST', credentials: 'include',
headers: {'Content-Type':'application/json'}, headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload) body: JSON.stringify(payload)
@ -13800,8 +13993,12 @@
// ── shared modal helper ─────────────────────────────────────────── // ── shared modal helper ───────────────────────────────────────────
window._showRelModal = function(title, bodyHtml, footerBtns) { window._showRelModal = function(title, bodyHtml, footerBtns) {
let el = document.getElementById('relQaModalEl'); let el = document.querySelector('body > #relQaModalEl.modal');
if (!el) { if (!el) {
const nonModal = document.getElementById('relQaModalEl');
if (nonModal && !nonModal.classList.contains('modal')) {
nonModal.remove();
}
el = document.createElement('div'); el = document.createElement('div');
el.id = 'relQaModalEl'; el.id = 'relQaModalEl';
el.className = 'modal fade'; el.className = 'modal fade';

View File

@ -5,8 +5,10 @@ REST API endpoints for managing remote support sessions
import logging import logging
import json import json
import re
from uuid import uuid4 from uuid import uuid4
from typing import Optional from typing import Optional
from datetime import timedelta
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
@ -26,6 +28,23 @@ router = APIRouter()
anydesk_service = AnyDeskService() 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 # Session Management Endpoints
# ===================================================== # =====================================================
@ -342,6 +361,424 @@ async def register_manual_session(data: dict):
raise HTTPException(status_code=500, detail=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"]) @router.get("/anydesk/sessions", response_model=AnyDeskSessionHistory, tags=["Remote Support"])
async def get_session_history( async def get_session_history(
contact_id: Optional[int] = None, contact_id: Optional[int] = None,
@ -488,7 +925,7 @@ async def get_anydesk_stats():
"sessions_this_month": 0, "sessions_this_month": 0,
"active_sessions": 0, "active_sessions": 0,
"average_duration_minutes": 0, "average_duration_minutes": 0,
"total_support_hours": 0 "total_support_hours": 0.0
} }
# Get today's sessions # Get today's sessions
@ -676,8 +1113,162 @@ async def sessions_overview(
count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}" count_q = f"SELECT COUNT(*) AS total FROM anydesk_sessions s {where}"
total = (execute_query(count_q) or [{"total": 0}])[0]["total"] 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 = [] sessions = []
for r in (rows or []): for r in merged_rows:
sessions.append({ sessions.append({
"id": r["id"], "id": r["id"],
"anydesk_session_id": r["anydesk_session_id"], "anydesk_session_id": r["anydesk_session_id"],
@ -715,7 +1306,8 @@ async def sessions_overview(
} if r["sag_id"] else None, } if r["sag_id"] else None,
}) })
return JSONResponse(content={"sessions": sessions, "total": total, "limit": limit, "offset": offset}) # 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: except Exception as e:
logger.error(f"Error in sessions_overview: {e}") logger.error(f"Error in sessions_overview: {e}")

View File

@ -9,8 +9,9 @@ import hashlib
import hmac import hmac
import base64 import base64
import time import time
from uuid import uuid4
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Optional, Dict, Any from typing import Optional, Dict, Any, List
import httpx import httpx
import aiohttp import aiohttp
@ -577,3 +578,232 @@ class AnyDeskService:
"total_from_api": len(entries), "total_from_api": len(entries),
"errors": errors, "errors": errors,
} }
@staticmethod
def _extract_local_sessions(payload: Any) -> List[dict]:
if isinstance(payload, list):
return [item for item in payload if isinstance(item, dict)]
if isinstance(payload, dict):
for key in ("sessions", "list", "data", "items", "results"):
value = payload.get(key)
if isinstance(value, list):
return [item for item in value if isinstance(item, dict)]
return []
@staticmethod
def _parse_timestamp(value: Any) -> Optional[datetime]:
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, (int, float)):
if value > 10_000_000_000:
value = value / 1000
try:
return datetime.utcfromtimestamp(value)
except Exception:
return None
text = str(value).strip()
if not text:
return None
try:
if text.endswith("Z"):
text = text[:-1] + "+00:00"
return datetime.fromisoformat(text).replace(tzinfo=None)
except Exception:
return None
async def fetch_sessions_from_local_endpoint(
self,
endpoint_url: str,
timeout_seconds: int = 20,
dry_run: bool = False,
) -> Dict[str, Any]:
"""
Poll local AnyDesk bridge endpoint and upsert/enrich local sessions.
Endpoint expected: http://localhost:8001/anydesk/sessions
"""
imported = 0
updated = 0
matched = 0
errors: List[str] = []
try:
logger.info("📡 Polling local AnyDesk sessions from %s", endpoint_url)
async with httpx.AsyncClient(timeout=timeout_seconds) as client:
response = await client.get(endpoint_url)
response.raise_for_status()
payload = response.json()
except Exception as exc:
logger.error("❌ Local AnyDesk polling failed: %s", exc)
return {"error": str(exc), "imported": 0, "updated": 0, "matched": 0, "total": 0, "errors": [str(exc)]}
entries = self._extract_local_sessions(payload)
for entry in entries:
try:
sid = str(entry.get("sid") or entry.get("session_id") or entry.get("anydesk_session_id") or entry.get("id") or "").strip()
to_raw = entry.get("to")
to_obj = to_raw if isinstance(to_raw, dict) else {}
to_id = str(
entry.get("to_id")
or entry.get("anydesk_id")
or entry.get("customer_machine_id")
or to_obj.get("cid")
or ""
).strip()
from_raw = entry.get("from")
from_obj = from_raw if isinstance(from_raw, dict) else {}
from_id = str(entry.get("from_id") or from_obj.get("cid") or "").strip()
started = self._parse_timestamp(
entry.get("started_at")
or entry.get("start")
or entry.get("start-time")
or entry.get("start_time")
)
ended = self._parse_timestamp(
entry.get("ended_at")
or entry.get("end")
or entry.get("end-time")
or entry.get("end_time")
)
duration_minutes = entry.get("duration_minutes")
if duration_minutes is None:
duration_seconds = entry.get("duration_seconds")
if duration_seconds is None:
duration_seconds = entry.get("duration")
if duration_seconds is not None:
try:
duration_minutes = round(float(duration_seconds) / 60, 1)
except Exception:
duration_minutes = None
status = str(entry.get("status") or "").strip().lower()
if not status:
if bool(entry.get("active")):
status = "active"
elif ended or duration_minutes is not None:
status = "completed"
else:
status = "pending"
if not sid and not to_id:
continue
existing = []
if sid:
existing = execute_query(
"SELECT id FROM anydesk_sessions WHERE anydesk_session_id = %s",
(sid,),
) or []
if not existing and to_id:
existing = execute_query(
"""
SELECT id
FROM anydesk_sessions
WHERE COALESCE(device_info->>'to_id', '') = %s
AND started_at >= NOW() - INTERVAL '24 hours'
ORDER BY started_at DESC
LIMIT 1
""",
(to_id,),
) or []
device_info = {
"remote_alias": entry.get("remote_alias") or from_obj.get("alias"),
"from_id": from_id,
"to_id": to_id,
"local_alias": entry.get("local_alias") or to_obj.get("alias"),
"imported_from_local_endpoint": True,
}
metadata = {
"source": "local_anydesk_sessions",
"raw": entry,
}
if existing:
matched += 1
if dry_run:
continue
execute_query(
"""
UPDATE anydesk_sessions
SET
status = COALESCE(%s, status),
ended_at = COALESCE(%s, ended_at),
duration_minutes = COALESCE(%s, duration_minutes),
device_info = COALESCE(device_info, '{}'::jsonb) || %s::jsonb,
metadata = COALESCE(metadata, '{}'::jsonb) || %s::jsonb,
updated_at = NOW()
WHERE id = %s
""",
(
status,
ended,
duration_minutes,
json.dumps(device_info),
json.dumps(metadata),
existing[0]["id"],
),
)
updated += 1
continue
if dry_run:
continue
insert_sid = sid or f"local-{uuid4().hex[:12]}"
execute_query(
"""
INSERT INTO anydesk_sessions (
anydesk_session_id,
session_link,
started_at,
ended_at,
duration_minutes,
status,
device_info,
metadata,
created_at,
updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, NOW(), NOW())
""",
(
insert_sid,
f"anydesk:{to_id}" if to_id else None,
started or datetime.utcnow(),
ended,
duration_minutes,
status,
json.dumps(device_info),
json.dumps(metadata),
),
)
imported += 1
except Exception as exc:
logger.warning("⚠️ Failed local AnyDesk entry sync: %s", exc)
errors.append(str(exc))
logger.info(
"✅ Local AnyDesk sync done: %d imported, %d updated (%d matched), %d errors",
imported,
updated,
matched,
len(errors),
)
return {
"imported": imported,
"updated": updated,
"matched": matched,
"total": len(entries),
"errors": errors,
}

16
main.py
View File

@ -213,6 +213,22 @@ async def lifespan(app: FastAPI):
) )
logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES) logger.info("✅ ESET sync job scheduled (every %d minutes)", settings.ESET_SYNC_INTERVAL_MINUTES)
if settings.ANYDESK_LOCAL_SYNC_ENABLED:
from app.jobs.anydesk_local_sync import sync_anydesk_local_sessions
backup_scheduler.scheduler.add_job(
func=sync_anydesk_local_sessions,
trigger=IntervalTrigger(minutes=settings.ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES),
id='anydesk_local_sync',
name='AnyDesk Local Sessions Sync',
max_instances=1,
replace_existing=True
)
logger.info(
"✅ AnyDesk local sync job scheduled (every %d minutes)",
settings.ANYDESK_LOCAL_SYNC_INTERVAL_MINUTES,
)
if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED: if settings.LINKS_MODULE_ENABLED and settings.LINKS_DEAD_LINK_CHECK_ENABLED:
from app.modules.links.jobs.dead_link_check import check_links_health from app.modules.links.jobs.dead_link_check import check_links_health

View File

@ -0,0 +1,47 @@
-- Migration 165: AnyDesk IDs per case (multi-ID support)
-- Stores quick-connect IDs on cases and optional hardware relation
CREATE TABLE IF NOT EXISTS sag_anydesk_ids (
id SERIAL PRIMARY KEY,
sag_id INTEGER NOT NULL REFERENCES sag_sager(id) ON DELETE CASCADE,
anydesk_id VARCHAR(64) NOT NULL,
hardware_asset_id INTEGER REFERENCES hardware_assets(id) ON DELETE SET NULL,
is_primary BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
created_by_user_id INTEGER REFERENCES users(user_id) ON DELETE SET NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITH TIME ZONE,
UNIQUE (sag_id, anydesk_id)
);
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_sag_id
ON sag_anydesk_ids(sag_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_anydesk_id
ON sag_anydesk_ids(anydesk_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_hardware_asset_id
ON sag_anydesk_ids(hardware_asset_id)
WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_sag_anydesk_ids_primary
ON sag_anydesk_ids(sag_id, is_primary)
WHERE deleted_at IS NULL;
-- Keep updated_at fresh
CREATE OR REPLACE FUNCTION update_sag_anydesk_ids_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = CURRENT_TIMESTAMP;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS trg_update_sag_anydesk_ids_updated_at ON sag_anydesk_ids;
CREATE TRIGGER trg_update_sag_anydesk_ids_updated_at
BEFORE UPDATE ON sag_anydesk_ids
FOR EACH ROW
EXECUTE FUNCTION update_sag_anydesk_ids_updated_at();