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:
parent
ee8c517acc
commit
270af0e277
@ -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=...
|
||||||
|
|||||||
41
app/jobs/anydesk_local_sync.py
Normal file
41
app/jobs/anydesk_local_sync.py
Normal 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)
|
||||||
@ -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';
|
||||||
|
|||||||
@ -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}")
|
||||||
|
|||||||
@ -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
16
main.py
@ -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
|
||||||
|
|
||||||
|
|||||||
47
migrations/165_sag_anydesk_ids.sql
Normal file
47
migrations/165_sag_anydesk_ids.sql
Normal 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();
|
||||||
Loading…
Reference in New Issue
Block a user