Merge: t-paliad-089 — Admin Event-Type moderation panel (bulk archive, merge, promote, restore)
This commit is contained in:
@@ -35,6 +35,7 @@ import { renderAdminAuditLog } from "./src/admin-audit-log";
|
||||
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
|
||||
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
|
||||
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
|
||||
import { renderAdminEventTypes } from "./src/admin-event-types";
|
||||
import { renderNotFound } from "./src/notfound";
|
||||
|
||||
const DIST = join(import.meta.dir, "dist");
|
||||
@@ -258,6 +259,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/admin-partner-units.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates.ts"),
|
||||
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
|
||||
join(import.meta.dir, "src/client/admin-event-types.ts"),
|
||||
join(import.meta.dir, "src/client/notfound.ts"),
|
||||
],
|
||||
outdir: join(DIST, "assets"),
|
||||
@@ -366,6 +368,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
|
||||
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
|
||||
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
|
||||
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
|
||||
await Bun.write(join(DIST, "notfound.html"), renderNotFound());
|
||||
|
||||
// Append ?v=<buildVersion> to every /assets/*.js and /assets/*.css URL in
|
||||
|
||||
156
frontend/src/admin-event-types.tsx
Normal file
156
frontend/src/admin-event-types.tsx
Normal file
@@ -0,0 +1,156 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
export function renderAdminEventTypes(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="admin.event_types.title">Event-Typen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/admin/event-types" />
|
||||
<BottomNav currentPath="/admin/event-types" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div>
|
||||
<h1 data-i18n="admin.event_types.heading">Event-Typen</h1>
|
||||
<p className="tool-subtitle" data-i18n="admin.event_types.subtitle">
|
||||
Firmenweite Event-Typen moderieren: archivieren, zusammenführen, private Typen befördern.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-team-controls">
|
||||
<div className="glossar-search-wrap">
|
||||
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
id="aet-search"
|
||||
className="glossar-search"
|
||||
placeholder="Bezeichnung, Slug oder Author suchen..."
|
||||
data-i18n-placeholder="admin.event_types.search.placeholder"
|
||||
autocomplete="off"
|
||||
/>
|
||||
<span className="glossar-count" id="aet-count" />
|
||||
</div>
|
||||
<label className="admin-team-multi-opt">
|
||||
<input type="checkbox" id="aet-show-archived" />
|
||||
<span data-i18n="admin.event_types.show_archived">Archivierte anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="admin-team-actions" id="aet-bulk-actions" style="display:none">
|
||||
<span id="aet-bulk-count" className="admin-team-muted" />
|
||||
<button className="btn-primary" id="aet-bulk-archive" type="button" data-i18n="admin.event_types.action.archive_selected">
|
||||
Ausgewählte archivieren
|
||||
</button>
|
||||
<button className="btn-primary" id="aet-bulk-merge" type="button" data-i18n="admin.event_types.action.merge_selected">
|
||||
Zusammenführen…
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="aet-feedback" className="form-msg" style="display:none" />
|
||||
|
||||
<h3 className="section-heading" data-i18n="admin.event_types.section.firm_wide">Firmenweite Typen</h3>
|
||||
|
||||
<div className="akten-table-wrap admin-team-table-wrap">
|
||||
<table className="akten-table admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="aet-col-check" />
|
||||
<th data-i18n="admin.event_types.col.label">Bezeichnung</th>
|
||||
<th data-i18n="admin.event_types.col.category">Kategorie</th>
|
||||
<th data-i18n="admin.event_types.col.jurisdiction">Jurisdiktion</th>
|
||||
<th data-i18n="admin.event_types.col.author">Author</th>
|
||||
<th data-i18n="admin.event_types.col.created">Erstellt</th>
|
||||
<th data-i18n="admin.event_types.col.usage">Verwendung</th>
|
||||
<th data-i18n="admin.event_types.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="aet-tbody">
|
||||
<tr><td colspan={8} className="admin-team-loading" data-i18n="admin.event_types.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="akten-empty" id="aet-empty" style="display:none">
|
||||
<p data-i18n="admin.event_types.empty">Keine Treffer.</p>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading" data-i18n="admin.event_types.section.private_pending">
|
||||
Private Typen (zur Beförderung)
|
||||
</h3>
|
||||
<p className="tool-subtitle" data-i18n="admin.event_types.section.private_pending.hint">
|
||||
Private Typen anderer Kolleg:innen, sortiert nach Häufigkeit. Befördern macht den Typ firmenweit sichtbar.
|
||||
</p>
|
||||
|
||||
<div className="akten-table-wrap admin-team-table-wrap">
|
||||
<table className="akten-table admin-team-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="admin.event_types.col.label">Bezeichnung</th>
|
||||
<th data-i18n="admin.event_types.col.category">Kategorie</th>
|
||||
<th data-i18n="admin.event_types.col.jurisdiction">Jurisdiktion</th>
|
||||
<th data-i18n="admin.event_types.col.author">Author</th>
|
||||
<th data-i18n="admin.event_types.col.usage">Verwendung</th>
|
||||
<th data-i18n="admin.event_types.col.actions">Aktionen</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="aet-private-tbody">
|
||||
<tr><td colspan={6} className="admin-team-loading" data-i18n="admin.event_types.loading">Lade...</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="akten-empty" id="aet-private-empty" style="display:none">
|
||||
<p data-i18n="admin.event_types.private.empty">Keine privaten Typen.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Merge modal — list of selected types as candidates, admin picks one
|
||||
as winner. Confirms with usage count, then POST /merge atomically
|
||||
redirects junction rows + archives losers. */}
|
||||
<div className="modal-overlay" id="aet-merge-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="admin.event_types.merge.title">Typen zusammenführen</h2>
|
||||
<button className="modal-close" id="aet-merge-close" type="button" aria-label="Close">×</button>
|
||||
</div>
|
||||
<p data-i18n="admin.event_types.merge.body" className="invite-modal-body">
|
||||
Wählen Sie den Gewinner-Typ. Die Junction-Einträge der Verlierer werden auf den Gewinner umgeleitet, anschließend werden die Verlierer archiviert.
|
||||
</p>
|
||||
<form id="aet-merge-form" className="akten-form" autocomplete="off">
|
||||
<div id="aet-merge-options" className="aet-merge-options" />
|
||||
<p className="form-msg" id="aet-merge-msg" />
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="aet-merge-cancel" data-i18n="common.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary" id="aet-merge-submit" data-i18n="admin.event_types.merge.submit">Zusammenführen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/admin-event-types.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor
|
||||
const ICON_LOG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="9" y1="13" x2="15" y2="13"/><line x1="9" y1="17" x2="15" y2="17"/></svg>';
|
||||
const ICON_MAIL = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><polyline points="3 7 12 13 21 7"/></svg>';
|
||||
const ICON_FLAG = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
|
||||
interface PlannedCard {
|
||||
icon: string;
|
||||
@@ -77,6 +78,11 @@ export function renderAdmin(): string {
|
||||
<h2 data-i18n="admin.card.email_templates.title">Email-Templates</h2>
|
||||
<p data-i18n="admin.card.email_templates.desc">Vorlagen für Einladungen, Erinnerungen und Layout anpassen.</p>
|
||||
</a>
|
||||
<a href="/admin/event-types" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
|
||||
<h2 data-i18n="admin.card.event_types.title">Event-Typen</h2>
|
||||
<p data-i18n="admin.card.event_types.desc">Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>
|
||||
|
||||
418
frontend/src/client/admin-event-types.ts
Normal file
418
frontend/src/client/admin-event-types.ts
Normal file
@@ -0,0 +1,418 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// admin-event-types.ts — moderation panel for paliad.event_types
|
||||
// (t-paliad-089). Loads two tables: firm-wide (with archived toggle, bulk
|
||||
// archive, merge) and private-pending-promotion (per-row promote button).
|
||||
|
||||
interface EventTypeRow {
|
||||
id: string;
|
||||
slug: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
category: string;
|
||||
jurisdiction?: string | null;
|
||||
description?: string;
|
||||
is_firm_wide: boolean;
|
||||
archived_at?: string | null;
|
||||
created_by?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
usage_count: number;
|
||||
author_display_name?: string | null;
|
||||
}
|
||||
|
||||
let firmwide: EventTypeRow[] = [];
|
||||
let priv: EventTypeRow[] = [];
|
||||
let selected = new Set<string>();
|
||||
let showArchived = false;
|
||||
let searchQuery = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString();
|
||||
}
|
||||
|
||||
function categoryLabel(cat: string): string {
|
||||
return tDyn(`event_types.cat.${cat}`) || cat;
|
||||
}
|
||||
|
||||
function labelFor(row: EventTypeRow): string {
|
||||
// Show DE primary, EN as a small secondary line if it differs.
|
||||
return row.label_de;
|
||||
}
|
||||
|
||||
function rowMatchesSearch(row: EventTypeRow): boolean {
|
||||
if (!searchQuery) return true;
|
||||
const q = searchQuery.toLowerCase();
|
||||
return (
|
||||
row.label_de.toLowerCase().includes(q) ||
|
||||
row.label_en.toLowerCase().includes(q) ||
|
||||
row.slug.toLowerCase().includes(q) ||
|
||||
(row.author_display_name ?? "").toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
function showFeedback(msg: string, isError: boolean) {
|
||||
const el = document.getElementById("aet-feedback")!;
|
||||
el.textContent = msg;
|
||||
el.className = "form-msg " + (isError ? "form-msg-error" : "form-msg-ok");
|
||||
el.style.display = "block";
|
||||
if (!isError) {
|
||||
setTimeout(() => { el.style.display = "none"; }, 3500);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFirmwide(): Promise<void> {
|
||||
const url = "/api/admin/event-types" + (showArchived ? "?include_archived=1" : "");
|
||||
const resp = await fetch(url);
|
||||
if (resp.status === 403) {
|
||||
showFeedback(t("admin.event_types.error.forbidden") || "Nur Admins.", true);
|
||||
firmwide = [];
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) {
|
||||
firmwide = [];
|
||||
return;
|
||||
}
|
||||
firmwide = (await resp.json()) as EventTypeRow[];
|
||||
}
|
||||
|
||||
async function loadPrivate(): Promise<void> {
|
||||
const resp = await fetch("/api/admin/event-types/private");
|
||||
if (!resp.ok) {
|
||||
priv = [];
|
||||
return;
|
||||
}
|
||||
priv = (await resp.json()) as EventTypeRow[];
|
||||
}
|
||||
|
||||
function jurisdictionLabel(j: string | null | undefined): string {
|
||||
if (!j) return "—";
|
||||
if (j === "any") return tDyn("event_types.add.jurisdiction.any") || j;
|
||||
return j;
|
||||
}
|
||||
|
||||
function renderFirmwideRow(row: EventTypeRow): string {
|
||||
const archived = !!row.archived_at;
|
||||
const checked = selected.has(row.id) ? " checked" : "";
|
||||
const labelEn = row.label_en && row.label_en !== row.label_de
|
||||
? `<div class="admin-team-muted aet-label-en">${esc(row.label_en)}</div>`
|
||||
: "";
|
||||
const archivedBadge = archived
|
||||
? `<span class="aet-archived-badge">${esc(t("admin.event_types.row.archived") || "Archiviert")}</span>`
|
||||
: "";
|
||||
const restoreBtn = archived
|
||||
? `<button type="button" class="btn-link aet-restore" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.restore">Wiederherstellen</button>`
|
||||
: `<button type="button" class="btn-link aet-archive" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.archive">Archivieren</button>`;
|
||||
return `
|
||||
<tr data-row-id="${esc(row.id)}"${archived ? " class=\"aet-row-archived\"" : ""}>
|
||||
<td class="aet-col-check">
|
||||
${archived ? "" : `<input type="checkbox" class="aet-row-check" data-id="${esc(row.id)}"${checked} />`}
|
||||
</td>
|
||||
<td class="akten-col-title">
|
||||
${archivedBadge}
|
||||
<div>${esc(labelFor(row))}</div>
|
||||
${labelEn}
|
||||
<div class="admin-team-muted aet-slug">${esc(row.slug)}</div>
|
||||
</td>
|
||||
<td>${esc(categoryLabel(row.category))}</td>
|
||||
<td>${esc(jurisdictionLabel(row.jurisdiction))}</td>
|
||||
<td>${row.author_display_name ? esc(row.author_display_name) : `<span class="admin-team-muted">${esc(t("admin.event_types.author.system") || "System")}</span>`}</td>
|
||||
<td class="akten-col-updated">${esc(fmtDate(row.created_at))}</td>
|
||||
<td>${row.usage_count}</td>
|
||||
<td class="admin-team-actions-cell">${restoreBtn}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderPrivateRow(row: EventTypeRow): string {
|
||||
return `
|
||||
<tr data-row-id="${esc(row.id)}">
|
||||
<td class="akten-col-title">
|
||||
<div>${esc(row.label_de)}</div>
|
||||
${row.label_en && row.label_en !== row.label_de ? `<div class="admin-team-muted aet-label-en">${esc(row.label_en)}</div>` : ""}
|
||||
<div class="admin-team-muted aet-slug">${esc(row.slug)}</div>
|
||||
</td>
|
||||
<td>${esc(categoryLabel(row.category))}</td>
|
||||
<td>${esc(jurisdictionLabel(row.jurisdiction))}</td>
|
||||
<td>${row.author_display_name ? esc(row.author_display_name) : `<span class="admin-team-muted">${esc(t("admin.event_types.author.unknown") || "Unbekannt")}</span>`}</td>
|
||||
<td>${row.usage_count}</td>
|
||||
<td class="admin-team-actions-cell">
|
||||
<button type="button" class="btn-primary btn-small aet-promote" data-id="${esc(row.id)}" data-i18n="admin.event_types.action.promote">Befördern</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderFirmwide() {
|
||||
const tbody = document.getElementById("aet-tbody")!;
|
||||
const empty = document.getElementById("aet-empty")!;
|
||||
const count = document.getElementById("aet-count")!;
|
||||
|
||||
const filtered = firmwide.filter(rowMatchesSearch);
|
||||
count.textContent = `${filtered.length} / ${firmwide.length}`;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
updateBulkBar();
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = filtered.map(renderFirmwideRow).join("");
|
||||
attachFirmwideRowListeners();
|
||||
updateBulkBar();
|
||||
}
|
||||
|
||||
function renderPrivate() {
|
||||
const tbody = document.getElementById("aet-private-tbody")!;
|
||||
const empty = document.getElementById("aet-private-empty")!;
|
||||
|
||||
const filtered = priv.filter(rowMatchesSearch);
|
||||
if (filtered.length === 0) {
|
||||
tbody.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
tbody.innerHTML = filtered.map(renderPrivateRow).join("");
|
||||
attachPrivateRowListeners();
|
||||
}
|
||||
|
||||
function attachFirmwideRowListeners() {
|
||||
document.querySelectorAll<HTMLInputElement>(".aet-row-check").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.id!;
|
||||
if (cb.checked) selected.add(id);
|
||||
else selected.delete(id);
|
||||
updateBulkBar();
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".aet-archive").forEach((b) => {
|
||||
b.addEventListener("click", () => archiveOne(b.dataset.id!));
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".aet-restore").forEach((b) => {
|
||||
b.addEventListener("click", () => restoreOne(b.dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
function attachPrivateRowListeners() {
|
||||
document.querySelectorAll<HTMLButtonElement>(".aet-promote").forEach((b) => {
|
||||
b.addEventListener("click", () => promoteOne(b.dataset.id!));
|
||||
});
|
||||
}
|
||||
|
||||
function updateBulkBar() {
|
||||
const bar = document.getElementById("aet-bulk-actions")!;
|
||||
const count = document.getElementById("aet-bulk-count")!;
|
||||
const mergeBtn = document.getElementById("aet-bulk-merge") as HTMLButtonElement;
|
||||
// Drop selections that no longer correspond to a visible live row.
|
||||
const liveIDs = new Set(firmwide.filter((r) => !r.archived_at).map((r) => r.id));
|
||||
for (const id of Array.from(selected)) {
|
||||
if (!liveIDs.has(id)) selected.delete(id);
|
||||
}
|
||||
if (selected.size === 0) {
|
||||
bar.style.display = "none";
|
||||
return;
|
||||
}
|
||||
bar.style.display = "flex";
|
||||
const tmpl = t("admin.event_types.bulk.count") || "{n} ausgewählt";
|
||||
count.textContent = tmpl.replace("{n}", String(selected.size));
|
||||
mergeBtn.disabled = selected.size < 2;
|
||||
}
|
||||
|
||||
async function archiveOne(id: string) {
|
||||
const row = firmwide.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const confirmMsg = (t("admin.event_types.confirm.archive") || "„{label}\" wirklich archivieren?").replace("{label}", row.label_de);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
await bulkArchive([id]);
|
||||
}
|
||||
|
||||
async function bulkArchiveSelected() {
|
||||
if (selected.size === 0) return;
|
||||
const tmpl = t("admin.event_types.confirm.bulk_archive") || "{n} Typen wirklich archivieren?";
|
||||
if (!window.confirm(tmpl.replace("{n}", String(selected.size)))) return;
|
||||
await bulkArchive(Array.from(selected));
|
||||
}
|
||||
|
||||
async function bulkArchive(ids: string[]) {
|
||||
const resp = await fetch("/api/admin/event-types/archive", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || t("admin.event_types.feedback.archive_error") || "Archivierung fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
const body = (await resp.json()) as { archived: number };
|
||||
selected.clear();
|
||||
showFeedback((t("admin.event_types.feedback.archived") || "{n} archiviert.").replace("{n}", String(body.archived)), false);
|
||||
await Promise.all([loadFirmwide(), loadPrivate()]);
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
}
|
||||
|
||||
async function restoreOne(id: string) {
|
||||
const row = firmwide.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const resp = await fetch(`/api/admin/event-types/${id}/restore`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || t("admin.event_types.feedback.restore_error") || "Wiederherstellung fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.event_types.feedback.restored") || "Wiederhergestellt.", false);
|
||||
await loadFirmwide();
|
||||
renderFirmwide();
|
||||
}
|
||||
|
||||
async function promoteOne(id: string) {
|
||||
const row = priv.find((r) => r.id === id);
|
||||
if (!row) return;
|
||||
const confirmMsg = (t("admin.event_types.confirm.promote") || "„{label}\" firmenweit verfügbar machen?").replace("{label}", row.label_de);
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
const resp = await fetch(`/api/admin/event-types/${id}/promote`, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
showFeedback(body.error || t("admin.event_types.feedback.promote_error") || "Beförderung fehlgeschlagen.", true);
|
||||
return;
|
||||
}
|
||||
showFeedback(t("admin.event_types.feedback.promoted") || "Befördert.", false);
|
||||
await Promise.all([loadFirmwide(), loadPrivate()]);
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
}
|
||||
|
||||
function openMergeModal() {
|
||||
if (selected.size < 2) return;
|
||||
const candidates = firmwide.filter((r) => selected.has(r.id) && !r.archived_at);
|
||||
if (candidates.length < 2) return;
|
||||
|
||||
// Suggest the highest-usage row as the default winner — preserves the most
|
||||
// junction rows untouched (they don't even need an INSERT, just the others
|
||||
// get redirected onto it).
|
||||
const initialWinner = candidates.slice().sort((a, b) => b.usage_count - a.usage_count)[0]!.id;
|
||||
|
||||
const opts = document.getElementById("aet-merge-options")!;
|
||||
opts.innerHTML = candidates.map((r) => {
|
||||
const checked = r.id === initialWinner ? " checked" : "";
|
||||
return `
|
||||
<label class="aet-merge-option">
|
||||
<input type="radio" name="aet-merge-winner" value="${esc(r.id)}"${checked} />
|
||||
<div class="aet-merge-option-body">
|
||||
<div class="aet-merge-option-label">${esc(r.label_de)}</div>
|
||||
<div class="admin-team-muted aet-merge-option-meta">
|
||||
${esc(categoryLabel(r.category))} · ${esc(jurisdictionLabel(r.jurisdiction))} · ${r.usage_count}×
|
||||
</div>
|
||||
</div>
|
||||
</label>`;
|
||||
}).join("");
|
||||
|
||||
const msg = document.getElementById("aet-merge-msg")!;
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
document.getElementById("aet-merge-modal")!.style.display = "flex";
|
||||
}
|
||||
|
||||
function closeMergeModal() {
|
||||
document.getElementById("aet-merge-modal")!.style.display = "none";
|
||||
}
|
||||
|
||||
async function submitMerge(e: Event) {
|
||||
e.preventDefault();
|
||||
const winnerInput = document.querySelector<HTMLInputElement>('input[name="aet-merge-winner"]:checked');
|
||||
if (!winnerInput) return;
|
||||
const winnerID = winnerInput.value;
|
||||
const losers = Array.from(selected).filter((id) => id !== winnerID);
|
||||
if (losers.length === 0) return;
|
||||
|
||||
const winner = firmwide.find((r) => r.id === winnerID);
|
||||
const totalUsage = firmwide
|
||||
.filter((r) => losers.includes(r.id))
|
||||
.reduce((acc, r) => acc + r.usage_count, 0);
|
||||
const tmpl = t("admin.event_types.confirm.merge")
|
||||
|| "„{winner}\" als Gewinner: {n} Verlierer-Typ(en) werden archiviert, {usage} Junction-Eintrag/-träge umgeleitet. Fortfahren?";
|
||||
const confirmMsg = tmpl
|
||||
.replace("{winner}", winner?.label_de ?? winnerID)
|
||||
.replace("{n}", String(losers.length))
|
||||
.replace("{usage}", String(totalUsage));
|
||||
if (!window.confirm(confirmMsg)) return;
|
||||
|
||||
const resp = await fetch("/api/admin/event-types/merge", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ winner_id: winnerID, loser_ids: losers }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({ error: resp.statusText }));
|
||||
const msgEl = document.getElementById("aet-merge-msg")!;
|
||||
msgEl.textContent = body.error || t("admin.event_types.feedback.merge_error") || "Zusammenführung fehlgeschlagen.";
|
||||
msgEl.className = "form-msg form-msg-error";
|
||||
return;
|
||||
}
|
||||
closeMergeModal();
|
||||
selected.clear();
|
||||
showFeedback(t("admin.event_types.feedback.merged") || "Zusammengeführt.", false);
|
||||
await Promise.all([loadFirmwide(), loadPrivate()]);
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
}
|
||||
|
||||
function initSearch() {
|
||||
const input = document.getElementById("aet-search") as HTMLInputElement;
|
||||
input.addEventListener("input", () => {
|
||||
searchQuery = input.value;
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
});
|
||||
}
|
||||
|
||||
function initShowArchivedToggle() {
|
||||
const cb = document.getElementById("aet-show-archived") as HTMLInputElement;
|
||||
cb.addEventListener("change", async () => {
|
||||
showArchived = cb.checked;
|
||||
await loadFirmwide();
|
||||
renderFirmwide();
|
||||
});
|
||||
}
|
||||
|
||||
function initBulkActions() {
|
||||
document.getElementById("aet-bulk-archive")!.addEventListener("click", bulkArchiveSelected);
|
||||
document.getElementById("aet-bulk-merge")!.addEventListener("click", openMergeModal);
|
||||
}
|
||||
|
||||
function initMergeModal() {
|
||||
document.getElementById("aet-merge-close")!.addEventListener("click", closeMergeModal);
|
||||
document.getElementById("aet-merge-cancel")!.addEventListener("click", closeMergeModal);
|
||||
document.getElementById("aet-merge-modal")!.addEventListener("click", (e) => {
|
||||
if (e.target === e.currentTarget) closeMergeModal();
|
||||
});
|
||||
(document.getElementById("aet-merge-form") as HTMLFormElement).addEventListener("submit", submitMerge);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
initSearch();
|
||||
initShowArchivedToggle();
|
||||
initBulkActions();
|
||||
initMergeModal();
|
||||
onLangChange(() => {
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
});
|
||||
Promise.all([loadFirmwide(), loadPrivate()]).then(() => {
|
||||
renderFirmwide();
|
||||
renderPrivate();
|
||||
});
|
||||
});
|
||||
@@ -1420,6 +1420,54 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.event_type": "Typ",
|
||||
"deadlines.filter.event_type": "Typ",
|
||||
"agenda.filter.event_type": "Typ",
|
||||
|
||||
// t-paliad-089: Admin Event-Type moderation panel.
|
||||
"nav.admin.event_types": "Event-Typen",
|
||||
"admin.card.event_types.title": "Event-Typen",
|
||||
"admin.card.event_types.desc": "Firmenweite Event-Typen moderieren: archivieren, zusammenführen, befördern.",
|
||||
"admin.event_types.title": "Event-Typen — Paliad",
|
||||
"admin.event_types.heading": "Event-Typen",
|
||||
"admin.event_types.subtitle": "Firmenweite Event-Typen moderieren: archivieren, zusammenführen, private Typen befördern.",
|
||||
"admin.event_types.section.firm_wide": "Firmenweite Typen",
|
||||
"admin.event_types.section.private_pending": "Private Typen (zur Beförderung)",
|
||||
"admin.event_types.section.private_pending.hint": "Private Typen anderer Kolleg:innen, sortiert nach Häufigkeit. Befördern macht den Typ firmenweit sichtbar.",
|
||||
"admin.event_types.search.placeholder": "Bezeichnung, Slug oder Author suchen…",
|
||||
"admin.event_types.show_archived": "Archivierte anzeigen",
|
||||
"admin.event_types.loading": "Lade…",
|
||||
"admin.event_types.empty": "Keine Treffer.",
|
||||
"admin.event_types.private.empty": "Keine privaten Typen.",
|
||||
"admin.event_types.col.label": "Bezeichnung",
|
||||
"admin.event_types.col.category": "Kategorie",
|
||||
"admin.event_types.col.jurisdiction": "Jurisdiktion",
|
||||
"admin.event_types.col.author": "Author",
|
||||
"admin.event_types.col.created": "Erstellt",
|
||||
"admin.event_types.col.usage": "Verwendung",
|
||||
"admin.event_types.col.actions": "Aktionen",
|
||||
"admin.event_types.row.archived": "Archiviert",
|
||||
"admin.event_types.author.system": "System",
|
||||
"admin.event_types.author.unknown": "Unbekannt",
|
||||
"admin.event_types.action.archive": "Archivieren",
|
||||
"admin.event_types.action.archive_selected": "Ausgewählte archivieren",
|
||||
"admin.event_types.action.merge_selected": "Zusammenführen…",
|
||||
"admin.event_types.action.restore": "Wiederherstellen",
|
||||
"admin.event_types.action.promote": "Befördern",
|
||||
"admin.event_types.bulk.count": "{n} ausgewählt",
|
||||
"admin.event_types.confirm.archive": "„{label}\" wirklich archivieren?",
|
||||
"admin.event_types.confirm.bulk_archive": "{n} Typen wirklich archivieren?",
|
||||
"admin.event_types.confirm.promote": "„{label}\" firmenweit verfügbar machen?",
|
||||
"admin.event_types.confirm.merge": "„{winner}\" als Gewinner: {n} Verlierer-Typ(en) werden archiviert, {usage} Junction-Eintrag/-träge umgeleitet. Fortfahren?",
|
||||
"admin.event_types.feedback.archived": "{n} archiviert.",
|
||||
"admin.event_types.feedback.archive_error": "Archivierung fehlgeschlagen.",
|
||||
"admin.event_types.feedback.restored": "Wiederhergestellt.",
|
||||
"admin.event_types.feedback.restore_error": "Wiederherstellung fehlgeschlagen.",
|
||||
"admin.event_types.feedback.promoted": "Befördert.",
|
||||
"admin.event_types.feedback.promote_error": "Beförderung fehlgeschlagen.",
|
||||
"admin.event_types.feedback.merged": "Zusammengeführt.",
|
||||
"admin.event_types.feedback.merge_error": "Zusammenführung fehlgeschlagen.",
|
||||
"admin.event_types.error.forbidden": "Zugriff nur für Admins.",
|
||||
"admin.event_types.merge.title": "Typen zusammenführen",
|
||||
"admin.event_types.merge.body": "Wählen Sie den Gewinner-Typ. Die Junction-Einträge der Verlierer werden auf den Gewinner umgeleitet, anschließend werden die Verlierer archiviert.",
|
||||
"admin.event_types.merge.submit": "Zusammenführen",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -2819,6 +2867,54 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.col.event_type": "Type",
|
||||
"deadlines.filter.event_type": "Type",
|
||||
"agenda.filter.event_type": "Type",
|
||||
|
||||
// t-paliad-089: Admin Event-Type moderation panel.
|
||||
"nav.admin.event_types": "Event Types",
|
||||
"admin.card.event_types.title": "Event Types",
|
||||
"admin.card.event_types.desc": "Moderate firm-wide event types: archive, merge, promote.",
|
||||
"admin.event_types.title": "Event Types — Paliad",
|
||||
"admin.event_types.heading": "Event Types",
|
||||
"admin.event_types.subtitle": "Moderate firm-wide event types: archive, merge, promote private types.",
|
||||
"admin.event_types.section.firm_wide": "Firm-wide types",
|
||||
"admin.event_types.section.private_pending": "Private types (pending promotion)",
|
||||
"admin.event_types.section.private_pending.hint": "Private types from other colleagues, sorted by usage. Promoting makes the type firm-wide.",
|
||||
"admin.event_types.search.placeholder": "Search label, slug or author…",
|
||||
"admin.event_types.show_archived": "Show archived",
|
||||
"admin.event_types.loading": "Loading…",
|
||||
"admin.event_types.empty": "No matches.",
|
||||
"admin.event_types.private.empty": "No private types.",
|
||||
"admin.event_types.col.label": "Label",
|
||||
"admin.event_types.col.category": "Category",
|
||||
"admin.event_types.col.jurisdiction": "Jurisdiction",
|
||||
"admin.event_types.col.author": "Author",
|
||||
"admin.event_types.col.created": "Created",
|
||||
"admin.event_types.col.usage": "Usage",
|
||||
"admin.event_types.col.actions": "Actions",
|
||||
"admin.event_types.row.archived": "Archived",
|
||||
"admin.event_types.author.system": "System",
|
||||
"admin.event_types.author.unknown": "Unknown",
|
||||
"admin.event_types.action.archive": "Archive",
|
||||
"admin.event_types.action.archive_selected": "Archive selected",
|
||||
"admin.event_types.action.merge_selected": "Merge…",
|
||||
"admin.event_types.action.restore": "Restore",
|
||||
"admin.event_types.action.promote": "Promote",
|
||||
"admin.event_types.bulk.count": "{n} selected",
|
||||
"admin.event_types.confirm.archive": "Really archive \"{label}\"?",
|
||||
"admin.event_types.confirm.bulk_archive": "Really archive {n} types?",
|
||||
"admin.event_types.confirm.promote": "Make \"{label}\" firm-wide?",
|
||||
"admin.event_types.confirm.merge": "\"{winner}\" as winner: {n} loser type(s) will be archived, {usage} junction row(s) redirected. Proceed?",
|
||||
"admin.event_types.feedback.archived": "{n} archived.",
|
||||
"admin.event_types.feedback.archive_error": "Archive failed.",
|
||||
"admin.event_types.feedback.restored": "Restored.",
|
||||
"admin.event_types.feedback.restore_error": "Restore failed.",
|
||||
"admin.event_types.feedback.promoted": "Promoted.",
|
||||
"admin.event_types.feedback.promote_error": "Promotion failed.",
|
||||
"admin.event_types.feedback.merged": "Merged.",
|
||||
"admin.event_types.feedback.merge_error": "Merge failed.",
|
||||
"admin.event_types.error.forbidden": "Admins only.",
|
||||
"admin.event_types.merge.title": "Merge types",
|
||||
"admin.event_types.merge.body": "Pick the winner. Loser junction rows get redirected to the winner, then the losers are archived.",
|
||||
"admin.event_types.merge.submit": "Merge",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -152,6 +152,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{navItem("/admin", ICON_SHIELD, "nav.admin.bereich", "Admin-Bereich", currentPath)}
|
||||
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
|
||||
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
|
||||
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
|
||||
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -50,6 +50,8 @@ export type I18nKey =
|
||||
| "admin.card.audit.title"
|
||||
| "admin.card.email_templates.desc"
|
||||
| "admin.card.email_templates.title"
|
||||
| "admin.card.event_types.desc"
|
||||
| "admin.card.event_types.title"
|
||||
| "admin.card.feature_flags.desc"
|
||||
| "admin.card.feature_flags.title"
|
||||
| "admin.card.partner_units.desc"
|
||||
@@ -104,6 +106,49 @@ export type I18nKey =
|
||||
| "admin.email_templates.status.last_modified"
|
||||
| "admin.email_templates.subtitle"
|
||||
| "admin.email_templates.title"
|
||||
| "admin.event_types.action.archive"
|
||||
| "admin.event_types.action.archive_selected"
|
||||
| "admin.event_types.action.merge_selected"
|
||||
| "admin.event_types.action.promote"
|
||||
| "admin.event_types.action.restore"
|
||||
| "admin.event_types.author.system"
|
||||
| "admin.event_types.author.unknown"
|
||||
| "admin.event_types.bulk.count"
|
||||
| "admin.event_types.col.actions"
|
||||
| "admin.event_types.col.author"
|
||||
| "admin.event_types.col.category"
|
||||
| "admin.event_types.col.created"
|
||||
| "admin.event_types.col.jurisdiction"
|
||||
| "admin.event_types.col.label"
|
||||
| "admin.event_types.col.usage"
|
||||
| "admin.event_types.confirm.archive"
|
||||
| "admin.event_types.confirm.bulk_archive"
|
||||
| "admin.event_types.confirm.merge"
|
||||
| "admin.event_types.confirm.promote"
|
||||
| "admin.event_types.empty"
|
||||
| "admin.event_types.error.forbidden"
|
||||
| "admin.event_types.feedback.archive_error"
|
||||
| "admin.event_types.feedback.archived"
|
||||
| "admin.event_types.feedback.merge_error"
|
||||
| "admin.event_types.feedback.merged"
|
||||
| "admin.event_types.feedback.promote_error"
|
||||
| "admin.event_types.feedback.promoted"
|
||||
| "admin.event_types.feedback.restore_error"
|
||||
| "admin.event_types.feedback.restored"
|
||||
| "admin.event_types.heading"
|
||||
| "admin.event_types.loading"
|
||||
| "admin.event_types.merge.body"
|
||||
| "admin.event_types.merge.submit"
|
||||
| "admin.event_types.merge.title"
|
||||
| "admin.event_types.private.empty"
|
||||
| "admin.event_types.row.archived"
|
||||
| "admin.event_types.search.placeholder"
|
||||
| "admin.event_types.section.firm_wide"
|
||||
| "admin.event_types.section.private_pending"
|
||||
| "admin.event_types.section.private_pending.hint"
|
||||
| "admin.event_types.show_archived"
|
||||
| "admin.event_types.subtitle"
|
||||
| "admin.event_types.title"
|
||||
| "admin.heading"
|
||||
| "admin.partner_units.action.delete"
|
||||
| "admin.partner_units.action.edit"
|
||||
@@ -1004,6 +1049,7 @@ export type I18nKey =
|
||||
| "login.title"
|
||||
| "nav.admin.audit"
|
||||
| "nav.admin.bereich"
|
||||
| "nav.admin.event_types"
|
||||
| "nav.admin.partner_units"
|
||||
| "nav.admin.team"
|
||||
| "nav.agenda"
|
||||
|
||||
@@ -7466,6 +7466,74 @@ dialog.quick-add-sheet::backdrop {
|
||||
color: var(--color-accent-fg);
|
||||
}
|
||||
|
||||
/* --- Admin Event-Types moderation panel (t-paliad-089) ----------------- */
|
||||
.aet-col-check {
|
||||
width: 2.4rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.aet-archived-badge {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-muted, #f0f0f0);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.aet-row-archived td {
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.aet-label-en,
|
||||
.aet-slug {
|
||||
font-size: 0.72rem;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
.aet-merge-options {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 1rem;
|
||||
}
|
||||
|
||||
.aet-merge-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.8rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
|
||||
.aet-merge-option:hover {
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.aet-merge-option input[type="radio"] {
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
.aet-merge-option-body {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.aet-merge-option-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.aet-merge-option-meta {
|
||||
font-size: 0.78rem;
|
||||
margin-top: 0.15rem;
|
||||
}
|
||||
|
||||
/* --- Project breadcrumb (t-paliad-049) ---------------------------------
|
||||
Pill-style breadcrumbs with type icons, chevron separators and a hover
|
||||
lime accent. Horizontal-scroll fallback on narrow screens; the trailing
|
||||
|
||||
183
internal/handlers/admin_event_types.go
Normal file
183
internal/handlers/admin_event_types.go
Normal file
@@ -0,0 +1,183 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/patholo/internal/services"
|
||||
)
|
||||
|
||||
// admin_event_types.go — backing endpoints for /admin/event-types
|
||||
// (t-paliad-089). Routes are registered behind RequireAdminFunc in
|
||||
// handlers.go, so handlers assume the caller is global_admin and only the
|
||||
// operation itself needs validation. The service layer additionally
|
||||
// re-asserts the role (defence in depth) before any mutation.
|
||||
//
|
||||
// Q6 of t-paliad-088 left firm-wide event_type creation open to any user;
|
||||
// this is the moderation surface that handles the inevitable drift
|
||||
// (duplicates, typos, private types worth promoting).
|
||||
|
||||
// GET /api/admin/event-types?include_archived=1 — every firm-wide row
|
||||
// enriched with usage_count and author_display_name. Sorted live-first,
|
||||
// then by category, then label_de.
|
||||
func handleAdminListEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
includeArchived := r.URL.Query().Get("include_archived")
|
||||
rows, err := dbSvc.eventType.ListAllForAdmin(r.Context(), uid, services.EventTypeAdminFilter{
|
||||
IncludeArchived: includeArchived == "1" || includeArchived == "true",
|
||||
})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// GET /api/admin/event-types/private — every private (is_firm_wide=false),
|
||||
// non-archived row across all users. Powers the "promote pending" list in
|
||||
// the moderation panel.
|
||||
func handleAdminListPrivateEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
rows, err := dbSvc.eventType.ListPrivatePendingPromotion(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, rows)
|
||||
}
|
||||
|
||||
// adminBulkArchiveInput is the body for POST /api/admin/event-types/archive.
|
||||
type adminBulkArchiveInput struct {
|
||||
IDs []uuid.UUID `json:"ids"`
|
||||
}
|
||||
|
||||
// POST /api/admin/event-types/archive — archive a set of firm-wide rows in
|
||||
// one round-trip. Returns the count of rows actually archived (already-
|
||||
// archived rows are silently no-oped).
|
||||
func handleAdminBulkArchiveEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input adminBulkArchiveInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if len(input.IDs) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ids: at least one required"})
|
||||
return
|
||||
}
|
||||
n, err := dbSvc.eventType.ArchiveBulk(r.Context(), uid, input.IDs)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]int{"archived": n})
|
||||
}
|
||||
|
||||
// POST /api/admin/event-types/{id}/promote — flip is_firm_wide=true on a
|
||||
// private row.
|
||||
func handleAdminPromoteEventType(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.eventType.Promote(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// POST /api/admin/event-types/{id}/restore — flip archived_at back to NULL.
|
||||
func handleAdminRestoreEventType(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
row, err := dbSvc.eventType.Restore(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, row)
|
||||
}
|
||||
|
||||
// adminMergeInput is the body for POST /api/admin/event-types/merge.
|
||||
// The winner survives; loser_ids get their junction rows redirected to the
|
||||
// winner and are then archived.
|
||||
type adminMergeInput struct {
|
||||
WinnerID uuid.UUID `json:"winner_id"`
|
||||
LoserIDs []uuid.UUID `json:"loser_ids"`
|
||||
}
|
||||
|
||||
// POST /api/admin/event-types/merge — fold loser_ids into winner_id.
|
||||
//
|
||||
// Atomic in one tx: redirect deadline_event_types rows from losers → winner
|
||||
// (junction PK dedups duplicates), drop any leftover loser junction rows,
|
||||
// archive the losers. On failure, all three steps roll back together.
|
||||
func handleAdminMergeEventTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var input adminMergeInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if len(input.LoserIDs) == 0 {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "loser_ids: at least one required"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.eventType.MergeIDs(r.Context(), uid, input.WinnerID, input.LoserIDs); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// handleAdminEventTypesPage serves the SPA shell for /admin/event-types.
|
||||
// Same gate pattern as the other /admin/* pages — RequireAdminFunc wraps
|
||||
// the route at registration time.
|
||||
func handleAdminEventTypesPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/admin-event-types.html")
|
||||
}
|
||||
@@ -319,6 +319,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /admin/partner-units", adminGate(users, gateOnboarded(handleAdminPartnerUnitsPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates", adminGate(users, gateOnboarded(handleAdminEmailTemplatesPage)))
|
||||
protected.HandleFunc("GET /admin/email-templates/{key}", adminGate(users, gateOnboarded(handleAdminEmailTemplatesEditPage)))
|
||||
protected.HandleFunc("GET /admin/event-types", adminGate(users, gateOnboarded(handleAdminEventTypesPage)))
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("GET /api/admin/users/unonboarded", adminGate(users, handleAdminListUnonboarded))
|
||||
@@ -333,6 +334,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/admin/email-templates/{key}/{lang}/reset", adminGate(users, handleAdminResetEmailTemplate))
|
||||
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}/versions", adminGate(users, handleAdminListEmailTemplateVersions))
|
||||
protected.HandleFunc("POST /api/admin/email-templates/{key}/{lang}/restore/{version_id}", adminGate(users, handleAdminRestoreEmailTemplateVersion))
|
||||
|
||||
// t-paliad-089 — admin Event-Type moderation panel.
|
||||
protected.HandleFunc("GET /api/admin/event-types", adminGate(users, handleAdminListEventTypes))
|
||||
protected.HandleFunc("GET /api/admin/event-types/private", adminGate(users, handleAdminListPrivateEventTypes))
|
||||
protected.HandleFunc("POST /api/admin/event-types/archive", adminGate(users, handleAdminBulkArchiveEventTypes))
|
||||
protected.HandleFunc("POST /api/admin/event-types/merge", adminGate(users, handleAdminMergeEventTypes))
|
||||
protected.HandleFunc("POST /api/admin/event-types/{id}/promote", adminGate(users, handleAdminPromoteEventType))
|
||||
protected.HandleFunc("POST /api/admin/event-types/{id}/restore", adminGate(users, handleAdminRestoreEventType))
|
||||
}
|
||||
|
||||
// Catch-all 404 — runs for any authenticated path that no more-specific
|
||||
|
||||
@@ -326,6 +326,287 @@ func (s *EventTypeService) SuggestSimilar(ctx context.Context, userID uuid.UUID,
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Admin moderation surface (t-paliad-089)
|
||||
// ============================================================================
|
||||
//
|
||||
// Q6 of t-paliad-088 left firm-wide creation open to any authenticated user.
|
||||
// Admins moderate after the fact — bulk-archive, promote private→firm-wide,
|
||||
// merge duplicates, and restore archived rows. Authorization for every method
|
||||
// below is the caller's responsibility (handlers wrap the routes in
|
||||
// auth.RequireAdminFunc); the service trusts that adminID has already passed
|
||||
// the gate but still asserts global_admin defensively before any mutation.
|
||||
|
||||
// EventTypeWithUsage is one row in the admin moderation list. Embeds the
|
||||
// EventType plus the count of paliad.deadline_event_types rows referencing
|
||||
// it and the author's display_name (NULL on system seeds). Authors that have
|
||||
// since been deleted resolve to display_name=NULL — the row still renders.
|
||||
type EventTypeWithUsage struct {
|
||||
models.EventType
|
||||
UsageCount int `db:"usage_count" json:"usage_count"`
|
||||
AuthorDisplayName *string `db:"author_display_name" json:"author_display_name,omitempty"`
|
||||
}
|
||||
|
||||
// EventTypeAdminFilter narrows ListAllForAdmin / ListPrivatePendingPromotion.
|
||||
// IncludeArchived=true surfaces archived rows alongside live ones (the panel's
|
||||
// "Archivierte anzeigen" toggle).
|
||||
type EventTypeAdminFilter struct {
|
||||
IncludeArchived bool
|
||||
}
|
||||
|
||||
// requireAdmin is the defence-in-depth gate every admin-method below runs
|
||||
// before mutating. The handler already wrapped the route in RequireAdminFunc,
|
||||
// so this is belt-and-braces — it makes the service safe to call from a future
|
||||
// non-HTTP entry point (CLI, batch job) without re-implementing the role
|
||||
// check at every site.
|
||||
func (s *EventTypeService) requireAdmin(ctx context.Context, adminID uuid.UUID) error {
|
||||
user, err := s.users.GetByID(ctx, adminID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user == nil || user.GlobalRole != "global_admin" {
|
||||
return fmt.Errorf("%w: admin role required", ErrForbidden)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListAllForAdmin returns every firm-wide event_type (live + archived when
|
||||
// the filter requests it), enriched with the deadline_event_types usage count
|
||||
// and the author's display_name. Sorted by category, then label_de.
|
||||
//
|
||||
// Private types are intentionally excluded — the panel's "promote" surface
|
||||
// uses ListPrivatePendingPromotion for those.
|
||||
func (s *EventTypeService) ListAllForAdmin(ctx context.Context, adminID uuid.UUID, filter EventTypeAdminFilter) ([]EventTypeWithUsage, error) {
|
||||
if err := s.requireAdmin(ctx, adminID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
conds := []string{"et.is_firm_wide = TRUE"}
|
||||
if !filter.IncludeArchived {
|
||||
conds = append(conds, "et.archived_at IS NULL")
|
||||
}
|
||||
rows := []EventTypeWithUsage{}
|
||||
q := `SELECT et.id, et.slug, et.label_de, et.label_en, et.category, et.jurisdiction,
|
||||
et.description, et.trigger_event_id, et.created_by, et.is_firm_wide,
|
||||
et.archived_at, et.created_at, et.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM paliad.deadline_event_types det
|
||||
WHERE det.event_type_id = et.id), 0) AS usage_count,
|
||||
u.display_name AS author_display_name
|
||||
FROM paliad.event_types et
|
||||
LEFT JOIN paliad.users u ON u.id = et.created_by
|
||||
WHERE ` + strings.Join(conds, " AND ") + `
|
||||
ORDER BY et.archived_at IS NOT NULL ASC, et.category ASC, et.label_de ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q); err != nil {
|
||||
return nil, fmt.Errorf("list event_types for admin: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ListPrivatePendingPromotion returns every private (is_firm_wide=false),
|
||||
// non-archived event_type across all users — feeds the admin's "promote"
|
||||
// list. Same enrichment as ListAllForAdmin so the UI can reuse the row
|
||||
// shape. Sorted by usage_count DESC (most-used first), then label_de.
|
||||
func (s *EventTypeService) ListPrivatePendingPromotion(ctx context.Context, adminID uuid.UUID) ([]EventTypeWithUsage, error) {
|
||||
if err := s.requireAdmin(ctx, adminID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows := []EventTypeWithUsage{}
|
||||
const q = `SELECT et.id, et.slug, et.label_de, et.label_en, et.category, et.jurisdiction,
|
||||
et.description, et.trigger_event_id, et.created_by, et.is_firm_wide,
|
||||
et.archived_at, et.created_at, et.updated_at,
|
||||
COALESCE((SELECT COUNT(*) FROM paliad.deadline_event_types det
|
||||
WHERE det.event_type_id = et.id), 0) AS usage_count,
|
||||
u.display_name AS author_display_name
|
||||
FROM paliad.event_types et
|
||||
LEFT JOIN paliad.users u ON u.id = et.created_by
|
||||
WHERE et.is_firm_wide = FALSE
|
||||
AND et.archived_at IS NULL
|
||||
ORDER BY usage_count DESC, et.label_de ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, q); err != nil {
|
||||
return nil, fmt.Errorf("list private event_types for admin: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// ArchiveBulk soft-deletes a set of firm-wide event_types in one round-trip.
|
||||
// Already-archived rows are silently no-oped (the SET ... WHERE skips them).
|
||||
// Returns the count of rows actually archived.
|
||||
func (s *EventTypeService) ArchiveBulk(ctx context.Context, adminID uuid.UUID, ids []uuid.UUID) (int, error) {
|
||||
if err := s.requireAdmin(ctx, adminID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.event_types
|
||||
SET archived_at = now(), updated_at = now()
|
||||
WHERE id = ANY($1)
|
||||
AND is_firm_wide = TRUE
|
||||
AND archived_at IS NULL`, ids)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("archive bulk event_types: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("archive bulk event_types rowcount: %w", err)
|
||||
}
|
||||
return int(n), nil
|
||||
}
|
||||
|
||||
// Promote flips is_firm_wide=true on a private event_type. Slug uniqueness
|
||||
// could collide (a firm-wide row with the same slug may already exist) —
|
||||
// promotion fails with ErrEventTypeSlugTaken in that case so the admin can
|
||||
// merge instead. Author retains created_by; the row simply becomes visible
|
||||
// to everyone.
|
||||
func (s *EventTypeService) Promote(ctx context.Context, adminID, id uuid.UUID) (*models.EventType, error) {
|
||||
if err := s.requireAdmin(ctx, adminID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.event_types
|
||||
SET is_firm_wide = TRUE, updated_at = now()
|
||||
WHERE id = $1
|
||||
AND is_firm_wide = FALSE
|
||||
AND archived_at IS NULL`, id)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrEventTypeSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("promote event_type: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("promote event_type rowcount: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
var et models.EventType
|
||||
if err := s.db.GetContext(ctx, &et,
|
||||
`SELECT `+eventTypeColumns+` FROM paliad.event_types WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("fetch promoted event_type: %w", err)
|
||||
}
|
||||
return &et, nil
|
||||
}
|
||||
|
||||
// Restore flips archived_at back to NULL. Slug-uniqueness collisions are
|
||||
// possible if a new firm-wide row with the same slug was created in the
|
||||
// meantime — surface as ErrEventTypeSlugTaken so the admin can merge.
|
||||
func (s *EventTypeService) Restore(ctx context.Context, adminID, id uuid.UUID) (*models.EventType, error) {
|
||||
if err := s.requireAdmin(ctx, adminID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.event_types
|
||||
SET archived_at = NULL, updated_at = now()
|
||||
WHERE id = $1
|
||||
AND archived_at IS NOT NULL`, id)
|
||||
if err != nil {
|
||||
if isUniqueViolation(err) {
|
||||
return nil, ErrEventTypeSlugTaken
|
||||
}
|
||||
return nil, fmt.Errorf("restore event_type: %w", err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("restore event_type rowcount: %w", err)
|
||||
}
|
||||
if n == 0 {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
var et models.EventType
|
||||
if err := s.db.GetContext(ctx, &et,
|
||||
`SELECT `+eventTypeColumns+` FROM paliad.event_types WHERE id = $1`, id); err != nil {
|
||||
return nil, fmt.Errorf("fetch restored event_type: %w", err)
|
||||
}
|
||||
return &et, nil
|
||||
}
|
||||
|
||||
// MergeIDs redirects every paliad.deadline_event_types row referencing one
|
||||
// of `loserIDs` to `winnerID`, then archives the losers. Junction PK dedups
|
||||
// duplicate (deadline, winner) pairs that arise from the redirect — the
|
||||
// pattern is INSERT ... ON CONFLICT DO NOTHING followed by DELETE on the
|
||||
// loser rows that didn't get redirected.
|
||||
//
|
||||
// The whole flow runs in one tx: if anything fails halfway, both the
|
||||
// junction redirect and the archive roll back together. winnerID must exist
|
||||
// and not be in loserIDs; loserIDs must be non-empty.
|
||||
func (s *EventTypeService) MergeIDs(ctx context.Context, adminID, winnerID uuid.UUID, loserIDs []uuid.UUID) error {
|
||||
if err := s.requireAdmin(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(loserIDs) == 0 {
|
||||
return fmt.Errorf("%w: at least one loser id required", ErrInvalidInput)
|
||||
}
|
||||
for _, id := range loserIDs {
|
||||
if id == winnerID {
|
||||
return fmt.Errorf("%w: winner cannot also be a loser", ErrInvalidInput)
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin merge tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// Verify the winner exists and is firm-wide. Allow archived winners only
|
||||
// if the admin explicitly restores them first — merging onto an archived
|
||||
// row would leave the new junction rows pointing at an invisible type.
|
||||
var winnerInfo struct {
|
||||
ArchivedAt sql.NullTime `db:"archived_at"`
|
||||
IsFirmWide bool `db:"is_firm_wide"`
|
||||
}
|
||||
if err := tx.GetContext(ctx, &winnerInfo,
|
||||
`SELECT archived_at, is_firm_wide FROM paliad.event_types WHERE id = $1`, winnerID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: winner %s not found", ErrInvalidInput, winnerID)
|
||||
}
|
||||
return fmt.Errorf("fetch merge winner: %w", err)
|
||||
}
|
||||
if winnerInfo.ArchivedAt.Valid {
|
||||
return fmt.Errorf("%w: winner %s is archived — restore first", ErrInvalidInput, winnerID)
|
||||
}
|
||||
if !winnerInfo.IsFirmWide {
|
||||
return fmt.Errorf("%w: winner %s must be firm-wide — promote first", ErrInvalidInput, winnerID)
|
||||
}
|
||||
|
||||
// Step 1 — copy each (deadline_id, loser_id) row to (deadline_id, winner_id),
|
||||
// dedupping on the junction's PK. Rows where the deadline already references
|
||||
// the winner are skipped by ON CONFLICT.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id)
|
||||
SELECT deadline_id, $1
|
||||
FROM paliad.deadline_event_types
|
||||
WHERE event_type_id = ANY($2)
|
||||
ON CONFLICT DO NOTHING`, winnerID, loserIDs); err != nil {
|
||||
return fmt.Errorf("redirect deadline_event_types to winner: %w", err)
|
||||
}
|
||||
|
||||
// Step 2 — drop the loser junction rows. The unique PK on (deadline_id,
|
||||
// event_type_id) ensures no duplicates are left.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_event_types WHERE event_type_id = ANY($1)`, loserIDs); err != nil {
|
||||
return fmt.Errorf("delete loser deadline_event_types: %w", err)
|
||||
}
|
||||
|
||||
// Step 3 — archive the losers so they vanish from pickers. Hard-delete is
|
||||
// off the table per design (preserve history; the junction is already
|
||||
// redirected so live deadlines all point at the winner).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.event_types
|
||||
SET archived_at = now(), updated_at = now()
|
||||
WHERE id = ANY($1)
|
||||
AND archived_at IS NULL`, loserIDs); err != nil {
|
||||
return fmt.Errorf("archive merge losers: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit merge tx: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Junction: paliad.deadline_event_types
|
||||
// ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user