/** * TeamViewer Web API: Connection Reports (/api/v1/reports/connections) */ const CONNECTIONS_URL = 'https://webapi.teamviewer.com/api/v1/reports/connections'; const FETCH_HEADERS_BASE = { Accept: 'application/json', 'User-Agent': 'SDS-CRM/1.0 (Node.js; TeamViewer reports/connections)', }; /** * TeamViewer akzeptiert ISO-8601-UTC ohne Millisekunden, z. B. 2019-01-31T19:20:30Z. * JavaScripts toISOString() liefert .sssZ — das führt zu HTTP 400 „from_date is not valid“. */ export function formatTeamViewerDateParam(date) { return new Date(date).toISOString().replace(/\.\d{3}Z$/, 'Z'); } /** Dauer aus TeamViewer-Feldern `start_date` / `end_date` (ISO-Strings, ggf. leer). */ export function computeRemoteDurationSeconds(startIso, endIso) { const ts = startIso != null ? String(startIso).trim() : ''; const te = endIso != null ? String(endIso).trim() : ''; if (!ts || !te) return null; const s = new Date(ts); const e = new Date(te); if (Number.isNaN(s.getTime()) || Number.isNaN(e.getTime()) || e < s) { return null; } return Math.max(0, Math.round((e - s) / 1000)); } /** * Gruppiert nach Benutzer (userid, sonst username), pro Benutzer Geräte mit * jüngster Session je Gerät; Anzeigename Gerät = devicename oder sonst deviceid. */ /** TeamViewer liefert Feldnamen je nach Version leicht unterschiedlich (userid / user_id / …). */ function pickUserIdentity(rec) { const uid = String( rec.userid ?? rec.user_id ?? rec.userId ?? rec.UserID ?? '', ).trim(); const un = String( rec.username ?? rec.user_name ?? rec.userName ?? rec.UserName ?? '', ).trim(); return { uid, un }; } function buildSessionsByUser(allRecords) { const byUser = new Map(); for (const rec of allRecords) { const { uid, un } = pickUserIdentity(rec); const userKey = uid || un || '_unbekannt'; if (!byUser.has(userKey)) { byUser.set(userKey, { userid: uid || null, username: un || userKey, records: [], }); } byUser.get(userKey).records.push(rec); } const users = []; for (const [userKey, { userid, username, records }] of byUser) { const byDev = new Map(); for (const rec of records) { const did = String( rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '', ).trim(); const dn = String( rec.devicename ?? rec.device_name ?? rec.deviceName ?? '', ).trim(); const dkey = did || dn; if (!dkey) continue; if (!byDev.has(dkey)) byDev.set(dkey, []); byDev.get(dkey).push(rec); } const devices = []; for (const [, recs] of byDev) { recs.sort((a, b) => { const eb = (b.end_date ?? b.endDate ?? b.EndDate) ? new Date(b.end_date ?? b.endDate ?? b.EndDate).getTime() : 0; const ea = (a.end_date ?? a.endDate ?? a.EndDate) ? new Date(a.end_date ?? a.endDate ?? a.EndDate).getTime() : 0; return eb - ea; }); const rec = recs[0]; const did = String( rec.deviceid ?? rec.device_id ?? rec.deviceId ?? '', ).trim(); const dn = String( rec.devicename ?? rec.device_name ?? rec.deviceName ?? '', ).trim(); const label = dn || did; const idForCrm = did || dn; const startRaw = rec.start_date ?? rec.startDate ?? rec.StartDate; const endRaw = rec.end_date ?? rec.endDate ?? rec.EndDate; const startDate = startRaw ? String(startRaw).trim() : ''; const endDate = endRaw ? String(endRaw).trim() : ''; devices.push({ deviceid: idForCrm, label, startDate: startDate || null, endDate: endDate || null, durationSeconds: computeRemoteDurationSeconds(startDate, endDate), }); } devices.sort((a, b) => a.label.localeCompare(b.label, 'de', { sensitivity: 'base' }), ); users.push({ userKey, userid, username, devices, }); } users.sort((a, b) => a.username.localeCompare(b.username, 'de', { sensitivity: 'base' }), ); return users; } function upstreamError(res, httpStatus, raw) { const text = String(raw ?? '').trim(); if (text.startsWith('<')) { return res.status(502).json({ message: 'TeamViewer-Web-API lieferte HTML statt JSON. Typisch: Script-Token ungültig/abgelaufen, falsche Berechtigungen (Verbindungsberichte), oder Dienst kurzzeitig nicht erreichbar.', hint: 'TeamViewer Management Console → Apps → Script-Token: Zugriff auf Reporting / Connection Reports prüfen.', devices: [], users: [], }); } let detail = text.slice(0, 600); try { const j = JSON.parse(text); const msg = (typeof j.error_description === 'string' && j.error_description) || (typeof j.error === 'string' && j.error) || (typeof j.message === 'string' && j.message); if (msg) detail = String(msg); } catch { /* Rohtext */ } return res.status(502).json({ message: `TeamViewer-API meldet HTTP ${httpStatus}.`, detail, devices: [], users: [], }); } function bodyNotJson(res, raw) { const text = String(raw ?? '').trim(); if (text.startsWith('<')) { return upstreamError(res, 200, raw); } return res.status(502).json({ message: 'TeamViewer: Antwort ist kein gültiges JSON.', detail: text.slice(0, 400), devices: [], users: [], }); } /** TeamViewer-Antworten: `records` (Reports) oder andere Schlüssel / verschachtelt. */ function extractReportRecords(data) { if (!data || typeof data !== 'object') return []; if (Array.isArray(data)) return data; const candidates = [ data.records, data.Records, data.connections, data.Connections, data.items, data.Items, data.values, data.data?.records, data.data?.connections, data.report?.records, ]; let fallbackEmpty = null; for (const c of candidates) { if (Array.isArray(c)) { if (c.length > 0) return c; if (fallbackEmpty === null) fallbackEmpty = c; } } return fallbackEmpty ?? []; } function hasNextPage(data) { const next = data?.next_offset ?? data?.nextOffset ?? data?.NextOffset; if (next == null) return false; const s = String(next).trim(); return s.length > 0; } /** @param {import('express').Router} api @param {() => object} loadIntegrations */ export function registerTeamViewerRoutes(api, loadIntegrations) { api.get('/integrations/teamviewer/connections', async (_req, res) => { const integ = loadIntegrations(); const token = String( integ.teamviewer?.bearerToken || integ.teamviewer?.apiToken || '', ).trim(); if (!token) { return res.status(400).json({ message: 'TeamViewer Bearer-Token in den Optionen hinterlegen (Abschnitt TeamViewer).', devices: [], users: [], }); } const now = new Date(); const from = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const fromStr = formatTeamViewerDateParam(from); const toStr = formatTeamViewerDateParam(now); try { const allRecords = []; let offset; let pagesFetched = 0; for (let page = 0; page < 25; page++) { pagesFetched += 1; const u = new URL(CONNECTIONS_URL); u.searchParams.set('from_date', fromStr); u.searchParams.set('to_date', toStr); if (offset != null && offset !== '') { u.searchParams.set('offset', String(offset)); } const r = await fetch(u, { headers: { ...FETCH_HEADERS_BASE, Authorization: `Bearer ${token}`, }, }); const raw = await r.text(); if (!r.ok) { console.error( '[TeamViewer API] upstream HTTP', r.status, String(raw ?? '').slice(0, 500), ); return upstreamError(res, r.status, raw); } let data; try { data = JSON.parse(raw); } catch { return bodyNotJson(res, raw); } const recs = extractReportRecords(data); for (const rec of recs) { allRecords.push(rec); } /* * Wichtig: nicht abbrechen, nur weil eine Seite 0 Zeilen hat — TeamViewer liefert * manchmal leere erste Seiten, setzt aber weiter next_offset (vgl. records_remaining). * Früher: if (!next || recs.length === 0) break → alle Daten verloren. */ if (!hasNextPage(data)) break; offset = data.next_offset ?? data.nextOffset ?? data.NextOffset; } const users = buildSessionsByUser(allRecords); res.json({ users, devices: [], meta: { recordCount: allRecords.length, pagesFetched, source: 'reports/connections', }, }); } catch (e) { console.error('[TeamViewer API] interner Fehler', e); res.status(500).json({ message: 'TeamViewer-Verbindungen konnten nicht geladen werden.', devices: [], users: [], }); } }); }