Merge: t-paliad-089 — Admin Event-Type moderation panel (bulk archive, merge, promote, restore)

This commit is contained in:
m
2026-04-30 16:43:09 +02:00
11 changed files with 1267 additions and 0 deletions

View File

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

View 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 &mdash; 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&uuml;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&auml;hlte archivieren
</button>
<button className="btn-primary" id="aet-bulk-merge" type="button" data-i18n="admin.event_types.action.merge_selected">
Zusammenf&uuml;hren&hellip;
</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&ouml;rderung)
</h3>
<p className="tool-subtitle" data-i18n="admin.event_types.section.private_pending.hint">
Private Typen anderer Kolleg:innen, sortiert nach H&auml;ufigkeit. Bef&ouml;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&uuml;hren</h2>
<button className="modal-close" id="aet-merge-close" type="button" aria-label="Close">&times;</button>
</div>
<p data-i18n="admin.event_types.merge.body" className="invite-modal-body">
W&auml;hlen Sie den Gewinner-Typ. Die Junction-Eintr&auml;ge der Verlierer werden auf den Gewinner umgeleitet, anschlie&szlig;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&uuml;hren</button>
</div>
</form>
</div>
</div>
<Footer />
<script src="/assets/admin-event-types.js"></script>
</body>
</html>
);
}

View File

@@ -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&uuml;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&uuml;hren, bef&ouml;rdern.</p>
</a>
</div>
<h3 className="section-heading admin-section-planned" data-i18n="admin.section.planned">Geplant</h3>

View 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&ouml;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}&times;
</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();
});
});

View File

@@ -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",
},
};

View File

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

View File

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

View File

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

View 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")
}

View File

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

View File

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