pdo = $pdo; } public function renderTemplate(string $template, array $vars): string { foreach ($vars as $key => $value) { $template = preg_replace( '/{{\s*' . preg_quote($key, '/') . '\s*}}/', (string)$value, $template ); } return $template; } public function getDataByID($id, $column){ $stmt = $this->pdo->prepare("SELECT * FROM users WHERE id = :id"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row[$column] ?? null; } public function getTerminDataById($id, $column){ $stmt = $this->pdo->prepare("SELECT * FROM termine WHERE id = :id"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row[$column] ?? null; } public function getSettingsData($column){ $stmt = $this->pdo->prepare("SELECT * FROM settings_calendar WHERE id = 1"); $stmt->execute(); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row[$column] ?? null; } public function timestampToDate($timestamp){ return date('d.m.Y H:i', $timestamp); } /* ====================================== KALENDER HASH BERECHNEN (zentral) ====================================== */ private function buildCalendarHashFromTermin(array $t): string { $data = [ 'start' => (int)$t['startzeit'], 'end' => (int)$t['endzeit'], 'title' => trim((string)$t['location']), 'desc' => trim((string)$t['beschreibung']), ]; return hash('sha256', json_encode($data)); } /* ====================================== KALENDER SYNC STATUS SPEICHERN ====================================== */ private function storeCalendarSyncState(int $terminId, array $terminRow): void { $hash = $this->buildCalendarHashFromTermin($terminRow); $eventId = "termin-$terminId@anjuma"; $stmt = $this->pdo->prepare(" UPDATE termine SET calendar_event_id = :eid, calendar_hash = :hash, calendar_synced_at = :ts WHERE id = :id "); $stmt->execute([ ':eid' => $eventId, ':hash' => $hash, ':ts' => time(), ':id' => $terminId ]); } public function getTerminLabel($id) { $sql = "SELECT status FROM termine WHERE id = :id"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':id' => $id]); $status = $stmt->fetchColumn(); return match ((int)$status) { 0 => "status-open", // Blau 1 => "status-ready", // Gelb 2 => "status-done", // Grün 3 => "status-cancel", // Rot default => "", }; } public function getOnlineStatusLabel($id): string { $stmt = $this->pdo->prepare("SELECT online FROM users WHERE id = :id"); $stmt->execute([':id' => $id]); $online = $stmt->fetchColumn(); if ($online === false) { return "badge-secondary"; } return ((int)$online === 0) ? "badge-danger" : "badge-success"; } public function getTodoStatusLabel($id): string { $stmt = $this->pdo->prepare("SELECT status FROM todo WHERE id = :id"); $stmt->execute([':id' => $id]); $status = $stmt->fetchColumn(); return match ((int)$status) { 0 => "danger", 1 => "warning", 2 => "success", default => "secondary", }; } private function getLastEditorInfo($terminId) { $sql = $this->pdo->prepare(" SELECT user_id, timestamp FROM changes WHERE termin_id = :id ORDER BY timestamp DESC LIMIT 1 "); $sql->execute([':id' => $terminId]); $row = $sql->fetch(PDO::FETCH_ASSOC); if (!$row) { return [ 'name' => '—', 'date' => '—' ]; } return [ 'name' => $this->getDataByID($row['user_id'], "name"), 'date' => date("d.m.Y H:i", $row['timestamp']) ]; } private function caldavPut($url, $ics, $isUpdate = false) { $ch = curl_init($url); curl_setopt($ch, CURLOPT_USERPWD, $this->icloudUser . ":" . $this->icloudPass); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); curl_setopt($ch, CURLOPT_HTTPAUTH, CURLAUTH_BASIC); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "PUT"); curl_setopt($ch, CURLOPT_HTTPHEADER, $isUpdate ? [ "Content-Type: text/calendar" // KEIN If-None-Match bei Updates! ] : [ "Content-Type: text/calendar", "If-None-Match: *" // nur bei neuen Events ]); curl_setopt($ch, CURLOPT_POSTFIELDS, $ics); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); // 🔍 DEBUG VOR dem return $this->debugLog("PUT to $url | status=$status | error=$err | isUpdate=" . ($isUpdate ? "1" : "0")); if ($status !== 201 && $status !== 204) { $this->debugLog("Response body: " . substr((string)$response, 0, 500)); } return $status === 201 || $status === 204; } public function createCalendarEvent($id, $startTs, $endTs, $location, $beschreibung) { $uid = "termin-$id@anjuma"; $tz = 'Europe/Berlin'; $dtstamp = gmdate('Ymd\THis\Z'); // Sicherheits-Fallback, falls endTs 0 ist if (empty($endTs) || $endTs <= 0) { $endTs = $startTs + 2 * 3600; // +2 Stunden } // Start/Ende in lokaler Zeit $dtstartStr = (new DateTime("@$startTs")) ->setTimezone(new DateTimeZone($tz)) ->format('Ymd\THis'); $dtendStr = (new DateTime("@$endTs")) ->setTimezone(new DateTimeZone($tz)) ->format('Ymd\THis'); $location = $this->icsEscape($location); $beschreibung = $this->icsEscape($beschreibung); // Status-Name bestimmen $statusRaw = $this->getTerminDataById($id, "status"); $statusName = [ 0 => "Offen", 1 => "Bereit", 2 => "Erledigt", 3 => "Storniert" ][$statusRaw] ?? "Unbekannt"; // Ersteller-Name holen $creatorId = $this->getTerminDataById($id, "user_id"); $creatorName = $this->getDataByID($creatorId, "name"); // Letzten Bearbeiter holen $editInfo = $this->getLastEditorInfo($id); // Gage holen $gage = $this->getTerminDataById($id, "gage"); $vars = [ 'id' => $id, 'status' => $statusName, 'locationtype' => $this->getTerminDataById($id, "locationtype"), 'location' => $this->getTerminDataById($id, "location"), 'startzeit' => date('d.m.Y H:i', $this->getTerminDataById($id, "startzeit")), 'endzeit' => date('H:i', $this->getTerminDataById($id, "endzeit")), 'gage' => $this->getTerminDataById($id, "gage"), 'desc' => $this->getTerminDataById($id, "beschreibung"), 'creator' => $creatorName, 'last_editor' => $editInfo['name'], 'last_edited' => $editInfo['date'] ]; $summary = $this->renderTemplate($this->getSettingsData("title_template"), $vars); $beschreibungFull = $this->renderTemplate($this->getSettingsData("description_template"), $vars); $beschreibungFull = $this->icsEscape($beschreibungFull); $ics = "BEGIN:VCALENDAR\r\n" . "VERSION:2.0\r\n" . "PRODID:-//ANJUMA//DE\r\n" . "CALSCALE:GREGORIAN\r\n" . "BEGIN:VEVENT\r\n" . "UID:$uid\r\n" . "DTSTAMP:$dtstamp\r\n" . "DTSTART;TZID=$tz:$dtstartStr\r\n" . "DTEND;TZID=$tz:$dtendStr\r\n" . "SUMMARY:$summary\r\n" . "LOCATION:$location\r\n" . "DESCRIPTION:$beschreibungFull\r\n" . "URL:https://dashboard.anjuma-crew.de/termin_show.php?id=$id\r\n" . // Erinnerung 1 Tag vorher "BEGIN:VALARM\r\n" . "TRIGGER:-P1D\r\n" . "ACTION:DISPLAY\r\n" . "DESCRIPTION:Erinnerung\r\n" . "END:VALARM\r\n" . "END:VEVENT\r\n" . "END:VCALENDAR\r\n"; $url = $this->icloudCalendarUrl . "termin-$id.ics"; // Create = isUpdate=false $success = $this->caldavPut($url, $ics, false); if ($success) { // Termin neu laden (für Hash) $stmt = $this->pdo->prepare("SELECT * FROM termine WHERE id = :id"); $stmt->execute([':id' => $id]); $termin = $stmt->fetch(PDO::FETCH_ASSOC); if ($termin) { $this->storeCalendarSyncState($id, $termin); } } return $success; } public function updateCalendarEvent($id) { $stmt = $this->pdo->prepare("SELECT * FROM termine WHERE id = :id"); $stmt->execute([':id' => $id]); $t = $stmt->fetch(PDO::FETCH_ASSOC); if (!$t) { $this->debugLog("updateCalendarEvent: Termin $id nicht gefunden"); return false; } $startTs = (int)$t["startzeit"]; $endTs = (int)$t["endzeit"]; $location = $t["location"] ?? ""; $beschreibung = $t["beschreibung"] ?? ""; $uid = "termin-$id@anjuma"; $tz = 'Europe/Berlin'; $dtstamp = gmdate('Ymd\THis\Z'); if (empty($endTs) || $endTs <= 0) { $endTs = $startTs + 2 * 3600; } $dtstartStr = (new DateTime("@$startTs")) ->setTimezone(new DateTimeZone($tz)) ->format('Ymd\THis'); $dtendStr = (new DateTime("@$endTs")) ->setTimezone(new DateTimeZone($tz)) ->format('Ymd\THis'); $locationEsc = $this->icsEscape($location); $beschreibungEsc = $this->icsEscape($beschreibung); $summary = "ANJUMA DJ $locationEsc ($id)"; // Status-Name bestimmen $statusRaw = $this->getTerminDataById($id, "status"); $statusName = [ 0 => "Offen", 1 => "Bereit", 2 => "Erledigt", 3 => "Storniert" ][$statusRaw] ?? "Unbekannt"; // Letzten Bearbeiter holen $editInfo = $this->getLastEditorInfo($id); // Gage holen $gage = $this->getTerminDataById($id, "gage"); $creatorId = $this->getTerminDataById($id, "user_id"); $creatorName = $this->getDataByID($creatorId, "name"); $vars = [ 'id' => $id, 'status' => $statusName, 'locationtype' => $this->getTerminDataById($id, "locationtype"), 'location' => $this->getTerminDataById($id, "location"), 'startzeit' => date('d.m.Y H:i', $this->getTerminDataById($id, "startzeit")), 'endzeit' => date('H:i', $this->getTerminDataById($id, "endzeit")), 'gage' => $this->getTerminDataById($id, "gage"), 'desc' => $this->getTerminDataById($id, "beschreibung"), 'creator' => $creatorName, 'last_editor' => $editInfo['name'], 'last_edited' => $editInfo['date'] ]; $summary = $this->renderTemplate($this->getSettingsData("title_template"), $vars); $beschreibungFull = $this->renderTemplate($this->getSettingsData("description_template"), $vars); $beschreibungFull = $this->icsEscape($beschreibungFull); $ics = "BEGIN:VCALENDAR\r\n" . "VERSION:2.0\r\n" . "PRODID:-//ANJUMA//DE\r\n" . "CALSCALE:GREGORIAN\r\n" . "BEGIN:VEVENT\r\n" . "UID:$uid\r\n" . "DTSTAMP:$dtstamp\r\n" . "DTSTART;TZID=$tz:$dtstartStr\r\n" . "DTEND;TZID=$tz:$dtendStr\r\n" . "SUMMARY:$summary\r\n" . "LOCATION:$locationEsc\r\n" . "DESCRIPTION:$beschreibungFull\r\n" . "URL:https://dashboard.anjuma-crew.de/termin_show.php?id=$id\r\n" . "BEGIN:VALARM\r\n" . "TRIGGER:-P1D\r\n" . "ACTION:DISPLAY\r\n" . "DESCRIPTION:Erinnerung\r\n" . "END:VALARM\r\n" . "BEGIN:VALARM\r\n" . "TRIGGER:-PT2H\r\n" . "ACTION:DISPLAY\r\n" . "DESCRIPTION:Erinnerung\r\n" . "END:VALARM\r\n" . "END:VEVENT\r\n" . "END:VCALENDAR\r\n"; $url = $this->icloudCalendarUrl . "termin-$id.ics"; // Update = isUpdate=true $success = $this->caldavPut($url, $ics, true); if ($success) { $this->storeCalendarSyncState($id, $t); } return $success; } public function deleteCalendarEvent($id) { $url = $this->icloudCalendarUrl . "termin-$id.ics"; $ch = curl_init($url); curl_setopt($ch, CURLOPT_CUSTOMREQUEST, "DELETE"); curl_setopt($ch, CURLOPT_USERPWD, $this->icloudUser . ":" . $this->icloudPass); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); $response = curl_exec($ch); $status = curl_getinfo($ch, CURLINFO_HTTP_CODE); curl_close($ch); return $status === 200 || $status === 204; } public function changeTermin($id, $was, $value): void { $allowed = [ 'startzeit', 'endzeit', 'location', 'locationtype', 'gage', 'anwesend', 'beschreibung', 'status', 'ansprechpartner', 'preisprostunde', 'realendzeit', ]; if (!in_array($was, $allowed, true)) { throw new InvalidArgumentException("Ungültige Spalte: " . $was); } $sql = "UPDATE termine SET `" . $was . "` = :value WHERE id = :id"; $stmt = $this->pdo->prepare($sql); $stmt->execute([':value' => $value, ':id' => $id]); } private function icsEscape($text) { // Normalize line endings (Windows/Mac/Linux) $text = str_replace(["\r\n", "\r"], "\n", $text); // ICS requires escaping BACKSLASH first! $text = str_replace("\\", "\\\\", $text); // Then escape comma and semicolon $text = str_replace(",", "\\,", $text); $text = str_replace(";", "\\;", $text); // Only now convert \n to ICS linebreaks $text = str_replace("\n", "\\n", $text); return $text; } public function createTermin($startzeit, $endzeit, $location, $user_id, $gage, $locationtype) { if ($gage === "" || $gage === null) $gage = 0; $sql = "INSERT INTO termine (startzeit, endzeit, locationtype, location, user_id, gage, status, anwesend, beschreibung) VALUES (:startzeit, :endzeit, :locationtype, :location, :user_id, :gage, 0, '[]', '')"; $stmt = $this->pdo->prepare($sql); $stmt->execute([ ':startzeit' => $startzeit, ':endzeit' => $endzeit, ':locationtype' => $locationtype, ':location' => $location, ':user_id' => $user_id, ':gage' => $gage ]); $id = $this->pdo->lastInsertId(); $this->createCalendarEvent($id, $startzeit, $endzeit, $location, ""); return $id; } public function createChanges($id, $user_id, $was, $alt, $neu) { // ============================================= // 1) CHANGE SPEICHERN // ============================================= $stmt = $this->pdo->prepare(" INSERT INTO changes (termin_id, user_id, was, alt, neu, timestamp) VALUES (:id, :user, :was, :alt, :neu, :ts) "); $stmt->execute([ ':id' => $id, ':user' => $user_id, ':was' => $was, ':alt' => $alt, ':neu' => $neu, ':ts' => time() ]); // ------------------------------------------- // NTFY BENACHRICHTIGUNG (schön formatiert) // ------------------------------------------- // === NTFY CHANNEL === /*$ntfy_url = "https://ntfy.anjuma-crew.de/anjuma-dashboard"; // <<< anpassen // === Basic Auth === $auth = "Basic anVzdGluOm5ZZ0dwNnNNRWdMRWwx"; // === Nachricht bauen === $username = $this->getDataByID($user_id, "name"); if ($was === "anwesend") { $altDecoded = json_decode($alt, true) ?: []; $neuDecoded = json_decode($neu, true) ?: []; // IDs zu Namen umwandeln $altNames = array_map(fn($u) => $this->getDataByID($u, "name"), $altDecoded); $neuNames = array_map(fn($u) => $this->getDataByID($u, "name"), $neuDecoded); $alt = implode(", ", $altNames); $neu = implode(", ", $neuNames); } // Mapping für schönere Bezeichnungen syfe-xapm-vcjx-dnbi $labelMap = [ "status" => "Status", "anwesend" => "Anwesende Personen", "location" => "Location", "ansprechpartner" => "Ansprechpartner", "gage" => "Gage", "preisprostunde" => "Preis pro Stunde", "startzeit" => "Startzeit", "endzeit" => "Endzeit", "realendzeit" => "Tatsächliche Endzeit", "create" => "Termin Erstellt" ]; $label = $labelMap[$was] ?? ucfirst($was); // Titel $title = "Änderung in Termin #$id – $label"; // Nachricht $body = "$username hat $label geändert.\n\n" . "🔻 Alt: $alt\n" . "🔺 Neu: $neu"; // === CURL Request === $ch = curl_init($ntfy_url); curl_setopt($ch, CURLOPT_POST, true); curl_setopt($ch, CURLOPT_POSTFIELDS, $body); curl_setopt($ch, CURLOPT_HTTPHEADER, [ "Authorization: $auth", "X-Title: $title", "X-Priority: 3", "Content-Type: text/plain" ]); curl_setopt($ch, CURLOPT_TIMEOUT, 1); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_exec($ch); curl_close($ch);*/ return; } public function setTerminStatus($id, $status): void { $this->changeTermin($id, 'status', $status); } public function setTodoTitle($id, $title): void { $stmt = $this->pdo->prepare("UPDATE todo SET title = :title WHERE id = :id"); $stmt->execute([':title' => $title, ':id' => $id]); } public function setTodoDescription($id, $description): void { $stmt = $this->pdo->prepare("UPDATE todo SET description = :desc WHERE id = :id"); $stmt->execute([':desc' => $description, ':id' => $id]); } public function deleteTodoById($id): void { $stmt = $this->pdo->prepare("DELETE FROM todo WHERE id = :id"); $stmt->execute([':id' => $id]); } public function createTodo($id, $title, $description): void { $stmt = $this->pdo->prepare("INSERT INTO todo (creator, title, description) VALUES (:creator, :title, :desc)"); $stmt->execute([':creator' => $id, ':title' => $title, ':desc' => $description]); } public function getTodoDataById($id, $column){ $stmt = $this->pdo->prepare("SELECT * FROM todo WHERE id = :id"); $stmt->execute([':id' => $id]); $row = $stmt->fetch(PDO::FETCH_ASSOC); return $row[$column] ?? null; } public function getAllReadyGigs(): int { $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM termine WHERE status = :status"); $stmt->execute([':status' => 1]); return (int)$stmt->fetchColumn(); } public function getAllProcessingGigs(): int { $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM termine WHERE status = :status"); $stmt->execute([':status' => 0]); return (int)$stmt->fetchColumn(); } public function getAllCompletedGigs(): int { $stmt = $this->pdo->prepare("SELECT COUNT(*) FROM termine WHERE status = :status"); $stmt->execute([':status' => 2]); return (int)$stmt->fetchColumn(); } private function debugLog($msg) { $file = __DIR__ . "/debug/caldav_debug.log"; file_put_contents($file, date("[Y-m-d H:i:s] ") . $msg . "\n", FILE_APPEND); } } ?>