Upload via GUI (258 Dateien)

This commit is contained in:
2026-10-16 19:12:13 +00:00
parent 893bef356c
commit 491b87f468
50 changed files with 4601 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
Options -Indexes
<IfModule mod_headers.c>
Header always set Cache-Control "no-store"
</IfModule>
@@ -0,0 +1,73 @@
<?php
declare(strict_types=1);
/**
* Öffentliche Ticket-API für Fremdsysteme (z. B. Website-Formulare, Shop, Monitoring).
*
* POST /api/public/v1/tickets.php Ticket anlegen (Recht "Tickets anlegen")
* JSON: { "name", "email", "subject", "message", "helpTopicId"?, "departmentId"?, "priorityId"? }
* GET /api/public/v1/tickets.php?number=…&email=… Status + öffentlicher Verlauf (Recht "Tickets lesen")
*
* Authentifizierung: Header "X-API-Key: dh_…" oder "Authorization: Bearer dh_…".
* Schlüssel verwaltet ein Administrator unter Verwalten → API-Schlüssel.
*/
require_once __DIR__ . '/../../../includes/bootstrap.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
function public_api_fail(int $status, string $code, string $message): never
{
http_response_code($status);
echo json_encode(['error' => ['code' => $code, 'message' => $message]], JSON_UNESCAPED_UNICODE);
exit;
}
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
if (!in_array($method, ['GET', 'POST'], true)) {
header('Allow: GET, POST');
public_api_fail(405, 'METHOD_NOT_ALLOWED', 'Erlaubt sind GET und POST.');
}
$key = trim((string) ($_SERVER['HTTP_X_API_KEY'] ?? ''));
if ($key === '' && preg_match('/^Bearer\s+(\S+)$/i', (string) ($_SERVER['HTTP_AUTHORIZATION'] ?? ''), $m)) $key = $m[1];
$pdo = get_db();
$apiKey = $key !== '' ? api_key_verify($pdo, $key, client_ip()) : null;
if (!$apiKey) public_api_fail(401, 'UNAUTHENTICATED', 'Ein gültiger API-Schlüssel ist erforderlich (Header X-API-Key).');
if ($method === 'POST') {
if (!$apiKey['can_create_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets anlegen.');
// Schutz vor Überflutung: höchstens 60 über die API angelegte Tickets pro Minute
$recent = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE source = 'api' AND created_at >= ?");
$recent->execute([date('Y-m-d H:i:s', time() - 60)]);
if ((int) $recent->fetchColumn() >= 60) public_api_fail(429, 'RATE_LIMITED', 'Zu viele Anfragen. Bitte in einer Minute erneut versuchen.');
$raw = file_get_contents('php://input') ?: '';
if (strlen($raw) > 200000) public_api_fail(413, 'TOO_LARGE', 'Die Anfrage ist zu groß.');
$body = json_decode($raw, true);
if (!is_array($body)) public_api_fail(400, 'INVALID_JSON', 'Der Anfragekörper muss gültiges JSON sein.');
try {
$created = public_ticket_create($pdo, $body, client_ip());
} catch (AdminModelException $e) {
public_api_fail($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
http_response_code(201);
echo json_encode(['number' => $created['number'], 'status' => 'open'], JSON_UNESCAPED_UNICODE);
exit;
}
// GET: Status eines Tickets (nur mit passender E-Mail-Adresse des Anfragenden)
if (!$apiKey['can_read_tickets']) public_api_fail(403, 'FORBIDDEN', 'Dieser Schlüssel darf keine Tickets lesen.');
$number = trim((string) ($_GET['number'] ?? ''));
$email = strtolower(trim((string) ($_GET['email'] ?? '')));
if ($number === '' || $email === '') public_api_fail(422, 'MISSING_PARAMS', 'Die Parameter number und email sind erforderlich.');
$stmt = $pdo->prepare('SELECT * FROM tickets WHERE ticket_number = ? AND LOWER(requester_email) = ?');
$stmt->execute([$number, $email]);
$ticket = $stmt->fetch();
if (!$ticket) public_api_fail(404, 'NOT_FOUND', 'Ticket nicht gefunden.');
$threads = $pdo->prepare('SELECT author_type, message, created_at FROM ticket_threads WHERE ticket_id = ? AND is_internal_note = 0 ORDER BY created_at, id');
$threads->execute([$ticket['id']]);
echo json_encode(['ticket' => [
'number' => $ticket['ticket_number'], 'subject' => $ticket['subject'], 'status' => $ticket['status'],
'createdAt' => $ticket['created_at'], 'updatedAt' => $ticket['updated_at'],
'messages' => array_map(fn($t) => ['author' => $t['author_type'] === 'customer' ? 'customer' : 'staff', 'message' => trim(strip_tags((string) $t['message'])), 'createdAt' => $t['created_at']], $threads->fetchAll()),
]], JSON_UNESCAPED_UNICODE);
@@ -0,0 +1,41 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$since = trim($_GET['since'] ?? '');
if ($since === '') {
// Erster Aufruf nach Login: nur die Serverzeit als Referenzpunkt liefern, noch nichts zählen
// (verhindert Uhrzeit-Abweichungen zwischen Client und Server bei der Baseline).
api_json(['newReplies' => 0, 'newUnassigned' => 0, 'serverTime' => db_now(), 'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : []]);
}
if (strtotime($since) === false) {
api_error(422, 'INVALID_SINCE', 'Der since-Zeitstempel ist ungültig.');
}
$sinceSql = date('Y-m-d H:i:s', strtotime($since));
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM tickets
WHERE assigned_staff_id = ? AND updated_at > ? AND status NOT IN ('resolved','closed')"
);
$stmt->execute([$staff['id'], $sinceSql]);
$newReplies = (int) $stmt->fetchColumn();
$departmentIds = staff_department_ids($staff);
[$deptFilter, $deptParams] = dept_filter_sql($departmentIds, 'department_id');
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM tickets
WHERE assigned_staff_id IS NULL AND created_at > ?{$deptFilter}"
);
$stmt->execute([$sinceSql, ...$deptParams]);
$newUnassigned = (int) $stmt->fetchColumn();
api_json([
'newReplies' => $newReplies,
'newUnassigned' => $newUnassigned,
'serverTime' => db_now(),
'health' => has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [],
]);
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'view_audit_log');
$pdo = get_db();
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = 50;
$offset = ($page - 1) * $perPage;
$total = (int) $pdo->query('SELECT COUNT(*) FROM admin_audit_log')->fetchColumn();
$stmt = $pdo->prepare(
'SELECT al.*, s.name AS staff_name FROM admin_audit_log al
LEFT JOIN staff s ON s.id = al.staff_id
ORDER BY al.created_at DESC LIMIT ? OFFSET ?'
);
$stmt->bindValue(1, $perPage, PDO::PARAM_INT);
$stmt->bindValue(2, $offset, PDO::PARAM_INT);
$stmt->execute();
$entries = $stmt->fetchAll();
api_json([
'items' => array_map(fn(array $e) => [
'id' => (int) $e['id'],
'staffName' => $e['staff_name'],
'action' => $e['action'],
'createdAt' => $e['created_at'],
], $entries),
'page' => $page,
'perPage' => $perPage,
'total' => $total,
'hasMore' => $offset + count($entries) < $total,
]);
@@ -0,0 +1,68 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* api/staff/v1/availability.php
*
* Mobile-Pendant zu staff/availability.php (nur der Status-Teil, kein Schichtplan-
* Editor – der bleibt eine Web-Admin-Aufgabe). Gleiche staff_availability-Tabelle
* und dieselbe "keine Zeile = Offline"-Konvention wie im Web (siehe dort und
* live_chat_locked_by_other()/chat_any_staff_available() in includes/functions.php
* bzw. live_chat.php für die Stellen, die sich darauf verlassen).
*/
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
const AVAILABILITY_STATUSES = ['available', 'busy', 'break', 'offline'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$agents = $pdo->query(
'SELECT s.id, s.name, s.is_on_vacation, sa.status, sa.note, sa.updated_at
FROM staff s
LEFT JOIN staff_availability sa ON sa.staff_id = s.id
WHERE s.is_active = 1
ORDER BY s.name'
)->fetchAll();
$me = null;
$team = [];
foreach ($agents as $a) {
$entry = [
'id' => (int) $a['id'],
'name' => $a['name'],
'status' => $a['is_on_vacation'] ? 'offline' : ($a['status'] ?: 'offline'),
'note' => $a['is_on_vacation'] ? 'Urlaub' : ($a['note'] ?? ''),
'isOnVacation' => (bool) $a['is_on_vacation'],
'updatedAt' => $a['updated_at'],
];
$team[] = $entry;
if ((int) $a['id'] === (int) $staff['id']) {
// "Mein Status" fällt (wie im Web) auf "available" zurück, solange nie gesetzt –
// reine Anzeige-Vorauswahl, ändert nichts an der "Offline"-Team-Konvention oben.
$me = ['status' => $a['status'] ?: 'available', 'note' => $a['note'] ?? ''];
}
}
api_json(['myStatus' => $me['status'] ?? 'available', 'myNote' => $me['note'] ?? '', 'team' => $team]);
}
$body = api_request_body();
$newStatus = (string) ($body['status'] ?? '');
$note = trim((string) ($body['note'] ?? ''));
if (!in_array($newStatus, AVAILABILITY_STATUSES, true)) {
api_error(422, 'INVALID_STATUS', 'Ungültiger Status.');
}
$drv = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME);
$sql = $drv === 'sqlite'
? 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?)
ON CONFLICT(staff_id) DO UPDATE SET status=excluded.status, note=excluded.note, updated_at=excluded.updated_at'
: 'INSERT INTO staff_availability (staff_id, status, note, updated_at) VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE status=VALUES(status), note=VALUES(note), updated_at=VALUES(updated_at)';
$pdo->prepare($sql)->execute([$staff['id'], $newStatus, $note ?: null, db_now()]);
api_json(['ok' => true, 'status' => $newStatus]);
+100
View File
@@ -0,0 +1,100 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin($staff);
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$entries = $pdo->query(
'SELECT b.*, s.name AS added_by_name FROM banlist b LEFT JOIN staff s ON s.id = b.created_by ORDER BY b.created_at DESC'
)->fetchAll();
api_json([
'items' => array_map(fn(array $b) => [
'id' => (int) $b['id'],
'matchType' => $b['match_type'],
'value' => $b['value'],
'reason' => $b['reason'],
'addedByName' => $b['added_by_name'],
'createdAt' => $b['created_at'],
], $entries),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
/** Schließt alle offenen Tickets, die zur gerade gesperrten E-Mail/Domain passen. */
function api_close_banned_tickets(PDO $pdo, string $matchType, string $value): void
{
$col = $matchType === 'email' ? 'requester_email' : sql_email_domain('requester_email');
$pdo->prepare("UPDATE tickets SET status='closed',closed_at=?,updated_at=? WHERE $col=? AND status NOT IN ('closed')")
->execute([db_now(), db_now(), $value]);
}
if ($action === 'create') {
$matchType = ($body['matchType'] ?? 'email') === 'domain' ? 'domain' : 'email';
$value = trim(strtolower((string) ($body['value'] ?? '')));
$reason = trim((string) ($body['reason'] ?? ''));
if ($value === '') {
api_error(422, 'VALUE_REQUIRED', 'Bitte einen Wert angeben.');
}
if ($matchType === 'email' && !filter_var($value, FILTER_VALIDATE_EMAIL)) {
api_error(422, 'INVALID_EMAIL', 'Ungültige E-Mail-Adresse.');
}
if ($matchType === 'domain' && (!str_contains($value, '.') || str_contains($value, '@'))) {
api_error(422, 'INVALID_DOMAIN', 'Nur Domain angeben, z.B. spam.de (ohne @).');
}
$chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
$chk->execute([$value]);
if (!$chk->fetchColumn()) {
$pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
->execute([$matchType, $value, $reason ?: null, $staff['id'], db_now()]);
api_close_banned_tickets($pdo, $matchType, $value);
audit_log($staff['id'], "Sperrliste: {$value} hinzugefügt ({$matchType}, Mobile App)");
}
api_json(['ok' => true]);
}
if ($action === 'bulk_add') {
$raw = trim((string) ($body['values'] ?? ''));
$reason = trim((string) ($body['reason'] ?? ''));
$lines = array_filter(array_map('trim', preg_split('/[\r\n,;]+/', $raw)));
$added = 0;
$skipped = 0;
foreach ($lines as $val) {
$val = strtolower($val);
$type = str_contains($val, '@') ? 'email' : 'domain';
if ($type === 'email' && !filter_var($val, FILTER_VALIDATE_EMAIL)) { $skipped++; continue; }
if ($type === 'domain' && !str_contains($val, '.')) { $skipped++; continue; }
$chk = $pdo->prepare('SELECT id FROM banlist WHERE value=?');
$chk->execute([$val]);
if ($chk->fetchColumn()) { $skipped++; continue; }
$pdo->prepare('INSERT INTO banlist (match_type,value,reason,created_by,created_at) VALUES (?,?,?,?,?)')
->execute([$type, $val, $reason ?: null, $staff['id'], db_now()]);
api_close_banned_tickets($pdo, $type, $val);
$added++;
}
audit_log($staff['id'], "Sperrliste Massen-Import: {$added} hinzugefügt, {$skipped} übersprungen (Mobile App)");
api_json(['added' => $added, 'skipped' => $skipped]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$val = $pdo->prepare('SELECT value FROM banlist WHERE id=?');
$val->execute([$id]);
$v = $val->fetchColumn();
if ($v === false) {
api_error(404, 'ENTRY_NOT_FOUND', 'Der Eintrag wurde nicht gefunden.');
}
$pdo->prepare('DELETE FROM banlist WHERE id=?')->execute([$id]);
audit_log($staff['id'], "Sperrliste: {$v} entfernt (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,77 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_business_hours');
$pdo = get_db();
const BUSINESS_WEEKDAYS = [1, 2, 3, 4, 5, 6, 0]; // Mo..So, wie im Web-Adminbereich
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$hoursByWeekday = [];
foreach ($pdo->query('SELECT * FROM business_hours')->fetchAll() as $r) {
$hoursByWeekday[(int) $r['weekday']] = $r;
}
$holidays = $pdo->query('SELECT * FROM business_holidays ORDER BY holiday_date')->fetchAll();
api_json([
'enabled' => get_setting('business_hours_enabled', '0') === '1',
'hours' => array_map(function (int $wd) use ($hoursByWeekday) {
$h = $hoursByWeekday[$wd] ?? ['is_open' => 0, 'open_time' => '09:00:00', 'close_time' => '17:00:00'];
return [
'weekday' => $wd,
'isOpen' => (bool) $h['is_open'],
'openTime' => substr($h['open_time'], 0, 5),
'closeTime' => substr($h['close_time'], 0, 5),
];
}, BUSINESS_WEEKDAYS),
'holidays' => array_map(fn(array $h) => [
'id' => (int) $h['id'],
'date' => $h['holiday_date'],
'name' => $h['name'],
], $holidays),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'save_hours') {
set_setting('business_hours_enabled', !empty($body['enabled']) ? '1' : '0');
$incoming = (array) ($body['hours'] ?? []);
$byWeekday = [];
foreach ($incoming as $entry) {
$byWeekday[(int) ($entry['weekday'] ?? -1)] = $entry;
}
$stmt = $pdo->prepare('UPDATE business_hours SET is_open=?, open_time=?, close_time=? WHERE weekday=?');
foreach (BUSINESS_WEEKDAYS as $wd) {
$entry = $byWeekday[$wd] ?? null;
$isOpen = !empty($entry['isOpen']) ? 1 : 0;
$openTime = trim((string) ($entry['openTime'] ?? '09:00')) . ':00';
$closeTime = trim((string) ($entry['closeTime'] ?? '17:00')) . ':00';
$stmt->execute([$isOpen, $openTime, $closeTime, $wd]);
}
audit_log($staff['id'], 'Geschäftszeiten aktualisiert (Mobile App)');
api_json(['ok' => true]);
}
if ($action === 'add_holiday') {
$date = trim((string) ($body['date'] ?? ''));
$name = trim((string) ($body['name'] ?? ''));
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $date)) api_error(422, 'INVALID_DATE', 'Bitte ein gültiges Datum angeben.');
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen für den Feiertag angeben.');
$pdo->prepare('INSERT INTO business_holidays (holiday_date, name) VALUES (?,?)')->execute([$date, $name]);
audit_log($staff['id'], "Feiertag hinzugefügt: {$name} ({$date}) (Mobile App)");
api_json(['id' => (int) $pdo->lastInsertId()]);
}
if ($action === 'delete_holiday') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('DELETE FROM business_holidays WHERE id = ?')->execute([$id]);
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
// department_id ist NULLable (fuer alle Abteilungen sichtbar) — dept_filter_sql() kann das nicht
// abbilden (es haengt ein UND an, wir brauchen aber "IS NULL ODER eigene Abteilung"), daher hier
// manuell gebaut.
$departmentIds = staff_department_ids($staff);
$deptFilter = '';
$deptParams = [];
if ($departmentIds !== null) {
if (count($departmentIds) === 0) {
$deptFilter = ' AND cr.department_id IS NULL';
} else {
$placeholders = implode(',', array_fill(0, count($departmentIds), '?'));
$deptFilter = " AND (cr.department_id IS NULL OR cr.department_id IN ({$placeholders}))";
$deptParams = $departmentIds;
}
}
$stmt = $pdo->prepare(
"SELECT cr.id, cr.title, cr.body, cr.department_id
FROM canned_responses cr
WHERE 1=1{$deptFilter}
ORDER BY cr.title"
);
$stmt->execute($deptParams);
$responses = array_map(fn(array $row) => [
'id' => (int) $row['id'],
'title' => $row['title'],
'body' => api_plain_text($row['body']),
'departmentId' => $row['department_id'] !== null ? (int) $row['department_id'] : null,
], $stmt->fetchAll());
api_json(['items' => $responses]);
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
// Interner Chat ist für alle Mitarbeiter da, nicht nur Admins.
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$withId = (int) ($_GET['with'] ?? 0);
if ($withId) {
$withStmt = $pdo->prepare('SELECT id, name FROM staff WHERE id = ?');
$withStmt->execute([$withId]);
$withStaff = $withStmt->fetch();
if (!$withStaff) {
api_error(404, 'STAFF_NOT_FOUND', 'Der Mitarbeiter wurde nicht gefunden.');
}
$pdo->prepare('UPDATE staff_chat_messages SET is_read = 1 WHERE from_staff_id = ? AND to_staff_id = ?')
->execute([$withId, $staff['id']]);
$cStmt = $pdo->prepare(
'SELECT m.*, sf.name AS from_name
FROM staff_chat_messages m
JOIN staff sf ON sf.id = m.from_staff_id
WHERE (m.from_staff_id = ? AND m.to_staff_id = ?)
OR (m.from_staff_id = ? AND m.to_staff_id = ?)
ORDER BY m.created_at ASC LIMIT 300'
);
$cStmt->execute([$staff['id'], $withId, $withId, $staff['id']]);
$conversation = $cStmt->fetchAll();
api_json([
'with' => ['id' => (int) $withStaff['id'], 'name' => $withStaff['name']],
'messages' => array_map(fn(array $m) => [
'id' => (int) $m['id'],
'fromStaffId' => (int) $m['from_staff_id'],
'fromName' => $m['from_name'],
'message' => $m['message'],
'createdAt' => $m['created_at'],
], $conversation),
]);
}
$allStaff = $pdo->prepare('SELECT id, name FROM staff WHERE is_active = 1 AND id != ? ORDER BY name');
$allStaff->execute([$staff['id']]);
$contacts = $allStaff->fetchAll();
$unreadStmt = $pdo->prepare(
'SELECT from_staff_id, COUNT(*) AS cnt FROM staff_chat_messages WHERE to_staff_id = ? AND is_read = 0 GROUP BY from_staff_id'
);
$unreadStmt->execute([$staff['id']]);
$unreadByStaff = [];
foreach ($unreadStmt->fetchAll() as $row) {
$unreadByStaff[(int) $row['from_staff_id']] = (int) $row['cnt'];
}
$lastStmt = $pdo->prepare(
"SELECT CASE WHEN from_staff_id = ? THEN to_staff_id ELSE from_staff_id END AS other_id,
MAX(created_at) AS last_at
FROM staff_chat_messages
WHERE from_staff_id = ? OR to_staff_id = ?
GROUP BY other_id"
);
$lastStmt->execute([$staff['id'], $staff['id'], $staff['id']]);
$lastByStaff = [];
foreach ($lastStmt->fetchAll() as $row) {
$lastByStaff[(int) $row['other_id']] = $row['last_at'];
}
api_json([
'contacts' => array_map(fn(array $s) => [
'id' => (int) $s['id'],
'name' => $s['name'],
'unreadCount' => $unreadByStaff[(int) $s['id']] ?? 0,
'lastMessageAt' => $lastByStaff[(int) $s['id']] ?? null,
], $contacts),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'send') {
$toId = (int) ($body['toId'] ?? 0);
$message = trim((string) ($body['message'] ?? ''));
if ($toId === 0) api_error(422, 'RECIPIENT_REQUIRED', 'Bitte einen Empfänger angeben.');
if ($message === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.');
if (strlen($message) > 5000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
$toCheck = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
$toCheck->execute([$toId]);
if (!(bool) $toCheck->fetchColumn()) {
api_error(422, 'RECIPIENT_NOT_FOUND', 'Der Empfänger wurde nicht gefunden.');
}
$pdo->prepare('INSERT INTO staff_chat_messages (from_staff_id, to_staff_id, message, created_at) VALUES (?,?,?,?)')
->execute([$staff['id'], $toId, $message, db_now()]);
api_json(['id' => (int) $pdo->lastInsertId()]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+346
View File
@@ -0,0 +1,346 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/../../../includes/bootstrap.php';
require_once __DIR__ . '/../../../includes/totp.php';
header('Content-Type: application/json; charset=utf-8');
header('Cache-Control: no-store');
header('Pragma: no-cache');
const MOBILE_API_STATUSES = ['open', 'pending', 'answered', 'on_hold', 'resolved', 'closed'];
set_exception_handler(function (Throwable $error): void {
error_log('Mobile Staff API: ' . $error->getMessage());
api_error(500, 'INTERNAL_ERROR', 'Die Anfrage konnte nicht verarbeitet werden.');
});
function api_json(array $payload, int $status = 200): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
function api_error(int $status, string $code, string $message, array $details = []): never
{
api_json(['error' => ['code' => $code, 'message' => $message, 'details' => $details]], $status);
}
function api_require_method(string ...$methods): void
{
if (!in_array($_SERVER['REQUEST_METHOD'] ?? '', $methods, true)) {
header('Allow: ' . implode(', ', $methods));
api_error(405, 'METHOD_NOT_ALLOWED', 'Diese HTTP-Methode wird nicht unterstützt.');
}
}
function api_request_body(): array
{
$length = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
if ($length > 1024 * 1024) {
api_error(413, 'PAYLOAD_TOO_LARGE', 'Die Anfrage ist zu groß.');
}
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
try {
$body = json_decode($raw, true, 64, JSON_THROW_ON_ERROR);
} catch (JsonException $e) {
api_error(400, 'INVALID_JSON', 'Der JSON-Inhalt ist ungültig.');
}
if (!is_array($body)) {
api_error(400, 'INVALID_JSON', 'Ein JSON-Objekt wird erwartet.');
}
return $body;
}
function api_require_https(): void
{
$https = request_is_https();
$local = in_array($_SERVER['REMOTE_ADDR'] ?? '', ['127.0.0.1', '::1'], true);
$allowInsecure = getenv('DESKHUB_API_ALLOW_INSECURE') === '1';
if (!$https && !$local && !$allowInsecure) {
api_error(400, 'HTTPS_REQUIRED', 'Die mobile Anmeldung ist ausschließlich über HTTPS erlaubt.');
}
}
function api_authorization_header(): string
{
$header = $_SERVER['HTTP_AUTHORIZATION'] ?? $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ?? '';
if ($header === '' && function_exists('getallheaders')) {
$headers = getallheaders();
$header = $headers['Authorization'] ?? $headers['authorization'] ?? '';
}
return trim($header);
}
function api_bearer_token(): ?string
{
$header = api_authorization_header();
if (!preg_match('/^Bearer\s+([A-Za-z0-9_-]{40,})$/i', $header, $matches)) {
return null;
}
return $matches[1];
}
function api_issue_token(PDO $pdo, int $staffId, ?string $deviceName): array
{
$token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
$days = max(1, min(365, (int) get_setting('mobile_api_token_days', '30')));
$expiresAt = date('Y-m-d H:i:s', time() + ($days * 86400));
$deviceName = trim((string) $deviceName);
if ($deviceName === '') {
$deviceName = null;
} else {
$deviceName = mb_substr($deviceName, 0, 120);
}
$pdo->prepare(
'INSERT INTO mobile_api_tokens (staff_id, token_hash, device_name, expires_at, created_at)
VALUES (?,?,?,?,?)'
)->execute([$staffId, hash('sha256', $token), $deviceName, $expiresAt, db_now()]);
return ['token' => $token, 'expires_at' => $expiresAt];
}
function api_require_staff(): array
{
$token = api_bearer_token();
if ($token === null) {
api_error(401, 'UNAUTHENTICATED', 'Ein gültiges Bearer-Token ist erforderlich.');
}
$pdo = get_db();
$stmt = $pdo->prepare(
'SELECT s.*, mt.id AS mobile_token_id, mt.last_used_at AS mobile_token_last_used_at
FROM mobile_api_tokens mt
JOIN staff s ON s.id = mt.staff_id
WHERE mt.token_hash = ? AND mt.revoked_at IS NULL AND mt.expires_at > ? AND s.is_active = 1
LIMIT 1'
);
$stmt->execute([hash('sha256', $token), db_now()]);
$staff = $stmt->fetch();
if (!$staff) {
api_error(401, 'TOKEN_EXPIRED', 'Die Anmeldung ist abgelaufen. Bitte melden Sie sich erneut an.');
}
$lastUsed = $staff['mobile_token_last_used_at'] ?? null;
if (!$lastUsed || strtotime($lastUsed) < time() - 300) {
$pdo->prepare('UPDATE mobile_api_tokens SET last_used_at = ? WHERE id = ?')
->execute([db_now(), $staff['mobile_token_id']]);
}
// "Eingebauter" Ersatz für echte Server-Cronjobs (siehe includes/pseudo_cron.php):
// auch App-Nutzung hält die automatischen Hintergrund-Jobs am Laufen.
pseudo_cron_schedule_background();
return $staff;
}
function api_staff_payload(array $staff): array
{
$permissionNames = array_merge([
'reply_ticket', 'close_ticket', 'post_note', 'assign_ticket', 'transfer_ticket',
'create_ticket', 'view_reports',
], array_keys(admin_section_permissions()));
$permissions = [];
foreach ($permissionNames as $permission) {
$permissions[$permission] = has_permission($staff, $permission);
}
return [
'id' => (int) $staff['id'],
'name' => $staff['name'],
'email' => $staff['email'],
'role' => $staff['role'],
'permissions' => $permissions,
];
}
/** $departmentId: Abteilung des Tickets – dort kann eine eigene Rolle (erweiterter Zugriff) gelten. */
function api_require_permission(array $staff, string $permission, ?int $departmentId = null): void
{
if (!has_permission($staff, $permission, $departmentId)) {
api_error(403, 'FORBIDDEN', 'Für diese Aktion fehlt die erforderliche Berechtigung.');
}
}
function api_require_admin(array $staff): void
{
if ($staff['role'] !== 'admin') {
api_error(403, 'FORBIDDEN', 'Diese Funktion ist nur für Administratoren verfügbar.');
}
}
/** Wie api_require_admin(), lässt aber Agenten mit passend zugewiesener Co-Admin-Berechtigung durch. */
function api_require_admin_or_permission(array $staff, string $permission): void
{
if ($staff['role'] !== 'admin' && !has_permission($staff, $permission)) {
api_error(403, 'FORBIDDEN', 'Für diese Funktion fehlt die erforderliche Berechtigung.');
}
}
function api_find_ticket(PDO $pdo, array $staff, string $number): array
{
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id
WHERE t.ticket_number = ?'
);
$stmt->execute([$number]);
$ticket = $stmt->fetch();
if (!$ticket) {
api_error(404, 'TICKET_NOT_FOUND', 'Das Ticket wurde nicht gefunden.');
}
$departmentIds = staff_department_ids($staff);
if ($departmentIds !== null && !in_array((int) $ticket['department_id'], $departmentIds, true)) {
api_error(403, 'TICKET_FORBIDDEN', 'Auf dieses Ticket besteht kein Zugriff.');
}
return $ticket;
}
function api_plain_text(?string $html): string
{
$text = preg_replace('/<br\s*\/?>/i', "\n", (string) $html);
$text = preg_replace('/<\/p\s*>/i', "\n\n", $text);
return trim(html_entity_decode(strip_tags($text), ENT_QUOTES | ENT_HTML5, 'UTF-8'));
}
function api_ticket_summary(array $ticket): array
{
return [
'id' => (int) $ticket['id'],
'number' => $ticket['ticket_number'],
'subject' => $ticket['subject'],
'requesterName' => $ticket['requester_name'],
'requesterEmail' => $ticket['requester_email'],
'status' => $ticket['status'],
'priority' => [
'id' => (int) $ticket['priority_id'],
'name' => $ticket['priority_name'],
'color' => $ticket['priority_color'],
],
'department' => [
'id' => (int) $ticket['department_id'],
'name' => $ticket['department_name'],
],
'assignedStaff' => $ticket['assigned_staff_id'] === null ? null : [
'id' => (int) $ticket['assigned_staff_id'],
'name' => $ticket['staff_name'],
],
'isOverdue' => !empty($ticket['resolution_due_at'])
&& strtotime($ticket['resolution_due_at']) < time()
&& !in_array($ticket['status'], ['resolved', 'closed'], true),
// Fertig formatierter Fälligkeitstext ("in 3 Std 12 Min" / "vor 2 Std 5 Min
// (überfällig)") – bewusst serverseitig über dieselbe time_until()-Funktion wie im
// Web berechnet, statt das Datumsformat in Kotlin nachzubauen (siehe die Lehren aus
// den PHP-Datums-Bugs dieser Session: eine einzige Quelle der Wahrheit fürs Format).
'dueLabel' => !empty($ticket['resolution_due_at']) ? time_until($ticket['resolution_due_at']) : null,
'isSpam' => !empty($ticket['is_spam']),
'createdAt' => $ticket['created_at'],
'updatedAt' => $ticket['updated_at'],
];
}
function api_add_ticket_message(PDO $pdo, array $staff, array $ticket, string $type, string $rawMessage, ?string $setStatus): int
{
$permission = $type === 'note' ? 'post_note' : 'reply_ticket';
api_require_permission($staff, $permission, (int) $ticket['department_id']);
if (!in_array($type, ['reply', 'note'], true)) {
api_error(422, 'INVALID_MESSAGE_TYPE', 'Der Nachrichtentyp ist ungültig.');
}
$message = sanitize_staff_html(resolve_canned_vars(trim($rawMessage), $ticket, $staff));
if (trim(strip_tags($message)) === '') {
api_error(422, 'MESSAGE_REQUIRED', 'Bitte geben Sie eine Nachricht ein.');
}
if (strlen($message) > 100000) {
api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
}
$isNote = $type === 'note';
// Live-Chat-Sperre (siehe includes/functions.php): eine öffentliche Antwort auf
// einen Chat, der gerade frisch von einem anderen Mitarbeiter (egal ob Web oder
// App) "besessen" wird, wird abgelehnt – interne Notizen bleiben immer erlaubt,
// die sind ja ohnehin nur für Mitarbeiter sichtbar und kein Konflikt für den Kunden.
if (!$isNote && $ticket['source'] === 'chat') {
$lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
if ($lockedByOtherName) {
api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
}
}
// Generelle Ticket-Sperre für alle anderen Tickets (siehe ticket.php – wer die GET-
// Anfrage erfolgreich gemacht hat, besitzt die Sperre bereits; das hier fängt nur
// den seltenen Fall ab, dass sie zwischen Laden und Absenden abgelaufen ist bzw. ein
// anderer Mitarbeiter sie in der Zwischenzeit übernommen hat).
if ($ticket['source'] !== 'chat') {
$lockedByOtherName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo);
if ($lockedByOtherName) {
api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByOtherName\" bearbeitet.");
}
}
$newStatus = $setStatus ?: 'answered';
if (!$isNote) {
if (!in_array($newStatus, MOBILE_API_STATUSES, true)) {
api_error(422, 'INVALID_STATUS', 'Der Zielstatus ist ungültig.');
}
if (in_array($newStatus, ['resolved', 'closed'], true)) {
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
}
}
$now = db_now();
$pdo->beginTransaction();
try {
$stmt = $pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
VALUES (?,?,?,?,?,?)'
);
$stmt->execute([$ticket['id'], 'staff', $staff['id'], $message, $isNote ? 1 : 0, $now]);
$threadId = (int) $pdo->lastInsertId();
if ($isNote) {
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Interne Notiz hinzugefügt (Mobile App)');
} else {
$firstResponse = empty($ticket['first_responded_at']);
$autoAssign = empty($ticket['assigned_staff_id']);
$sql = 'UPDATE tickets SET status = ?, updated_at = ?'
. ($firstResponse ? ', first_responded_at = ?' : '')
. ($autoAssign ? ', assigned_staff_id = ?' : '')
. ' WHERE id = ?';
$args = [$newStatus, $now];
if ($firstResponse) $args[] = $now;
if ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id'];
$pdo->prepare($sql)->execute($args);
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Öffentliche Antwort gesendet (Mobile App)');
}
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
if (!$isNote) {
$signature = trim((string) ($staff['signature'] ?? ''));
if ($signature === '') {
$signatureStmt = $pdo->prepare('SELECT signature FROM departments WHERE id = ?');
$signatureStmt->execute([$ticket['department_id']]);
$signature = (string) $signatureStmt->fetchColumn();
}
$collabStmt = $pdo->prepare('SELECT email FROM ticket_collaborators WHERE ticket_id = ?');
$collabStmt->execute([$ticket['id']]);
notify_customer_new_reply($ticket, $message, $signature, $collabStmt->fetchAll(PDO::FETCH_COLUMN));
}
webhook_dispatch($pdo, 'ticket.replied', (int) $ticket['id'], ['author' => 'staff', 'internal' => $isNote]);
return $threadId;
}
@@ -0,0 +1,71 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$userId = ($_GET['userId'] ?? '') !== '' ? (int) $_GET['userId'] : null;
$email = trim($_GET['email'] ?? '');
if ($userId !== null) {
$stmt = $pdo->prepare(
'SELECT u.id, u.name, u.email, u.created_at, o.name AS organization_name
FROM users u LEFT JOIN organizations o ON o.id = u.organization_id
WHERE u.id = ?'
);
$stmt->execute([$userId]);
$customer = $stmt->fetch();
if (!$customer) {
api_error(404, 'CUSTOMER_NOT_FOUND', 'Der Kunde wurde nicht gefunden.');
}
$email = $customer['email'];
$isGuest = false;
} else {
if ($email === '') {
api_error(422, 'CUSTOMER_IDENTIFIER_REQUIRED', 'Bitte userId oder email angeben.');
}
$ticketStmt = $pdo->prepare(
'SELECT MAX(requester_name) AS name, MIN(created_at) AS created_at
FROM tickets WHERE requester_email = ? AND user_id IS NULL'
);
$ticketStmt->execute([$email]);
$guestRow = $ticketStmt->fetch();
if (!$guestRow || $guestRow['name'] === null) {
api_error(404, 'CUSTOMER_NOT_FOUND', 'Der Kunde wurde nicht gefunden.');
}
$customer = [
'id' => null,
'name' => $guestRow['name'],
'email' => $email,
'created_at' => $guestRow['created_at'],
'organization_name' => null,
];
$isGuest = true;
}
$ticketStmt = $pdo->prepare(
"SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name,
s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id
WHERE t.requester_email = ? OR t.user_id = ?
ORDER BY t.created_at DESC LIMIT 100"
);
$ticketStmt->execute([$email, $userId]);
$tickets = array_map('api_ticket_summary', $ticketStmt->fetchAll());
api_json([
'customer' => [
'id' => $customer['id'] !== null ? (int) $customer['id'] : null,
'name' => $customer['name'],
'email' => $customer['email'],
'organizationName' => $customer['organization_name'],
'isGuest' => $isGuest,
'createdAt' => $customer['created_at'],
],
'tickets' => $tickets,
]);
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$search = trim($_GET['q'] ?? '');
$like = '%' . $search . '%';
$regSql = "SELECT u.id, u.name, u.email, o.name AS organization_name,
(SELECT COUNT(*) FROM tickets t WHERE t.user_id = u.id) AS ticket_count
FROM users u LEFT JOIN organizations o ON o.id = u.organization_id";
$regParams = [];
if ($search !== '') {
$regSql .= ' WHERE u.name LIKE ? OR u.email LIKE ?';
$regParams = [$like, $like];
}
$regSql .= ' ORDER BY u.name LIMIT 100';
$regStmt = $pdo->prepare($regSql);
$regStmt->execute($regParams);
$registered = array_map(fn(array $row) => [
'id' => (int) $row['id'],
'name' => $row['name'],
'email' => $row['email'],
'organizationName' => $row['organization_name'],
'ticketCount' => (int) $row['ticket_count'],
'isGuest' => false,
], $regStmt->fetchAll());
$guestSql = "SELECT requester_email AS email, MAX(requester_name) AS name, COUNT(*) AS ticket_count
FROM tickets WHERE user_id IS NULL";
$guestParams = [];
if ($search !== '') {
$guestSql .= ' AND (requester_name LIKE ? OR requester_email LIKE ?)';
$guestParams = [$like, $like];
}
$guestSql .= ' GROUP BY requester_email ORDER BY requester_email LIMIT 100';
$guestStmt = $pdo->prepare($guestSql);
$guestStmt->execute($guestParams);
$guests = array_map(fn(array $row) => [
'id' => null,
'name' => $row['name'],
'email' => $row['email'],
'organizationName' => null,
'ticketCount' => (int) $row['ticket_count'],
'isGuest' => true,
], $guestStmt->fetchAll());
$customers = array_merge($registered, $guests);
usort($customers, fn($a, $b) => strcasecmp($a['name'] ?? '', $b['name'] ?? ''));
$customers = array_slice($customers, 0, 100);
api_json(['items' => $customers]);
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$departmentIds = staff_department_ids($staff);
[$deptFilter, $deptParams] = dept_filter_sql($departmentIds, 'department_id');
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM tickets
WHERE assigned_staff_id = ? AND status NOT IN ('resolved','closed'){$deptFilter}"
);
$stmt->execute([$staff['id'], ...$deptParams]);
$myOpen = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM tickets
WHERE resolution_due_at IS NOT NULL AND resolution_due_at < ?
AND status NOT IN ('resolved','closed'){$deptFilter}"
);
$stmt->execute([db_now(), ...$deptParams]);
$overdue = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare(
"SELECT COUNT(*) FROM tickets
WHERE assigned_staff_id IS NULL AND status NOT IN ('resolved','closed'){$deptFilter}"
);
$stmt->execute($deptParams);
$unassigned = (int) $stmt->fetchColumn();
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE status = 'open'{$deptFilter}");
$stmt->execute($deptParams);
$unanswered = (int) $stmt->fetchColumn();
$todayStart = date('Y-m-d 00:00:00');
$stmt = $pdo->prepare("SELECT COUNT(*) FROM tickets WHERE resolved_at >= ?{$deptFilter}");
$stmt->execute([$todayStart, ...$deptParams]);
$resolvedToday = (int) $stmt->fetchColumn();
$periodStart = date('Y-m-d 00:00:00', strtotime('-30 days'));
$stmt = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(CASE WHEN resolution_due_at IS NULL OR resolved_at <= resolution_due_at THEN 1 ELSE 0 END) AS on_time
FROM tickets
WHERE resolved_at >= ?{$deptFilter}"
);
$stmt->execute([$periodStart, ...$deptParams]);
$slaRow = $stmt->fetch();
$slaTotal = (int) ($slaRow['total'] ?? 0);
$slaPercent = $slaTotal > 0 ? round(((int) $slaRow['on_time'] / $slaTotal) * 100, 1) : null;
// "Priorisiert": dieselbe Auswahl wie in staff/dashboard.php ("mir zugewiesen oder
// unbearbeitet"), damit die App-Übersicht dieselben Tickets zeigt wie die Web-Oberfläche.
[$deptFilterAliased, $deptParamsAliased] = dept_filter_sql($departmentIds, 't.department_id');
$stmt = $pdo->prepare(
"SELECT t.*, p.name AS priority_name, p.color AS priority_color, d.name AS department_name, s.name AS staff_name
FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id
WHERE t.status NOT IN ('resolved','closed') AND (t.assigned_staff_id = ? OR t.assigned_staff_id IS NULL){$deptFilterAliased}
ORDER BY t.resolution_due_at ASC
LIMIT 12"
);
$stmt->execute([$staff['id'], ...$deptParamsAliased]);
$recent = array_map('api_ticket_summary', $stmt->fetchAll());
$response = [
'myOpen' => $myOpen,
'overdue' => $overdue,
'unassigned' => $unassigned,
'unanswered' => $unanswered,
'resolvedToday' => $resolvedToday,
'slaPercent' => $slaPercent,
'recent' => $recent,
'departments' => null,
];
// Voller Überblick über alle Abteilungen nur für uneingeschränkten Zugriff (Admin).
if ($departmentIds === null) {
$stmt = $pdo->prepare(
"SELECT d.id, d.name,
COUNT(CASE WHEN t.status NOT IN ('resolved','closed') THEN 1 END) AS open_count,
COUNT(CASE WHEN t.status NOT IN ('resolved','closed')
AND t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ?
THEN 1 END) AS overdue_count
FROM departments d
LEFT JOIN tickets t ON t.department_id = d.id
GROUP BY d.id, d.name
ORDER BY d.name"
);
$stmt->execute([db_now()]);
$rows = $stmt->fetchAll();
$response['departments'] = array_map(fn(array $row) => [
'name' => $row['name'],
'open' => (int) $row['open_count'],
'overdue' => (int) $row['overdue_count'],
], $rows);
}
api_json($response);
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_departments');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$departments = $pdo->query(
'SELECT d.*, m.name AS manager_name FROM departments d LEFT JOIN staff m ON m.id = d.manager_id ORDER BY d.name'
)->fetchAll();
$emailOptions = $pdo->query(
'SELECT ea.email, d.name AS department_name FROM email_addresses ea JOIN departments d ON d.id = ea.department_id ORDER BY ea.email'
)->fetchAll();
$staffOptions = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
// Mitglieder = Hauptabteilung oder zusätzlich zugewiesen
$membersByDept = [];
$memberRows = $pdo->query(
'SELECT s.id, s.name, s.department_id AS dept_id, 1 AS is_primary FROM staff s WHERE s.department_id IS NOT NULL
UNION ALL
SELECT s.id, s.name, sd.department_id, 0 FROM staff_department sd JOIN staff s ON s.id = sd.staff_id'
)->fetchAll();
foreach ($memberRows as $r) {
$membersByDept[(int) $r['dept_id']][(int) $r['id']] ??= ['id' => (int) $r['id'], 'name' => $r['name'], 'isPrimary' => (bool) $r['is_primary']];
}
api_json([
'emailOptions' => array_map(fn(array $o) => ['email' => $o['email'], 'departmentName' => $o['department_name']], $emailOptions),
'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffOptions),
'slaPlans' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM sla_plans WHERE is_active = 1 ORDER BY name')->fetchAll()),
'items' => array_map(fn(array $d) => [
'id' => (int) $d['id'],
'name' => $d['name'],
'email' => $d['email'],
'isDefault' => (bool) $d['is_default'],
'signature' => $d['signature'],
'isActive' => (bool) ($d['is_active'] ?? 1),
'slaPlanId' => !empty($d['sla_plan_id']) ? (int) $d['sla_plan_id'] : null,
'managerId' => !empty($d['manager_id']) ? (int) $d['manager_id'] : null,
'managerName' => $d['manager_name'] ?? null,
'notes' => $d['notes'] ?? null,
'members' => array_values($membersByDept[(int) $d['id']] ?? []),
'createdAt' => $d['created_at'] ?? null,
'updatedAt' => $d['updated_at'] ?? null,
], $departments),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
$id = (int) ($body['id'] ?? 0);
try {
if ($action === 'save') {
$in = [
'name' => $body['name'] ?? '', 'email' => $body['email'] ?? '', 'is_default' => !empty($body['isDefault']),
'manager_id' => $body['managerId'] ?? null, 'notes' => $body['notes'] ?? '', 'signature' => $body['signature'] ?? '',
];
// Fehlende Schlüssel (ältere App-Version) lassen Status und SLA-Plan unverändert
if (array_key_exists('isActive', $body)) $in['is_active'] = !empty($body['isActive']);
if (array_key_exists('slaPlanId', $body)) $in['sla_plan_id'] = $body['slaPlanId'];
$newId = department_save($pdo, $id, $in);
audit_log($staff['id'], ($id === 0 ? 'Abteilung erstellt: ' : 'Abteilung aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$deleted = department_delete($pdo, $id);
audit_log($staff['id'], "Abteilung gelöscht: {$deleted} (Mobile App)");
api_json(['ok' => true]);
}
} catch (AdminModelException $e) {
api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_email_addresses');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$addresses = $pdo->query(
'SELECT ea.*, d.name AS department_name, p.name AS priority_name, h.name AS topic_name
FROM email_addresses ea
JOIN departments d ON d.id = ea.department_id
LEFT JOIN priorities p ON p.id = ea.priority_id
LEFT JOIN help_topics h ON h.id = ea.help_topic_id
ORDER BY d.name, ea.email'
)->fetchAll();
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM email_addresses')->fetchAll(PDO::FETCH_COLUMN)));
$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
$topics = help_topic_options($pdo, false, false);
$imapByAddress = [];
foreach ($pdo->query('SELECT * FROM imap_accounts WHERE email_address_id IS NOT NULL ORDER BY id')->fetchAll() as $row) {
$imapByAddress[(int) $row['email_address_id']] ??= $row;
}
api_json([
'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
'helpTopics' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['path']], $topics),
'items' => array_map(function (array $a) use ($imapByAddress) {
$imap = $imapByAddress[(int) $a['id']] ?? null;
return [
'id' => (int) $a['id'],
'email' => $a['email'],
'label' => $a['label'],
'departmentId' => (int) $a['department_id'],
'departmentName' => $a['department_name'],
'priorityId' => $a['priority_id'] !== null ? (int) $a['priority_id'] : null,
'priorityName' => $a['priority_name'],
'helpTopicId' => $a['help_topic_id'] !== null ? (int) $a['help_topic_id'] : null,
'helpTopicName' => $a['topic_name'],
'isDefault' => (bool) $a['is_default'],
'isActive' => (bool) ($a['is_active'] ?? 1),
'notes' => $a['notes'] ?? null,
'updatedAt' => $a['updated_at'] ?? null,
'imapEnabled' => $imap !== null && (bool) $imap['is_active'],
'imapHost' => $imap['host'] ?? '',
'imapPort' => (int) ($imap['port'] ?? 993),
'imapEncryption' => $imap['encryption'] ?? 'ssl',
'imapUsername' => $imap['username'] ?? '',
'imapFolder' => $imap['folder'] ?? 'INBOX',
'imapAfterFetch' => $imap['after_fetch'] ?? 'seen',
'imapArchiveFolder' => $imap['archive_folder'] ?? 'Processed',
'imapSpamFolder' => $imap['spam_folder'] ?? 'Junk',
'imapHasPassword' => $imap !== null && ($imap['password_enc'] ?? '') !== '',
'imapLastRunAt' => $imap['last_run_at'] ?? null,
'smtpEnabled' => (bool) ($a['smtp_enabled'] ?? 0),
'smtpHost' => $a['smtp_host'] ?? '',
'smtpPort' => (int) ($a['smtp_port'] ?? 587),
'smtpEncryption' => $a['smtp_encryption'] ?? 'tls',
'smtpUsername' => $a['smtp_username'] ?? '',
'smtpHasPassword' => ($a['smtp_password_enc'] ?? '') !== '',
];
}, $addresses),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
$id = (int) ($body['id'] ?? 0);
try {
if ($action === 'save') {
$b = fn(string $k, $d = null) => $body[$k] ?? $d;
$in = [
'email' => $b('email', ''), 'label' => $b('label', ''), 'department_id' => $b('departmentId', 0),
'priority_id' => $b('priorityId'), 'help_topic_id' => $b('helpTopicId'), 'is_default' => !empty($body['isDefault']),
'notes' => $b('notes', ''),
'imap_enabled' => !empty($body['imapEnabled']), 'imap_host' => $b('imapHost', ''), 'imap_port' => $b('imapPort', 993),
'imap_encryption' => $b('imapEncryption', 'ssl'), 'imap_username' => $b('imapUsername', ''), 'imap_password' => $b('imapPassword', ''),
'imap_folder' => $b('imapFolder', ''), 'imap_after_fetch' => $b('imapAfterFetch', 'seen'),
'imap_archive_folder' => $b('imapArchiveFolder', ''), 'imap_spam_folder' => $b('imapSpamFolder', ''),
'smtp_enabled' => !empty($body['smtpEnabled']), 'smtp_host' => $b('smtpHost', ''), 'smtp_port' => $b('smtpPort', 587),
'smtp_encryption' => $b('smtpEncryption', 'tls'), 'smtp_username' => $b('smtpUsername', ''), 'smtp_password' => $b('smtpPassword', ''),
];
if (array_key_exists('isActive', $body)) $in['is_active'] = !empty($body['isActive']);
$newId = email_address_save($pdo, $id, $in);
audit_log($staff['id'], ($id === 0 ? 'E-Mail-Adresse erstellt: ' : 'E-Mail-Adresse aktualisiert: ') . strtolower(trim((string) ($body['email'] ?? ''))) . ' (Mobile App)');
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$deleted = email_address_delete($pdo, $id);
audit_log($staff['id'], "E-Mail-Adresse gelöscht: {$deleted} (Mobile App)");
api_json(['ok' => true]);
}
// Tests: Test-Mail an die eigene Adresse, IMAP-Verbindung, SMTP-Erreichbarkeit
if ($action === 'sendTest') api_json(['ok' => true, 'message' => email_address_send_test($pdo, $id, (string) $staff['email'])]);
if ($action === 'testImap') api_json(['ok' => true, 'message' => email_address_test_imap($pdo, $id)]);
if ($action === 'testSmtp') api_json(['ok' => true, 'message' => email_address_test_smtp($pdo, $id)]);
} catch (EmailAddressException $e) {
api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_email_templates');
$pdo = get_db();
function api_email_templates_definitions(): array
{
return [
'ticket_created' => [
'label' => 'Ticket erstellt (an Kunde)',
'placeholders' => ['ticket_number', 'subject', 'customer_name', 'department_name', 'ticket_url', 'lookup_url', 'site_name'],
'defaultSubject' => 'Ihr Ticket #{ticket_number} wurde erstellt',
'defaultBody' => email_ticket_created_default_body(),
],
'new_reply' => [
'label' => 'Neue Antwort (an Kunde)',
'placeholders' => ['ticket_number', 'subject', 'customer_name', 'message', 'ticket_url'],
'defaultSubject' => '[#{ticket_number}] {subject}',
'defaultBody' => email_new_reply_default_body(),
],
'new_ticket_department' => [
'label' => 'Neues Ticket (an Abteilung)',
'placeholders' => ['ticket_number', 'subject', 'customer_name', 'customer_email', 'department_name', 'ticket_url'],
'defaultSubject' => 'Neues Ticket [#{ticket_number}] {subject}',
'defaultBody' => email_new_ticket_department_default_body(),
],
'ticket_overdue' => [
'label' => 'Ticket überfällig (an Admins)',
'placeholders' => ['ticket_number', 'subject', 'department_name', 'ticket_url'],
'defaultSubject' => 'SLA überschritten: Ticket #{ticket_number}',
'defaultBody' => email_ticket_overdue_default_body(),
],
];
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$items = [];
foreach (api_email_templates_definitions() as $key => $tpl) {
$customSubject = get_setting("email_template_{$key}_subject");
$customBody = get_setting("email_template_{$key}_body");
$items[] = [
'key' => $key,
'label' => $tpl['label'],
'placeholders' => $tpl['placeholders'],
'subject' => $customSubject ?: $tpl['defaultSubject'],
'body' => $customBody ?: $tpl['defaultBody'],
'isCustomized' => $customSubject !== null,
];
}
api_json(['items' => $items]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
$definitions = api_email_templates_definitions();
$key = (string) ($body['key'] ?? '');
if (!isset($definitions[$key])) {
api_error(422, 'INVALID_TEMPLATE', 'Unbekannte Vorlage.');
}
if ($action === 'save') {
$subject = trim((string) ($body['subject'] ?? ''));
$bodyText = sanitize_staff_html(trim((string) ($body['body'] ?? '')));
if ($subject === '') api_error(422, 'SUBJECT_REQUIRED', 'Bitte einen Betreff angeben.');
if (trim(strip_tags($bodyText)) === '') api_error(422, 'BODY_REQUIRED', 'Bitte einen Text angeben.');
set_setting("email_template_{$key}_subject", $subject);
set_setting("email_template_{$key}_body", $bodyText);
audit_log($staff['id'], "E-Mail-Vorlage geändert: {$definitions[$key]['label']} (Mobile App)");
api_json(['ok' => true]);
}
if ($action === 'reset') {
set_setting("email_template_{$key}_subject", null);
set_setting("email_template_{$key}_body", null);
audit_log($staff['id'], "E-Mail-Vorlage zurückgesetzt: {$definitions[$key]['label']} (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_escalations');
$pdo = get_db();
const ESCALATION_TRIGGER_TYPES = ['no_reply', 'no_update', 'overdue', 'open_too_long'];
const ESCALATION_SET_STATUSES = ['open', 'pending', 'on_hold'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
$staffList = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$depts = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
try {
$rules = $pdo->query(
'SELECT er.*,
p.name AS prio_name,
s.name AS staff_name,
d.name AS dept_name,
cp.name AS cond_prio_name,
(SELECT COUNT(*) FROM escalation_log el WHERE el.rule_id = er.id) AS applied_count
FROM escalation_rules er
LEFT JOIN priorities p ON p.id = er.action_set_priority_id
LEFT JOIN staff s ON s.id = er.action_assign_staff_id
LEFT JOIN departments d ON d.id = er.condition_dept_id
LEFT JOIN priorities cp ON cp.id = er.condition_priority_id
ORDER BY er.sort_order, er.id'
)->fetchAll();
} catch (Throwable $e) {
$rules = [];
}
api_json([
'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffList),
'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $depts),
'rules' => array_map(fn(array $r) => [
'id' => (int) $r['id'],
'name' => $r['name'],
'isActive' => (bool) $r['is_active'],
'triggerType' => $r['trigger_type'],
'triggerHours' => (int) $r['trigger_hours'],
'conditionStatus' => $r['condition_status'],
'conditionPriorityId' => $r['condition_priority_id'] !== null ? (int) $r['condition_priority_id'] : null,
'conditionPriorityName' => $r['cond_prio_name'],
'conditionDeptId' => $r['condition_dept_id'] !== null ? (int) $r['condition_dept_id'] : null,
'conditionDeptName' => $r['dept_name'],
'actionSetPriorityId' => $r['action_set_priority_id'] !== null ? (int) $r['action_set_priority_id'] : null,
'actionSetPriorityName' => $r['prio_name'],
'actionAssignStaffId' => $r['action_assign_staff_id'] !== null ? (int) $r['action_assign_staff_id'] : null,
'actionAssignStaffName' => $r['staff_name'],
'actionSetStatus' => $r['action_set_status'],
'actionNotifyAdmin' => (bool) $r['action_notify_admin'],
'actionNotifyStaff' => (bool) $r['action_notify_staff'],
'sortOrder' => (int) $r['sort_order'],
'appliedCount' => (int) $r['applied_count'],
], $rules),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'toggle') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('UPDATE escalation_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
api_json(['ok' => true]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('DELETE FROM escalation_rules WHERE id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM escalation_log WHERE rule_id = ?')->execute([$id]);
audit_log($staff['id'], "Eskalationsregel gelöscht: #$id (Mobile App)");
api_json(['ok' => true]);
}
if ($action === 'save') {
$id = (int) ($body['id'] ?? 0);
$name = trim((string) ($body['name'] ?? ''));
$isActive = !empty($body['isActive']) ? 1 : 0;
$triggerType = (string) ($body['triggerType'] ?? 'no_reply');
$triggerHours = max(1, (int) ($body['triggerHours'] ?? 24));
$condStatus = trim((string) ($body['conditionStatus'] ?? ''));
$condPrioId = isset($body['conditionPriorityId']) && $body['conditionPriorityId'] !== null ? (int) $body['conditionPriorityId'] : null;
$condDeptId = isset($body['conditionDeptId']) && $body['conditionDeptId'] !== null ? (int) $body['conditionDeptId'] : null;
$actPrioId = isset($body['actionSetPriorityId']) && $body['actionSetPriorityId'] !== null ? (int) $body['actionSetPriorityId'] : null;
$actStaffId = isset($body['actionAssignStaffId']) && $body['actionAssignStaffId'] !== null ? (int) $body['actionAssignStaffId'] : null;
$actStatus = (string) ($body['actionSetStatus'] ?? '');
$actNotAdmin = !empty($body['actionNotifyAdmin']) ? 1 : 0;
$actNotStaff = !empty($body['actionNotifyStaff']) ? 1 : 0;
$sortOrder = (int) ($body['sortOrder'] ?? 0);
if ($name === '') {
api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
}
if (!in_array($triggerType, ESCALATION_TRIGGER_TYPES, true)) {
api_error(422, 'INVALID_TRIGGER', 'Ungültiger Trigger.');
}
if ($actStatus !== '' && !in_array($actStatus, ESCALATION_SET_STATUSES, true)) {
api_error(422, 'INVALID_STATUS', 'Ungültiger Zielstatus.');
}
$data = [$name, $isActive, $triggerType, $triggerHours, $condStatus ?: null,
$condPrioId, $condDeptId, $actPrioId, $actStaffId,
$actStatus ?: null, $actNotAdmin, $actNotStaff, $sortOrder];
if ($id) {
$existsStmt = $pdo->prepare('SELECT COUNT(*) FROM escalation_rules WHERE id = ?');
$existsStmt->execute([$id]);
if (!(bool) $existsStmt->fetchColumn()) {
api_error(404, 'RULE_NOT_FOUND', 'Die Regel wurde nicht gefunden.');
}
$pdo->prepare(
'UPDATE escalation_rules SET name=?,is_active=?,trigger_type=?,trigger_hours=?,
condition_status=?,condition_priority_id=?,condition_dept_id=?,
action_set_priority_id=?,action_assign_staff_id=?,action_set_status=?,
action_notify_admin=?,action_notify_staff=?,sort_order=? WHERE id=?'
)->execute(array_merge($data, [$id]));
audit_log($staff['id'], "Eskalationsregel geändert: $name (Mobile App)");
$newId = $id;
} else {
$pdo->prepare(
'INSERT INTO escalation_rules (name,is_active,trigger_type,trigger_hours,
condition_status,condition_priority_id,condition_dept_id,
action_set_priority_id,action_assign_staff_id,action_set_status,
action_notify_admin,action_notify_staff,sort_order,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute(array_merge($data, [db_now()]));
$newId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "Eskalationsregel erstellt: $name (Mobile App)");
}
api_json(['id' => $newId]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,80 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_help_topics');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$topics = $pdo->query(
'SELECT h.*, d.name AS department_name, p.name AS priority_name, p.color AS priority_color
FROM help_topics h
JOIN departments d ON d.id = h.department_id
JOIN priorities p ON p.id = h.priority_id
ORDER BY h.sort_order, h.name'
)->fetchAll();
$departments = departments_for_select($pdo, array_map('intval', $pdo->query('SELECT department_id FROM help_topics')->fetchAll(PDO::FETCH_COLUMN)));
$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
$names = array_column($topics, 'name', 'id');
$parents = array_column($topics, 'parent_id', 'id');
$pathOf = function (int $id) use ($names, $parents): string {
$parts = []; $seen = [];
while ($id && isset($names[$id]) && !isset($seen[$id])) { $seen[$id] = true; array_unshift($parts, $names[$id]); $id = (int) ($parents[$id] ?? 0); }
return implode(' / ', $parts);
};
api_json([
'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
'slaPlans' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $pdo->query('SELECT id, name FROM sla_plans WHERE is_active = 1 ORDER BY name')->fetchAll()),
'items' => array_map(fn(array $t) => [
'id' => (int) $t['id'],
'name' => $t['name'],
'departmentId' => (int) $t['department_id'],
'departmentName' => $t['department_name'],
'priorityId' => (int) $t['priority_id'],
'priorityName' => $t['priority_name'],
'priorityColor' => $t['priority_color'],
'isActive' => (bool) $t['is_active'],
'isPublic' => (bool) ($t['is_public'] ?? 1),
'slaPlanId' => !empty($t['sla_plan_id']) ? (int) $t['sla_plan_id'] : null,
'parentId' => !empty($t['parent_id']) ? (int) $t['parent_id'] : null,
'path' => $pathOf((int) $t['id']),
'notes' => $t['notes'] ?? null,
'sortOrder' => (int) $t['sort_order'],
'createdAt' => $t['created_at'] ?? null,
'updatedAt' => $t['updated_at'] ?? null,
], $topics),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
$id = (int) ($body['id'] ?? 0);
try {
if ($action === 'save') {
$in = [
'name' => $body['name'] ?? '', 'department_id' => $body['departmentId'] ?? 0, 'priority_id' => $body['priorityId'] ?? 0,
'sort_order' => $body['sortOrder'] ?? 0,
];
// Fehlende Schlüssel (ältere App-Version) lassen den bisherigen Wert unverändert
foreach (['isActive' => 'is_active', 'isPublic' => 'is_public', 'parentId' => 'parent_id', 'slaPlanId' => 'sla_plan_id', 'notes' => 'notes'] as $camel => $snake) {
if (array_key_exists($camel, $body)) $in[$snake] = $body[$camel];
}
$newId = help_topic_save($pdo, $id, $in);
audit_log($staff['id'], ($id === 0 ? 'Hilfethema erstellt: ' : 'Hilfethema aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$deleted = help_topic_delete($pdo, $id);
audit_log($staff['id'], "Hilfethema gelöscht: {$deleted} (Mobile App)");
api_json(['ok' => true]);
}
} catch (AdminModelException $e) {
api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+123
View File
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_kb');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$categories = $pdo->query('SELECT * FROM kb_categories ORDER BY sort_order, name')->fetchAll();
$articles = $pdo->query(
'SELECT a.*, c.name AS category_name FROM kb_articles a JOIN kb_categories c ON c.id=a.category_id ORDER BY a.title'
)->fetchAll();
api_json([
'categories' => array_map(fn(array $c) => [
'id' => (int) $c['id'],
'name' => $c['name'],
], $categories),
'articles' => array_map(fn(array $a) => [
'id' => (int) $a['id'],
'categoryId' => (int) $a['category_id'],
'categoryName' => $a['category_name'],
'title' => $a['title'],
'body' => $a['body'],
'isPublished' => (bool) $a['is_published'],
'views' => (int) $a['views'],
], $articles),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'create_category') {
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') {
api_error(422, 'NAME_REQUIRED', 'Bitte einen Kategorienamen angeben.');
}
$slug = slugify($name);
$pdo->prepare('INSERT INTO kb_categories (name, slug, sort_order) VALUES (?,?,0)')->execute([$name, $slug]);
$newId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "KB-Kategorie erstellt: {$name} (Mobile App)");
api_json(['id' => $newId]);
}
if ($action === 'delete_category') {
$id = (int) ($body['id'] ?? 0);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE category_id = ?');
$stmt->execute([$id]);
if ((int) $stmt->fetchColumn() > 0) {
api_error(422, 'CATEGORY_NOT_EMPTY', 'Diese Kategorie enthält noch Artikel und kann nicht gelöscht werden.');
}
$nameStmt = $pdo->prepare('SELECT name FROM kb_categories WHERE id = ?');
$nameStmt->execute([$id]);
$catName = $nameStmt->fetchColumn();
$pdo->prepare('DELETE FROM kb_categories WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "KB-Kategorie gelöscht: {$catName} (Mobile App)");
api_json(['ok' => true]);
}
if ($action === 'save_article') {
$id = (int) ($body['id'] ?? 0);
$title = trim((string) ($body['title'] ?? ''));
$categoryId = (int) ($body['categoryId'] ?? 0);
$articleBody = sanitize_staff_html((string) ($body['body'] ?? ''));
$isPublished = !empty($body['isPublished']) ? 1 : 0;
if ($title === '') {
api_error(422, 'TITLE_REQUIRED', 'Bitte einen Titel angeben.');
}
if (trim(strip_tags($articleBody)) === '') {
api_error(422, 'BODY_REQUIRED', 'Bitte einen Inhalt angeben.');
}
$catCheck = $pdo->prepare('SELECT COUNT(*) FROM kb_categories WHERE id = ?');
$catCheck->execute([$categoryId]);
if (!(bool) $catCheck->fetchColumn()) {
api_error(422, 'INVALID_CATEGORY', 'Bitte eine gültige Kategorie wählen.');
}
if ($id === 0) {
$slug = slugify($title);
$base = $slug;
$i = 2;
$check = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE slug = ?');
while (true) {
$check->execute([$slug]);
if ((int) $check->fetchColumn() === 0) break;
$slug = $base . '-' . $i++;
}
$pdo->prepare('INSERT INTO kb_articles (category_id, title, slug, body, is_published, created_at, updated_at) VALUES (?,?,?,?,?,?,?)')
->execute([$categoryId, $title, $slug, $articleBody, $isPublished, db_now(), db_now()]);
$newId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "KB-Artikel erstellt: {$title} (Mobile App)");
} else {
$existsStmt = $pdo->prepare('SELECT COUNT(*) FROM kb_articles WHERE id = ?');
$existsStmt->execute([$id]);
if (!(bool) $existsStmt->fetchColumn()) {
api_error(404, 'ARTICLE_NOT_FOUND', 'Der Artikel wurde nicht gefunden.');
}
$pdo->prepare('UPDATE kb_articles SET category_id=?, title=?, body=?, is_published=?, updated_at=? WHERE id=?')
->execute([$categoryId, $title, $articleBody, $isPublished, db_now(), $id]);
$newId = $id;
audit_log($staff['id'], "KB-Artikel aktualisiert: {$title} (Mobile App)");
}
api_json(['id' => $newId]);
}
if ($action === 'delete_article') {
$id = (int) ($body['id'] ?? 0);
$titleStmt = $pdo->prepare('SELECT title FROM kb_articles WHERE id = ?');
$titleStmt->execute([$id]);
$artTitle = $titleStmt->fetchColumn();
if ($artTitle === false) {
api_error(404, 'ARTICLE_NOT_FOUND', 'Der Artikel wurde nicht gefunden.');
}
$pdo->prepare('DELETE FROM kb_articles WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "KB-Artikel gelöscht: {$artTitle} (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+185
View File
@@ -0,0 +1,185 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* api/staff/v1/live_chat.php
*
* Mobile-Pendant zu staff/live_chat.php (Web-Postfach) – eigener Endpunkt statt
* Wiederverwendung von ticket.php/ticket_message.php, weil die App eine dedizierte,
* auf schnelles Chatten zugeschnittene Ansicht bekommt (eigener Tab, siehe
* mobile/.../ui/DeskHubApp.kt LiveChatScreen), analog zum separaten Web-Postfach.
* Teilt sich die Sperr-Logik (live_chat_locked_by_other()) und den Transkript-Mailversand
* (notify_customer_chat_transcript()) mit dem Web-Backend – siehe includes/functions.php
* bzw. includes/mailer.php.
*/
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
[$deptFilterSql, $deptFilterParams] = dept_filter_sql(staff_department_ids($staff), 't.department_id');
function lc_api_find_ticket(PDO $pdo, string $number, string $deptFilterSql, array $deptFilterParams): ?array
{
$stmt = $pdo->prepare(
'SELECT t.*, d.name AS department_name FROM tickets t JOIN departments d ON d.id = t.department_id
WHERE t.ticket_number = ? AND t.source = \'chat\''
. ($deptFilterSql !== '' ? ' AND ' . substr($deptFilterSql, 5) : '')
);
$stmt->execute(array_merge([$number], $deptFilterParams));
return $stmt->fetch() ?: null;
}
function lc_api_messages(PDO $pdo, int $ticketId, int $since): array
{
$stmt = $pdo->prepare(
'SELECT tt.id, tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staffName
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 AND tt.id > ?
ORDER BY tt.created_at ASC, tt.id ASC'
);
$stmt->execute([$ticketId, $since]);
$attStmt = $pdo->prepare('SELECT id, original_name AS name, filesize AS size FROM ticket_attachments WHERE thread_id = ? ORDER BY id');
return array_map(function (array $m) use ($attStmt) {
$attStmt->execute([$m['id']]);
return [
'id' => (int) $m['id'],
'authorType' => $m['authorType'],
'authorName' => $m['authorType'] === 'staff' ? ($m['staffName'] ?: 'Support') : null,
'message' => api_plain_text($m['message']),
'createdAt' => $m['createdAt'],
'attachments' => array_map(fn(array $a) => ['id' => (int) $a['id'], 'name' => $a['name'], 'size' => (int) $a['size']], $attStmt->fetchAll()),
];
}, $stmt->fetchAll());
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$action = $_GET['action'] ?? '';
if ($action === 'list') {
$listStmt = $pdo->prepare(
"SELECT t.ticket_number AS number, t.requester_name AS name, t.updated_at AS updatedAt,
(SELECT tt.author_type FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS lastAuthor,
(SELECT tt.message FROM ticket_threads tt WHERE tt.ticket_id = t.id ORDER BY tt.created_at DESC, tt.id DESC LIMIT 1) AS lastMessage
FROM tickets t
WHERE t.source = 'chat' AND t.status != 'closed'" . ($deptFilterSql !== '' ? " AND $deptFilterSql" : '') . "
ORDER BY t.updated_at DESC LIMIT 100"
);
$listStmt->execute($deptFilterParams);
api_json(['items' => array_map(fn(array $c) => [
'number' => $c['number'],
'name' => $c['name'],
'preview' => mb_substr(strip_tags((string) $c['lastMessage']), 0, 60),
'needsReply' => $c['lastAuthor'] === 'customer',
'updatedAt' => $c['updatedAt'],
], $listStmt->fetchAll())]);
}
if ($action === 'messages') {
$number = trim((string) ($_GET['number'] ?? ''));
if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
$ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams);
if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.');
$since = (int) ($_GET['since'] ?? 0);
// Sperre übernehmen/auffrischen bzw. feststellen, dass gerade jemand anders aktiv
// dran ist – wie im Web-Postfach (siehe staff/live_chat.php). Lesen bleibt für
// alle möglich, nur das Schreiben wird unten (Aktion "send") verwehrt.
$lockedByOtherName = null;
if ($ticket['status'] !== 'closed') {
$lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
if (!$lockedByOtherName) {
live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo);
}
}
$customerTyping = !empty($ticket['typing_customer_until']) && strtotime($ticket['typing_customer_until']) > time();
api_json([
'status' => $ticket['status'],
'requesterName' => $ticket['requester_name'],
'requesterEmail' => $ticket['requester_email'],
'departmentName' => $ticket['department_name'],
'lockedByOtherName' => $lockedByOtherName,
'customerTyping' => $customerTyping,
'messages' => lc_api_messages($pdo, (int) $ticket['id'], $since),
]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
}
// ── POST: send / end / typing ───────────────────────────────────────────────
$body = api_request_body();
$action = $body['action'] ?? '';
$number = trim((string) ($body['number'] ?? ''));
if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
$ticket = lc_api_find_ticket($pdo, $number, $deptFilterSql, $deptFilterParams);
if (!$ticket) api_error(404, 'TICKET_NOT_FOUND', 'Chat nicht gefunden oder kein Zugriff.');
if ($action === 'typing') {
if ($ticket['status'] !== 'closed' && !live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
$pdo->prepare('UPDATE tickets SET typing_staff_until = ? WHERE id = ?')
->execute([date('Y-m-d H:i:s', time() + 6), $ticket['id']]);
}
api_json(['ok' => true]);
}
if ($action === 'send') {
api_require_permission($staff, 'reply_ticket', (int) $ticket['department_id']);
if ($ticket['status'] === 'closed') api_error(409, 'CHAT_CLOSED', 'Dieser Chat wurde bereits beendet.');
if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
}
$message = sanitize_staff_html(trim((string) ($body['message'] ?? '')));
if (trim(strip_tags($message)) === '') api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Nachricht eingeben.');
if (strlen($message) > 100000) api_error(422, 'MESSAGE_TOO_LONG', 'Die Nachricht ist zu lang.');
$now = db_now();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at) VALUES (?,?,?,?,0,?)'
)->execute([$ticket['id'], 'staff', $staff['id'], $message, $now]);
$threadId = (int) $pdo->lastInsertId();
$autoAssign = ($ticket['assigned_staff_id'] === null || $ticket['assigned_staff_id'] === '');
$updateFirstResponse = $ticket['first_responded_at'] === null;
$sql = 'UPDATE tickets SET status = ?, updated_at = ?'
. ($updateFirstResponse ? ', first_responded_at = ?' : '')
. ($autoAssign ? ', assigned_staff_id = ?' : '')
. ' WHERE id = ?';
$args = ['answered', $now];
if ($updateFirstResponse) $args[] = $now;
if ($autoAssign) $args[] = $staff['id'];
$args[] = $ticket['id'];
$pdo->prepare($sql)->execute($args);
if ($autoAssign) log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Automatisch zugewiesen (Live-Chat, Mobile App)');
save_mentions($pdo, (int) $ticket['id'], $threadId, (int) $staff['id'], $message, $ticket);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat-Antwort gesendet (Mobile App)');
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
api_json(['id' => $threadId, 'createdAt' => $now, 'status' => 'answered']);
}
if ($action === 'end') {
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
if ($lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo)) {
api_error(409, 'CHAT_LOCKED', "Dieser Chat wird gerade von \"$lockedByOtherName\" bearbeitet. Sie können nur zuschauen.");
}
$now = db_now();
$pdo->prepare(
"UPDATE tickets SET status='closed', closed_at=COALESCE(closed_at, ?), updated_at=?, locked_by=NULL, locked_at=NULL WHERE id=?"
)->execute([$now, $now, $ticket['id']]);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Live-Chat beendet (Mobile App)');
finalize_work_session($pdo, (int) $ticket['id'], (int) $staff['id']);
if ($ticket['requester_email']) {
try {
notify_customer_chat_transcript($ticket, lc_api_messages($pdo, (int) $ticket['id'], 0));
} catch (Throwable $e) {
error_log('live_chat.php (Mobile App) transcript mail: ' . $e->getMessage());
}
}
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+44
View File
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('POST');
api_require_https();
$body = api_request_body();
$email = mb_strtolower(trim((string) ($body['email'] ?? '')));
$password = (string) ($body['password'] ?? '');
$totpCode = trim((string) ($body['totpCode'] ?? ''));
if (!filter_var($email, FILTER_VALIDATE_EMAIL) || $password === '') {
api_error(422, 'CREDENTIALS_REQUIRED', 'E-Mail-Adresse und Passwort sind erforderlich.');
}
if (!rate_limit_check('mobile_staff_login_ip', client_ip(), 10, 15)
|| !rate_limit_check('mobile_staff_login_email', $email, 5, 15)) {
api_error(429, 'RATE_LIMITED', 'Zu viele Anmeldeversuche. Bitte versuchen Sie es später erneut.');
}
$pdo = get_db();
$stmt = $pdo->prepare('SELECT * FROM staff WHERE email = ? AND is_active = 1');
$stmt->execute([$email]);
$staff = $stmt->fetch();
if (!$staff || !password_verify($password, $staff['password_hash'])) {
api_error(401, 'INVALID_CREDENTIALS', 'E-Mail-Adresse oder Passwort ist falsch.');
}
if (!empty($staff['totp_enabled'])) {
if ($totpCode === '') {
api_json(['requiresTotp' => true], 202);
}
if (!preg_match('/^\d{6}$/', $totpCode) || !totp_verify($staff['totp_secret'], $totpCode)) {
api_error(401, 'INVALID_TOTP', 'Der Authenticator-Code ist ungültig.');
}
}
rate_limit_reset('mobile_staff_login_email', $email);
rate_limit_reset('mobile_staff_login_ip', client_ip());
$issued = api_issue_token($pdo, (int) $staff['id'], isset($body['deviceName']) ? (string) $body['deviceName'] : null);
audit_log((int) $staff['id'], 'Mobile App: Anmeldung');
api_json([
'token' => $issued['token'],
'expiresAt' => $issued['expires_at'],
'staff' => api_staff_payload($staff),
]);
+10
View File
@@ -0,0 +1,10 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('POST');
$staff = api_require_staff();
get_db()->prepare('UPDATE mobile_api_tokens SET revoked_at = ? WHERE id = ?')
->execute([db_now(), $staff['mobile_token_id']]);
audit_log((int) $staff['id'], 'Mobile App: Abmeldung');
api_json(['ok' => true]);
+7
View File
@@ -0,0 +1,7 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
api_json(['staff' => api_staff_payload($staff)]);
@@ -0,0 +1,45 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$departmentIds = staff_department_ids($staff);
[$filter, $params] = dept_filter_sql($departmentIds, 'id');
$stmt = $pdo->prepare('SELECT id, name, is_active FROM departments WHERE 1=1' . $filter . ' ORDER BY name');
$stmt->execute($params);
$departments = array_map(
fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name'] . ($row['is_active'] ? '' : ' (deaktiviert)')],
$stmt->fetchAll()
);
$priorities = array_map(
fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name'], 'color' => $row['color']],
$pdo->query('SELECT id, name, color FROM priorities ORDER BY sort_order')->fetchAll()
);
$agents = array_map(
fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name']],
$pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll()
);
$statuses = array_map(function (string $value): array {
[$label, $color] = status_label($value);
return ['value' => $value, 'label' => $label, 'color' => $color];
}, MOBILE_API_STATUSES);
$tags = array_map(
fn(array $row) => ['id' => (int) $row['id'], 'name' => $row['name']],
$pdo->query('SELECT id, name FROM tags ORDER BY name')->fetchAll()
);
$health = has_permission($staff, 'manage_email_addresses') ? system_health($pdo) : [];
api_json([
'health' => $health,
'staff' => api_staff_payload($staff),
'departments' => $departments,
'priorities' => $priorities,
'agents' => $agents,
'statuses' => $statuses,
'tags' => $tags,
'liveChatEnabled' => get_setting('live_chat_enabled', '0') === '1',
]);
@@ -0,0 +1,54 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_organizations');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$organizations = $pdo->query(
'SELECT o.*, (SELECT COUNT(*) FROM users u WHERE u.organization_id = o.id) AS member_count
FROM organizations o ORDER BY o.name'
)->fetchAll();
api_json(['items' => array_map(fn(array $o) => [
'id' => (int) $o['id'],
'name' => $o['name'],
'notes' => $o['notes'],
'memberCount' => (int) $o['member_count'],
], $organizations)]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'save') {
$id = (int) ($body['id'] ?? 0);
$name = trim((string) ($body['name'] ?? ''));
$notes = trim((string) ($body['notes'] ?? ''));
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
if ($id === 0) {
$pdo->prepare('INSERT INTO organizations (name, notes, created_at) VALUES (?,?,?)')
->execute([$name, $notes, db_now()]);
$newId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "Organisation erstellt: {$name} (Mobile App)");
} else {
$pdo->prepare('UPDATE organizations SET name=?, notes=? WHERE id=?')->execute([$name, $notes, $id]);
$newId = $id;
audit_log($staff['id'], "Organisation aktualisiert: {$name} (Mobile App)");
}
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('UPDATE users SET organization_id = NULL WHERE organization_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM organizations WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "Organisation gelöscht: #{$id} (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
$slug = (string) ($_GET['p'] ?? '');
$endpoint = (string) ($_GET['endpoint'] ?? '');
$entry = plugin_valid_slug($slug) ? (plugin_state()['api'][$slug][$endpoint] ?? null) : null;
if (!$entry) api_error(404, 'NOT_FOUND', 'Plugin-Endpunkt nicht gefunden oder Plugin nicht aktiv.');
if ($entry['permission'] !== null) api_require_permission($staff, $entry['permission']);
$request = $_SERVER['REQUEST_METHOD'] === 'POST' ? api_request_body() : $_GET;
$failed = false;
$result = plugin_guard($slug, fn() => ($entry['fn'])($staff, $request), null);
if (!is_array($result)) api_error(500, 'PLUGIN_ERROR', 'Das Plugin hat keine gültige Antwort geliefert.');
api_json($result);
@@ -0,0 +1,78 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_priorities');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$priorities = $pdo->query('SELECT * FROM priorities ORDER BY sort_order')->fetchAll();
api_json(['items' => array_map(fn(array $p) => [
'id' => (int) $p['id'],
'name' => $p['name'],
'color' => $p['color'],
'responseHours' => (int) $p['response_hours'],
'resolutionHours' => (int) $p['resolution_hours'],
'sortOrder' => (int) $p['sort_order'],
'notes' => $p['notes'] ?? null,
'createdAt' => $p['created_at'] ?? null,
'updatedAt' => $p['updated_at'] ?? null,
], $priorities)]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'save') {
$id = (int) ($body['id'] ?? 0);
$name = trim((string) ($body['name'] ?? ''));
$color = trim((string) ($body['color'] ?? '#888888'));
$response = (int) ($body['responseHours'] ?? 0);
$resolution = (int) ($body['resolutionHours'] ?? 0);
$sort = (int) ($body['sortOrder'] ?? 0);
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
if ($response <= 0 || $resolution <= 0) api_error(422, 'INVALID_HOURS', 'Bitte gültige SLA-Stunden angeben (größer als 0).');
if ($response > $resolution) api_error(422, 'INVALID_HOURS', 'Die Reaktionszeit darf nicht länger als die Lösungszeit sein.');
$dup = $pdo->prepare('SELECT COUNT(*) FROM priorities WHERE LOWER(name) = LOWER(?) AND id <> ?');
$dup->execute([$name, $id]);
if ((int) $dup->fetchColumn() > 0) api_error(422, 'NAME_TAKEN', 'Eine Priorität mit diesem Namen existiert bereits.');
$notes = null;
if (array_key_exists('notes', $body)) {
$notes = trim((string) $body['notes']) ?: null;
} elseif ($id) {
$n = $pdo->prepare('SELECT notes FROM priorities WHERE id = ?');
$n->execute([$id]);
$notes = $n->fetchColumn() ?: null;
}
if (!preg_match('/^#[0-9a-fA-F]{6}$/', $color)) api_error(422, 'INVALID_COLOR', 'Bitte eine gültige Farbe im Format #RRGGBB angeben.');
if ($id === 0) {
$pdo->prepare('INSERT INTO priorities (name, color, response_hours, resolution_hours, sort_order, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?)')
->execute([$name, $color, $response, $resolution, $sort, $notes, db_now(), db_now()]);
$newId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "Priorität erstellt: {$name} (Mobile App)");
} else {
$pdo->prepare('UPDATE priorities SET name=?, color=?, response_hours=?, resolution_hours=?, sort_order=?, notes=?, updated_at=? WHERE id=?')
->execute([$name, $color, $response, $resolution, $sort, $notes, db_now(), $id]);
$newId = $id;
audit_log($staff['id'], "Priorität aktualisiert: {$name} (Mobile App)");
}
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$stmt = $pdo->prepare('SELECT COUNT(*) FROM tickets WHERE priority_id = ?');
$stmt->execute([$id]);
if ((int) $stmt->fetchColumn() > 0) {
api_error(422, 'PRIORITY_IN_USE', 'Diese Priorität wird noch von Tickets verwendet und kann nicht gelöscht werden.');
}
$pdo->prepare('DELETE FROM priorities WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "Priorität gelöscht: #{$id} (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+42
View File
@@ -0,0 +1,42 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
api_json(['items' => array_map(fn(array $q) => [
'id' => (int) $q['id'],
'name' => $q['name'],
'isShared' => (bool) $q['is_shared'],
'mine' => (bool) $q['mine'],
'ownerName' => $q['owner_name'] ?? null,
'filters' => queue_filters_to_api($q['filters']),
], queues_for($pdo, (int) $staff['id']))]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
try {
if ($action === 'save') {
$id = queue_create($pdo, (int) $staff['id'], (string) ($body['name'] ?? ''), (array) ($body['filters'] ?? []), !empty($body['isShared']));
api_json(['id' => $id]);
}
if ($action === 'delete') {
$qid = (int) ($body['id'] ?? 0);
$stmt = $pdo->prepare('SELECT * FROM saved_queues WHERE id = ?');
$stmt->execute([$qid]);
$row = $stmt->fetch();
if (!$row) api_error(404, 'NOT_FOUND', 'Ansicht nicht gefunden.');
if (!queue_can_edit($row, $staff)) api_error(403, 'FORBIDDEN', 'Diese Ansicht gehört einem anderen Mitarbeiter.');
$pdo->prepare('DELETE FROM saved_queues WHERE id = ?')->execute([$qid]);
api_json(['ok' => true]);
}
} catch (AdminModelException $e) {
api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,53 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_permission($staff, 'view_reports');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
// Parameter: dimension, from, to, metrics (kommagetrennt), sort, desc, department/priority/agent/helpTopic/status/source,
// oder saved=<ID> für einen gespeicherten Bericht
$in = [
'dimension' => $_GET['dimension'] ?? null, 'from' => $_GET['from'] ?? null, 'to' => $_GET['to'] ?? null,
'metrics' => array_filter(explode(',', (string) ($_GET['metrics'] ?? ''))), 'sort' => $_GET['sort'] ?? 'label', 'desc' => !empty($_GET['desc']),
'filters' => array_intersect_key($_GET, report_filter_columns()),
];
$savedStmt = $pdo->prepare('SELECT r.*, s.name AS owner FROM saved_reports r LEFT JOIN staff s ON s.id = r.staff_id WHERE r.staff_id = ? OR r.is_shared = 1 ORDER BY r.name');
$savedStmt->execute([$staff['id']]);
$saved = $savedStmt->fetchAll();
if (isset($_GET['saved'])) {
foreach ($saved as $sr) if ((int) $sr['id'] === (int) $_GET['saved']) $in = json_decode($sr['config'], true) ?: [];
}
$report = report_run($pdo, $in, staff_department_ids($staff));
api_json([
'config' => $report['config'], 'columns' => $report['columns'], 'rows' => $report['rows'], 'totals' => $report['totals'],
'dimensions' => array_map(fn($k, $d) => ['key' => $k, 'label' => $d[0]], array_keys(report_dimensions($pdo)), report_dimensions($pdo)),
'metrics' => array_map(fn($k, $m) => ['key' => $k, 'label' => $m[0], 'unit' => $m[2]], array_keys(report_metrics($pdo)), report_metrics($pdo)),
'saved' => array_map(fn($sr) => ['id' => (int) $sr['id'], 'name' => $sr['name'], 'isShared' => (bool) $sr['is_shared'], 'mine' => (int) $sr['staff_id'] === (int) $staff['id'], 'ownerName' => $sr['owner']], $saved),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'save') {
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen für den Bericht angeben.');
$cfg = report_clean_config($pdo, (array) ($body['config'] ?? []));
$now = db_now();
$pdo->prepare('INSERT INTO saved_reports (staff_id, name, config, is_shared, created_at, updated_at) VALUES (?,?,?,?,?,?)')
->execute([$staff['id'], mb_substr($name, 0, 120), json_encode($cfg, JSON_UNESCAPED_UNICODE), !empty($body['isShared']) ? 1 : 0, $now, $now]);
api_json(['id' => (int) $pdo->lastInsertId()]);
}
if ($action === 'delete') {
$stmt = $pdo->prepare('SELECT * FROM saved_reports WHERE id = ?');
$stmt->execute([(int) ($body['id'] ?? 0)]);
$row = $stmt->fetch();
if (!$row) api_error(404, 'NOT_FOUND', 'Bericht nicht gefunden.');
if ((int) $row['staff_id'] !== (int) $staff['id'] && $staff['role'] !== 'admin') api_error(403, 'FORBIDDEN', 'Dieser Bericht gehört einem anderen Mitarbeiter.');
$pdo->prepare('DELETE FROM saved_reports WHERE id = ?')->execute([$row['id']]);
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+86
View File
@@ -0,0 +1,86 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_roles');
$pdo = get_db();
// Ticket-Rechte + alle Adminportal-Bereichsrechte (admin_section_permissions(), includes/functions.php) –
// zusammen ergeben sie das vollständige Set, das über eine benutzerdefinierte Rolle vergeben werden kann.
$rolePermissionKeys = array_merge([
'reply_ticket', 'close_ticket', 'delete_ticket', 'transfer_ticket', 'post_note',
'assign_ticket', 'manage_canned', 'view_reports', 'manage_kb', 'create_ticket',
], array_keys(admin_section_permissions()));
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$roles = $pdo->query(
'SELECT r.*, (SELECT COUNT(*) FROM staff s WHERE s.role_id=r.id) + (SELECT COUNT(*) FROM staff_department sd WHERE sd.role_id=r.id) AS staff_count FROM roles r ORDER BY r.name'
)->fetchAll();
api_json([
'permissionKeys' => $rolePermissionKeys,
'defaultAgentPermissions' => default_permissions('agent'),
'items' => array_map(fn(array $r) => [
'id' => (int) $r['id'],
'name' => $r['name'],
'permissions' => json_decode($r['permissions'] ?? '{}', true) ?? [],
'staffCount' => (int) $r['staff_count'],
'isActive' => (bool) ($r['is_active'] ?? 1),
'notes' => $r['notes'] ?? null,
'createdAt' => $r['created_at'] ?? null,
'updatedAt' => $r['updated_at'] ?? null,
], $roles),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'save') {
$id = (int) ($body['id'] ?? 0);
$name = trim((string) ($body['name'] ?? ''));
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
$incoming = (array) ($body['permissions'] ?? []);
$perms = [];
foreach ($rolePermissionKeys as $p) {
$perms[$p] = !empty($incoming[$p]);
}
$json = json_encode($perms);
$dup = $pdo->prepare('SELECT COUNT(*) FROM roles WHERE LOWER(name) = LOWER(?) AND id <> ?');
$dup->execute([$name, $id]);
if ((int) $dup->fetchColumn() > 0) api_error(422, 'NAME_TAKEN', 'Eine Rolle mit diesem Namen existiert bereits.');
// Fehlende Schlüssel (ältere App-Version) lassen Status/Notizen unverändert
$cur = null;
if ($id) {
$c = $pdo->prepare('SELECT is_active, notes FROM roles WHERE id = ?');
$c->execute([$id]);
$cur = $c->fetch() ?: null;
}
$isActive = array_key_exists('isActive', $body) ? (!empty($body['isActive']) ? 1 : 0) : (int) ($cur['is_active'] ?? 1);
$notes = array_key_exists('notes', $body) ? (trim((string) $body['notes']) ?: null) : ($cur['notes'] ?? null);
if ($id === 0) {
$pdo->prepare('INSERT INTO roles (name, permissions, is_active, notes, created_at, updated_at) VALUES (?,?,?,?,?,?)')->execute([$name, $json, $isActive, $notes, db_now(), db_now()]);
$newId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "Rolle erstellt: {$name} (Mobile App)");
} else {
$pdo->prepare('UPDATE roles SET name=?, permissions=?, is_active=?, notes=?, updated_at=? WHERE id=?')->execute([$name, $json, $isActive, $notes, db_now(), $id]);
$newId = $id;
audit_log($staff['id'], "Rolle aktualisiert: {$name} (Mobile App)");
}
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('UPDATE staff SET role_id = NULL WHERE role_id = ?')->execute([$id]);
$pdo->prepare('UPDATE staff_department SET role_id = NULL WHERE role_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM roles WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "Rolle gelöscht: #{$id} (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,51 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_priorities');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$plans = $pdo->query(
'SELECT p.*,
(SELECT COUNT(*) FROM departments d WHERE d.sla_plan_id = p.id) AS dept_count,
(SELECT COUNT(*) FROM help_topics h WHERE h.sla_plan_id = p.id) AS topic_count
FROM sla_plans p ORDER BY p.name'
)->fetchAll();
api_json(['items' => array_map(fn(array $p) => [
'id' => (int) $p['id'],
'name' => $p['name'],
'responseHours' => (int) $p['response_hours'],
'resolutionHours' => (int) $p['resolution_hours'],
'isActive' => (bool) $p['is_active'],
'notes' => $p['notes'],
'departmentCount' => (int) $p['dept_count'],
'helpTopicCount' => (int) $p['topic_count'],
'updatedAt' => $p['updated_at'],
], $plans)]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
$id = (int) ($body['id'] ?? 0);
try {
if ($action === 'save') {
$in = ['name' => $body['name'] ?? '', 'response_hours' => $body['responseHours'] ?? 0, 'resolution_hours' => $body['resolutionHours'] ?? 0, 'notes' => $body['notes'] ?? ''];
if (array_key_exists('isActive', $body)) $in['is_active'] = $body['isActive'];
$newId = sla_plan_save($pdo, $id, $in);
audit_log($staff['id'], ($id === 0 ? 'SLA-Plan erstellt: ' : 'SLA-Plan aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
api_json(['id' => $newId]);
}
if ($action === 'delete') {
$deleted = sla_plan_delete($pdo, $id);
audit_log($staff['id'], "SLA-Plan gelöscht: {$deleted} (Mobile App)");
api_json(['ok' => true]);
}
} catch (AdminModelException $e) {
api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,163 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$me = api_require_staff();
api_require_admin_or_permission($me, 'manage_staff');
$isFullAdmin = $me['role'] === 'admin';
$pdo = get_db();
/** Setzt den erweiterten Abteilungszugriff (inkl. Rolle pro Abteilung) neu. */
function api_save_staff_access(PDO $pdo, int $staffId, ?int $primaryDeptId, array $entries): void
{
$pdo->prepare('DELETE FROM staff_department WHERE staff_id = ?')->execute([$staffId]);
$ins = $pdo->prepare('INSERT INTO staff_department (staff_id, department_id, notify, role_id) VALUES (?,?,?,?)');
$validRoles = array_map('intval', $pdo->query('SELECT id FROM roles')->fetchAll(PDO::FETCH_COLUMN));
$seen = [];
foreach ($entries as $entry) {
$dId = (int) ($entry['departmentId'] ?? 0);
if ($dId === 0 || $dId === (int) $primaryDeptId || isset($seen[$dId])) continue;
$seen[$dId] = true;
$rid = (int) ($entry['roleId'] ?? 0);
$ins->execute([$staffId, $dId, !empty($entry['notify']) ? 1 : 0, in_array($rid, $validRoles, true) ? $rid : null]);
}
}
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$allStaff = $pdo->query(
'SELECT s.*, d.name AS department_name FROM staff s LEFT JOIN departments d ON d.id = s.department_id ORDER BY s.name'
)->fetchAll();
$departments = $pdo->query('SELECT id, name FROM departments ORDER BY name')->fetchAll();
$roles = $pdo->query('SELECT id, name FROM roles ORDER BY name')->fetchAll();
$teams = $pdo->query('SELECT id, name FROM teams ORDER BY name')->fetchAll();
$teamStmt = $pdo->prepare('SELECT team_id FROM team_members WHERE staff_id = ?');
$extraStmt = $pdo->prepare('SELECT department_id, notify, role_id FROM staff_department WHERE staff_id = ?');
$items = [];
foreach ($allStaff as $s) {
$extraStmt->execute([$s['id']]);
$extra = [];
foreach ($extraStmt->fetchAll() as $row) {
$extra[] = ['departmentId' => (int) $row['department_id'], 'notify' => (bool) $row['notify'], 'roleId' => $row['role_id'] !== null ? (int) $row['role_id'] : null];
}
$teamStmt->execute([$s['id']]);
$items[] = [
'id' => (int) $s['id'],
'name' => $s['name'],
'email' => $s['email'],
'role' => $s['role'],
'roleId' => $s['role_id'] !== null ? (int) $s['role_id'] : null,
'departmentId' => $s['department_id'] !== null ? (int) $s['department_id'] : null,
'departmentName' => $s['department_name'],
'isActive' => (bool) $s['is_active'],
'signature' => $s['signature'],
'extraDepartments' => $extra,
'teamIds' => array_map('intval', $teamStmt->fetchAll(PDO::FETCH_COLUMN)),
'createdAt' => $s['created_at'] ?? null,
];
}
api_json([
'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
'roles' => array_map(fn(array $r) => ['id' => (int) $r['id'], 'name' => $r['name']], $roles),
'teams' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['name']], $teams),
'canManageTeams' => has_permission($me, 'manage_teams'),
'items' => $items,
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'save') {
$id = (int) ($body['id'] ?? 0);
$name = trim((string) ($body['name'] ?? ''));
$email = trim((string) ($body['email'] ?? ''));
// Nur echte Administratoren dürfen die Administrator-Rolle vergeben – sonst könnte sich ein
// per Co-Admin-Recht (nur manage_staff) berechtigter Agent selbst (oder andere) zum
// Vollzugriff-Admin machen. Beim Bearbeiten durch einen Co-Admin bleibt die vorhandene
// Rolle des Ziel-Kontos unverändert, statt sie stillschweigend auf "Mitarbeiter" zu setzen.
if ($isFullAdmin) {
$role = ($body['role'] ?? '') === 'admin' ? 'admin' : 'agent';
} elseif ($id !== 0) {
$currentRoleStmt = $pdo->prepare('SELECT role FROM staff WHERE id = ?');
$currentRoleStmt->execute([$id]);
$role = $currentRoleStmt->fetchColumn() === 'admin' ? 'admin' : 'agent';
} else {
$role = 'agent';
}
$roleId = isset($body['roleId']) && $body['roleId'] !== null ? (int) $body['roleId'] : null;
$deptId = isset($body['departmentId']) && $body['departmentId'] !== null ? (int) $body['departmentId'] : null;
$isActive = !empty($body['isActive']) ? 1 : 0;
$password = (string) ($body['password'] ?? '');
$signature = sanitize_staff_html((string) ($body['signature'] ?? ''));
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) api_error(422, 'INVALID_EMAIL', 'Bitte eine gültige E-Mail-Adresse angeben.');
$dup = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE LOWER(email) = LOWER(?) AND id <> ?');
$dup->execute([$email, $id]);
if ((int) $dup->fetchColumn() > 0) {
api_error(422, 'EMAIL_TAKEN', 'Ein Mitarbeiter mit dieser E-Mail-Adresse existiert bereits.');
}
if ($id !== 0 && $id === (int) $me['id'] && !$isActive) {
api_error(422, 'CANNOT_DEACTIVATE_SELF', 'Sie können Ihr eigenes Konto nicht deaktivieren.');
}
if ($id === 0) {
$pwErr = validate_password_strength($password);
if ($pwErr) api_error(422, 'WEAK_PASSWORD', $pwErr);
$pdo->prepare('INSERT INTO staff (name, email, password_hash, role, role_id, department_id, signature, is_active, created_at) VALUES (?,?,?,?,?,?,?,?,?)')
->execute([$name, $email, password_hash($password, PASSWORD_DEFAULT), $role, $roleId, $deptId, $signature ?: null, $isActive, db_now()]);
$newId = (int) $pdo->lastInsertId();
audit_log($me['id'], "Mitarbeiter angelegt: {$name} ({$email}) (Mobile App)");
} else {
$newId = $id;
if ($password !== '') {
$pwErr = validate_password_strength($password);
if ($pwErr) api_error(422, 'WEAK_PASSWORD', $pwErr);
$pdo->prepare('UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=?, password_hash=? WHERE id=?')
->execute([$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive, password_hash($password, PASSWORD_DEFAULT), $id]);
} else {
$pdo->prepare('UPDATE staff SET name=?, email=?, role=?, role_id=?, department_id=?, signature=?, is_active=? WHERE id=?')
->execute([$name, $email, $role, $roleId, $deptId, $signature ?: null, $isActive, $id]);
}
audit_log($me['id'], "Mitarbeiter aktualisiert: {$name} ({$email}) (Mobile App)");
}
// Optional im selben Aufruf: erweiterter Zugriff und Team-Mitgliedschaften
if (array_key_exists('extraDepartments', $body)) {
api_save_staff_access($pdo, $newId, $deptId, (array) $body['extraDepartments']);
}
if (array_key_exists('teamIds', $body) && has_permission($me, 'manage_teams')) {
$pdo->prepare('DELETE FROM team_members WHERE staff_id = ?')->execute([$newId]);
$ins = $pdo->prepare('INSERT INTO team_members (team_id, staff_id) VALUES (?,?)');
foreach (array_unique(array_filter(array_map('intval', (array) $body['teamIds']))) as $tid) $ins->execute([$tid, $newId]);
}
api_json(['id' => $newId]);
}
if ($action === 'save_access') {
$id = (int) ($body['id'] ?? 0);
$stmt = $pdo->prepare('SELECT department_id FROM staff WHERE id = ?');
$stmt->execute([$id]);
$primaryDeptId = (int) $stmt->fetchColumn();
api_save_staff_access($pdo, $id, $primaryDeptId, (array) ($body['extraDepartments'] ?? []));
audit_log($me['id'], "Abteilungszugriff geändert für Mitarbeiter #{$id} (Mobile App)");
api_json(['ok' => true]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
if ($id === (int) $me['id']) {
api_error(422, 'CANNOT_DELETE_SELF', 'Sie können Ihr eigenes Konto nicht löschen.');
}
$pdo->prepare('UPDATE tickets SET assigned_staff_id = NULL WHERE assigned_staff_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM staff WHERE id = ?')->execute([$id]);
audit_log($me['id'], "Mitarbeiter gelöscht: #{$id} (Mobile App)");
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
// Aufgaben sind für alle Mitarbeiter da, nicht nur Admins.
api_require_method('GET', 'POST');
$staff = api_require_staff();
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$filter = $_GET['filter'] ?? 'my';
$where = [];
$params = [];
if ($filter === 'my') {
$where[] = 'ta.assigned_to = ?';
$params[] = $staff['id'];
} elseif ($filter === 'open') {
$where[] = "ta.status != 'done'";
} elseif ($filter === 'done') {
$where[] = "ta.status = 'done'";
}
$sql = 'SELECT ta.*, s.name AS assigned_name, s2.name AS creator_name,
t.ticket_number, t.subject AS ticket_subject
FROM tasks ta
LEFT JOIN staff s ON s.id = ta.assigned_to
LEFT JOIN staff s2 ON s2.id = ta.created_by
LEFT JOIN tickets t ON t.id = ta.ticket_id'
. ($where ? ' WHERE ' . implode(' AND ', $where) : '')
. ' ORDER BY ta.due_at IS NULL, ta.due_at ASC, ta.created_at DESC LIMIT 200';
$stmt = $pdo->prepare($sql);
$stmt->execute($params);
$tasks = $stmt->fetchAll();
$allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
api_json([
'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff),
'items' => array_map(fn(array $t) => [
'id' => (int) $t['id'],
'title' => $t['title'],
'description' => $t['description'],
'status' => $t['status'],
'assignedToId' => $t['assigned_to'] !== null ? (int) $t['assigned_to'] : null,
'assignedName' => $t['assigned_name'],
'creatorName' => $t['creator_name'],
'dueAt' => $t['due_at'],
'ticketNumber' => $t['ticket_number'],
'ticketSubject' => $t['ticket_subject'],
], $tasks),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'create') {
$title = trim((string) ($body['title'] ?? ''));
$desc = trim((string) ($body['description'] ?? ''));
$assignTo = isset($body['assignedToId']) && $body['assignedToId'] !== null ? (int) $body['assignedToId'] : null;
$dueAt = trim((string) ($body['dueAt'] ?? '')) ?: null;
$ticketNumber = trim((string) ($body['ticketNumber'] ?? ''));
if ($title === '') api_error(422, 'TITLE_REQUIRED', 'Bitte einen Titel angeben.');
$ticketId = null;
if ($ticketNumber !== '') {
$tStmt = $pdo->prepare('SELECT id FROM tickets WHERE ticket_number = ?');
$tStmt->execute([$ticketNumber]);
$ticketId = $tStmt->fetchColumn() ?: null;
if ($ticketId === null) api_error(422, 'TICKET_NOT_FOUND', 'Das verknüpfte Ticket wurde nicht gefunden.');
$ticketId = (int) $ticketId;
}
$pdo->prepare('INSERT INTO tasks (ticket_id,title,description,assigned_to,due_at,created_by,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)')
->execute([$ticketId, $title, $desc ?: null, $assignTo, $dueAt, $staff['id'], db_now(), db_now()]);
api_json(['id' => (int) $pdo->lastInsertId()]);
}
if ($action === 'update_status') {
$id = (int) ($body['id'] ?? 0);
$status = in_array($body['status'] ?? '', ['open', 'in_progress', 'done'], true) ? $body['status'] : 'open';
$pdo->prepare('UPDATE tasks SET status=?, updated_at=? WHERE id=?')->execute([$status, db_now(), $id]);
api_json(['ok' => true]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('DELETE FROM tasks WHERE id=?')->execute([$id]);
api_json(['ok' => true]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+69
View File
@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET', 'POST');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'manage_teams');
$pdo = get_db();
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$teams = $pdo->query(
'SELECT t.*, s.name AS lead_name,
(SELECT COUNT(*) FROM team_members tm WHERE tm.team_id = t.id) AS member_count
FROM teams t LEFT JOIN staff s ON s.id = t.lead_id ORDER BY t.name'
)->fetchAll();
$allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name')->fetchAll();
$memberStmt = $pdo->prepare('SELECT staff_id FROM team_members WHERE team_id = ?');
$teamsOut = [];
foreach ($teams as $t) {
$memberStmt->execute([$t['id']]);
$teamsOut[] = [
'id' => (int) $t['id'],
'name' => $t['name'],
'leadId' => $t['lead_id'] !== null ? (int) $t['lead_id'] : null,
'leadName' => $t['lead_name'],
'notes' => $t['notes'],
'isActive' => (bool) $t['is_active'],
'noAlerts' => (bool) ($t['no_alerts'] ?? 0),
'createdAt' => $t['created_at'] ?? null,
'updatedAt' => $t['updated_at'] ?? null,
'memberCount' => (int) $t['member_count'],
'memberIds' => array_map('intval', $memberStmt->fetchAll(PDO::FETCH_COLUMN)),
];
}
api_json([
'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff),
'items' => $teamsOut,
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
$id = (int) ($body['id'] ?? 0);
try {
if ($action === 'save') {
$in = ['name' => $body['name'] ?? '', 'lead_id' => $body['leadId'] ?? null, 'notes' => $body['notes'] ?? '', 'members' => $body['memberIds'] ?? []];
if (array_key_exists('isActive', $body)) $in['is_active'] = $body['isActive'];
if (array_key_exists('noAlerts', $body)) $in['no_alerts'] = $body['noAlerts'];
$newId = team_save($pdo, $id, $in);
audit_log($staff['id'], ($id === 0 ? 'Team erstellt: ' : 'Team aktualisiert: ') . trim((string) ($body['name'] ?? '')) . ' (Mobile App)');
api_json(['id' => $newId]);
}
if ($action === 'toggle') {
$pdo->prepare('UPDATE teams SET is_active = 1 - is_active, updated_at = ? WHERE id = ?')->execute([db_now(), $id]);
api_json(['ok' => true]);
}
if ($action === 'delete') {
$deleted = team_delete($pdo, $id);
audit_log($staff['id'], "Team gelöscht: {$deleted} (Mobile App)");
api_json(['ok' => true]);
}
} catch (AdminModelException $e) {
api_error($e->httpStatus(), $e->errorCode(), $e->getMessage());
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$number = trim((string) ($_GET['number'] ?? ''));
if ($number === '') {
api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
}
$pdo = get_db();
$ticket = api_find_ticket($pdo, $staff, $number);
// Generelle Ticket-Sperre (nicht Live-Chat-spezifisch, siehe ticket_locked_by_other()
// in includes/functions.php): wird von einem anderen Mitarbeiter (Web oder App) gerade
// frisch bearbeitet, wird das Ticket hier – anders als beim milderen Live-Chat-Lock
// weiter unten – gar nicht erst geladen, genau wie im Web-Backend (ticket_view.php).
if ($ticket['source'] !== 'chat') {
$lockedByName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo);
if ($lockedByName) {
api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByName\" bearbeitet und kann nicht gleichzeitig geöffnet werden.");
}
ticket_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo);
}
$threadStmt = $pdo->prepare(
'SELECT tt.*, s.name AS staff_name, u.name AS user_name
FROM ticket_threads tt
LEFT JOIN staff s ON s.id = tt.staff_id
LEFT JOIN users u ON u.id = tt.user_id
WHERE tt.ticket_id = ?
ORDER BY tt.created_at ASC, tt.id ASC'
);
$threadStmt->execute([$ticket['id']]);
$attachmentStmt = $pdo->prepare(
'SELECT id, original_name, filesize FROM ticket_attachments WHERE thread_id = ? ORDER BY id'
);
$commentStmt = $pdo->prepare(
'SELECT tc.id, tc.message, tc.created_at, s.id AS staff_id, s.name AS staff_name
FROM ticket_thread_comments tc
JOIN staff s ON s.id = tc.staff_id
WHERE tc.thread_id = ? ORDER BY tc.created_at, tc.id'
);
$threads = [];
foreach ($threadStmt->fetchAll() as $row) {
$attachmentStmt->execute([$row['id']]);
$commentStmt->execute([$row['id']]);
$threads[] = [
'id' => (int) $row['id'],
'authorType' => $row['author_type'],
'authorName' => $row['staff_name'] ?: $row['user_name'] ?: $ticket['requester_name'],
'message' => api_plain_text($row['message']),
'isInternalNote' => (bool) $row['is_internal_note'],
'createdAt' => $row['created_at'],
'attachments' => array_map(fn(array $item) => [
'id' => (int) $item['id'],
'name' => $item['original_name'],
'size' => (int) $item['filesize'],
], $attachmentStmt->fetchAll()),
'comments' => array_map(fn(array $item) => [
'id' => (int) $item['id'],
'message' => api_plain_text($item['message']),
'staff' => ['id' => (int) $item['staff_id'], 'name' => $item['staff_name']],
'createdAt' => $item['created_at'],
], $commentStmt->fetchAll()),
];
}
// Live-Chat-Sperre: wie im Web-Backend (staff/live_chat.php) "besitzt" der Mitarbeiter,
// der den Chat gerade ansieht, ihn für ein paar Minuten – verhindert, dass App und Web
// gleichzeitig unbemerkt im selben Chat antworten. Nur für Live-Chat-Tickets relevant,
// normale Tickets bleiben unangetastet. Lesen bleibt für alle möglich, nur das
// Schreiben wird in api_add_ticket_message() serverseitig blockiert.
$lockedByOtherName = null;
if ($ticket['source'] === 'chat' && $ticket['status'] !== 'closed') {
$lockedByOtherName = live_chat_locked_by_other($ticket, (int) $staff['id'], $pdo);
if (!$lockedByOtherName) {
live_chat_acquire_lock((int) $ticket['id'], (int) $staff['id'], $pdo);
}
}
$summary = api_ticket_summary($ticket);
$summary['source'] = $ticket['source'];
$summary['firstResponseDueAt'] = $ticket['first_response_due_at'];
$summary['resolutionDueAt'] = $ticket['resolution_due_at'];
$summary['lockedByOtherName'] = $lockedByOtherName;
$summary['threads'] = $threads;
// Rechte für GENAU dieses Ticket: gelten pro Abteilung (Rolle des Mitarbeiters in der Abteilung des Tickets)
$summary['permissions'] = [];
foreach (['reply_ticket', 'post_note', 'close_ticket', 'assign_ticket', 'transfer_ticket', 'delete_ticket'] as $__perm) {
$summary['permissions'][$__perm] = has_permission($staff, $__perm, (int) $ticket['department_id']);
}
api_json(['ticket' => $summary]);
@@ -0,0 +1,108 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('POST');
$staff = api_require_staff();
api_require_permission($staff, 'create_ticket');
$pdo = get_db();
$body = api_request_body();
$requesterName = trim((string) ($body['requesterName'] ?? ''));
$requesterEmail = trim((string) ($body['requesterEmail'] ?? ''));
$subject = trim((string) ($body['subject'] ?? ''));
$message = sanitize_staff_html(trim((string) ($body['message'] ?? '')));
$departmentId = (int) ($body['departmentId'] ?? 0);
$priorityId = (int) ($body['priorityId'] ?? 0);
$assignedStaffId = ($body['assignedStaffId'] ?? null) !== null ? (int) $body['assignedStaffId'] : null;
$notifyCustomer = !empty($body['notifyCustomer']);
if ($requesterName === '') {
api_error(422, 'REQUESTER_NAME_REQUIRED', 'Bitte einen Namen angeben.');
}
if ($requesterEmail !== '' && !filter_var($requesterEmail, FILTER_VALIDATE_EMAIL)) {
api_error(422, 'INVALID_EMAIL', 'Bitte eine gültige E-Mail-Adresse angeben (oder leer lassen).');
}
if ($subject === '') {
api_error(422, 'SUBJECT_REQUIRED', 'Bitte einen Betreff angeben.');
}
if (trim(strip_tags($message)) === '') {
api_error(422, 'MESSAGE_REQUIRED', 'Bitte eine Beschreibung eingeben.');
}
if ($requesterEmail !== '' && is_banned($requesterEmail)) {
api_error(403, 'REQUESTER_BANNED', 'Diese E-Mail-Adresse steht auf der Sperrliste.');
}
$departmentIds = staff_department_ids($staff);
if ($departmentIds !== null && !in_array($departmentId, $departmentIds, true)) {
api_error(403, 'DEPARTMENT_FORBIDDEN', 'Auf diese Abteilung besteht kein Zugriff.');
}
$deptStmt = $pdo->prepare('SELECT * FROM departments WHERE id = ?');
$deptStmt->execute([$departmentId]);
$department = $deptStmt->fetch();
if ($department && empty($department['is_active'])) {
api_error(422, 'DEPARTMENT_INACTIVE', 'Diese Abteilung ist deaktiviert. Bitte eine aktive Abteilung wählen.');
}
if (!$department) {
api_error(422, 'INVALID_DEPARTMENT', 'Die Abteilung ist ungültig.');
}
$prioStmt = $pdo->prepare('SELECT * FROM priorities WHERE id = ?');
$prioStmt->execute([$priorityId]);
$priority = $prioStmt->fetch();
if (!$priority) {
api_error(422, 'INVALID_PRIORITY', 'Die Priorität ist ungültig.');
}
$sla = calc_sla_dates($priority, null, (int) $departmentId, null);
$now = db_now();
$ticketNumber = generate_ticket_number($pdo);
$userId = null;
if ($requesterEmail !== '') {
$userStmt = $pdo->prepare('SELECT id FROM users WHERE LOWER(email) = LOWER(?)');
$userStmt->execute([$requesterEmail]);
$userId = $userStmt->fetchColumn() ?: null;
}
$pdo->beginTransaction();
try {
$pdo->prepare(
'INSERT INTO tickets (ticket_number, subject, user_id, requester_name, requester_email, department_id,
priority_id, status, source, assigned_staff_id,
first_response_due_at, resolution_due_at, created_at, updated_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
)->execute([
$ticketNumber, $subject, $userId, $requesterName, $requesterEmail, $departmentId,
$priorityId, 'open', 'staff', $assignedStaffId,
$sla['first_response_due_at'], $sla['resolution_due_at'], $now, $now,
]);
$ticketId = (int) $pdo->lastInsertId();
$pdo->prepare(
'INSERT INTO ticket_threads (ticket_id, author_type, staff_id, message, is_internal_note, created_at)
VALUES (?,?,?,?,0,?)'
)->execute([$ticketId, 'staff', $staff['id'], $message, $now]);
log_activity($pdo, $ticketId, (int) $staff['id'], 'Ticket durch Mitarbeiter erstellt (Mobile App)');
$pdo->commit();
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
if ($requesterEmail !== '' && $notifyCustomer) {
notify_customer_ticket_created([
'ticket_number' => $ticketNumber,
'subject' => $subject,
'requester_name' => $requesterName,
'requester_email' => $requesterEmail,
'department_id' => $departmentId,
'department_name' => $department['name'],
]);
}
$ticket = api_find_ticket($pdo, $staff, $ticketNumber);
webhook_dispatch($pdo, 'ticket.created', (int) $ticket['id']);
api_json(['ticket' => api_ticket_summary($ticket)], 201);
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('POST');
$staff = api_require_staff();
$body = api_request_body();
$number = trim((string) ($body['number'] ?? ''));
$type = (string) ($body['type'] ?? '');
$message = (string) ($body['message'] ?? '');
$setStatus = isset($body['setStatus']) ? (string) $body['setStatus'] : null;
if ($number === '') {
api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
}
$pdo = get_db();
$ticket = api_find_ticket($pdo, $staff, $number);
$threadId = api_add_ticket_message($pdo, $staff, $ticket, $type, $message, $setStatus);
api_json(['threadId' => $threadId], 201);
@@ -0,0 +1,176 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
/**
* api/staff/v1/ticket_rules.php
*
* Mobile-Pendant zu staff/ticket_rules.php – gleiche Tabellen (ticket_rules,
* ticket_rule_conditions, ticket_rule_actions), nur als JSON-API statt HTML-Formular.
* Nur für echte Admins (wie im Web-Backend, kein granulares Recht dafür).
*/
api_require_method('GET', 'POST');
$staff = api_require_staff();
if ($staff['role'] !== 'admin') {
api_error(403, 'ADMIN_REQUIRED', 'Nur Administratoren können Ticket-Regeln verwalten.');
}
$pdo = get_db();
const RULE_FIELDS = ['subject' => 'Betreff', 'message' => 'Nachricht', 'requester_email' => 'Absender-E-Mail', 'requester_name' => 'Absender-Name', 'source' => 'Kanal (web/email)'];
const RULE_OPERATORS = ['contains' => 'enthält', 'not_contains' => 'enthält nicht', 'equals' => 'ist genau', 'starts_with' => 'beginnt mit', 'ends_with' => 'endet mit'];
const RULE_ACTIONS = ['set_department' => 'Abteilung setzen', 'set_priority' => 'Priorität setzen', 'set_help_topic' => 'Hilfe-Thema setzen', 'assign_staff' => 'Mitarbeiter zuweisen', 'add_tag' => 'Tag hinzufügen', 'auto_reply' => 'Auto-Antwort senden', 'reject' => 'Ticket verwerfen (z.B. Spam)'];
if ($_SERVER['REQUEST_METHOD'] === 'GET') {
$departments = departments_for_select($pdo, array_map('intval', $pdo->query("SELECT action_value FROM ticket_rule_actions WHERE action_type = 'set_department'")->fetchAll(PDO::FETCH_COLUMN)));
$priorities = $pdo->query('SELECT id, name FROM priorities ORDER BY sort_order')->fetchAll();
$helpTopics = $pdo->query('SELECT id, name FROM help_topics ORDER BY name')->fetchAll();
$staffList = $pdo->query("SELECT id, name FROM staff WHERE is_active = 1 ORDER BY name")->fetchAll();
$cannedResponses = $pdo->query('SELECT id, title FROM canned_responses ORDER BY title')->fetchAll();
$deptById = array_column($departments, 'name', 'id');
$prioById = array_column($priorities, 'name', 'id');
$topicById = array_column($helpTopics, 'name', 'id');
$staffById = array_column($staffList, 'name', 'id');
$cannedById = array_column($cannedResponses, 'title', 'id');
$rules = $pdo->query('SELECT * FROM ticket_rules ORDER BY sort_order, id')->fetchAll();
$condStmt = $pdo->prepare('SELECT field, operator, value FROM ticket_rule_conditions WHERE rule_id = ? ORDER BY id');
$actStmt = $pdo->prepare('SELECT action_type, action_value FROM ticket_rule_actions WHERE rule_id = ? ORDER BY id');
$rulesOut = [];
foreach ($rules as $r) {
$condStmt->execute([$r['id']]);
$actStmt->execute([$r['id']]);
$actionsOut = [];
foreach ($actStmt->fetchAll() as $a) {
$type = $a['action_type'];
$value = $a['action_value'];
$label = match ($type) {
'set_department' => $deptById[$value] ?? null,
'set_priority' => $prioById[$value] ?? null,
'set_help_topic' => $topicById[$value] ?? null,
'assign_staff' => $staffById[$value] ?? null,
'auto_reply' => $cannedById[$value] ?? null,
'add_tag' => $value,
'reject' => null,
default => null,
};
$actionsOut[] = ['type' => $type, 'value' => $value, 'valueLabel' => $label];
}
$rulesOut[] = [
'id' => (int) $r['id'],
'name' => $r['name'],
'matchType' => $r['match_type'],
'isActive' => (bool) $r['is_active'],
'sortOrder' => (int) $r['sort_order'],
'conditions' => $condStmt->fetchAll(),
'actions' => $actionsOut,
];
}
api_json([
'fieldOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_FIELDS), RULE_FIELDS),
'operatorOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_OPERATORS), RULE_OPERATORS),
'actionOptions' => array_map(fn($v, $l) => ['value' => $v, 'label' => $l], array_keys(RULE_ACTIONS), RULE_ACTIONS),
'departments' => array_map(fn(array $d) => ['id' => (int) $d['id'], 'name' => $d['name']], $departments),
'priorities' => array_map(fn(array $p) => ['id' => (int) $p['id'], 'name' => $p['name']], $priorities),
'helpTopics' => array_map(fn(array $t) => ['id' => (int) $t['id'], 'name' => $t['name']], $helpTopics),
'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $staffList),
'cannedResponses' => array_map(fn(array $c) => ['id' => (int) $c['id'], 'name' => $c['title']], $cannedResponses),
'rules' => $rulesOut,
'autoCloseEnabled' => get_setting('auto_close_enabled', '0') === '1',
'autoCloseDays' => (int) get_setting('auto_close_days', '7'),
]);
}
$body = api_request_body();
$action = $body['action'] ?? '';
if ($action === 'toggle') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('UPDATE ticket_rules SET is_active = 1 - is_active WHERE id = ?')->execute([$id]);
api_json(['ok' => true]);
}
if ($action === 'save_auto_close') {
$enabled = !empty($body['enabled']) ? '1' : '0';
$days = max(1, min(90, (int) ($body['days'] ?? 7)));
set_setting('auto_close_enabled', $enabled);
set_setting('auto_close_days', (string) $days);
audit_log($staff['id'], 'Automatisches Schließen geändert (Mobile App)');
api_json(['ok' => true]);
}
if ($action === 'delete') {
$id = (int) ($body['id'] ?? 0);
$pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM ticket_rules WHERE id = ?')->execute([$id]);
audit_log($staff['id'], "Ticket-Regel gelöscht: #$id (Mobile App)");
api_json(['ok' => true]);
}
if ($action === 'save') {
$id = (int) ($body['id'] ?? 0);
$name = trim((string) ($body['name'] ?? ''));
$matchType = ($body['matchType'] ?? 'all') === 'any' ? 'any' : 'all';
$isActive = !empty($body['isActive']) ? 1 : 0;
$sortOrder = (int) ($body['sortOrder'] ?? 0);
$rawConditions = is_array($body['conditions'] ?? null) ? $body['conditions'] : [];
$rawActions = is_array($body['actions'] ?? null) ? $body['actions'] : [];
if ($name === '') api_error(422, 'NAME_REQUIRED', 'Bitte einen Namen angeben.');
$conditions = [];
foreach ($rawConditions as $c) {
$field = (string) ($c['field'] ?? '');
$operator = (string) ($c['operator'] ?? '');
$value = trim((string) ($c['value'] ?? ''));
if ($value === '' || !isset(RULE_FIELDS[$field]) || !isset(RULE_OPERATORS[$operator])) continue;
$conditions[] = ['field' => $field, 'operator' => $operator, 'value' => $value];
}
if (!$conditions) api_error(422, 'CONDITIONS_REQUIRED', 'Bitte mindestens eine Bedingung mit einem Wert angeben.');
$actionsToSave = [];
foreach ($rawActions as $a) {
$type = (string) ($a['type'] ?? '');
if (!isset(RULE_ACTIONS[$type])) continue;
$value = $type === 'reject' ? '1' : trim((string) ($a['value'] ?? ''));
if ($type !== 'reject' && $value === '') continue;
$actionsToSave[] = ['type' => $type, 'value' => $value];
}
if (!$actionsToSave) api_error(422, 'ACTIONS_REQUIRED', 'Bitte mindestens eine Aktion festlegen.');
if ($id) {
$existsStmt = $pdo->prepare('SELECT COUNT(*) FROM ticket_rules WHERE id = ?');
$existsStmt->execute([$id]);
if (!(bool) $existsStmt->fetchColumn()) api_error(404, 'RULE_NOT_FOUND', 'Die Regel wurde nicht gefunden.');
$pdo->prepare('UPDATE ticket_rules SET name=?, match_type=?, is_active=?, sort_order=? WHERE id=?')
->execute([$name, $matchType, $isActive, $sortOrder, $id]);
$pdo->prepare('DELETE FROM ticket_rule_conditions WHERE rule_id = ?')->execute([$id]);
$pdo->prepare('DELETE FROM ticket_rule_actions WHERE rule_id = ?')->execute([$id]);
audit_log($staff['id'], "Ticket-Regel aktualisiert: $name (Mobile App)");
$ruleId = $id;
} else {
$pdo->prepare('INSERT INTO ticket_rules (name, match_type, is_active, sort_order, created_at) VALUES (?,?,?,?,?)')
->execute([$name, $matchType, $isActive, $sortOrder, db_now()]);
$ruleId = (int) $pdo->lastInsertId();
audit_log($staff['id'], "Ticket-Regel erstellt: $name (Mobile App)");
}
$condStmt = $pdo->prepare('INSERT INTO ticket_rule_conditions (rule_id, field, operator, value) VALUES (?,?,?,?)');
foreach ($conditions as $c) {
$condStmt->execute([$ruleId, $c['field'], $c['operator'], $c['value']]);
}
$actStmt = $pdo->prepare('INSERT INTO ticket_rule_actions (rule_id, action_type, action_value) VALUES (?,?,?)');
foreach ($actionsToSave as $a) {
$actStmt->execute([$ruleId, $a['type'], $a['value']]);
}
api_json(['id' => $ruleId]);
}
api_error(422, 'INVALID_ACTION', 'Unbekannte Aktion.');
@@ -0,0 +1,19 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
require_once __DIR__ . '/../../../includes/imap_spam.php';
api_require_method('POST');
$staff = api_require_staff();
$body = api_request_body();
$number = trim((string)($body['number'] ?? ''));
$action = ($body['action'] ?? '') === 'restore' ? 'restore' : 'mark';
if ($number === '') api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
$pdo = get_db();
$ticket = api_find_ticket($pdo, $staff, $number);
api_require_permission($staff, 'close_ticket', (int) $ticket['department_id']);
if ($ticket['source'] !== 'email') api_error(422, 'EMAIL_TICKET_REQUIRED', 'Nur E-Mail-Tickets können als Spam geändert werden.');
$result = set_ticket_spam($pdo, $ticket, (int)$staff['id'], $action === 'restore');
if ($result['error']) api_error(422, 'SPAM_MOVE_FAILED', $result['error']);
$updated = api_find_ticket($pdo, $staff, $number);
api_json(['ticket' => api_ticket_summary($updated), 'moved' => $result['moved'], 'failed' => $result['failed']]);
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('PATCH', 'POST');
$staff = api_require_staff();
$body = api_request_body();
$number = trim((string) ($body['number'] ?? ''));
if ($number === '') {
api_error(422, 'TICKET_NUMBER_REQUIRED', 'Eine Ticketnummer ist erforderlich.');
}
$pdo = get_db();
$ticket = api_find_ticket($pdo, $staff, $number);
if ($ticket['source'] !== 'chat') {
$lockedByName = ticket_locked_by_other($ticket, (int) $staff['id'], $pdo);
if ($lockedByName) {
api_error(409, 'TICKET_LOCKED', "Dieses Ticket wird gerade von \"$lockedByName\" bearbeitet.");
}
}
$updates = [];
$params = [];
$now = db_now();
$chatJustClosed = false;
if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) {
$status = (string) $body['status'];
if (!in_array($status, MOBILE_API_STATUSES, true)) {
api_error(422, 'INVALID_STATUS', 'Der Status ist ungültig.');
}
api_require_permission($staff, in_array($status, ['resolved', 'closed'], true) ? 'close_ticket' : 'reply_ticket', (int) $ticket['department_id']);
$updates[] = 'status = ?';
$params[] = $status;
if ($status === 'resolved' && empty($ticket['resolved_at'])) {
$updates[] = 'resolved_at = ?';
$params[] = $now;
}
if ($status === 'closed' && empty($ticket['closed_at'])) {
$updates[] = 'closed_at = ?';
$params[] = $now;
}
// Live-Chat, der über die App geschlossen wird, bekommt denselben Chatverlauf-Mail-
// Versand wie über staff/live_chat.php (Aktion "end") – siehe unten nach dem UPDATE.
$chatJustClosed = $status === 'closed' && $ticket['source'] === 'chat';
}
if (array_key_exists('assignedStaffId', $body)) {
$assignedId = $body['assignedStaffId'] === null ? null : filter_var($body['assignedStaffId'], FILTER_VALIDATE_INT);
if ($assignedId === false) {
api_error(422, 'INVALID_ASSIGNEE', 'Die Mitarbeiter-ID ist ungültig.');
}
if ($assignedId !== null) {
$agentStmt = $pdo->prepare('SELECT COUNT(*) FROM staff WHERE id = ? AND is_active = 1');
$agentStmt->execute([$assignedId]);
if (!(bool) $agentStmt->fetchColumn()) {
api_error(422, 'INVALID_ASSIGNEE', 'Der ausgewählte Mitarbeiter ist nicht verfügbar.');
}
}
$currentId = $ticket['assigned_staff_id'] === null ? null : (int) $ticket['assigned_staff_id'];
if ($assignedId !== $currentId) {
api_require_permission($staff, 'assign_ticket', (int) $ticket['department_id']);
$updates[] = 'assigned_staff_id = ?';
$params[] = $assignedId;
}
}
if (!$updates) {
api_json(['ticket' => api_ticket_summary($ticket)]);
}
$updates[] = 'updated_at = ?';
$params[] = $now;
$params[] = $ticket['id'];
$pdo->prepare('UPDATE tickets SET ' . implode(', ', $updates) . ' WHERE id = ?')->execute($params);
if (array_key_exists('status', $body) && $body['status'] !== $ticket['status']) {
[$label] = status_label((string) $body['status']);
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Status geändert: ' . $label . ' (Mobile App)');
}
if (array_key_exists('assignedStaffId', $body)) {
log_activity($pdo, (int) $ticket['id'], (int) $staff['id'], 'Zuweisung geändert (Mobile App)');
}
if ($chatJustClosed && $ticket['requester_email']) {
try {
$transcriptStmt = $pdo->prepare(
'SELECT tt.author_type AS authorType, tt.message, tt.created_at AS createdAt, s.name AS staff_name
FROM ticket_threads tt LEFT JOIN staff s ON s.id = tt.staff_id
WHERE tt.ticket_id = ? AND tt.is_internal_note = 0 ORDER BY tt.created_at ASC, tt.id ASC'
);
$transcriptStmt->execute([$ticket['id']]);
$transcriptMessages = array_map(fn(array $m) => [
'authorType' => $m['authorType'],
'authorName' => $m['staff_name'],
'message' => $m['message'],
'createdAt' => $m['createdAt'],
], $transcriptStmt->fetchAll());
notify_customer_chat_transcript($ticket, $transcriptMessages);
} catch (Throwable $e) {
error_log('ticket_update (Mobile App) transcript mail: ' . $e->getMessage());
}
}
$updated = api_find_ticket($pdo, $staff, $number);
if ($updated['status'] !== $ticket['status']) webhook_dispatch($pdo, 'ticket.status_changed', (int) $ticket['id'], ['previousStatus' => $ticket['status']]);
if ((int) ($updated['assigned_staff_id'] ?? 0) !== (int) ($ticket['assigned_staff_id'] ?? 0)) webhook_dispatch($pdo, 'ticket.assigned', (int) $ticket['id']);
api_json(['ticket' => api_ticket_summary($updated)]);
+111
View File
@@ -0,0 +1,111 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
$pdo = get_db();
$where = [];
$params = [];
$departmentIds = staff_department_ids($staff);
[$departmentFilter, $departmentParams] = dept_filter_sql($departmentIds, 't.department_id');
if ($departmentFilter !== '') {
$where[] = substr($departmentFilter, 5);
array_push($params, ...$departmentParams);
}
$statuses = array_values(array_filter(
array_map('trim', explode(',', (string) ($_GET['status'] ?? 'open,pending,answered,on_hold'))),
fn(string $status) => in_array($status, MOBILE_API_STATUSES, true)
));
if ($statuses) {
$where[] = 't.status IN (' . implode(',', array_fill(0, count($statuses), '?')) . ')';
array_push($params, ...$statuses);
}
$department = filter_var($_GET['departmentId'] ?? null, FILTER_VALIDATE_INT);
if ($department !== false && $department !== null) {
if ($departmentIds !== null && !in_array((int) $department, $departmentIds, true)) {
api_error(403, 'DEPARTMENT_FORBIDDEN', 'Auf diese Abteilung besteht kein Zugriff.');
}
$where[] = 't.department_id = ?';
$params[] = (int) $department;
}
$assigned = trim((string) ($_GET['assigned'] ?? ''));
if ($assigned === 'me') {
$where[] = 't.assigned_staff_id = ?';
$params[] = $staff['id'];
} elseif ($assigned === 'unassigned') {
$where[] = 't.assigned_staff_id IS NULL';
} elseif ($assigned !== '' && ctype_digit($assigned)) {
$where[] = 't.assigned_staff_id = ?';
$params[] = (int) $assigned;
}
$priority = filter_var($_GET['priorityId'] ?? null, FILTER_VALIDATE_INT);
if ($priority !== false && $priority !== null) {
$where[] = 't.priority_id = ?';
$params[] = (int) $priority;
}
$tag = filter_var($_GET['tagId'] ?? null, FILTER_VALIDATE_INT);
if ($tag !== false && $tag !== null) {
$where[] = 't.id IN (SELECT ticket_id FROM ticket_tags WHERE tag_id = ?)';
$params[] = (int) $tag;
}
// SLA-Status: siehe staff/tickets.php für dieselbe Logik (Web und App teilen die
// 4-Stunden-Risiko-Schwelle, damit beide Oberflächen dasselbe "bald fällig" meinen).
$sla = trim((string) ($_GET['sla'] ?? ''));
if ($sla !== '') {
$now = db_now();
$riskThreshold = date('Y-m-d H:i:s', time() + 4 * 3600);
if ($sla === 'overdue') {
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
$params[] = $now;
} elseif ($sla === 'at_risk') {
$where[] = "t.resolution_due_at IS NOT NULL AND t.resolution_due_at >= ? AND t.resolution_due_at < ? AND t.status NOT IN ('resolved','closed')";
array_push($params, $now, $riskThreshold);
} elseif ($sla === 'on_time') {
$where[] = "(t.resolution_due_at IS NULL OR t.resolution_due_at >= ?) AND t.status NOT IN ('resolved','closed')";
$params[] = $riskThreshold;
}
}
$search = mb_substr(trim((string) ($_GET['q'] ?? '')), 0, 100);
if ($search !== '') {
$like = '%' . $search . '%';
$where[] = '(t.subject LIKE ? OR t.ticket_number LIKE ? OR t.requester_email LIKE ?
OR t.requester_name LIKE ? OR t.id IN (SELECT ticket_id FROM ticket_threads WHERE message LIKE ?))';
array_push($params, $like, $like, $like, $like, $like);
}
$page = max(1, (int) ($_GET['page'] ?? 1));
$perPage = max(10, min(100, (int) ($_GET['perPage'] ?? 30)));
$offset = ($page - 1) * $perPage;
$from = ' FROM tickets t
JOIN priorities p ON p.id = t.priority_id
JOIN departments d ON d.id = t.department_id
LEFT JOIN staff s ON s.id = t.assigned_staff_id';
$whereSql = $where ? ' WHERE ' . implode(' AND ', $where) : '';
$countStmt = $pdo->prepare('SELECT COUNT(*)' . $from . $whereSql);
$countStmt->execute($params);
$total = (int) $countStmt->fetchColumn();
$stmt = $pdo->prepare(
'SELECT t.*, p.name AS priority_name, p.color AS priority_color,
d.name AS department_name, s.name AS staff_name'
. $from . $whereSql
. ' ORDER BY t.updated_at DESC, t.id DESC LIMIT ' . $perPage . ' OFFSET ' . $offset
);
$stmt->execute($params);
api_json([
'items' => array_map('api_ticket_summary', $stmt->fetchAll()),
'page' => $page,
'perPage' => $perPage,
'total' => $total,
'hasMore' => ($offset + $perPage) < $total,
]);
@@ -0,0 +1,55 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
api_require_method('GET');
$staff = api_require_staff();
api_require_admin_or_permission($staff, 'view_time_entries');
$pdo = get_db();
$filterStaffId = trim((string) ($_GET['staffId'] ?? ''));
$filterFrom = trim((string) ($_GET['from'] ?? '')) ?: date('Y-m-01');
$filterTo = trim((string) ($_GET['to'] ?? '')) ?: date('Y-m-d');
$allStaff = $pdo->query('SELECT id, name FROM staff WHERE is_active=1 ORDER BY name')->fetchAll();
$where = ['te.logged_at BETWEEN ? AND ?'];
$params = [$filterFrom . ' 00:00:00', $filterTo . ' 23:59:59'];
if ($filterStaffId !== '') {
$where[] = 'te.staff_id = ?';
$params[] = (int) $filterStaffId;
}
$stmt = $pdo->prepare(
"SELECT te.*, s.name AS staff_name, t.ticket_number, t.subject
FROM ticket_time_entries te
JOIN staff s ON s.id = te.staff_id
JOIN tickets t ON t.id = te.ticket_id
WHERE " . implode(' AND ', $where) . "
ORDER BY te.logged_at DESC
LIMIT 500"
);
$stmt->execute($params);
$entries = $stmt->fetchAll();
$byStaff = [];
foreach ($entries as $e) {
$byStaff[$e['staff_name']] = ($byStaff[$e['staff_name']] ?? 0) + (int) $e['minutes'];
}
$totalMinutes = array_sum(array_column($entries, 'minutes'));
api_json([
'staffOptions' => array_map(fn(array $s) => ['id' => (int) $s['id'], 'name' => $s['name']], $allStaff),
'entries' => array_map(fn(array $e) => [
'id' => (int) $e['id'],
'staffName' => $e['staff_name'],
'ticketNumber' => $e['ticket_number'],
'subject' => $e['subject'],
'minutes' => (int) $e['minutes'],
'note' => $e['note'],
'loggedAt' => $e['logged_at'],
], $entries),
'totalsByStaff' => array_map(fn($name, $mins) => ['staffName' => $name, 'minutes' => $mins], array_keys($byStaff), array_values($byStaff)),
'totalMinutes' => $totalMinutes,
]);
@@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/common.php';
// Für Signatur-Bilder (Mitarbeiter/Abteilung). Jeder angemeldete Mitarbeiter darf
// eigene Bilder hochladen – die Rechteprüfung für die eigentliche Signatur passiert
// beim Speichern (staff_manage.php / departments.php), nicht hier.
api_require_method('POST');
$staff = api_require_staff();
if (empty($_FILES['image'])) {
api_error(422, 'FILE_REQUIRED', 'Bitte eine Bilddatei auswählen.');
}
$file = $_FILES['image'];
// Bewusst auf die vom Server ohnehin erlaubten Bild-Endungen beschränkt (siehe config.php /
// Einstellungen → Uploads), damit handle_ticket_upload() den Upload nicht widersprüchlich ablehnt.
$allowedImageExts = array_values(array_intersect(get_allowed_extensions(), ['png', 'jpg', 'jpeg', 'gif']));
$ext = strtolower(pathinfo($file['name'] ?? '', PATHINFO_EXTENSION));
if ($file['error'] !== UPLOAD_ERR_OK) {
api_error(422, 'UPLOAD_FAILED', 'Der Upload ist fehlgeschlagen.');
}
if (!in_array($ext, $allowedImageExts, true)) {
api_error(422, 'INVALID_TYPE', 'Nur Bilder erlaubt (' . implode(', ', array_map('strtoupper', $allowedImageExts)) . ').');
}
if ($file['size'] > 5 * 1024 * 1024) {
api_error(422, 'FILE_TOO_LARGE', 'Die Datei ist zu groß (max. 5 MB).');
}
$result = handle_ticket_upload($file);
if (isset($result['error'])) {
api_error(422, 'UPLOAD_FAILED', $result['error']);
}
audit_log($staff['id'], "Bild hochgeladen: {$result['stored_name']} (Mobile App)");
api_json([
'url' => base_url() . '/uploads/' . $result['stored_name'],
]);