From e01c643683991b206908f328bab36b1fe1185c6f Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Tue, 20 Jan 2026 02:24:23 +0100 Subject: [PATCH] update schema and so on --- config/emailtemplate.conf.php | 1 + public/assets/js/ui-editor.js | 87 +++++++++++++-- public/index.php | 5 + schema.sql | 23 ++++ src/ApiKernel.php | 197 +++++++++++++++++++++++++++++++++- 5 files changed, 305 insertions(+), 8 deletions(-) diff --git a/config/emailtemplate.conf.php b/config/emailtemplate.conf.php index daf459e..428e025 100644 --- a/config/emailtemplate.conf.php +++ b/config/emailtemplate.conf.php @@ -112,6 +112,7 @@ $tablesDefaults = [ 'snippets' => 'emailtemplate_snippets', 'content_items' => 'emailtemplate_content_items', 'content_sections' => 'emailtemplate_content_sections', + 'content_versions' => 'emailtemplate_content_versions', ]; $tables = array_replace($tablesDefaults, $overrides['tables'] ?? []); diff --git a/public/assets/js/ui-editor.js b/public/assets/js/ui-editor.js index 73660cf..926d8fb 100644 --- a/public/assets/js/ui-editor.js +++ b/public/assets/js/ui-editor.js @@ -14,6 +14,8 @@ export function initEditor() { const btnClose = document.getElementById('btn-close'); const btnClear = document.getElementById('btn-clear-main'); const editorSelect = document.getElementById('editorTypeSelect'); + const versionSelect = document.getElementById('versionSelect'); + const btnRestoreVersion = document.getElementById('btn-restore-version'); const craftEditor = initCraftEditor();   const prevDlg      = document.getElementById('previewDialog'); @@ -35,6 +37,7 @@ export function initEditor() { let senderOptions = []; let senderLoadPromise = null; let currentEditorType = 'grapesjs'; + let versionItems = [];   const ok  = (m) => toast(m, true);   const err = (m) => toast(m, false); @@ -66,6 +69,56 @@ export function initEditor() { } } + function formatVersionDate(value) { + if (!value) return ''; + try { + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + return date.toLocaleString('de-DE'); + } catch { + return value; + } + } + + function setVersionUiVisible(show) { + if (versionSelect) versionSelect.classList.toggle('hidden', !show); + if (btnRestoreVersion) btnRestoreVersion.classList.toggle('hidden', !show); + } + + setVersionUiVisible(false); + + function renderVersionOptions(items) { + versionItems = items || []; + if (!versionSelect) return; + const rows = Array.isArray(versionItems) ? versionItems : []; + versionSelect.innerHTML = ''; + if (!rows.length) { + versionSelect.disabled = true; + return; + } + versionSelect.disabled = false; + rows.forEach((item) => { + const opt = document.createElement('option'); + const label = `#${item.version_no} – ${formatVersionDate(item.created_at)}`; + opt.value = String(item.id); + opt.textContent = label; + versionSelect.appendChild(opt); + }); + } + + async function loadVersionsForCurrent() { + if (!current?.id || !current?.section?.is_template) { + renderVersionOptions([]); + return; + } + try { + const res = await apiAction('content_versions.list', { method: 'GET', data: { content_id: current.id } }); + renderVersionOptions(Array.isArray(res?.items) ? res.items : []); + } catch { + renderVersionOptions([]); + } + } +   function writeHtmlToFrame(html) {     iframe.srcdoc = `       @@ -286,7 +339,7 @@ export function initEditor() {   // ---------- Öffnen ---------- async function open(item, resource, sectionOverride) { const section = item?.section || sectionOverride || window.__activeSection || null; -    current = { + current = {       resource: 'content',       id: Number(item?.id || 0),       name: item?.name || '', @@ -297,9 +350,10 @@ export function initEditor() {     // globaler Kontext     window.__currentItemId    = current.id; -    window.__currentEditorCtx = { id: current.id, mode: current.section.slug, section: current.section }; -    setSendContext(current.section?.is_template ? current.id : 0, current.name); -    if (btnTest) btnTest.classList.toggle('hidden', !current.section?.is_template); + window.__currentEditorCtx = { id: current.id, mode: current.section.slug, section: current.section }; + setSendContext(current.section?.is_template ? current.id : 0, current.name); + if (btnTest) btnTest.classList.toggle('hidden', !current.section?.is_template); + setVersionUiVisible(!!current.section?.is_template);     // Neuen Token erzeugen & alten Listener entfernen     reqToken++; @@ -339,7 +393,8 @@ export function initEditor() { } catch {} })(),       (async() => { snippets = await buildSnippetsForContext(current); })(), -      (async() => { refLib   = await buildRefLibForContext(current); })() +      (async() => { refLib   = await buildRefLibForContext(current); })(), + (async() => { await loadVersionsForCurrent(); })()     ]); editorType = editorType === 'craftjs' ? 'craftjs' : 'grapesjs'; @@ -446,10 +501,13 @@ export function initEditor() { const res = await apiUpdate('content', current.id, payload); if (res?.ok) ok('Gespeichert'); else err(res?.error || 'Speichern fehlgeschlagen'); + if (res?.ok) setTimeout(loadVersionsForCurrent, 300); return res?.ok; } - return delegateCommand('save-data'); + const okSave = await delegateCommand('save-data'); + if (okSave) setTimeout(loadVersionsForCurrent, 800); + return okSave; }   // ... (Der Rest der Funktionen bleibt unverändert) ... @@ -598,6 +656,23 @@ export function initEditor() { btnCancelSend&& (btnCancelSend.onclick= closeSend); sendForm && (sendForm.onsubmit = doSend); editorSelect && (editorSelect.onchange = () => switchEditor(editorSelect.value)); + btnRestoreVersion && (btnRestoreVersion.onclick = async () => { + if (!current?.id || !current?.section?.is_template) return; + const versionId = Number(versionSelect?.value || 0); + if (!versionId) { + err('Bitte eine Version auswählen'); + return; + } + if (!confirm('Version wiederherstellen? Der aktuelle Stand wird überschrieben.')) return; + try { + const res = await apiAction('content_versions.restore', { method: 'POST', data: { id: versionId, content_id: current.id } }); + if (!res?.ok) throw new Error(res?.error || 'Wiederherstellen fehlgeschlagen'); + ok('Version wiederhergestellt'); + await open({ id: current.id, name: current.name, section: current.section }, null, current.section); + } catch (e) { + err(e.message || 'Wiederherstellen fehlgeschlagen'); + } + }); window.AdminTestSend = window.AdminTestSend || {}; window.AdminTestSend.open = (opts = {}) => { diff --git a/public/index.php b/public/index.php index fe76778..90db251 100644 --- a/public/index.php +++ b/public/index.php @@ -58,6 +58,11 @@ require __DIR__ . '/../partials/structure/layout_start.php'; + + + diff --git a/schema.sql b/schema.sql index 6d48299..b22cbd1 100644 --- a/schema.sql +++ b/schema.sql @@ -314,5 +314,28 @@ CREATE TABLE IF NOT EXISTS `emailtemplate_content_items` ( CONSTRAINT `fk_content_items_customer` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; +-- Tabelle: emailtemplate_content_versions +CREATE TABLE IF NOT EXISTS `emailtemplate_content_versions` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT, + `customer_id` int(10) unsigned NOT NULL, + `content_id` int(10) unsigned NOT NULL, + `section_id` int(10) unsigned NOT NULL, + `version_no` int(10) unsigned NOT NULL, + `editor_type` varchar(32) DEFAULT NULL, + `json_content` mediumtext DEFAULT NULL, + `html` mediumtext DEFAULT NULL, + `craft_json` mediumtext DEFAULT NULL, + `settings_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_bin DEFAULT NULL CHECK (json_valid(`settings_json`)), + `created_at` timestamp NOT NULL DEFAULT current_timestamp(), + PRIMARY KEY (`id`), + UNIQUE KEY `uq_content_version` (`content_id`,`version_no`), + KEY `idx_versions_content` (`content_id`,`id`), + KEY `idx_versions_customer` (`customer_id`,`content_id`), + KEY `idx_versions_section` (`section_id`,`id`), + CONSTRAINT `fk_content_versions_customer` FOREIGN KEY (`customer_id`) REFERENCES `emailtemplate_customers` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_content_versions_content` FOREIGN KEY (`content_id`) REFERENCES `emailtemplate_content_items` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT `fk_content_versions_section` FOREIGN KEY (`section_id`) REFERENCES `emailtemplate_content_sections` (`id`) ON DELETE CASCADE ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; + SET FOREIGN_KEY_CHECKS = 1; -- Ende des Schema-Dumps diff --git a/src/ApiKernel.php b/src/ApiKernel.php index fd2aeb0..21f030f 100644 --- a/src/ApiKernel.php +++ b/src/ApiKernel.php @@ -172,7 +172,7 @@ class ApiKernel private function resolveAction(): void { /* ... Logik bleibt unverändert ... */ $action = $this->val($this->in, 'action', ''); $resource = $this->val($this->in, 'resource', null); - $allowedResources = ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config']; + $allowedResources = ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config', 'content_versions']; if ($resource && in_array($resource, $allowedResources, true) && strpos((string)$action, '.') === false) { $verb = strtolower((string)$action); if (in_array($verb, ['list', 'get', 'create', 'update', 'delete'], true)) $action = $resource . '.' . $verb; @@ -188,6 +188,7 @@ class ApiKernel 'snippets' => $tables['snippets'] ?? 'emailtemplate_snippets', 'content_items' => $tables['content_items'] ?? 'emailtemplate_content_items', 'content_sections' => $tables['content_sections'] ?? 'emailtemplate_content_sections', + 'content_versions' => $tables['content_versions'] ?? 'emailtemplate_content_versions', ]; } @@ -342,6 +343,11 @@ class ApiKernel return $this->tableMap['content_sections'] ?? $this->lookupTableName('content_sections', 'emailtemplate_content_sections'); } + private function contentVersionsTable(): string + { + return $this->tableMap['content_versions'] ?? $this->lookupTableName('content_versions', 'emailtemplate_content_versions'); + } + private function resolveContentItemColumns(string $table): array { $cols = $this->tableColumns($table); @@ -360,6 +366,59 @@ class ApiKernel return $this->tableExists($this->contentItemsTable()) && $this->tableExists($this->contentSectionsTable()); } + private function createContentVersion(array $current, array $itemCols, int $customerId, int $sectionId): void + { + $table = $this->contentVersionsTable(); + if (!$this->tableExists($table)) return; + $contentId = (int)($current['id'] ?? 0); + if ($contentId <= 0) return; + + $jsonCol = $itemCols['json'] ?? null; + $htmlCol = $itemCols['html'] ?? null; + $editorCol = $itemCols['editor'] ?? null; + $craftCol = $itemCols['craft'] ?? null; + $settingsCol = $itemCols['settings'] ?? null; + + $json = $jsonCol ? ($current[$jsonCol] ?? null) : null; + $html = $htmlCol ? ($current[$htmlCol] ?? null) : null; + $editorType = $editorCol ? ($current[$editorCol] ?? null) : null; + $craftJson = $craftCol ? ($current[$craftCol] ?? null) : null; + $settings = $settingsCol ? ($current[$settingsCol] ?? null) : null; + + try { + $stmt = $this->pdo->prepare("SELECT MAX(`version_no`) FROM `$table` WHERE `content_id` = :cid"); + $stmt->execute([':cid' => $contentId]); + $nextVersion = (int)($stmt->fetchColumn() ?: 0) + 1; + + $stmt = $this->pdo->prepare( + "INSERT INTO `$table` (`customer_id`,`content_id`,`section_id`,`version_no`,`editor_type`,`json_content`,`html`,`craft_json`,`settings_json`) + VALUES (:cust,:content,:section,:ver,:editor,:json,:html,:craft,:settings)" + ); + $stmt->execute([ + ':cust' => $customerId, + ':content' => $contentId, + ':section' => $sectionId, + ':ver' => $nextVersion, + ':editor' => $editorType, + ':json' => $json, + ':html' => $html, + ':craft' => $craftJson, + ':settings' => $settings, + ]); + + $cleanup = $this->pdo->prepare( + "DELETE FROM `$table` WHERE `id` IN ( + SELECT `id` FROM ( + SELECT `id` FROM `$table` WHERE `content_id` = :cid ORDER BY `id` DESC LIMIT 10, 1000000 + ) t + )" + ); + $cleanup->execute([':cid' => $contentId]); + } catch (Throwable $e) { + // Versioning darf nicht das Speichern blockieren. + } + } + private function isLegacyContentKind(string $kind): bool { return in_array($kind, ['templates', 'sections', 'blocks', 'snippets'], true); @@ -843,6 +902,20 @@ class ApiKernel return; } + if (!empty($section['is_template'])) { + $versionCols = array_filter([$jsonCol, $htmlCol, $craftCol, $settingsCol, $editorCol]); + $shouldSnapshot = false; + foreach ($versionCols as $col) { + if (array_key_exists($col, $data)) { + $shouldSnapshot = true; + break; + } + } + if ($shouldSnapshot) { + $this->createContentVersion($current, $itemCols, $customerId, (int)($section['id'] ?? 0)); + } + } + $set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data))); $data['id'] = $id; $data['customer_id'] = $customerId; @@ -880,6 +953,121 @@ class ApiKernel $this->respond(['ok' => true, 'kind' => 'content', 'id' => $id, 'deleted' => true]); } + private function handleContentVersionsList(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $contentId = (int)$this->val($this->in, ['content_id', 'id'], 0); + if ($contentId <= 0) $this->fail('content_id required', null, 422); + + $table = $this->contentVersionsTable(); + if (!$this->tableExists($table)) { + $this->respond(['ok' => true, 'items' => [], 'data' => []]); + return; + } + + $itemsTable = $this->contentItemsTable(); + if ($this->tableExists($itemsTable)) { + $stmt = $this->pdo->prepare("SELECT `id` FROM `$itemsTable` WHERE `id` = :id AND `customer_id` = :cid LIMIT 1"); + $stmt->execute([':id' => $contentId, ':cid' => $customerId]); + if (!$stmt->fetch()) $this->fail('Not found', ['id' => $contentId], 404); + } + + $stmt = $this->pdo->prepare( + "SELECT `id`,`content_id`,`section_id`,`version_no`,`editor_type`,`created_at` + FROM `$table` WHERE `customer_id` = :cid AND `content_id` = :content + ORDER BY `id` DESC LIMIT 10" + ); + $stmt->execute([':cid' => $customerId, ':content' => $contentId]); + $rows = $stmt->fetchAll() ?: []; + $items = array_map(static function ($row) { + return [ + 'id' => (int)($row['id'] ?? 0), + 'content_id' => (int)($row['content_id'] ?? 0), + 'section_id' => (int)($row['section_id'] ?? 0), + 'version_no' => (int)($row['version_no'] ?? 0), + 'editor_type' => $row['editor_type'] ?? null, + 'created_at' => $row['created_at'] ?? null, + ]; + }, $rows); + $this->respond(['ok' => true, 'items' => $items, 'data' => $items]); + } + + private function handleContentVersionsGet(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $id = (int)$this->pullId($this->in); + if ($id <= 0) $this->fail('id required', null, 422); + $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); + + $table = $this->contentVersionsTable(); + if (!$this->tableExists($table)) $this->fail('Versions table not available', null, 500); + + $sql = "SELECT * FROM `$table` WHERE `id` = :id AND `customer_id` = :cid"; + $params = [':id' => $id, ':cid' => $customerId]; + if ($contentId > 0) { + $sql .= " AND `content_id` = :content"; + $params[':content'] = $contentId; + } + $sql .= " LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $row = $stmt->fetch(); + if (!$row) $this->fail('Not found', ['id' => $id], 404); + $this->respond(['ok' => true, 'item' => $row, 'data' => $row]); + } + + private function handleContentVersionsRestore(): void + { + $auth = $this->requireAuth(); + $customerId = (int)($auth['customer_id'] ?? 0); + if ($customerId <= 0) $this->fail('Customer context missing', null, 500); + $versionId = (int)$this->val($this->in, ['id', 'version_id', 'version'], 0); + if ($versionId <= 0) $this->fail('version id required', null, 422); + $contentId = (int)$this->val($this->in, ['content_id', 'content'], 0); + + $versionsTable = $this->contentVersionsTable(); + $itemsTable = $this->contentItemsTable(); + if (!$this->tableExists($versionsTable) || !$this->tableExists($itemsTable)) { + $this->fail('Content tables not available', null, 500); + } + + $sql = "SELECT * FROM `$versionsTable` WHERE `id` = :id AND `customer_id` = :cid"; + $params = [':id' => $versionId, ':cid' => $customerId]; + if ($contentId > 0) { + $sql .= " AND `content_id` = :content"; + $params[':content'] = $contentId; + } + $sql .= " LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + $stmt->execute($params); + $version = $stmt->fetch(); + if (!$version) $this->fail('Not found', ['id' => $versionId], 404); + + $itemCols = $this->resolveContentItemColumns($itemsTable); + $data = []; + if (!empty($itemCols['json'])) $data[$itemCols['json']] = $version['json_content'] ?? null; + if (!empty($itemCols['html'])) $data[$itemCols['html']] = $version['html'] ?? null; + if (!empty($itemCols['craft'])) $data[$itemCols['craft']] = $version['craft_json'] ?? null; + if (!empty($itemCols['settings'])) $data[$itemCols['settings']] = $version['settings_json'] ?? null; + if (!empty($itemCols['editor'])) $data[$itemCols['editor']] = $version['editor_type'] ?? null; + + if ($data) { + $set = implode(',', array_map(static fn($c) => "`$c` = :$c", array_keys($data))); + $data['id'] = (int)($version['content_id'] ?? 0); + $data['customer_id'] = $customerId; + $sql = "UPDATE `$itemsTable` SET $set WHERE `id` = :id AND `customer_id` = :customer_id LIMIT 1"; + $stmt = $this->pdo->prepare($sql); + foreach ($data as $k => $v) $stmt->bindValue(":$k", $v); + $stmt->execute(); + } + + $this->respond(['ok' => true, 'restored' => true, 'content_id' => (int)($version['content_id'] ?? 0)]); + } + private function handleSectionsConfigList(): void { $auth = $this->requireAuth(); @@ -2072,19 +2260,24 @@ class ApiKernel case 'sections_config.reorder': $this->handleSectionsConfigReorder(); break; + case 'content_versions.restore': + $this->handleContentVersionsRestore(); + break; /* ---------- CRUD HANDLER ---------- */ default: - if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config'])) { + if (in_array($kind, ['templates', 'sections', 'blocks', 'snippets', 'content', 'sections_config', 'content_versions'])) { switch ($operation) { case 'list': if ($kind === 'content') $this->handleContentList(); elseif ($kind === 'sections_config') $this->handleSectionsConfigList(); + elseif ($kind === 'content_versions') $this->handleContentVersionsList(); else $this->handleList($kind); break; case 'get': if ($kind === 'content') $this->handleContentGet(); elseif ($kind === 'sections_config') $this->handleSectionsConfigGet(); + elseif ($kind === 'content_versions') $this->handleContentVersionsGet(); else $this->handleGet($kind); break; case 'create':