From b2edb63a0bcfdca865b5c76422c9fbbd6b1a584c Mon Sep 17 00:00:00 2001 From: M_Viper Date: Fri, 23 Oct 2026 17:50:23 +0000 Subject: [PATCH] Upload via GUI (304 Dateien) --- DeskHub-Webseite/tests/staff_chat.php | 134 ++++++++++++++++++ .../tools/demo-video/build_video.sh | 26 ++++ DeskHub-Webseite/tools/demo-video/capture.js | 93 ++++++++++++ .../tools/demo-video/capture_app.js | 47 ++++++ .../tools/demo-video/capture_titles.js | 34 +++++ .../tools/demo-video/seed_demo.php | 120 ++++++++++++++++ 6 files changed, 454 insertions(+) create mode 100644 DeskHub-Webseite/tests/staff_chat.php create mode 100644 DeskHub-Webseite/tools/demo-video/build_video.sh create mode 100644 DeskHub-Webseite/tools/demo-video/capture.js create mode 100644 DeskHub-Webseite/tools/demo-video/capture_app.js create mode 100644 DeskHub-Webseite/tools/demo-video/capture_titles.js create mode 100644 DeskHub-Webseite/tools/demo-video/seed_demo.php diff --git a/DeskHub-Webseite/tests/staff_chat.php b/DeskHub-Webseite/tests/staff_chat.php new file mode 100644 index 00000000..967d498e --- /dev/null +++ b/DeskHub-Webseite/tests/staff_chat.php @@ -0,0 +1,134 @@ +getPathname(), strlen($root) + 1)); + if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; + if ($f->isDir()) { @mkdir($tmp . '/' . $rel, 0755, true); continue; } + @mkdir(dirname($tmp . '/' . $rel), 0755, true); + copy($f->getPathname(), $tmp . '/' . $rel); +} +@mkdir($tmp . '/uploads'); +@mkdir($tmp . '/config'); +file_put_contents($tmp . '/config/config.php', ' bin2hex(random_bytes(32)), + 'db' => ['driver' => 'sqlite', 'sqlite_path' => $tmp . '/database/deskhub.sqlite'], + 'site' => ['name' => 'Test', 'url' => 'http://127.0.0.1', 'admin_email' => 'a@test.de', 'timezone' => 'Europe/Berlin'], + 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => $tmp . '/uploads'], +], true) . ';'); + +putenv('DESKHUB_CONFIG_JSON'); +putenv('DESKHUB_SQLITE_PATH'); +putenv('DESKHUB_TESTING'); +putenv('DESKHUB_API_ALLOW_INSECURE=1'); +require_once $root . '/database/sqlite_schema.php'; +require_once $root . '/database/migrate.php'; +$db = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); +$db->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$db->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$db->exec('PRAGMA foreign_keys = ON'); +$db->exec(convert_schema_to_sqlite((string) file_get_contents($root . '/database/schema.sql'))); +$mig = run_migrations($db, true); +$now = date('Y-m-d H:i:s'); +$dept = (int) $db->query('SELECT id FROM departments ORDER BY id LIMIT 1')->fetchColumn(); +$db->exec('DELETE FROM staff'); +$ins = $db->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +$ids = []; +foreach ([['Chef', 'chef@test.de', 'admin'], ['Agent', 'agent@test.de', 'agent']] as [$n, $e, $r]) { + $ins->execute([$n, $e, password_hash('Sehr-Sicher-1234', PASSWORD_DEFAULT), $r, $dept, $now]); + $ids[$n] = (int) $db->lastInsertId(); +} +$db = null; + +$failures = []; +$total = 0; +$check = function (bool $ok, string $what) use (&$failures, &$total): void { $total++; if (!$ok) $failures[] = $what; }; +$webProc = proc_open([PHP_BINARY, '-S', "127.0.0.1:$webPort", '-t', $tmp], [['pipe', 'r'], ['file', $work . '/web.out', 'w'], ['file', $work . '/web.out', 'a']], $wp, $tmp); + +try { + for ($i = 0; $i < 50; $i++) { if (@fsockopen('127.0.0.1', $webPort)) break; usleep(100000); } + $pdo = function () use ($tmp): PDO { + $p = new PDO('sqlite:' . $tmp . '/database/deskhub.sqlite'); + $p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); + return $p; + }; + $call = function (string $method, string $path, string $jar, array $form = []) use ($webPort): array { + $ch = curl_init("http://127.0.0.1:$webPort$path"); + curl_setopt_array($ch, [CURLOPT_RETURNTRANSFER => true, CURLOPT_FOLLOWLOCATION => false, CURLOPT_TIMEOUT => 60, CURLOPT_COOKIEJAR => $jar, CURLOPT_COOKIEFILE => $jar, CURLOPT_HTTPHEADER => ['Sec-Fetch-Mode: navigate', 'Sec-Fetch-User: ?1', 'X-Requested-With: XMLHttpRequest']]); + if ($method === 'POST') { curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($form)); } + $b = (string) curl_exec($ch); + $s = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE); + curl_close($ch); + return [$s, $b]; + }; + $login = function (string $email) use ($call): string { + $jar = tempnam(sys_get_temp_dir(), 'chat'); + [, $lp] = $call('GET', '/staff/login.php', $jar); + preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $lp, $m); + $call('POST', '/staff/login.php', $jar, ['csrf_token' => $m[1] ?? '', 'email' => $email, 'password' => 'Sehr-Sicher-1234']); + return $jar; + }; + $token = function (string $jar, string $path) use ($call): string { [, $p] = $call('GET', $path, $jar); preg_match('/name="csrf_token" value="([a-f0-9]+)"/', $p, $m); return $m[1] ?? ''; }; + $ids = $pdo()->query('SELECT email, id FROM staff')->fetchAll(PDO::FETCH_KEY_PAIR); + $chefId = (int) $ids['chef@test.de']; $agentId = (int) $ids['agent@test.de']; + $jarA = $login('chef@test.de'); + $jarB = $login('agent@test.de'); + + $send = function (string $jar, int $toId, string $text) use ($call, $token): void { + $t = $token($jar, '/staff/chat.php?with=' . $toId); + $call('POST', '/staff/chat.php', $jar, ['csrf_token' => $t, 'to_id' => (string) $toId, 'message' => $text]); + }; + $poll = function (string $jar, int $withId, int $after) use ($call): array { [, $b] = $call('GET', "/staff/chat.php?with=$withId&action=poll&after=$after", $jar); return json_decode($b, true) ?? []; }; + + $send($jarA, $agentId, 'Hallo Agent'); + $send($jarA, $agentId, "Zweite Zeile\nmit Umbruch fett"); + $m = $poll($jarB, $chefId, 0); + $check(count($m) === 2 && $m[0]['message'] === 'Hallo Agent' && $m[0]['id'] < $m[1]['id'], 'Abruf liefert die neuen Nachrichten in der Reihenfolge ihrer Nummer'); + $check(($m[0]['initial'] ?? '') === 'C' && !empty($m[0]['av_class']) && $m[0]['from_name'] === 'Chef' && (int) $m[0]['from_staff_id'] === $chefId, 'Abruf enthält Initiale, Farbklasse und Absender für die Anzeige'); + $check(count($poll($jarB, $chefId, (int) $m[1]['id'])) === 0, 'Nach der letzten Nummer kommt nichts mehr (keine Doppelten)'); + $check(count($poll($jarB, $chefId, (int) $m[0]['id'])) === 1, 'Ab einer mittleren Nummer kommt nur die jüngere Nachricht'); + + // Auch die eigene Nachricht kommt mit Nummer zurück, damit die Seite sie ohne Neuladen richtig zeigt + $own = $poll($jarA, $agentId, 0); + $check(count($own) === 2 && (int) $own[0]['from_staff_id'] === $chefId, 'Der Absender sieht seine Nachrichten im Abruf (mit Nummer und Serverzeit)'); + + // Seite: Nachrichten tragen ihre Nummer, die Skripte nutzen die Klassen der Seite (nicht die alten) + [, $page] = $call('GET', '/staff/chat.php?with=' . $chefId, $jarB); + $check(preg_match_all('/class="ac-msg [a-z]+" data-id="\d+"/', $page) === 2, 'Die Seite zeigt beide Nachrichten mit ihrer Nummer'); + $check(str_contains($page, "'ac-msg-bubble'") && str_contains($page, "'ac-msg-time'") && !str_contains($page, 'chat-bubble') && !str_contains($page, 'bubble-text'), 'Neue Nachrichten werden mit denselben Klassen gebaut wie beim Laden der Seite'); + $check(str_contains($page, 'var lastId = ' . (int) $m[1]['id'] . ';'), 'Der Abruf startet bei der letzten bekannten Nummer'); + $check(!str_contains($page, 'toISOString'), 'Die Seite benutzt keine Browser-Uhrzeit mehr (Zeitzonen-Fehler)'); + $check(str_contains($page, 'Zweite Zeile
') && str_contains($page, '<b>fett</b>'), 'Text wird maskiert, Zeilenumbrüche bleiben'); + + // fremde Konversation bleibt fremd + $jarC = $login('agent@test.de'); + $check(count($poll($jarC, $chefId, 0)) === 2, 'Gegenstelle sieht dieselben Nachrichten'); + @unlink($jarA); @unlink($jarB); @unlink($jarC); +} finally { + if (isset($webProc) && is_resource($webProc)) { proc_terminate($webProc); proc_close($webProc); } +} + +usleep(300000); +$rm = function (string $d) use (&$rm) { + foreach (scandir($d) ?: [] as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") && !is_link("$d/$e") ? $rm("$d/$e") : @unlink("$d/$e"); } + @rmdir($d); +}; +if (str_starts_with($work, sys_get_temp_dir() . '/deskhub-staffchat-')) $rm($work); + +if ($failures) { + fwrite(STDERR, "Fehlgeschlagen:\n - " . implode("\n - ", $failures) . "\n"); + exit(1); +} +echo "Staff chat checks passed ({$total} Prüfungen).\n"; diff --git a/DeskHub-Webseite/tools/demo-video/build_video.sh b/DeskHub-Webseite/tools/demo-video/build_video.sh new file mode 100644 index 00000000..bae42568 --- /dev/null +++ b/DeskHub-Webseite/tools/demo-video/build_video.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash +# tools/demo-video/build_video.sh: baut aus den Bildern (Reihenfolge nach Dateiname) das Demo-Video und eine kurze GIF-Vorschau. +# bash tools/demo-video/build_video.sh +# Benötigt ffmpeg. +set -euo pipefail +FRAMES="${1:?Bilderordner fehlt}" +OUT="${2:?Zielordner fehlt}" +SEC="${SECONDS_PER_SLIDE:-3.6}" +mkdir -p "$OUT" +TMP="$OUT/.build"; rm -rf "$TMP"; mkdir -p "$TMP" +LIST="$TMP/list.txt" +: > "$LIST" +i=0 +for f in $(ls "$FRAMES"/*.png | sort); do + i=$((i + 1)) + clip="$TMP/clip_$(printf '%02d' $i).mp4" + ffmpeg -loglevel error -y -loop 1 -t "$SEC" -i "$f" \ + -vf "scale=1440:900,format=yuv420p,fade=t=in:st=0:d=0.45,fade=t=out:st=$(awk "BEGIN{print $SEC-0.45}"):d=0.45" \ + -r 25 -c:v libx264 -crf 22 -preset medium -pix_fmt yuv420p "$clip" + echo "file 'clip_$(printf "%02d" $i).mp4'" >> "$LIST" +done +ffmpeg -loglevel error -y -f concat -safe 0 -i "$LIST" -c copy -movflags +faststart "$OUT/deskhub-demo.mp4" +# GIF-Vorschau (kleiner, für die README, weil nicht jede Seite Videos direkt anzeigt) +ffmpeg -loglevel error -y -i "$OUT/deskhub-demo.mp4" -vf "fps=4,scale=800:-1:flags=lanczos,split[a][b];[a]palettegen=max_colors=96[p];[b][p]paletteuse=dither=bayer:bayer_scale=4" "$OUT/deskhub-demo.gif" +rm -rf "$TMP" +ls -la "$OUT"/deskhub-demo.* diff --git a/DeskHub-Webseite/tools/demo-video/capture.js b/DeskHub-Webseite/tools/demo-video/capture.js new file mode 100644 index 00000000..615a69ef --- /dev/null +++ b/DeskHub-Webseite/tools/demo-video/capture.js @@ -0,0 +1,93 @@ +'use strict'; +/** + * tools/demo-video/capture.js: nimmt die Bilder für das Demo-Video auf (Weboberfläche mit erfundenen Beispieldaten). + * 1. php tools/demo-video/seed_demo.php (Demo-Installation anlegen) + * 2. php -S 127.0.0.1:8097 -t (im Hintergrund starten) + * 3. electron tools/demo-video/capture.js + * Danach baut build_video.sh aus den Bildern das Video. + */ +const { app, BrowserWindow } = require('electron'); +const fs = require('fs'); +const path = require('path'); + +const BASE = 'http://127.0.0.1:8097'; +const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames')); +fs.mkdirSync(OUT, { recursive: true }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let win, n = 0; + +const run = (js) => win.webContents.executeJavaScript(js, true); +/** Seite laden und Hinweise entfernen, die nur zur Demo-Installation gehören (SQLite-Hinweis, Abmelde-Zähler). */ +const load = async (url) => { + await win.loadURL(BASE + url); + await sleep(700); + await run(`(() => { document.querySelectorAll('div,p,section').forEach((e) => { if (e.children.length < 6 && /SQLite-Modus aktiv/.test(e.textContent) && e.textContent.length < 260) e.remove(); }); + document.getElementById('session-timer-bar')?.remove(); })()`); +}; + +/** Bildunterschrift unten einblenden, Bild speichern, Unterschrift wieder entfernen. */ +async function shot(caption, name) { + n += 1; + await run(`(() => { const d = document.createElement('div'); d.id = '__cap'; d.textContent = ${JSON.stringify(caption)}; + d.style.cssText = 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:2147483647;background:rgba(10,30,45,.92);color:#fff;font:600 21px Segoe UI,Arial,sans-serif;padding:13px 26px;border-radius:40px;box-shadow:0 6px 24px rgba(0,0,0,.35);max-width:1180px;text-align:center'; + document.body.appendChild(d); })()`); + await sleep(450); + const img = await win.webContents.capturePage(); + fs.writeFileSync(path.join(OUT, `${String(n).padStart(2, '0')}_${name}.png`), img.toPNG()); + await run(`document.getElementById('__cap')?.remove()`); +} + +async function ask(question) { + await run(`(() => { const i = document.getElementById('as-input'); i.value = ${JSON.stringify(question)}; document.getElementById('as-form').dispatchEvent(new Event('submit', { cancelable: true })); })()`); + await sleep(1300); +} + +app.whenReady().then(async () => { + win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true, webPreferences: { contextIsolation: true, backgroundThrottling: false } }); + try { + // ── Kundenportal ── + await load('/home.php'); + await shot('Kundenportal: Hilfe finden und Tickets verfolgen', 'portal'); + await run(`document.getElementById('as-bubble').click()`); + await sleep(900); + await ask('Wie lange dauert der Versand?'); + await shot('Der digitale Assistent antwortet aus der Wissensdatenbank', 'assistent'); + await ask('Wann seid ihr erreichbar?'); + await ask('Wie kann ich meinen Vertrag beenden?'); + await shot('Und bietet ein Ticket an, wenn er nichts findet', 'assistent_ticket'); + await load('/ticket_new.php'); + await shot('Tickets mit Thema, Priorität und Anhängen erstellen', 'neues_ticket'); + + // ── Mitarbeiterbereich ── + await load('/staff/login.php'); + await run(`(() => { document.getElementById('email').value = 'lena@demo.example'; document.getElementById('password').value = 'Demo-Passwort-1234'; document.querySelector('form button[type=submit]').click(); })()`); + await sleep(1500); + await load('/staff/dashboard.php'); + await shot('Übersicht: Zahlen, Status des Teams und priorisierte Tickets', 'uebersicht'); + await load('/staff/tickets.php?tab=open'); + await shot('Ticketliste mit Filtern, Tabs und gespeicherten Ansichten', 'tickets'); + await load('/staff/ticket_view.php?n=260102'); + await shot('Ticket bearbeiten: Verlauf, Antworten, interne Notizen', 'ticket'); + await run(`(() => { const el = [...document.querySelectorAll('span')].find((s) => /Wiedervorlage/.test(s.textContent)); if (el) el.scrollIntoView({ block: 'center' }); })()`); + await sleep(500); + await shot('Wiedervorlage, Beobachter und passende Artikel zum Ticket', 'ticket_extras'); + await load('/staff/tickets.php?tab=snoozed'); + await shot('Wiedervorlage: Tickets später wieder vorlegen lassen', 'wiedervorlage'); + + // ── Digitaler Assistent (Verwaltung) ── + await load('/staff/assistant.php'); + await shot('Digitaler Assistent: Einstellungen, Name und Begrüßung', 'assistent_admin'); + await load('/staff/assistant.php?tab=answers'); + await shot('Eigene Antworten zusätzlich zur Wissensdatenbank', 'assistent_antworten'); + await load('/staff/assistant.php?tab=log'); + await shot('Fragen-Log: Was wurde gefragt, was blieb ohne Antwort?', 'assistent_log'); + + await load('/staff/reports.php'); + await shot('Berichte mit Bericht-Baukasten und CSV-Export', 'berichte'); + } catch (e) { + console.error('Fehler:', e); + process.exitCode = 1; + } + console.log(`${n} Bilder in ${OUT}`); + app.quit(); +}); diff --git a/DeskHub-Webseite/tools/demo-video/capture_app.js b/DeskHub-Webseite/tools/demo-video/capture_app.js new file mode 100644 index 00000000..c343e31d --- /dev/null +++ b/DeskHub-Webseite/tools/demo-video/capture_app.js @@ -0,0 +1,47 @@ +'use strict'; +/** + * tools/demo-video/capture_app.js: nimmt Bilder des Windows-Programms mit den erfundenen Beispieldaten aus desktop/test/mock.html auf. + * desktop/node_modules/.bin/electron tools/demo-video/capture_app.js [Startnummer] + */ +const { app, BrowserWindow } = require('electron'); +const fs = require('fs'); +const path = require('path'); + +const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames')); +let n = parseInt(process.argv[3] || '50', 10) - 1; +fs.mkdirSync(OUT, { recursive: true }); +const MOCK = path.resolve(__dirname, '..', '..', '..', 'desktop', 'test', 'mock.html'); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +let win; +const run = (js) => win.webContents.executeJavaScript(js, true); + +async function shot(caption, name) { + n += 1; + await run(`(() => { const d = document.createElement('div'); d.id = '__cap'; d.textContent = ${JSON.stringify(caption)}; + d.style.cssText = 'position:fixed;left:50%;bottom:26px;transform:translateX(-50%);z-index:2147483647;background:rgba(10,30,45,.92);color:#fff;font:600 21px Segoe UI,Arial,sans-serif;padding:13px 26px;border-radius:40px;box-shadow:0 6px 24px rgba(0,0,0,.35);max-width:1180px;text-align:center'; + document.body.appendChild(d); })()`); + await sleep(450); + fs.writeFileSync(path.join(OUT, `${String(n).padStart(2, '0')}_${name}.png`), (await win.webContents.capturePage()).toPNG()); + await run(`document.getElementById('__cap')?.remove()`); +} + +app.whenReady().then(async () => { + win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true, webPreferences: { contextIsolation: false, nodeIntegration: false, backgroundThrottling: false } }); + try { + await win.loadFile(MOCK); + await run(`(() => { const o = window.deskhub.api; window.deskhub.api = async (r) => { const res = await o(r); return res.error && /^Mock:/.test(res.error.message) ? { data: { items: [], rules: [], holidays: [], hours: [], staffOptions: [], departments: [], permissionKeys: [], fieldOptions: [], operatorOptions: [], actionOptions: [] } } : res; }; })()`); + await sleep(800); + await run(`go('dashboard')`); await sleep(900); + await shot('Windows-Programm: Übersicht mit Kennzahlen und priorisierten Tickets', 'app_uebersicht'); + await run(`go('tickets')`); await sleep(900); + await shot('Windows-Programm: Tickets mit Reitern, Ansichten und Wiedervorlage', 'app_tickets'); + await run(`go('ticket', 1)`); await sleep(1100); + await shot('Windows-Programm: Ticket bearbeiten, auf später legen, beobachten', 'app_ticket'); + await run(`state.staff.role = 'admin'; go('admin', 'assistant')`); await sleep(900); + await shot('Windows-Programm: Digitalen Assistenten komplett verwalten', 'app_assistent'); + await run(`[...document.querySelectorAll('#main .tabs button')].find((b) => b.innerText.includes('Fragen-Log')).click()`); await sleep(700); + await shot('Fragen-Log mit häufigen Fragen ohne Antwort', 'app_assistent_log'); + } catch (e) { console.error('Fehler:', e); process.exitCode = 1; } + console.log(`${n - (parseInt(process.argv[3] || '50', 10) - 1)} Bilder in ${OUT}`); + app.quit(); +}); diff --git a/DeskHub-Webseite/tools/demo-video/capture_titles.js b/DeskHub-Webseite/tools/demo-video/capture_titles.js new file mode 100644 index 00000000..67bda437 --- /dev/null +++ b/DeskHub-Webseite/tools/demo-video/capture_titles.js @@ -0,0 +1,34 @@ +'use strict'; +/** tools/demo-video/capture_titles.js: erzeugt Titel-, Android- und Abspannbild für das Demo-Video. */ +const { app, BrowserWindow } = require('electron'); +const fs = require('fs'); +const path = require('path'); + +const OUT = path.resolve(process.argv[2] || path.join(__dirname, 'frames')); +const VERSION = process.argv[3] || ''; +fs.mkdirSync(OUT, { recursive: true }); +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +const page = (body) => 'data:text/html;charset=utf-8,' + encodeURIComponent(`
${body}
`); + +app.whenReady().then(async () => { + const win = new BrowserWindow({ width: 1440, height: 900, show: true, useContentSize: true }); + const cards = [ + ['00_intro', `

DeskHub

Das Helpdesk-System zum Selbsthosten

Demo mit erfundenen Beispieldaten ${VERSION ? '· Version ' + VERSION : ''}

`], + ['40_android', `

Android-App

  • Neues, helles Design mit schwebender Navigation
  • Start, Tickets, Chat, Kunden und Verwaltung nach Rechten
  • Wiedervorlage, Beobachten und „Dazu passend“
  • Digitalen Assistenten komplett verwalten
  • Widget in drei Größen, hell und dunkel
`], + ['99_outro', `

Alles aus einer Hand

  • Kundenportal mit Wissensdatenbank und Assistent
  • Weboberfläche, Windows-Programm und Android-App
  • E-Mail-Import, SLA, Regeln, Berichte, Plugins

Installationsanleitung und Funktionen: siehe README

`], + ]; + for (const [name, body] of cards) { + await win.loadURL(page(body)); + await sleep(500); + fs.writeFileSync(path.join(OUT, name + '.png'), (await win.webContents.capturePage()).toPNG()); + } + console.log(`${cards.length} Titelbilder in ${OUT}`); + app.quit(); +}); diff --git a/DeskHub-Webseite/tools/demo-video/seed_demo.php b/DeskHub-Webseite/tools/demo-video/seed_demo.php new file mode 100644 index 00000000..9da7a43c --- /dev/null +++ b/DeskHub-Webseite/tools/demo-video/seed_demo.php @@ -0,0 +1,120 @@ + (Zugang: lena@demo.example / Demo-Passwort-1234) + */ +$root = dirname(__DIR__, 2); +$tmp = $argv[1] ?? (sys_get_temp_dir() . '/deskhub-demo'); +function rrm(string $d): void { if (!is_dir($d)) return; foreach (scandir($d) as $e) { if ($e === '.' || $e === '..') continue; is_dir("$d/$e") ? rrm("$d/$e") : @unlink("$d/$e"); } @rmdir($d); } +rrm($tmp); +mkdir($tmp, 0755, true); +$skip = '#^(config/config\.php|database/[^/]+\.sqlite[^/]*|backups|uploads|tests|tools|plugins/[^/]+|docs|\.git)(/|$)#'; +foreach (new RecursiveIteratorIterator(new RecursiveDirectoryIterator($root, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::SELF_FIRST) as $f) { + $rel = str_replace('\\', '/', substr($f->getPathname(), strlen($root) + 1)); + if (preg_match($skip, $rel) && !preg_match('#^(backups|uploads|plugins)/(\.htaccess|README\.md)$#', $rel)) continue; + if ($f->isDir()) { @mkdir("$tmp/$rel", 0755, true); continue; } + @mkdir(dirname("$tmp/$rel"), 0755, true); + copy($f->getPathname(), "$tmp/$rel"); +} +@mkdir("$tmp/uploads"); @mkdir("$tmp/config"); +file_put_contents("$tmp/config/config.php", ' bin2hex(random_bytes(32)), + 'db' => ['driver' => 'sqlite', 'sqlite_path' => "$tmp/database/deskhub.sqlite"], + 'site' => ['name' => 'Muster Support', 'url' => 'http://127.0.0.1:8097', 'admin_email' => 'lena@demo.example', 'timezone' => 'Europe/Berlin'], + 'uploads' => ['max_size_bytes' => 10485760, 'allowed_extensions' => ['png'], 'path' => "$tmp/uploads"], +], true) . ';'); +putenv('DESKHUB_CONFIG_JSON'); putenv('DESKHUB_SQLITE_PATH'); +require_once "$root/database/sqlite_schema.php"; +require_once "$root/database/migrate.php"; +date_default_timezone_set('Europe/Berlin'); +$p = new PDO("sqlite:$tmp/database/deskhub.sqlite"); +$p->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); +$p->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC); +$p->exec('PRAGMA foreign_keys = ON'); +$p->exec(convert_schema_to_sqlite(file_get_contents("$root/database/schema.sql"))); +run_migrations($p, true); + +$ts = fn(string $rel) => date('Y-m-d H:i:s', strtotime($rel)); +$dept = fn(string $n) => (int) $p->query("SELECT id FROM departments WHERE name = " . $p->quote($n))->fetchColumn(); +$prio = fn(string $n) => (int) $p->query("SELECT id FROM priorities WHERE name = " . $p->quote($n))->fetchColumn(); + +// Mitarbeiter +$p->exec('DELETE FROM staff'); +$ins = $p->prepare('INSERT INTO staff (name, email, password_hash, role, department_id, is_active, created_at) VALUES (?,?,?,?,?,1,?)'); +$staff = []; +foreach ([['Lena Hartmann', 'lena@demo.example', 'admin', 'Allgemeiner Support'], ['Tim Roth', 'tim@demo.example', 'agent', 'Allgemeiner Support'], ['Mira Schulz', 'mira@demo.example', 'agent', 'Technik']] as [$n, $e, $r, $d]) { + $ins->execute([$n, $e, password_hash('Demo-Passwort-1234', PASSWORD_DEFAULT), $r, $dept($d), $ts('-30 days')]); + $staff[$n] = (int) $p->lastInsertId(); +} +$av = $p->prepare("INSERT OR REPLACE INTO staff_availability (staff_id, status, updated_at) VALUES (?,?,?)"); +$av->execute([$staff['Lena Hartmann'], 'available', $ts('now')]); +$av->execute([$staff['Tim Roth'], 'busy', $ts('now')]); +$av->execute([$staff['Mira Schulz'], 'break', $ts('now')]); + +// Einstellungen +$set = $p->prepare('INSERT OR REPLACE INTO settings (setting_key, setting_value) VALUES (?, ?)'); +foreach (['assistant_enabled' => '1', 'live_chat_enabled' => '0', 'kb_enabled' => '1', 'display_name' => 'Muster Support', 'assistant_name' => 'Mia, die digitale Assistentin', + 'assistant_greeting' => 'Hallo! Ich bin Mia. Ich beantworte Fragen zu Lieferung, Rechnung und Konto oder erstelle ein Ticket für dich.', 'assistant_department_id' => (string) $dept('Allgemeiner Support'), + 'assistant_priority_id' => (string) $prio('Normal'), 'assistant_synonyms' => "Rechnung, Beleg, Quittung\nKündigung, kündigen, Vertrag beenden", + 'remind_enabled' => '1', 'remind_after_days' => '3', 'auto_close_enabled' => '1', 'auto_close_days' => '7'] as $k => $v) $set->execute([$k, $v]); + +// Wissensdatenbank +$p->exec("DELETE FROM kb_articles"); $p->exec("DELETE FROM kb_categories"); +$p->exec("INSERT INTO kb_categories (name, slug, sort_order) VALUES ('Konto', 'konto', 1), ('Bestellung und Versand', 'versand', 2), ('Rechnungen', 'rechnungen', 3)"); +$cat = fn(string $s) => (int) $p->query("SELECT id FROM kb_categories WHERE slug = " . $p->quote($s))->fetchColumn(); +$art = $p->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, views, created_at, updated_at) VALUES (?,?,?,?,1,?,?,?)'); +foreach ([ + ['konto', 'Passwort zurücksetzen', 'passwort-zuruecksetzen', '

Klicke auf der Anmeldeseite auf Passwort vergessen. Wir senden dir einen Link per E-Mail, mit dem du ein neues Passwort festlegst. Der Link gilt eine Stunde.

', 128], + ['konto', 'E-Mail-Adresse ändern', 'email-aendern', '

Unter Mein Profil kannst du deine E-Mail-Adresse ändern. Zur Sicherheit bestätigst du die neue Adresse per Link.

', 41], + ['versand', 'Wie lange dauert der Versand?', 'versandzeiten', '

Bestellungen kommen in der Regel in 2 bis 3 Werktagen an. Bei Auslandsversand dauert es bis zu 7 Werktage.

', 203], + ['versand', 'Meine Sendung ist nicht angekommen', 'sendung-fehlt', '

Prüfe zuerst die Sendungsverfolgung. Kommt das Paket nach 5 Werktagen nicht an, erstelle bitte ein Ticket mit der Bestellnummer.

', 77], + ['rechnungen', 'Rechnungen herunterladen', 'rechnungen', '

Alle Rechnungen findest du im Kundenbereich unter Rechnungen. Dort lädst du sie als PDF herunter.

', 156], +] as [$c, $t, $s, $b, $v]) $art->execute([$cat($c), $t, $s, $b, $v, $ts('-20 days'), $ts('-5 days')]); + +// zusätzliche Antworten des Assistenten +$ans = $p->prepare('INSERT INTO assistant_answers (title, keywords, answer, is_active, hits, created_at, updated_at) VALUES (?,?,?,?,?,?,?)'); +foreach ([ + ['Wie sind eure Öffnungszeiten?', 'geöffnet, Uhrzeit, erreichbar', "Wir sind Montag bis Freitag von 9 bis 17 Uhr für dich da.\nAußerhalb dieser Zeit beantwortet dich Mia oder du erstellst ein Ticket.", 1, 34], + ['Kann ich eine Bestellung stornieren?', 'Storno, abbrechen, stornieren', 'Solange die Bestellung noch nicht versendet ist, kannst du sie im Kundenbereich unter „Bestellungen“ stornieren.', 1, 19], + ['Wo finde ich meine Rechnung?', 'Beleg, Quittung', 'Deine Rechnungen findest du im Kundenbereich unter „Rechnungen“ als PDF.', 1, 52], +] as $a) $ans->execute([$a[0], $a[1], $a[2], $a[3], $a[4], $ts('-15 days'), $ts('-2 days')]); + +// Fragen-Log +$lg = $p->prepare('INSERT INTO assistant_log (question, result, source, matched_title, ticket_number, helpful, created_at) VALUES (?,?,?,?,?,?,?)'); +foreach ([ + ['Wie lange dauert der Versand?', 'kb', 'kb', 'Wie lange dauert der Versand?', null, 1, '-2 hours'], ['Wo finde ich meine Rechnung?', 'custom', 'custom', 'Wo finde ich meine Rechnung?', null, 1, '-3 hours'], + ['Wann seid ihr erreichbar?', 'custom', 'custom', 'Wie sind eure Öffnungszeiten?', null, 1, '-5 hours'], ['Passwort vergessen was tun', 'kb', 'kb', 'Passwort zurücksetzen', null, null, '-1 day'], + ['Wie kann ich meinen Vertrag kündigen?', 'none', null, null, null, null, '-1 day'], ['Wie kann ich meinen Vertrag kündigen?', 'none', null, null, null, null, '-2 days'], + ['Gibt es Rabatt für Schulen?', 'none', null, null, null, null, '-2 days'], ['Mein Paket ist beschädigt', 'kb', 'kb', 'Meine Sendung ist nicht angekommen', null, 0, '-3 days'], + ['Ich brauche Hilfe von einem Menschen', 'ticket_request', null, null, null, null, '-3 days'], ['Mein Paket ist beschädigt', 'ticket_created', null, null, '260001', null, '-3 days'], +] as [$q, $r, $s, $m, $tn, $h, $when]) $lg->execute([$q, $r, $s, $m, $tn, $h, $ts($when)]); + +// Tickets +$tk = $p->prepare('INSERT INTO tickets (ticket_number, subject, requester_name, requester_email, department_id, priority_id, status, source, assigned_staff_id, created_at, updated_at, resolution_due_at, snoozed_until, snoozed_by) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'); +$th = $p->prepare('INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,?,?)'); +$defs = [ + ['260101', 'Rechnung für März fehlt', 'Erika Muster', 'Buchhaltung', 'Hoch', 'open', 'Lena Hartmann', '-3 hours', '+5 hours', [['customer', null, 'Guten Tag, in meinem Kundenbereich fehlt die Rechnung für März. Können Sie mir diese bitte zuschicken? Danke!', 0, '-3 hours'], ['staff', 'Lena Hartmann', 'Hallo Frau Muster, ich schaue gleich nach und melde mich noch heute bei Ihnen.', 0, '-2 hours'], ['staff', 'Lena Hartmann', 'Intern: Rechnung wurde nicht automatisch erzeugt, bitte Buchhaltung prüfen lassen.', 1, '-90 minutes']]], + ['260102', 'Passwort zurücksetzen funktioniert nicht', 'Jonas Weber', 'Allgemeiner Support', 'Dringend', 'open', 'Tim Roth', '-26 hours', '-2 hours', [['customer', null, 'Der Link zum Zurücksetzen meines Passworts kommt nie an. Ich habe schon dreimal gedrückt.', 0, '-26 hours']]], + ['260103', 'Lieferstatus meiner Bestellung', 'Sophie Klein', 'Allgemeiner Support', 'Normal', 'answered', 'Tim Roth', '-30 hours', '+20 hours', [['customer', null, 'Wo ist meine Bestellung 48211? Sie sollte gestern kommen.', 0, '-30 hours'], ['staff', 'Tim Roth', 'Hallo Sophie, Ihre Bestellung ist beim Zusteller und kommt morgen. Hier die Sendungsnummer: 0034 5521 8890.', 0, '-28 hours']]], + ['260104', 'Frage zur Kündigung', 'Markus Lehmann', 'Allgemeiner Support', 'Normal', 'open', null, '-45 minutes', '+23 hours', [['customer', null, 'Wie kann ich meinen Vertrag zum Monatsende kündigen?', 0, '-45 minutes']]], + ['260105', 'App stürzt beim Start ab', 'Lea Wagner', 'Technik', 'Hoch', 'open', 'Mira Schulz', '-5 hours', '+3 hours', [['customer', null, 'Seit dem letzten Update stürzt die App auf meinem Handy direkt nach dem Start ab (Android 14).', 0, '-5 hours'], ['staff', 'Mira Schulz', 'Danke für den Hinweis. Wir können das reproduzieren und arbeiten an einem Fix.', 0, '-4 hours']]], + ['260106', 'Rückruf gewünscht', 'Dirk Neumann', 'Allgemeiner Support', 'Niedrig', 'open', 'Lena Hartmann', '-2 days', '+1 day', [['customer', null, 'Bitte rufen Sie mich nächste Woche wegen meines Angebots zurück.', 0, '-2 days']], '+1 day 08:00'], + ['259901', 'Passwort vergessen: Link kommt nicht an', 'Nina Fischer', 'Allgemeiner Support', 'Normal', 'resolved', 'Tim Roth', '-12 days', null, [['customer', null, 'Ich habe mein Passwort vergessen und bekomme keine E-Mail.', 0, '-12 days'], ['staff', 'Tim Roth', 'Der Link war im Spam-Ordner. Ich habe ihn erneut gesendet, jetzt klappt es.', 0, '-12 days']]], + ['259902', 'Rechnung als PDF anfordern', 'Paul Schmitt', 'Buchhaltung', 'Normal', 'closed', 'Lena Hartmann', '-20 days', null, [['customer', null, 'Bitte schicken Sie mir die Rechnung Nr. 5521 als PDF.', 0, '-20 days'], ['staff', 'Lena Hartmann', 'Die Rechnung ist angehängt. Viele Grüße', 0, '-20 days']]], + ['259903', 'Paket beschädigt angekommen', 'Clara Braun', 'Allgemeiner Support', 'Hoch', 'closed', 'Tim Roth', '-25 days', null, [['customer', null, 'Der Karton war aufgerissen.', 0, '-25 days'], ['staff', 'Tim Roth', 'Das tut uns leid, Ersatz ist unterwegs.', 0, '-24 days']]], + ['259904', 'Kündigung zum Jahresende', 'Felix Vogel', 'Allgemeiner Support', 'Normal', 'closed', 'Lena Hartmann', '-40 days', null, [['customer', null, 'Ich möchte kündigen.', 0, '-40 days'], ['staff', 'Lena Hartmann', 'Kündigung bestätigt.', 0, '-39 days']]], +]; +foreach ($defs as $d) { + [$no, $subject, $name, $dn, $pn, $status, $assignee, $created, $due, $threads] = $d; + $snooze = $d[10] ?? null; + $tk->execute([$no, $subject, $name, strtolower(str_replace(' ', '.', $name)) . '@kunde.example', $dept($dn), $prio($pn), $status, 'web', $assignee ? $staff[$assignee] : null, + $ts($created), $ts($threads[count($threads) - 1][4]), $due ? $ts($due) : null, $snooze ? $ts($snooze) : null, $snooze ? $staff['Lena Hartmann'] : null]); + $id = (int) $p->lastInsertId(); + foreach ($threads as [$who, $sn, $msg, $note, $when]) $th->execute([$id, $who, $sn ? $staff[$sn] : null, $msg, $note, $ts($when)]); + if ($snooze) $p->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES (?,?,?)')->execute([$id, $staff['Lena Hartmann'], $ts('-1 day')]); +} +$p->prepare('INSERT INTO ticket_followers (ticket_id, staff_id, created_at) VALUES ((SELECT id FROM tickets WHERE ticket_number = ?),?,?)')->execute(['260102', $staff['Lena Hartmann'], $ts('-20 hours')]); +echo "Demo bereit: $tmp\n";