Upload via GUI (258 Dateien)
This commit is contained in:
@@ -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]);
|
||||
@@ -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]);
|
||||
@@ -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.');
|
||||
@@ -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]);
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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),
|
||||
]);
|
||||
@@ -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]);
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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.');
|
||||
@@ -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)]);
|
||||
@@ -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'],
|
||||
]);
|
||||
Reference in New Issue
Block a user