update schema and so on

This commit is contained in:
2026-01-20 02:24:23 +01:00
parent da290a8ff8
commit e01c643683
5 changed files with 305 additions and 8 deletions

View File

@@ -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'] ?? []);

View File

@@ -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 = '<option value="">Letzte Versionen</option>';
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 = `<!doctype html><html>
      <head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head>
@@ -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 = {}) => {

View File

@@ -58,6 +58,11 @@ require __DIR__ . '/../partials/structure/layout_start.php';
<option value="grapesjs">GrapesJS</option>
<option value="craftjs">Craft.js</option>
</select>
<label class="text-xs text-slate-600">Version</label>
<select id="versionSelect" class="input h-8 py-0 text-sm min-w-[200px]">
<option value="">Letzte Versionen</option>
</select>
<button id="btn-restore-version" type="button" class="btn">Wiederherstellen</button>
<button id="btn-clear-main" type="button" class="btn" title="Leeren">🧹</button>
<button id="btn-preview" type="button" class="btn">Vorschau</button>
<button id="btn-test" type="button" class="btn">Testversand</button>

View File

@@ -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

View File

@@ -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':