Compare commits

..

1 Commits

Author SHA1 Message Date
mAi
99c9d89daa feat(backups): t-paliad-246 — Backup Mode Slice A (on-demand admin org export)
m/paliad#77 Slice A. Folds the unbuilt t-paliad-214 Slice 3 (org async
export) into a new "Backup Mode" surface gated by adminGate.

m's calls (all 4 material picks per design §2):
- Storage: local disk PALIAD_EXPORT_DIR (LocalDiskStore only)
- Format: .zip bundle (xlsx + JSON + CSV + README) — no-lock-in preserved
- paliadin_turns + paliadin_aichat_conversation: EXCLUDE structurally
- Scheduler (Slice B): nightly 03:00 UTC, env-tunable

Wiring:
- mig 123 adds paliad.backups catalog table (kind/status/storage_uri/
  size/row_counts/warnings/error/deleted_at + admin-only RLS).
- ExportService.WriteOrg + orgSheetQueries enumerate 37 entity sheets
  + 12 ref sheets; REPEATABLE READ READ ONLY tx wraps the dump for
  snapshot consistency (design §3.3).
- writeBundle + runSheetQuery refactored to take a sqlx.QueryerContext
  so both *sqlx.DB (personal/project paths, unchanged) and *sqlx.Tx
  (org snapshot path) work.
- BackupRunner orchestrates: catalog INSERT → audit INSERT
  (event_type='backup_created') → WriteOrg → ArtifactStore.Put → patch
  catalog + audit on success/failure.
- ArtifactStore interface + LocalDiskStore impl (defense-in-depth key
  validation + URI-outside-dir guard).
- Sentinel actor for scheduled runs: actor_email='system@paliad',
  actor_id=NULL — no phantom user in paliad.users.
- Admin handlers POST /api/admin/backups/run + GET list/get/download
  behind adminGate(users, …); /admin/backups page + sidebar entry +
  bilingual i18n keys.
- BackupRunner only wired when PALIAD_EXPORT_DIR is set; routes return
  503 otherwise (same shape as requireDB).

Tests: 8 pure-function tests cover registry shape (no dups, paliadin
absent both as sheet name and SQL substring, ref__* sheets unscoped,
every sheet has ORDER BY) and LocalDiskStore (round-trip, bad-key
rejection, URI-traversal rejection, mkdir on construction).

go build ./... + go test ./internal/... clean. bun run build clean.

Slice B (BackupScheduler + retention cleanup) and Slice C (UI polish)
are separate follow-ups per head's instruction.
2026-05-25 15:28:37 +02:00
23 changed files with 1705 additions and 921 deletions

View File

@@ -220,6 +220,23 @@ func main() {
Export: services.NewExportService(pool, branding.Name),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
// PALIAD_EXPORT_DIR is set (LocalDiskStore needs a target
// directory). Without it the /admin/backups handlers return 503
// in the same shape as Paliadin's gate. The directory is created
// (0700) on first use; a malformed path fails fast at boot so
// misconfig surfaces before the server starts taking traffic.
if exportDir := strings.TrimSpace(os.Getenv("PALIAD_EXPORT_DIR")); exportDir != "" {
store, err := services.NewLocalDiskStore(exportDir)
if err != nil {
log.Fatalf("PALIAD_EXPORT_DIR: %v", err)
}
svcBundle.Backup = services.NewBackupRunner(pool, svcBundle.Export, store)
log.Printf("backup: LocalDiskStore at %s (/admin/backups active)", exportDir)
} else {
log.Println("PALIAD_EXPORT_DIR not set — /admin/backups will return 503")
}
// t-paliad-219 Slice A3 — stitch DashboardService → ApprovalService
// for the inbox-approvals widget. Done post-construction to avoid
// a circular constructor dependency (ApprovalService doesn't need

View File

@@ -49,6 +49,7 @@ import { renderAdminRulesEdit } from "./src/admin-rules-edit";
import { renderAdminRulesExport } from "./src/admin-rules-export";
import { renderPaliadin } from "./src/paliadin";
import { renderAdminPaliadin } from "./src/admin-paliadin";
import { renderAdminBackups } from "./src/admin-backups";
import { renderNotFound } from "./src/notfound";
const DIST = join(import.meta.dir, "dist");
@@ -291,6 +292,7 @@ async function build() {
// skip the re-fetch.
join(import.meta.dir, "src/client/paliadin-widget.ts"),
join(import.meta.dir, "src/client/admin-paliadin.ts"),
join(import.meta.dir, "src/client/admin-backups.ts"),
join(import.meta.dir, "src/client/notfound.ts"),
],
outdir: join(DIST, "assets"),
@@ -417,6 +419,7 @@ async function build() {
await Bun.write(join(DIST, "admin-rules-export.html"), renderAdminRulesExport());
await Bun.write(join(DIST, "paliadin.html"), renderPaliadin());
await Bun.write(join(DIST, "admin-paliadin.html"), renderAdminPaliadin());
await Bun.write(join(DIST, "admin-backups.html"), renderAdminBackups());
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,96 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Backup Mode admin page (t-paliad-246 / m/paliad#77 Slice A).
//
// global_admin only — gated by adminGate(...) in handlers.go. Shows the
// chronological list of backup runs (one row per kind in
// {scheduled, on_demand}) plus a button to kick off an on-demand backup.
// Catalog rows + the "run now" action are fetched client-side via
// /api/admin/backups.
export function renderAdminBackups(): 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.backups.title">Backups &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/backups" />
<BottomNav currentPath="/admin/backups" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.backups.heading">Backups</h1>
<p className="tool-subtitle" data-i18n="admin.backups.subtitle">
Vollst&auml;ndige Snapshots aller Daten &mdash; manuell oder zeitgesteuert.
</p>
</div>
<div>
<button
className="btn-primary"
id="admin-backups-run-btn"
type="button"
data-i18n="admin.backups.run_now"
>
Backup jetzt erstellen
</button>
</div>
</div>
<div id="admin-backups-feedback" className="form-msg" style="display:none" />
<div className="entity-table-wrap">
<table className="entity-table entity-table--readonly">
<thead>
<tr>
<th data-i18n="admin.backups.col.started">Erstellt</th>
<th data-i18n="admin.backups.col.kind">Auslöser</th>
<th data-i18n="admin.backups.col.status">Status</th>
<th data-i18n="admin.backups.col.requested_by">Angefordert von</th>
<th data-i18n="admin.backups.col.size">Gr&ouml;&szlig;e</th>
<th data-i18n="admin.backups.col.rows">Zeilen</th>
<th data-i18n="admin.backups.col.actions">Aktion</th>
</tr>
</thead>
<tbody id="admin-backups-tbody">
<tr>
<td colspan={7} data-i18n="admin.backups.loading">Lade &hellip;</td>
</tr>
</tbody>
</table>
</div>
<div className="entity-empty" id="admin-backups-empty" style="display:none">
<p data-i18n="admin.backups.empty">Noch keine Backups vorhanden.</p>
</div>
<p className="tool-footer-note" id="admin-backups-footer">
<span data-i18n="admin.backups.footer.note">
Geplante Backups werden in einer sp&auml;teren Slice aktiviert. Manuelle Backups stehen jetzt zur Verf&uuml;gung.
</span>
</p>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-backups.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,192 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
// Backup Mode admin client (t-paliad-246 / m/paliad#77 Slice A).
//
// Reads /api/admin/backups (chronological list) and wires the
// "Backup jetzt erstellen" button to POST /api/admin/backups/run.
// Synchronous: the server holds the connection for the duration of
// the backup (sub-second at firm-scale today), then returns the new
// catalog row inline. No polling needed at v1's data shape; if the
// run takes > 5 minutes the handler returns 500 and the UI surfaces
// the error.
interface BackupRow {
id: string;
kind: "scheduled" | "on_demand";
status: "running" | "done" | "failed";
requested_by?: string;
requested_by_email: string;
audit_id?: string;
storage_uri?: string;
size_bytes?: number;
row_counts?: unknown; // jsonb passes through as raw bytes; we don't read it
sheet_count?: number;
warnings?: unknown;
error?: string;
started_at: string;
finished_at?: string;
deleted_at?: string;
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
await refreshList();
wireRunButton();
});
function wireRunButton(): void {
const btn = document.getElementById("admin-backups-run-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
btn.disabled = true;
const originalText = btn.textContent;
btn.textContent = t("admin.backups.running") || "Läuft …";
clearFeedback();
try {
const r = await fetch("/api/admin/backups/run", {
method: "POST",
credentials: "same-origin",
});
if (!r.ok) {
const body = await r.json().catch(() => ({ error: "request failed" }));
showFeedback("error", body.error || `HTTP ${r.status}`);
return;
}
// The created row is in the response; refresh the list to land it.
await refreshList();
showFeedback("success", t("admin.backups.success") || "Backup erfolgreich erstellt.");
} catch (e) {
showFeedback("error", (e as Error).message || "network error");
} finally {
btn.disabled = false;
btn.textContent = originalText;
}
});
}
async function refreshList(): Promise<void> {
const rows = await fetchJSON<BackupRow[]>("/api/admin/backups?limit=200");
const tbody = document.getElementById("admin-backups-tbody") as HTMLTableSectionElement | null;
const empty = document.getElementById("admin-backups-empty") as HTMLElement | null;
if (!tbody) return;
if (!rows || rows.length === 0) {
tbody.innerHTML = "";
if (empty) empty.style.display = "";
return;
}
if (empty) empty.style.display = "none";
tbody.innerHTML = rows.map(renderRow).join("");
}
function renderRow(b: BackupRow): string {
const started = formatTimestamp(b.started_at);
const kind =
b.kind === "scheduled"
? t("admin.backups.kind.scheduled") || "Geplant"
: t("admin.backups.kind.on_demand") || "Manuell";
const status = renderStatus(b);
const requestedBy =
b.kind === "scheduled" ? "—" : escapeHTML(b.requested_by_email);
const size = b.size_bytes != null ? formatBytes(b.size_bytes) : "—";
const rows = b.sheet_count != null ? String(b.sheet_count) : "—";
const action = renderAction(b);
return `<tr>
<td>${started}</td>
<td>${kind}</td>
<td>${status}</td>
<td>${requestedBy}</td>
<td>${size}</td>
<td>${rows}</td>
<td>${action}</td>
</tr>`;
}
function renderStatus(b: BackupRow): string {
switch (b.status) {
case "done":
return `<span class="status-done">${escapeHTML(t("admin.backups.status.done") || "✓ Fertig")}</span>`;
case "running":
return `<span class="status-running">${escapeHTML(t("admin.backups.status.running") || "Läuft …")}</span>`;
case "failed":
const label = t("admin.backups.status.failed") || "✗ Fehlgeschlagen";
const tip = b.error ? ` title="${escapeAttr(b.error)}"` : "";
return `<span class="status-failed"${tip}>${escapeHTML(label)}</span>`;
default:
return escapeHTML(b.status);
}
}
function renderAction(b: BackupRow): string {
if (b.status !== "done" || !b.storage_uri || b.deleted_at) {
return "—";
}
const label = t("admin.backups.download") || "Download";
return `<a class="btn-link" href="/api/admin/backups/${encodeURIComponent(b.id)}/file">${escapeHTML(label)}</a>`;
}
// --- helpers ---
async function fetchJSON<T>(url: string): Promise<T | null> {
try {
const r = await fetch(url, { credentials: "same-origin" });
if (!r.ok) return null;
return (await r.json()) as T;
} catch {
return null;
}
}
function formatTimestamp(iso: string): string {
const d = new Date(iso);
if (isNaN(d.getTime())) return escapeHTML(iso);
const yyyy = d.getUTCFullYear();
const mm = String(d.getUTCMonth() + 1).padStart(2, "0");
const dd = String(d.getUTCDate()).padStart(2, "0");
const hh = String(d.getUTCHours()).padStart(2, "0");
const mi = String(d.getUTCMinutes()).padStart(2, "0");
return `${yyyy}-${mm}-${dd} ${hh}:${mi} UTC`;
}
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`;
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function escapeHTML(s: string): string {
return s.replace(/[&<>"']/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
case "'": return "&#39;";
default: return c;
}
});
}
function escapeAttr(s: string): string {
return escapeHTML(s);
}
function showFeedback(kind: "success" | "error", text: string): void {
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
if (!el) return;
el.textContent = text;
el.classList.remove("form-msg-success", "form-msg-error");
el.classList.add(kind === "success" ? "form-msg-success" : "form-msg-error");
el.style.display = "";
}
function clearFeedback(): void {
const el = document.getElementById("admin-backups-feedback") as HTMLElement | null;
if (!el) return;
el.style.display = "none";
el.textContent = "";
el.classList.remove("form-msg-success", "form-msg-error");
}

View File

@@ -2350,6 +2350,31 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin audit log (t-paliad-071)
"nav.admin.audit": "Audit-Log",
"nav.admin.partner_units": "Partner Units",
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
"nav.admin.backups": "Backups",
"admin.backups.title": "Backups — Paliad",
"admin.backups.heading": "Backups",
"admin.backups.subtitle": "Vollständige Snapshots aller Daten — manuell oder zeitgesteuert.",
"admin.backups.run_now": "Backup jetzt erstellen",
"admin.backups.running": "Läuft …",
"admin.backups.success": "Backup erfolgreich erstellt.",
"admin.backups.empty": "Noch keine Backups vorhanden.",
"admin.backups.loading": "Lade …",
"admin.backups.col.started": "Erstellt",
"admin.backups.col.kind": "Auslöser",
"admin.backups.col.status": "Status",
"admin.backups.col.requested_by": "Angefordert von",
"admin.backups.col.size": "Größe",
"admin.backups.col.rows": "Sheets",
"admin.backups.col.actions": "Aktion",
"admin.backups.kind.scheduled": "Geplant",
"admin.backups.kind.on_demand": "Manuell",
"admin.backups.status.running": "Läuft …",
"admin.backups.status.done": "✓ Fertig",
"admin.backups.status.failed": "✗ Fehlgeschlagen",
"admin.backups.download": "Download",
"admin.backups.footer.note": "Geplante Backups werden in einer späteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.",
"admin.audit.title": "Audit-Log — Paliad",
"admin.audit.heading": "Audit-Log",
"admin.audit.subtitle": "Globale Zeitleiste über Projekt-, CalDAV-, Reminder- und Partner-Unit-Ereignisse.",
@@ -5293,6 +5318,31 @@ const translations: Record<Lang, Record<string, string>> = {
// Admin audit log (t-paliad-071)
"nav.admin.audit": "Audit Log",
"nav.admin.partner_units": "Partner Units",
// Admin Backup Mode (t-paliad-246 / m/paliad#77)
"nav.admin.backups": "Backups",
"admin.backups.title": "Backups — Paliad",
"admin.backups.heading": "Backups",
"admin.backups.subtitle": "Full snapshots of all data — manual or scheduled.",
"admin.backups.run_now": "Run backup now",
"admin.backups.running": "Running …",
"admin.backups.success": "Backup created successfully.",
"admin.backups.empty": "No backups yet.",
"admin.backups.loading": "Loading …",
"admin.backups.col.started": "Started",
"admin.backups.col.kind": "Trigger",
"admin.backups.col.status": "Status",
"admin.backups.col.requested_by": "Requested by",
"admin.backups.col.size": "Size",
"admin.backups.col.rows": "Sheets",
"admin.backups.col.actions": "Action",
"admin.backups.kind.scheduled": "Scheduled",
"admin.backups.kind.on_demand": "Manual",
"admin.backups.status.running": "Running …",
"admin.backups.status.done": "✓ Done",
"admin.backups.status.failed": "✗ Failed",
"admin.backups.download": "Download",
"admin.backups.footer.note": "Scheduled backups land in a later slice. Manual backups are available now.",
"admin.audit.title": "Audit Log — Paliad",
"admin.audit.heading": "Audit Log",
"admin.audit.subtitle": "Global timeline across project, CalDAV, reminder and partner-unit events.",

View File

@@ -605,90 +605,6 @@ function paintPreview(): void {
const host = document.getElementById("submission-draft-preview");
if (!host || !state.view) return;
host.innerHTML = state.view.preview_html ?? "";
wireDraftVars(host);
}
// t-paliad-261 (B) — click a substituted variable in the preview to
// jump to the matching sidebar input. Re-wires on every paintPreview
// since the preview HTML is replaced wholesale. The server side wraps
// each substituted placeholder (resolved OR missing marker) in
// <span class="draft-var" data-var="<key>">…</span>; clicks here scroll
// the corresponding input into view, focus + select, and flash the row.
// If the key has no matching sidebar input (derived variables not
// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span
// is still rendered so the user gets the visible hint that this is a
// resolved variable.
function wireDraftVars(previewHost: HTMLElement): void {
previewHost.querySelectorAll<HTMLElement>(".draft-var").forEach((el) => {
const key = el.dataset.var;
if (!key) return;
if (findVarInput(key)) {
el.classList.add("draft-var--has-input");
el.setAttribute("role", "button");
el.setAttribute("tabindex", "0");
el.setAttribute(
"aria-label",
(isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key),
);
}
el.addEventListener("click", (ev) => onDraftVarClick(key, ev));
el.addEventListener("keydown", (ev) => {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
onDraftVarClick(key, ev);
}
});
});
}
function findVarInput(key: string): HTMLInputElement | null {
const host = document.getElementById("submission-draft-variables");
if (!host) return null;
return host.querySelector<HTMLInputElement>(
`.submission-draft-var-input[data-var="${cssEscape(key)}"]`,
);
}
function cssEscape(s: string): string {
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
// older browsers may lack it; defensive fallback escapes characters
// CSS treats as special. Placeholder keys never carry whitespace or
// quotes so escaping is straightforward.
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
return CSS.escape(s);
}
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
}
function onDraftVarClick(key: string, ev: Event): void {
const input = findVarInput(key);
if (!input) return;
ev.preventDefault();
ev.stopPropagation();
// Smooth-scroll the input into view, then focus on the next tick so
// the scroll animation has started and the focus call doesn't trigger
// a second jarring jump.
input.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => {
input.focus();
try {
input.select();
} catch {
/* select() throws on number/email inputs; safe to ignore */
}
}, 50);
flashVarRow(input);
}
function flashVarRow(input: HTMLElement): void {
const row = input.closest<HTMLElement>(".submission-draft-var-row");
if (!row) return;
row.classList.remove("submission-draft-var-row--flash");
// Force reflow so removing+re-adding the class restarts the animation
// even on rapid successive clicks.
void row.offsetWidth;
row.classList.add("submission-draft-var-row--flash");
window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200);
}
// ─────────────────────────────────────────────────────────────────────
@@ -727,18 +643,11 @@ async function flushAutosave(): Promise<void> {
if (!state.pendingOverrides) return;
const payload = { variables: state.pendingOverrides };
state.pendingOverrides = null;
// t-paliad-261 (A) — paintVariables() below replaces every input in
// the sidebar via innerHTML, which blows away the active-element
// reference. Capture the focused input's key + selection range before
// the repaint and restore on the new element after, so the user can
// keep typing without clicking back into the field.
const focusSnap = captureVarFocus();
try {
const view = await patchDraft(payload);
state.view = view;
paintVariables();
paintPreview();
restoreVarFocus(focusSnap);
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
@@ -747,64 +656,6 @@ async function flushAutosave(): Promise<void> {
}
}
// captureVarFocus / restoreVarFocus — focus-preservation across the
// paintVariables() innerHTML-replace cycle (t-paliad-261 part A).
// Tracks selection start/end/direction so the cursor lands exactly
// where it was before the repaint, including any active selection
// range. Handles both <input> and <textarea> via the shared
// HTMLInputElement|HTMLTextAreaElement contract for selectionStart /
// selectionEnd / selectionDirection / setSelectionRange.
interface VarFocusSnapshot {
key: string;
start: number | null;
end: number | null;
dir: "forward" | "backward" | "none";
}
type SelectableEl = HTMLInputElement | HTMLTextAreaElement;
function isVarField(el: Element | null): el is SelectableEl {
if (!el) return false;
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
return false;
}
return el.classList.contains("submission-draft-var-input");
}
function captureVarFocus(): VarFocusSnapshot | null {
const active = document.activeElement;
if (!isVarField(active)) return null;
const key = active.dataset.var;
if (!key) return null;
return {
key,
start: active.selectionStart,
end: active.selectionEnd,
dir: (active.selectionDirection as "forward" | "backward" | "none" | null) ?? "forward",
};
}
function restoreVarFocus(snap: VarFocusSnapshot | null): void {
if (!snap) return;
const host = document.getElementById("submission-draft-variables");
if (!host) return;
const next = host.querySelector<SelectableEl>(
`.submission-draft-var-input[data-var="${cssEscape(snap.key)}"]`,
);
if (!next) return;
next.focus();
if (snap.start !== null && snap.end !== null) {
try {
next.setSelectionRange(snap.start, snap.end, snap.dir);
} catch {
/* setSelectionRange throws on inputs whose type doesn't support
selection ranges (number, email, etc.); safe to ignore — the
focus() call above is enough for those. */
}
}
}
async function renameDraft(newName: string): Promise<void> {
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {

View File

@@ -207,6 +207,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/rules/export", ICON_DOWNLOAD, "nav.admin.rules_export", "Regel-Migrations", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}
<a href="/admin/paliadin" id="sidebar-admin-paliadin-link"
className={`sidebar-item${currentPath === "/admin/paliadin" ? " active" : ""}`}

View File

@@ -90,6 +90,28 @@ export type I18nKey =
| "admin.audit.source.reminder_log"
| "admin.audit.subtitle"
| "admin.audit.title"
| "admin.backups.col.actions"
| "admin.backups.col.kind"
| "admin.backups.col.requested_by"
| "admin.backups.col.rows"
| "admin.backups.col.size"
| "admin.backups.col.started"
| "admin.backups.col.status"
| "admin.backups.download"
| "admin.backups.empty"
| "admin.backups.footer.note"
| "admin.backups.heading"
| "admin.backups.kind.on_demand"
| "admin.backups.kind.scheduled"
| "admin.backups.loading"
| "admin.backups.run_now"
| "admin.backups.running"
| "admin.backups.status.done"
| "admin.backups.status.failed"
| "admin.backups.status.running"
| "admin.backups.subtitle"
| "admin.backups.success"
| "admin.backups.title"
| "admin.broadcasts.col.count"
| "admin.broadcasts.col.sender"
| "admin.broadcasts.col.sent_at"
@@ -1894,6 +1916,7 @@ export type I18nKey =
| "login.title"
| "modal.close.label"
| "nav.admin.audit"
| "nav.admin.backups"
| "nav.admin.bereich"
| "nav.admin.event_types"
| "nav.admin.paliadin"

View File

@@ -5880,66 +5880,6 @@ dialog.modal::backdrop {
font-style: italic;
}
/* t-paliad-261 (B) — substituted variables in the preview are wrapped
in <span class="draft-var" data-var="…"> by the Go HTML renderer.
.draft-var by itself shows a subtle dotted underline so the lawyer
can SEE which text was filled in from a variable. .draft-var--has-input
(added client-side when a matching sidebar input exists) layers on
the clickable affordance — pointer cursor + brighter hover background.
Non-matching draft-vars (derived variables not exposed in the
sidebar) stay visually distinct but non-interactive. */
.draft-var {
background-color: rgba(198, 244, 28, 0.12);
border-radius: 2px;
padding: 0 2px;
box-decoration-break: clone;
-webkit-box-decoration-break: clone;
transition: background-color 0.15s ease;
}
.draft-var--has-input {
cursor: pointer;
}
.draft-var--has-input:hover,
.draft-var--has-input:focus-visible {
background-color: rgba(198, 244, 28, 0.45);
outline: none;
}
/* t-paliad-261 (B) — brief lime flash on the sidebar row after a
click-jump from the preview, so the user's eye lands on the right
input even after the smooth-scroll motion. Animation restarts on
each click via class-remove + reflow + class-add. */
.submission-draft-var-row--flash {
animation: paliad-var-flash 1.2s ease;
border-radius: 4px;
}
@keyframes paliad-var-flash {
0% {
background-color: rgba(198, 244, 28, 0.55);
box-shadow: 0 0 0 4px rgba(198, 244, 28, 0.25);
}
100% {
background-color: transparent;
box-shadow: 0 0 0 4px transparent;
}
}
@media (prefers-reduced-motion: reduce) {
.submission-draft-var-row--flash {
animation: paliad-var-flash-still 1.2s steps(1, end);
}
@keyframes paliad-var-flash-still {
0%, 99% { background-color: rgba(198, 244, 28, 0.55); }
100% { background-color: transparent; }
}
.draft-var {
transition: none;
}
}
.submission-edit-btn {
margin-right: 0.4rem;
}

View File

@@ -0,0 +1,11 @@
-- t-paliad-246 / m/paliad#77 — revert Backup Mode catalog table.
SELECT set_config(
'paliad.audit_reason',
'mig 123 down: drop paliad.backups catalog (t-paliad-246 / m/paliad#77 Slice A)',
true);
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
DROP INDEX IF EXISTS paliad.backups_kind_status_idx;
DROP INDEX IF EXISTS paliad.backups_started_at_desc_idx;
DROP TABLE IF EXISTS paliad.backups;

View File

@@ -0,0 +1,86 @@
-- t-paliad-246 / m/paliad#77 — Backup Mode catalog table.
--
-- Design: docs/design-backup-mode-2026-05-25.md §4. One row per backup
-- run (on-demand or scheduled). The catalog is operational metadata for
-- the /admin/backups UI (size, row counts, storage URI, status). The
-- audit chain stays on paliad.system_audit_log — this table is the
-- richer-shape duplicate that the UI lists from without parsing JSON.
--
-- INSERT/UPDATE happen only through the Go service path (BackupRunner)
-- under the migration-runner role, so we don't add a write RLS policy
-- for end users. SELECT is admin-only, mirroring system_audit_log.
--
-- Idempotent: CREATE TABLE / INDEX / POLICY all guarded.
SELECT set_config(
'paliad.audit_reason',
'mig 123: add paliad.backups catalog for Backup Mode (t-paliad-246 / m/paliad#77 Slice A)',
true);
CREATE TABLE IF NOT EXISTS paliad.backups (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind text NOT NULL CHECK (kind IN ('scheduled', 'on_demand')),
status text NOT NULL CHECK (status IN ('running', 'done', 'failed')),
-- requested_by is NULL for kind='scheduled' (no human caller).
requested_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- requested_by_email is captured at write time so the row survives
-- a subsequent user deletion. For scheduled runs we write a sentinel
-- like 'system@paliad' (no real user attached).
requested_by_email text NOT NULL,
-- audit_id back-references the system_audit_log row written before
-- the artifact is generated. Nullable so a catalog row can still be
-- INSERTed if the audit write itself fails (defense-in-depth).
audit_id uuid REFERENCES paliad.system_audit_log(id) ON DELETE SET NULL,
-- storage_uri is populated when status flips to 'done'. Resolves
-- through the Go-side ArtifactStore interface ('file://...' for
-- LocalDiskStore today; future stores get their own URI scheme).
storage_uri text,
size_bytes bigint,
row_counts jsonb NOT NULL DEFAULT '{}'::jsonb,
sheet_count int,
warnings jsonb NOT NULL DEFAULT '[]'::jsonb,
-- error is NULL unless status='failed'. Free-form, captured from
-- the Go-side error.Error().
error text,
started_at timestamptz NOT NULL DEFAULT now(),
finished_at timestamptz,
-- deleted_at marks artifacts the lifecycle cleanup removed from
-- storage (Slice B). The catalog row itself stays forever — it's
-- part of the audit chain. NULL means "still on disk".
deleted_at timestamptz
);
-- Read patterns:
-- - "show me recent backups" — started_at DESC
-- - "find last successful scheduled backup today" — kind + status + started_at
CREATE INDEX IF NOT EXISTS backups_started_at_desc_idx
ON paliad.backups (started_at DESC);
CREATE INDEX IF NOT EXISTS backups_kind_status_idx
ON paliad.backups (kind, status);
ALTER TABLE paliad.backups ENABLE ROW LEVEL SECURITY;
-- Admin-only read. INSERT/UPDATE/DELETE happen via the Go service path
-- under the migration-runner role (no end-user write surface).
DROP POLICY IF EXISTS backups_select_admin ON paliad.backups;
CREATE POLICY backups_select_admin ON paliad.backups
FOR SELECT USING (
EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid()
AND u.global_role = 'global_admin'
)
);
COMMENT ON TABLE paliad.backups IS
'Catalog of org-scope backup runs (t-paliad-246 / m/paliad#77). One row per scheduled or on-demand backup. status transitions: running → done | failed. storage_uri is resolved by the Go-side ArtifactStore interface. audit_id links to system_audit_log; the catalog row is the richer-shape duplicate, the audit row is the trust signal.';
COMMENT ON COLUMN paliad.backups.requested_by_email IS
'Captured at write time so the row survives user deletion. Sentinel ''system@paliad'' for scheduled runs.';
COMMENT ON COLUMN paliad.backups.storage_uri IS
'Resolved by the Go-side ArtifactStore implementation. file://... for LocalDiskStore; future stores use their own URI scheme.';
COMMENT ON COLUMN paliad.backups.deleted_at IS
'Set when the artifact is removed from storage by lifecycle cleanup. Catalog row stays forever (audit chain). NULL means artifact is still on disk.';

View File

@@ -1,103 +0,0 @@
-- Down migration for 125_cross_cutting_filter_legal_source.up.sql.
--
-- Rebuilds the mig 098 matview shape (NULL legal_source on trigger
-- rows) and removes the trigger-207 backfill row. Two steps in
-- forward-reverse order so the matview drop doesn't trip on the
-- deadline_rules delete.
SELECT set_config(
'paliad.audit_reason',
'mig 125 down: revert cross-cutting filter legal_source (drop trigger-207 backfill + rebuild matview without LEFT JOIN to deadline_rules).',
true);
-- 1. Drop the matview before pulling rows underneath it.
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- 2. Delete the trigger 207 backfill row.
DELETE FROM paliad.deadline_rules
WHERE trigger_event_id = 207
AND sequence_order = 1207;
-- 3. Recreate the mig 098 matview verbatim (NULL legal_source on
-- trigger rows).
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
NULL::text,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);

View File

@@ -1,222 +0,0 @@
-- t-paliad-266 / m/paliad#97 — make cross-cutting trigger pills filter
-- by court system in the event-type / Fristen search modal.
--
-- Two things land here:
--
-- 1. DATA — backfill the missing deadline_rules row for trigger 207
-- (Wegfall des Hindernisses, UPC R.320). Mig 063 added the
-- trigger_event but never seeded its event_deadlines counterpart;
-- mig 092 then dropped event_deadlines after copying the four
-- sibling Wiedereinsetzungen (ids 200..203) into deadline_rules,
-- so trigger 207 stayed orphaned with no duration / legal_source.
-- Adding the row makes UPC R.320 Wiedereinsetzung calculable on
-- par with the four siblings (2 months from removal of obstacle,
-- legal_source = 'UPC.RoP.320', party = 'both') and gives the
-- matview a legal_source to surface for the UPC trigger pill.
-- Pattern mirrors the four sibling rows mig 085 inserted.
--
-- 2. MATVIEW — rebuild paliad.deadline_search with a LEFT JOIN on
-- paliad.deadline_rules for trigger pills, exposing the trigger's
-- legal_source on the row. The cross-cutting concept card pills
-- then carry a structured citation prefix (UPC.* / DE.ZPO.* /
-- DE.PatG.* / EU.EPC* / EU.EPÜ.*) that the search service can
-- match against the active forum-bucket filter — see
-- DeadlineSearchService.translateForums + ForumToLegalSourcePrefixes
-- (added in this same change). Without the matview surfacing
-- legal_source for trigger rows, every cross-cutting sub-row
-- ignored the court-system chip selection (the bug m reported).
--
-- The materialised view paliad.deadline_search refreshes on the next
-- server boot via services.RefreshSearchView (cmd/server/main.go), so
-- the new legal_source column for triggers becomes searchable as soon
-- as the deploy restarts the process. No matview refresh from the
-- migration itself.
SELECT set_config(
'paliad.audit_reason',
'mig 125: t-paliad-266 — backfill missing deadline_rules row for trigger 207 (UPC R.320 Wiedereinsetzung) and rebuild deadline_search matview so trigger pills carry legal_source (cross-cutting court-system filter, m/paliad#97).',
true);
-- =============================================================================
-- 1. Backfill: deadline_rules row for trigger 207.
--
-- Idempotency: gated on NOT EXISTS by (trigger_event_id, name). Mirrors
-- mig 085's guard so re-runs are no-ops once the row is present.
-- =============================================================================
INSERT INTO paliad.deadline_rules (
id,
proceeding_type_id,
parent_id,
trigger_event_id,
spawn_proceeding_type_id,
submission_code,
name,
name_en,
primary_party,
event_type,
is_mandatory,
is_optional,
is_court_set,
is_spawn,
duration_value,
duration_unit,
timing,
alt_duration_value,
alt_duration_unit,
combine_op,
rule_code,
deadline_notes,
deadline_notes_en,
legal_source,
condition_expr,
condition_flag,
sequence_order,
is_active,
priority,
lifecycle_state,
draft_of,
published_at,
concept_id
)
SELECT
gen_random_uuid(),
NULL::integer,
NULL::uuid,
207,
NULL::integer,
NULL::text,
'Wiedereinsetzungsantrag (UPC R.320)',
'Petition for re-establishment of rights (UPC R.320)',
NULL::text,
NULL::text,
true,
false,
false,
false,
2,
'months',
'after',
NULL::integer,
NULL::text,
NULL::text,
NULL::text,
'Frist beträgt 2 Monate ab Wegfall des Hindernisses (R.320 RoP). Spätestens 12 Monate nach Ablauf der versäumten Frist.',
'Period is 2 months from removal of the obstacle (UPC R.320 RoP). Latest 12 months after expiry of the missed deadline.',
'UPC.RoP.320',
NULL::jsonb,
NULL::text[],
1207,
true,
'mandatory',
'published',
NULL::uuid,
now(),
(SELECT id FROM paliad.deadline_concepts WHERE slug = 'wiedereinsetzung')
WHERE NOT EXISTS (
SELECT 1
FROM paliad.deadline_rules dr
WHERE dr.trigger_event_id = 207
);
-- =============================================================================
-- 2. Matview rebuild — LEFT JOIN deadline_rules on trigger_event_id so
-- cross-cutting trigger pills carry legal_source. Indexes reproduced
-- verbatim from mig 098 §5.
--
-- The trigger-row JOIN matches the Pipeline-C convention (mig 085 §2.5 /
-- mig 092 §2): each cross-cutting trigger has a single deadline_rules
-- row with proceeding_type_id IS NULL. A trigger event without that
-- row leaves legal_source NULL and the trigger pill keeps its current
-- "no jurisdiction filter match" semantics — same shape as before this
-- migration, just structurally surfaceable.
-- =============================================================================
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT
'rule'::text AS kind,
'r:' || dr.id::text AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction AS jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source AS legal_source,
dr.rule_code AS rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active
AND pt.is_active
AND pt.category = 'fristenrechner'
UNION ALL
SELECT
'trigger'::text,
't:' || te.id::text,
dc.id,
dc.slug,
dc.name_de,
dc.name_en,
dc.description,
dc.aliases,
dc.party,
dc.category,
dc.sort_order,
NULL::uuid,
te.id,
NULL::text,
NULL::text,
NULL::text,
'cross-cutting'::text,
9999::int AS proceeding_display_order,
te.code,
te.name_de,
te.name,
dr_trig.legal_source AS legal_source,
NULL::text,
NULL::int,
NULL::text,
NULL::text,
dc.party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
LEFT JOIN paliad.deadline_rules dr_trig
ON dr_trig.trigger_event_id = te.id
AND dr_trig.proceeding_type_id IS NULL
AND dr_trig.is_active
AND dr_trig.lifecycle_state = 'published'
WHERE te.is_active;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);

View File

@@ -0,0 +1,247 @@
package handlers
// Admin Backup Mode handlers (t-paliad-246 / m/paliad#77 Slice A).
//
// POST /api/admin/backups/run — kick off an on-demand backup
// GET /api/admin/backups — chronological list
// GET /api/admin/backups/{id} — single catalog row
// GET /api/admin/backups/{id}/file — stream the artifact (records
// a backup_downloaded audit row)
// GET /admin/backups — admin page (SPA shell)
//
// Authorisation: every route registers behind adminGate(users, …) in
// handlers.go, so every handler in this file can assume the caller is a
// global_admin and only validate the request shape.
//
// The runner is wired in cmd/server/main.go only when PALIAD_EXPORT_DIR
// is set. When unset, every handler returns 503 — same shape as
// requireDB.
import (
"context"
"database/sql"
"errors"
"fmt"
"io"
"log"
"net/http"
"strconv"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// backupRequestTimeout caps a single on-demand backup. At firm-scale
// data shapes (today: ~600 user-content rows + ~1000 reference rows)
// a backup runs sub-second; the watchdog surfaces "stuck" as a 500
// instead of letting the client hang forever.
const backupRequestTimeout = 5 * time.Minute
// requireBackup writes a 503 if the BackupRunner is not wired (typically
// PALIAD_EXPORT_DIR is unset) and returns false. Mirrors requireDB.
func requireBackup(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.backup == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "backup service not configured — set PALIAD_EXPORT_DIR on the server",
})
return false
}
return true
}
// handleAdminBackupsPage renders the /admin/backups SPA shell. The
// catalog rows are fetched client-side via /api/admin/backups.
func handleAdminBackupsPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-backups.html")
}
// handleAdminRunBackup kicks off a synchronous on-demand backup and
// returns the resulting BackupSummary as JSON. Synchronous: at firm-
// scale the whole run is under 5s; an async path with polling is Slice
// B (the scheduler reuses the same runner internally).
//
// Returns 201 on success with the catalog row, 500 on failure (the
// catalog/audit rows are still flipped to failed/backup_failed before
// the response).
func handleAdminRunBackup(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), backupRequestTimeout)
defer cancel()
user, err := dbSvc.users.GetByID(ctx, uid)
if err != nil || user == nil {
log.Printf("backup: user lookup failed for %s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "user lookup failed",
})
return
}
actor := services.BackupActor{
ID: &uid,
Email: user.Email,
Label: user.DisplayName,
}
result, err := dbSvc.backup.Run(ctx, services.BackupKindOnDemand, actor)
if err != nil {
log.Printf("backup: Run failed for admin=%s: %v", uid, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "backup generation failed: " + err.Error(),
})
return
}
// Return the freshly-written catalog row so the UI doesn't need a
// follow-up GET to render the new line item.
row, err := dbSvc.backup.GetBackup(ctx, result.ID)
if err != nil {
// The backup did succeed — log + return the bare result.
log.Printf("backup: post-run GetBackup failed for %s: %v", result.ID, err)
writeJSON(w, http.StatusCreated, result)
return
}
writeJSON(w, http.StatusCreated, row)
}
// handleAdminListBackups returns the most recent N catalog rows as
// JSON. ?limit=N caps the page (default 100).
func handleAdminListBackups(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
return
}
limit := 100
if q := strings.TrimSpace(r.URL.Query().Get("limit")); q != "" {
if n, err := strconv.Atoi(q); err == nil && n > 0 && n <= 500 {
limit = n
}
}
rows, err := dbSvc.backup.ListBackups(r.Context(), limit)
if err != nil {
log.Printf("backup: list failed: %v", err)
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "list failed",
})
return
}
if rows == nil {
rows = []services.BackupSummary{}
}
writeJSON(w, http.StatusOK, rows)
}
// handleAdminGetBackup returns one catalog row. Used by the UI for
// "is the backup I just kicked off done yet?" polling — though at the
// synchronous shape today this rarely matters.
func handleAdminGetBackup(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(w) {
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.backup.GetBackup(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
log.Printf("backup: get failed for %s: %v", id, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
return
}
writeJSON(w, http.StatusOK, row)
}
// handleAdminDownloadBackup streams the artifact bytes through the
// ArtifactStore (LocalDiskStore for v1). Records a backup_downloaded
// audit row before flushing.
//
// 404 if the catalog row is missing; 410 (Gone) if the artifact was
// already lifecycle-deleted; 409 if status is not 'done'; 500 on any
// store/IO error.
func handleAdminDownloadBackup(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) || !requireBackup(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.backup.GetBackup(r.Context(), id)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "not found"})
return
}
log.Printf("backup: download GetBackup failed for %s: %v", id, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "get failed"})
return
}
if row.Status != services.BackupStatusDone || row.StorageURI == nil {
writeJSON(w, http.StatusConflict, map[string]string{
"error": "backup not available for download",
"status": row.Status,
})
return
}
if row.DeletedAt != nil {
// 410 Gone — the artifact is past its retention window. Catalog
// row stays as the audit trail; clients should not retry.
writeJSON(w, http.StatusGone, map[string]string{
"error": "artifact has been removed (retention)",
})
return
}
rc, size, err := dbSvc.backup.Store().Get(r.Context(), *row.StorageURI)
if err != nil {
log.Printf("backup: download store.Get failed for %s: %v", id, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "store read failed"})
return
}
defer rc.Close()
// Record the download audit row before flushing. If the audit
// write fails we still serve the file (the user can see it; the
// chain just missed a row — surface in logs).
user, uErr := dbSvc.users.GetByID(r.Context(), uid)
if uErr == nil && user != nil {
auditErr := dbSvc.backup.RecordDownload(r.Context(), id, services.BackupActor{
ID: &uid,
Email: user.Email,
Label: user.DisplayName,
})
if auditErr != nil {
log.Printf("backup: RecordDownload failed for %s by %s: %v", id, uid, auditErr)
}
} else if uErr != nil {
log.Printf("backup: user lookup for audit failed (%s): %v", uid, uErr)
}
filename := fmt.Sprintf("paliad-backup-%s.zip", row.StartedAt.UTC().Format("20060102T1504Z"))
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.Header().Set("X-Paliad-Backup-Id", id.String())
if _, err := io.Copy(w, rc); err != nil {
log.Printf("backup: response write failed for %s: %v", id, err)
}
}

View File

@@ -98,6 +98,11 @@ type Services struct {
Projection *services.ProjectionService
Export *services.ExportService
// t-paliad-246 — Backup Mode (org-scope admin backups). Nil when
// DATABASE_URL or PALIAD_EXPORT_DIR is unset; the /admin/backups
// routes return 503 in that case.
Backup *services.BackupRunner
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
@@ -162,6 +167,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
firmDashboardDefault: svc.FirmDashboardDefault,
projection: svc.Projection,
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
}
}
@@ -570,6 +576,17 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
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)))
// t-paliad-246 / m/paliad#77 Slice A — Backup Mode admin page +
// API. Routes only register when Users is wired (matches the
// other admin routes); per-request 503 if BackupRunner itself
// is unwired (PALIAD_EXPORT_DIR unset).
protected.HandleFunc("GET /admin/backups", adminGate(users, gateOnboarded(handleAdminBackupsPage)))
protected.HandleFunc("POST /api/admin/backups/run", adminGate(users, handleAdminRunBackup))
protected.HandleFunc("GET /api/admin/backups", adminGate(users, handleAdminListBackups))
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))

View File

@@ -62,6 +62,10 @@ type dbServices struct {
projection *services.ProjectionService
export *services.ExportService
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
backup *services.BackupRunner
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
}

View File

@@ -0,0 +1,555 @@
package services
// Backup Mode runtime (t-paliad-246 / m/paliad#77 Slice A).
//
// One file because all four pieces are tightly coupled:
//
// - ArtifactStore interface + LocalDiskStore implementation
// (storage abstraction; m picked local disk for v1, the interface
// stays so a future swap to Supabase Storage is one impl away).
//
// - BackupRunner — the orchestration the on-demand handler and the
// (Slice B) scheduler share. Wraps the export pipeline:
// 1. INSERT paliad.backups (status='running')
// 2. INSERT paliad.system_audit_log (event_type='backup_created')
// 3. ExportService.WriteOrg → in-memory buffer
// 4. ArtifactStore.Put → file
// 5. UPDATE paliad.backups (status='done', storage_uri, …)
// 6. PATCH paliad.system_audit_log metadata
//
// Design: docs/design-backup-mode-2026-05-25.md.
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ---------------------------------------------------------------------------
// ArtifactStore interface + LocalDiskStore impl
// ---------------------------------------------------------------------------
// ArtifactStore persists the bytes of a backup artifact. The interface
// is deliberately small so Slice B can drop in a SupabaseStorageStore
// (or any object-store implementation) without changing the runner.
//
// URIs returned by Put are opaque to callers — they round-trip through
// Get/Delete. v1's LocalDiskStore uses `file://<absolute-path>`.
type ArtifactStore interface {
// Put writes the given body to the store under the given key and
// returns the URI for later retrieval. Implementations must overwrite
// an existing object at the same key (catalog rows make keys unique
// in practice, but the contract is overwrite-on-conflict to keep
// retries idempotent).
Put(ctx context.Context, key string, body []byte) (uri string, err error)
// Get streams the artifact bytes at the given URI.
Get(ctx context.Context, uri string) (rc io.ReadCloser, size int64, err error)
// Delete removes the artifact at the given URI. Returns nil if the
// artifact is already absent (idempotent).
Delete(ctx context.Context, uri string) error
}
// LocalDiskStore is the v1 ArtifactStore — writes artifacts to a local
// directory specified at construction time. Mode 0700 on the directory
// + 0600 on artifact files keeps the files private to the paliad
// process owner on the Dokploy host.
type LocalDiskStore struct {
dir string
}
// NewLocalDiskStore creates a LocalDiskStore rooted at dir. Creates the
// directory (0700) if it doesn't exist. Returns an error if dir is
// empty or the mkdir fails.
func NewLocalDiskStore(dir string) (*LocalDiskStore, error) {
if strings.TrimSpace(dir) == "" {
return nil, errors.New("LocalDiskStore: empty directory")
}
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("LocalDiskStore mkdir %q: %w", dir, err)
}
abs, err := filepath.Abs(dir)
if err != nil {
return nil, fmt.Errorf("LocalDiskStore abs %q: %w", dir, err)
}
return &LocalDiskStore{dir: abs}, nil
}
// Put writes body to <dir>/<key>. Returns a file:// URI.
func (s *LocalDiskStore) Put(_ context.Context, key string, body []byte) (string, error) {
if err := validateKey(key); err != nil {
return "", err
}
full := filepath.Join(s.dir, key)
if err := os.WriteFile(full, body, 0o600); err != nil {
return "", fmt.Errorf("LocalDiskStore write %q: %w", full, err)
}
return "file://" + full, nil
}
// Get opens the file referenced by uri. Returns a *os.File (io.ReadCloser)
// + the file's size in bytes.
func (s *LocalDiskStore) Get(_ context.Context, uri string) (io.ReadCloser, int64, error) {
path, err := s.pathFromURI(uri)
if err != nil {
return nil, 0, err
}
info, err := os.Stat(path)
if err != nil {
return nil, 0, fmt.Errorf("LocalDiskStore stat %q: %w", path, err)
}
f, err := os.Open(path)
if err != nil {
return nil, 0, fmt.Errorf("LocalDiskStore open %q: %w", path, err)
}
return f, info.Size(), nil
}
// Delete removes the file referenced by uri. Idempotent — missing file
// is treated as success.
func (s *LocalDiskStore) Delete(_ context.Context, uri string) error {
path, err := s.pathFromURI(uri)
if err != nil {
return err
}
if err := os.Remove(path); err != nil && !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("LocalDiskStore remove %q: %w", path, err)
}
return nil
}
// pathFromURI parses a file:// URI and validates that the resolved
// path is inside this store's directory. Defense-in-depth against a
// malformed catalog row pointing at an arbitrary file.
func (s *LocalDiskStore) pathFromURI(uri string) (string, error) {
u, err := url.Parse(uri)
if err != nil {
return "", fmt.Errorf("LocalDiskStore parse uri %q: %w", uri, err)
}
if u.Scheme != "file" {
return "", fmt.Errorf("LocalDiskStore: unsupported uri scheme %q (want file://)", u.Scheme)
}
// url.Parse drops the leading "/" for file:// URIs into u.Path.
path := u.Path
if u.Host != "" {
// "file://host/path" — we don't issue these. Reject.
return "", fmt.Errorf("LocalDiskStore: file:// uri with host is unsupported (%q)", uri)
}
clean := filepath.Clean(path)
rel, err := filepath.Rel(s.dir, clean)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("LocalDiskStore: uri %q resolves outside store dir %q", uri, s.dir)
}
return clean, nil
}
// validateKey rejects keys that would escape the store dir (path
// separators, "..", absolute paths). Backup runner uses
// "<uuid>.zip" so this is a defensive guard.
func validateKey(key string) error {
if key == "" {
return errors.New("ArtifactStore: empty key")
}
if strings.ContainsAny(key, "/\\") {
return fmt.Errorf("ArtifactStore: key %q contains path separator", key)
}
if strings.Contains(key, "..") {
return fmt.Errorf("ArtifactStore: key %q contains traversal", key)
}
if filepath.IsAbs(key) {
return fmt.Errorf("ArtifactStore: key %q is absolute", key)
}
return nil
}
// ---------------------------------------------------------------------------
// BackupRunner
// ---------------------------------------------------------------------------
// BackupKind discriminates a scheduled run from an on-demand one.
const (
BackupKindOnDemand = "on_demand"
BackupKindScheduled = "scheduled"
)
// BackupStatus values mirror the paliad.backups status check constraint.
const (
BackupStatusRunning = "running"
BackupStatusDone = "done"
BackupStatusFailed = "failed"
)
// SystemActorEmail is the sentinel actor_email written for scheduled
// backups (kind='scheduled'). Matches design §3.4 — we don't seed a
// phantom user, we just stamp the audit row with a stable sentinel.
const SystemActorEmail = "system@paliad"
// BackupActor identifies who requested a backup. For kind='scheduled'
// pass (nil, SystemActorEmail, "Paliad Backup System"). For on-demand
// pass the calling admin's id/email/display_name.
type BackupActor struct {
ID *uuid.UUID
Email string
Label string
}
// BackupResult is what Run returns to the caller. Empty on failure
// (the error gets the failure detail; the catalog/audit rows are
// already updated).
type BackupResult struct {
ID uuid.UUID
AuditID uuid.UUID
StorageURI string
SizeBytes int64
RowCounts map[string]int
SheetCount int
}
// BackupRunner orchestrates one backup run. Stateless except for the
// wired dependencies; safe to share across goroutines (the handler
// holds one instance; the Slice B scheduler will hold the same one).
type BackupRunner struct {
db *sqlx.DB
export *ExportService
store ArtifactStore
}
// NewBackupRunner wires the runner. All three deps are required; the
// caller (cmd/server/main.go) is responsible for instantiating the
// ArtifactStore from env config.
func NewBackupRunner(db *sqlx.DB, export *ExportService, store ArtifactStore) *BackupRunner {
return &BackupRunner{db: db, export: export, store: store}
}
// Store returns the configured store. Exposed for the download handler
// to stream artifacts via Get.
func (r *BackupRunner) Store() ArtifactStore { return r.store }
// Run performs one backup. Writes catalog + audit rows, generates the
// bundle via ExportService.WriteOrg, uploads to the configured store,
// patches catalog + audit on success/failure.
//
// On any error after the catalog/audit rows are written, the rows are
// patched to status='failed' / event_type='backup_failed' before
// returning. The returned error is always the export/upload failure —
// catalog-update failures during the failure-recovery path are best-
// effort logged but not surfaced (the real error is the one to bubble).
func (r *BackupRunner) Run(ctx context.Context, kind string, actor BackupActor) (BackupResult, error) {
if kind != BackupKindOnDemand && kind != BackupKindScheduled {
return BackupResult{}, fmt.Errorf("BackupRunner.Run: invalid kind %q", kind)
}
if actor.Email == "" {
return BackupResult{}, errors.New("BackupRunner.Run: empty actor email")
}
now := time.Now().UTC()
spec := ExportSpec{
Scope: ExportScopeOrg,
ActorID: uuid.Nil, // overwritten below when actor.ID != nil
ActorEmail: actor.Email,
ActorLabel: actor.Label,
GeneratedAt: now,
}
if actor.ID != nil {
spec.ActorID = *actor.ID
}
// Step 1+2: catalog row (status='running') + audit row
// (event_type='backup_created'). Both happen before the export
// generation so failure paths can always find them.
catalogID, err := r.insertCatalogRow(ctx, kind, actor, uuid.Nil, now)
if err != nil {
return BackupResult{}, fmt.Errorf("backup catalog insert: %w", err)
}
auditID, err := r.insertAuditRow(ctx, kind, actor, catalogID, now)
if err != nil {
// Best-effort patch on the catalog row so it doesn't sit
// "running" forever.
r.patchCatalogRowFailed(context.Background(), catalogID, fmt.Errorf("audit insert: %w", err))
return BackupResult{}, fmt.Errorf("backup audit insert: %w", err)
}
// Back-link the audit id into the catalog row so the UI can JOIN.
if err := r.linkAuditID(ctx, catalogID, auditID); err != nil {
// Non-fatal — the link is for UI convenience, not correctness.
// The error is logged via the patch path; we keep going.
}
// Step 3: generate the bundle into an in-memory buffer. We materialise
// fully before uploading so a partial upload doesn't strand bytes in
// the store under a "done" catalog row.
var buf bytes.Buffer
meta, err := r.export.WriteOrg(ctx, &buf, spec)
if err != nil {
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("generate: %w", err))
return BackupResult{}, fmt.Errorf("backup generate: %w", err)
}
// Step 4: upload to storage. Key = "<catalog_id>.zip".
key := catalogID.String() + ".zip"
uri, err := r.store.Put(ctx, key, buf.Bytes())
if err != nil {
r.failRun(context.Background(), catalogID, auditID, fmt.Errorf("upload: %w", err))
return BackupResult{}, fmt.Errorf("backup upload: %w", err)
}
// Step 5+6: patch catalog + audit on success.
size := int64(buf.Len())
sheetCount := len(meta.RowCounts)
if err := r.patchCatalogRowDone(ctx, catalogID, uri, size, sheetCount, meta); err != nil {
// At this point the artifact is on disk, the audit row was
// inserted, and the only thing that failed is the catalog
// flip. Surface as an error so the handler can log; the
// artifact is recoverable manually via the audit metadata.
return BackupResult{}, fmt.Errorf("backup catalog patch: %w", err)
}
if err := r.patchAuditRowDone(ctx, auditID, uri, size, sheetCount, meta); err != nil {
// Non-fatal — the catalog row is already authoritative; the
// audit row is the audit-trail twin. Log via the caller.
}
return BackupResult{
ID: catalogID,
AuditID: auditID,
StorageURI: uri,
SizeBytes: size,
RowCounts: meta.RowCounts,
SheetCount: sheetCount,
}, nil
}
// RecordDownload writes a paliad.system_audit_log row of
// event_type='backup_downloaded' when an admin downloads a backup
// via /api/admin/backups/{id}/file. Separate row per click — the
// existing 'backup_created' row stays untouched.
func (r *BackupRunner) RecordDownload(ctx context.Context, backupID uuid.UUID, by BackupActor) error {
if by.Email == "" {
return errors.New("BackupRunner.RecordDownload: empty actor email")
}
meta, _ := json.Marshal(map[string]any{
"backup_id": backupID.String(),
"downloaded_by_email": by.Email,
"downloaded_at": time.Now().UTC().Format(time.RFC3339),
})
var actorID any
if by.ID != nil {
actorID = *by.ID
}
_, err := r.db.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('backup_downloaded', $1, $2, 'org', NULL, $3::jsonb)`,
actorID, by.Email, string(meta),
)
if err != nil {
return fmt.Errorf("backup_downloaded audit insert: %w", err)
}
return nil
}
// ---------------------------------------------------------------------------
// Catalog read helpers (List + Get for the admin UI)
// ---------------------------------------------------------------------------
// BackupSummary is the row shape returned by ListBackups + GetBackup —
// shaped for the /admin/backups UI. Nullable columns are pointers.
type BackupSummary struct {
ID uuid.UUID `db:"id" json:"id"`
Kind string `db:"kind" json:"kind"`
Status string `db:"status" json:"status"`
RequestedBy *uuid.UUID `db:"requested_by" json:"requested_by,omitempty"`
RequestedByEmail string `db:"requested_by_email" json:"requested_by_email"`
AuditID *uuid.UUID `db:"audit_id" json:"audit_id,omitempty"`
StorageURI *string `db:"storage_uri" json:"storage_uri,omitempty"`
SizeBytes *int64 `db:"size_bytes" json:"size_bytes,omitempty"`
RowCounts []byte `db:"row_counts" json:"row_counts,omitempty"`
SheetCount *int `db:"sheet_count" json:"sheet_count,omitempty"`
Warnings []byte `db:"warnings" json:"warnings,omitempty"`
Error *string `db:"error" json:"error,omitempty"`
StartedAt time.Time `db:"started_at" json:"started_at"`
FinishedAt *time.Time `db:"finished_at" json:"finished_at,omitempty"`
DeletedAt *time.Time `db:"deleted_at" json:"deleted_at,omitempty"`
}
// ListBackups returns the most recent backups (highest started_at first),
// capped at limit. limit <= 0 means default (100).
func (r *BackupRunner) ListBackups(ctx context.Context, limit int) ([]BackupSummary, error) {
if limit <= 0 {
limit = 100
}
var rows []BackupSummary
err := r.db.SelectContext(ctx, &rows,
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
storage_uri, size_bytes, row_counts, sheet_count, warnings,
error, started_at, finished_at, deleted_at
FROM paliad.backups
ORDER BY started_at DESC
LIMIT $1`,
limit,
)
if err != nil {
return nil, fmt.Errorf("list backups: %w", err)
}
return rows, nil
}
// GetBackup fetches one backup by id. Returns sql.ErrNoRows when not
// found (caller maps to 404).
func (r *BackupRunner) GetBackup(ctx context.Context, id uuid.UUID) (BackupSummary, error) {
var row BackupSummary
err := r.db.GetContext(ctx, &row,
`SELECT id, kind, status, requested_by, requested_by_email, audit_id,
storage_uri, size_bytes, row_counts, sheet_count, warnings,
error, started_at, finished_at, deleted_at
FROM paliad.backups
WHERE id = $1`,
id,
)
if err != nil {
return BackupSummary{}, err
}
return row, nil
}
// ---------------------------------------------------------------------------
// Catalog + audit SQL helpers (private — used by Run + RecordDownload).
// ---------------------------------------------------------------------------
func (r *BackupRunner) insertCatalogRow(ctx context.Context, kind string, actor BackupActor, auditID uuid.UUID, now time.Time) (uuid.UUID, error) {
var actorID any
if actor.ID != nil {
actorID = *actor.ID
}
var auditArg any
if auditID != uuid.Nil {
auditArg = auditID
}
var id uuid.UUID
err := r.db.QueryRowxContext(ctx,
`INSERT INTO paliad.backups
(kind, status, requested_by, requested_by_email, audit_id, started_at)
VALUES ($1, 'running', $2, $3, $4, $5)
RETURNING id`,
kind, actorID, actor.Email, auditArg, now,
).Scan(&id)
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func (r *BackupRunner) insertAuditRow(ctx context.Context, kind string, actor BackupActor, catalogID uuid.UUID, now time.Time) (uuid.UUID, error) {
meta, _ := json.Marshal(map[string]any{
"kind": kind,
"catalog_id": catalogID.String(),
"requested_by_email": actor.Email,
"requested_at": now.Format(time.RFC3339),
})
var actorID any
if actor.ID != nil {
actorID = *actor.ID
}
var id uuid.UUID
err := r.db.QueryRowxContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('backup_created', $1, $2, 'org', NULL, $3::jsonb)
RETURNING id`,
actorID, actor.Email, string(meta),
).Scan(&id)
if err != nil {
return uuid.Nil, err
}
return id, nil
}
func (r *BackupRunner) linkAuditID(ctx context.Context, catalogID, auditID uuid.UUID) error {
_, err := r.db.ExecContext(ctx,
`UPDATE paliad.backups SET audit_id = $2 WHERE id = $1`,
catalogID, auditID,
)
return err
}
func (r *BackupRunner) patchCatalogRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
rcJSON, _ := json.Marshal(meta.RowCounts)
warnJSON, _ := json.Marshal(meta.Warnings)
if meta.Warnings == nil {
warnJSON = []byte("[]")
}
_, err := r.db.ExecContext(ctx,
`UPDATE paliad.backups
SET status = 'done',
storage_uri = $2,
size_bytes = $3,
sheet_count = $4,
row_counts = $5::jsonb,
warnings = $6::jsonb,
finished_at = now()
WHERE id = $1`,
id, uri, size, sheetCount, string(rcJSON), string(warnJSON),
)
return err
}
func (r *BackupRunner) patchCatalogRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
_, _ = r.db.ExecContext(ctx,
`UPDATE paliad.backups
SET status = 'failed',
error = $2,
finished_at = now()
WHERE id = $1`,
id, runErr.Error(),
)
}
func (r *BackupRunner) patchAuditRowDone(ctx context.Context, id uuid.UUID, uri string, size int64, sheetCount int, meta ExportMeta) error {
payload, _ := json.Marshal(map[string]any{
"row_counts": meta.RowCounts,
"file_size_bytes": size,
"sheet_count": sheetCount,
"storage_uri": uri,
"warnings": meta.Warnings,
"completed_at": time.Now().UTC().Format(time.RFC3339),
})
_, err := r.db.ExecContext(ctx,
`UPDATE paliad.system_audit_log
SET metadata = metadata || $2::jsonb,
updated_at = now()
WHERE id = $1`,
id, string(payload),
)
return err
}
func (r *BackupRunner) patchAuditRowFailed(ctx context.Context, id uuid.UUID, runErr error) {
payload, _ := json.Marshal(map[string]any{
"error": runErr.Error(),
"failed_at": time.Now().UTC().Format(time.RFC3339),
})
_, _ = r.db.ExecContext(ctx,
`UPDATE paliad.system_audit_log
SET event_type = 'backup_failed',
metadata = metadata || $2::jsonb,
updated_at = now()
WHERE id = $1`,
id, string(payload),
)
}
// failRun is the shared failure-recovery path: patch the catalog +
// audit rows to their failed states. Uses a context.Background so the
// patch happens even if the original ctx is already cancelled.
func (r *BackupRunner) failRun(ctx context.Context, catalogID, auditID uuid.UUID, runErr error) {
r.patchCatalogRowFailed(ctx, catalogID, runErr)
r.patchAuditRowFailed(ctx, auditID, runErr)
}

View File

@@ -0,0 +1,193 @@
package services
// Pure-function tests for the Backup Mode runtime (t-paliad-246 / m/paliad#77).
//
// Live DB behaviour (the actual org dump end-to-end) needs a Postgres;
// it would live in backup_service_live_test.go under TEST_DATABASE_URL.
// This file covers the bits that don't need a database:
//
// - orgSheetQueries registry shape: no duplicates, no excluded
// paliadin sheets, predictable prefix split between entity and ref.
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
// URI traversal rejection.
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"strings"
"testing"
)
// ---------------------------------------------------------------------------
// orgSheetQueries registry
// ---------------------------------------------------------------------------
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
seen := map[string]bool{}
for _, sq := range orgSheetQueries() {
if seen[sq.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetQueries: %q", sq.SheetName)
}
seen[sq.SheetName] = true
}
}
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
// m's t-paliad-214 Q5 decision + this design's §11 Q3 default:
// paliadin_turns and paliadin_aichat_conversation must be ABSENT
// from the registry (structural exclusion, not just column-drop).
for _, sq := range orgSheetQueries() {
name := sq.SheetName
if strings.Contains(name, "paliadin") {
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
}
// Belt-and-braces: SQL bodies should not reference the tables
// either (no UNION joins, no subqueries pulling them in).
if strings.Contains(sq.SQL, "paliadin_turns") || strings.Contains(sq.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetQueries[%q] SQL references a paliadin table: %s", name, sq.SQL)
}
}
}
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
// Every sheet whose data is read-only reference material is
// expected to use the `ref__` prefix. The writer's downstream
// consumers rely on this convention to group reference data
// visually in the workbook.
for _, sq := range orgSheetQueries() {
if !strings.HasPrefix(sq.SheetName, "ref__") {
continue
}
// Reference sheets shouldn't carry per-row WHERE clauses (they
// dump the whole reference table for portability).
if strings.Contains(strings.ToUpper(sq.SQL), "WHERE") {
t.Fatalf("orgSheetQueries[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sq.SheetName)
}
}
}
func TestOrgSheetQueries_OrderByForDeterminism(t *testing.T) {
// Every sheet must specify an ORDER BY so the byte-deterministic
// contract from t-paliad-214 §3 holds across runs.
for _, sq := range orgSheetQueries() {
if !strings.Contains(strings.ToUpper(sq.SQL), "ORDER BY") {
t.Fatalf("orgSheetQueries[%q] missing ORDER BY (determinism contract): %s", sq.SheetName, sq.SQL)
}
}
}
// ---------------------------------------------------------------------------
// LocalDiskStore round-trip
// ---------------------------------------------------------------------------
func TestLocalDiskStore_RoundTrip(t *testing.T) {
dir := t.TempDir()
store, err := NewLocalDiskStore(dir)
if err != nil {
t.Fatalf("NewLocalDiskStore: %v", err)
}
ctx := context.Background()
want := []byte("hello backup\n")
uri, err := store.Put(ctx, "test.zip", want)
if err != nil {
t.Fatalf("Put: %v", err)
}
if !strings.HasPrefix(uri, "file://") {
t.Fatalf("expected file:// uri, got %q", uri)
}
rc, size, err := store.Get(ctx, uri)
if err != nil {
t.Fatalf("Get: %v", err)
}
defer rc.Close()
if size != int64(len(want)) {
t.Fatalf("Get size = %d, want %d", size, len(want))
}
got, err := io.ReadAll(rc)
if err != nil {
t.Fatalf("ReadAll: %v", err)
}
if !bytes.Equal(got, want) {
t.Fatalf("Get body = %q, want %q", got, want)
}
if err := store.Delete(ctx, uri); err != nil {
t.Fatalf("Delete: %v", err)
}
// File should be gone; Get returns an error.
if _, _, err := store.Get(ctx, uri); err == nil {
t.Fatalf("Get after Delete should fail")
}
// Delete is idempotent.
if err := store.Delete(ctx, uri); err != nil {
t.Fatalf("idempotent Delete: %v", err)
}
}
func TestLocalDiskStore_RejectsBadKeys(t *testing.T) {
dir := t.TempDir()
store, err := NewLocalDiskStore(dir)
if err != nil {
t.Fatalf("NewLocalDiskStore: %v", err)
}
ctx := context.Background()
cases := []string{
"",
"sub/dir/file.zip",
"..\\evil.zip",
"../escape.zip",
"/abs/path.zip",
}
for _, k := range cases {
if _, err := store.Put(ctx, k, []byte("x")); err == nil {
t.Fatalf("Put with bad key %q should fail", k)
}
}
}
func TestLocalDiskStore_RejectsURIOutsideDir(t *testing.T) {
dir := t.TempDir()
store, err := NewLocalDiskStore(dir)
if err != nil {
t.Fatalf("NewLocalDiskStore: %v", err)
}
ctx := context.Background()
// A file:// URI pointing outside the store dir must be rejected
// by both Get and Delete (defense in depth against a corrupted
// catalog row).
outside := "file://" + filepath.Join(filepath.Dir(dir), "elsewhere.zip")
if _, _, err := store.Get(ctx, outside); err == nil {
t.Fatalf("Get outside store dir should fail")
}
if err := store.Delete(ctx, outside); err == nil {
t.Fatalf("Delete outside store dir should fail")
}
// Wrong scheme is also rejected.
if _, _, err := store.Get(ctx, "https://example.com/foo.zip"); err == nil {
t.Fatalf("Get with non-file:// scheme should fail")
}
}
func TestLocalDiskStore_CreatesDir(t *testing.T) {
// A non-existent parent gets created at construction; mode 0700.
base := t.TempDir()
target := filepath.Join(base, "nested", "exports")
store, err := NewLocalDiskStore(target)
if err != nil {
t.Fatalf("NewLocalDiskStore(non-existent): %v", err)
}
info, err := os.Stat(target)
if err != nil {
t.Fatalf("expected store dir to exist: %v", err)
}
if !info.IsDir() {
t.Fatalf("expected directory, got file")
}
// Smoke-write to confirm the dir is actually usable.
if _, err := store.Put(context.Background(), "ok.zip", []byte{}); err != nil {
t.Fatalf("Put into fresh dir: %v", err)
}
}

View File

@@ -33,12 +33,7 @@ import (
// tree alone is enough to produce a candidate concept set.
// - Forums: a list of forum slugs from the v3 bucket map. Translated
// to proceeding_type_codes by the search service; trigger-event
// pills carry a structured legal_source citation (via mig 123)
// and narrow by the per-forum legal-source prefix set instead of
// by proceeding_code — see ForumToLegalSourcePrefixes. Before mig
// 123 trigger pills bypassed the forum filter unconditionally;
// m/paliad#97 (t-paliad-266) requires the cross-cutting sub-rows
// to narrow with the active court-system chip.
// pills bypass the forum filter (cross-cutting by design).
//
// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
@@ -79,40 +74,6 @@ var ForumToProceedingCodes = map[string][]string{
"dpma": {CodeDPMAOpposition},
}
// ForumToLegalSourcePrefixes maps the v3 forum buckets to the
// structured legal_source prefixes that cross-cutting trigger pills
// must match against (t-paliad-266 / m/paliad#97). Rule pills already
// narrow by proceeding_code via ForumToProceedingCodes; trigger pills
// have no proceeding context, so the narrowing key is the citation
// body itself.
//
// Mapping mirrors m's spec on the issue:
//
// - UPC chips → UPC.* (UPC RoP / UPC Agreement / UPC Statute)
// - DE LG/OLG/BGH chips → DE.ZPO.* (civil-procedure path)
// - DE BPatG chip → DE.PatG.* (national patent path)
// - DPMA chip → DE.PatG.* (national patent path)
// - EPA chips → EU.EPC* / EU.EPÜ* (EPC / EPÜ citations)
//
// Two forums (de_bgh, de_bpatg) intentionally collapse: BGH hears
// both civil-patent and nullity appeals; PatG covers DPMA + BPatG
// patent jurisdiction. The matching SQL uses startsWith against the
// union of the active forums' prefixes, so a chip combination like
// "DPMA + de_bgh" surfaces every trigger whose legal_source starts
// with DE.PatG.* OR DE.ZPO.* — exactly the user's union expectation.
var ForumToLegalSourcePrefixes = map[string][]string{
"upc_cfi": {"UPC."},
"upc_coa": {"UPC."},
"de_lg": {"DE.ZPO."},
"de_olg": {"DE.ZPO."},
"de_bgh": {"DE.ZPO."},
"de_bpatg": {"DE.PatG."},
"epa_grant": {"EU.EPC", "EU.EPÜ"},
"epa_opp": {"EU.EPC", "EU.EPÜ"},
"epa_appeal": {"EU.EPC", "EU.EPÜ"},
"dpma": {"DE.PatG."},
}
// SearchOptions carries the optional facet filters from the URL query
// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
@@ -318,12 +279,8 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
subtree = newSubtreeFilter(outcomes)
}
// v3: translate forum slugs to proceeding_code allow-list (rule
// pills) and t-paliad-266: parallel legal_source prefix allow-list
// for trigger pills. Empty slice for either axis = no narrowing on
// that pill kind.
// v3: translate forum slugs to proceeding_code allow-list.
forumCodes := translateForums(opts.Forums)
forumLegalPrefixes := translateForumsToLegalSourcePrefixes(opts.Forums)
if !browseMode && qNorm == "" {
return resp, nil
@@ -336,11 +293,11 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
var ranks []rankRow
if browseMode {
// Browse mode: synthesize ranks from the allow-list directly.
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, forumLegalPrefixes, limit)
ranks = s.browseRanks(ctx, subtree, party, proc, source, forumCodes, limit)
} else {
qLow := strings.ToLower(qNorm)
var err error
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, forumLegalPrefixes, limit)
ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, subtree, forumCodes, limit)
if err != nil {
return nil, err
}
@@ -353,7 +310,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes, forumLegalPrefixes)
pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, subtree, forumCodes)
if err != nil {
return nil, err
}
@@ -461,33 +418,6 @@ func translateForums(slugs []string) []string {
return out
}
// translateForumsToLegalSourcePrefixes maps a list of forum slugs to
// the union of legal_source prefixes those forums admit for trigger
// pills (t-paliad-266). Empty when no slug carries a prefix mapping —
// callers must treat empty as "no trigger narrowing applies" rather
// than "match nothing", mirroring translateForums.
func translateForumsToLegalSourcePrefixes(slugs []string) []string {
if len(slugs) == 0 {
return nil
}
seen := map[string]bool{}
var out []string
for _, slug := range slugs {
prefixes, ok := ForumToLegalSourcePrefixes[slug]
if !ok {
continue
}
for _, p := range prefixes {
if seen[p] {
continue
}
seen[p] = true
out = append(out, p)
}
}
return out
}
// browseRanks synthesizes a rank list from a subtree-filter tuple set
// (v3 B1 browse mode). No trigram scoring — order is by concept
// sort_order then name. Forum filter applies post-hoc to keep concepts
@@ -500,7 +430,6 @@ func (s *DeadlineSearchService) browseRanks(
subtree *subtreeFilter,
party, proc, source *string,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) []rankRow {
const sqlText = `
@@ -523,18 +452,8 @@ SELECT DISTINCT
AND (
$6::text[] IS NULL
OR cardinality($6::text[]) = 0
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($6::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($6::text[])
)
ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
LIMIT $7
@@ -546,7 +465,6 @@ SELECT DISTINCT
party, proc, source,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
// Browse mode failures degrade to empty (taxonomy-driven UX
// shouldn't crash on a malformed slug); log via the caller.
@@ -572,12 +490,11 @@ func (s *DeadlineSearchService) rankConcepts(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
limit int,
) ([]rankRow, error) {
// $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
// $6 subtree_cids uuid[]? · $7 subtree_procs text[]? ·
// $8 forum_codes text[]? · $9 limit · $10 forum_legal_prefixes text[]?
// $8 forum_codes text[]? · $9 limit
const sqlText = `
WITH matched AS (
SELECT
@@ -627,18 +544,8 @@ WITH matched AS (
AND (
$8::text[] IS NULL
OR cardinality($8::text[]) = 0
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($8::text[])
)
OR (
s.kind = 'trigger'
AND ($10::text[] IS NULL OR cardinality($10::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($10::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($8::text[])
)
)
SELECT
@@ -662,7 +569,6 @@ SELECT
cidArg, procArg,
nullableArray(forumCodes),
limit,
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
@@ -675,11 +581,10 @@ func (s *DeadlineSearchService) loadPills(
party, proc, source *string,
subtree *subtreeFilter,
forumCodes []string,
forumLegalPrefixes []string,
) ([]pillRow, error) {
// $1 concept_ids uuid[] · $2 party · $3 proc · $4 source ·
// $5 subtree_cids uuid[]? · $6 subtree_procs text[]? ·
// $7 forum_codes text[]? · $8 forum_legal_prefixes text[]?
// $7 forum_codes text[]?
const sqlText = `
SELECT
s.kind,
@@ -722,18 +627,8 @@ SELECT
AND (
$7::text[] IS NULL
OR cardinality($7::text[]) = 0
OR (
s.kind = 'rule'
AND s.proceeding_code = ANY($7::text[])
)
OR (
s.kind = 'trigger'
AND ($8::text[] IS NULL OR cardinality($8::text[]) = 0
OR EXISTS (
SELECT 1 FROM unnest($8::text[]) AS lp
WHERE s.legal_source LIKE lp || '%'
))
)
OR s.kind = 'trigger'
OR s.proceeding_code = ANY($7::text[])
)
ORDER BY s.concept_id, s.kind, s.proceeding_display_order, s.proceeding_code NULLS LAST, s.rule_local_code
`
@@ -743,7 +638,6 @@ SELECT
pq.Array(conceptIDs), party, proc, source,
cidArg, procArg,
nullableArray(forumCodes),
nullableArray(forumLegalPrefixes),
); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}

View File

@@ -166,15 +166,15 @@ func TestDeadlineSearch(t *testing.T) {
mustHaveLegalSource(t, card, "DE.PatG.82.1")
})
t.Run("Wiedereinsetzung returns the cross-cutting concept with 5 trigger pills", func(t *testing.T) {
t.Run("Wiedereinsetzung returns the cross-cutting concept with 4 trigger pills", func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
// Exactly 5 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123, and UPC R.320 — trigger_event ids
// 200..203 from mig 046 plus 207 from mig 063.
// Exactly 4 trigger pills: PatG §123 (DE), ZPO §233 (DE), EPÜ
// Art.122 (EU), DPMA §123 — corresponding to trigger_event ids
// 200..203 from migration 046.
triggerIDs := []int64{}
for _, p := range card.Pills {
if p.Kind != "trigger" {
@@ -184,9 +184,9 @@ func TestDeadlineSearch(t *testing.T) {
triggerIDs = append(triggerIDs, *p.TriggerEventID)
}
}
want := map[int64]bool{200: true, 201: true, 202: true, 203: true, 207: true}
if len(triggerIDs) != 5 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 5 (ids 200..203, 207)", len(triggerIDs))
want := map[int64]bool{200: true, 201: true, 202: true, 203: true}
if len(triggerIDs) != 4 {
t.Fatalf("Wiedereinsetzung card: got %d trigger pills, want 4 (ids 200..203)", len(triggerIDs))
}
for _, id := range triggerIDs {
if !want[id] {
@@ -195,107 +195,6 @@ func TestDeadlineSearch(t *testing.T) {
}
})
// t-paliad-266 / m/paliad#97 — court-system filter narrows
// cross-cutting trigger pills via legal_source inference.
t.Run("forum filter narrows Wiedereinsetzung trigger pills by court system", func(t *testing.T) {
// Each pair is (forum slug, expected trigger_event_ids).
cases := []struct {
name string
forum string
wantTrigIDs []int64
}{
{"upc_cfi shows only UPC R.320", "upc_cfi", []int64{207}},
{"upc_coa shows only UPC R.320", "upc_coa", []int64{207}},
{"de_lg shows only ZPO §233", "de_lg", []int64{201}},
{"de_olg shows only ZPO §233", "de_olg", []int64{201}},
{"de_bgh shows only ZPO §233", "de_bgh", []int64{201}},
{"de_bpatg shows only PatG §123 (DE national)", "de_bpatg", []int64{200, 203}},
{"dpma shows only PatG §123 (DPMA)", "dpma", []int64{200, 203}},
{"epa_grant shows only EPC Art.122", "epa_grant", []int64{202}},
{"epa_opp shows only EPC Art.122", "epa_opp", []int64{202}},
{"epa_appeal shows only EPC Art.122", "epa_appeal", []int64{202}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{tc.forum},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{}
for _, id := range tc.wantTrigIDs {
want[id] = true
}
for id := range got {
if !want[id] {
t.Errorf("forum=%s leaked trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
for id := range want {
if !got[id] {
t.Errorf("forum=%s missing expected trigger id %d (got pills: %v)", tc.forum, id, got)
}
}
})
}
})
t.Run("multiple forum chips union the legal_source allow-list for triggers", func(t *testing.T) {
// upc_cfi + de_lg → UPC.* OR DE.ZPO.* → trigger ids 201 + 207.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{
Forums: []string{"upc_cfi", "de_lg"},
Limit: 12,
})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
got := map[int64]bool{}
for _, p := range card.Pills {
if p.TriggerEventID != nil {
got[*p.TriggerEventID] = true
}
}
want := map[int64]bool{201: true, 207: true}
for id := range got {
if !want[id] {
t.Errorf("union forum upc_cfi+de_lg leaked trigger id %d", id)
}
}
for id := range want {
if !got[id] {
t.Errorf("union forum upc_cfi+de_lg missing trigger id %d", id)
}
}
})
t.Run("empty forum filter leaves cross-cutting pills untouched", func(t *testing.T) {
// No forum chips = all 5 triggers stay visible.
resp, err := svc.Search(ctx, "Wiedereinsetzung", SearchOptions{Limit: 12})
if err != nil {
t.Fatalf("search: %v", err)
}
card := findCardBySlug(t, resp, "wiedereinsetzung")
count := 0
for _, p := range card.Pills {
if p.Kind == "trigger" {
count++
}
}
if count != 5 {
t.Errorf("empty forum filter dropped a trigger pill: got %d, want 5", count)
}
})
t.Run("party filter narrows to defendant-only", func(t *testing.T) {
resp, err := svc.Search(ctx, "Klageerwiderung", SearchOptions{Party: "claimant", Limit: 12})
if err != nil {

View File

@@ -40,6 +40,7 @@ import (
"archive/zip"
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"encoding/csv"
@@ -185,7 +186,7 @@ func (s *ExportService) WritePersonal(ctx context.Context, w io.Writer, spec Exp
}
sheets := personalSheetQueries(spec.ActorID)
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
return meta, err
}
return meta, nil
@@ -238,7 +239,7 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
}
sheets := projectSheetQueries(*spec.ScopeRoot, spec.DirectOnly)
if err := s.writeBundle(ctx, w, sheets, &meta); err != nil {
if err := s.writeBundle(ctx, s.db, w, sheets, &meta); err != nil {
return meta, err
}
@@ -254,6 +255,55 @@ func (s *ExportService) WriteProject(ctx context.Context, w io.Writer, spec Expo
return meta, nil
}
// WriteOrg streams the full org-scope backup bundle into w. Bypasses
// paliad.can_see_project — admin-only, gated at the handler layer (the
// service trusts the caller has been authorised).
//
// Wraps the entire read pass in a REPEATABLE READ READ ONLY transaction
// so every sheet sees the same snapshot. Without this a backup that runs
// while users are editing can land internally inconsistent rows (e.g. a
// deadlines.project_id pointing at a project the projects sheet just
// missed). Design §3.3.
//
// The handler is responsible for the audit-row INSERT / PATCH (the
// org-scope backup uses BackupRunner.Run, not WriteAuditRow, because the
// event_type is 'backup_created' not 'data_export').
func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSpec) (ExportMeta, error) {
if spec.Scope == "" {
spec.Scope = ExportScopeOrg
}
if spec.GeneratedAt.IsZero() {
spec.GeneratedAt = time.Now().UTC()
}
meta := ExportMeta{
SchemaVersion: ExportSchemaVersion,
FirmName: s.firmName,
Scope: spec.Scope,
GeneratedAt: spec.GeneratedAt,
GeneratedByID: spec.ActorID,
GeneratedByEml: spec.ActorEmail,
GeneratedByLbl: spec.ActorLabel,
RowCounts: map[string]int{},
}
tx, err := s.db.BeginTxx(ctx, &sql.TxOptions{
Isolation: sql.LevelRepeatableRead,
ReadOnly: true,
})
if err != nil {
return meta, fmt.Errorf("backup snapshot tx: %w", err)
}
// Always rollback — the tx is read-only by construction, the rollback
// is just bookkeeping that releases the snapshot.
defer func() { _ = tx.Rollback() }()
sheets := orgSheetQueries()
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
return meta, err
}
return meta, nil
}
// detectCrossSubtreeFKs scans subtree-resident projects for FKs that
// point outside the subtree (today: only projects.counterclaim_of). One
// warning row per outbound reference. Best-effort: a query error here
@@ -300,13 +350,17 @@ type collectedSheet struct {
// xlsx sheet + one JSON branch + one CSV per sheet, packs everything into
// the outer zip in sorted file-list order so two runs of the same row
// state produce byte-identical bundles.
func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
//
// queryer is the executor for sheet queries — typically s.db, but
// WriteOrg passes a REPEATABLE READ *sqlx.Tx so the org dump sees a
// consistent snapshot across all sheets (design §3.3).
func (s *ExportService) writeBundle(ctx context.Context, queryer sqlx.QueryerContext, w io.Writer, sheets []sheetQuery, meta *ExportMeta) error {
collectedSheets := make([]collectedSheet, 0, len(sheets))
jsonTables := make(map[string][]map[string]string, len(sheets))
warnings := []string{}
for _, sq := range sheets {
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, sq)
cols, rowMatrix, dropped, err := s.runSheetQuery(ctx, queryer, sq)
if err != nil {
return fmt.Errorf("export sheet %q: %w", sq.SheetName, err)
}
@@ -421,11 +475,13 @@ func (s *ExportService) writeBundle(ctx context.Context, w io.Writer, sheets []s
return nil
}
// runSheetQuery executes one sheetQuery and returns the kept columns,
// row matrix (pre-stringified per the design's value-as-string convention),
// and the list of columns that were dropped by the PII filter.
func (s *ExportService) runSheetQuery(ctx context.Context, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
rs, err := s.db.QueryxContext(ctx, sq.SQL, sq.Args...)
// runSheetQuery executes one sheetQuery against the given queryer and
// returns the kept columns, row matrix (pre-stringified per the design's
// value-as-string convention), and the list of columns that were dropped
// by the PII filter. queryer is typically s.db, but WriteOrg passes a
// REPEATABLE READ *sqlx.Tx (see writeBundle docs).
func (s *ExportService) runSheetQuery(ctx context.Context, queryer sqlx.QueryerContext, sq sheetQuery) (cols []string, rows [][]string, dropped []string, err error) {
rs, err := queryer.QueryxContext(ctx, sq.SQL, sq.Args...)
if err != nil {
return nil, nil, nil, fmt.Errorf("query: %w", err)
}
@@ -1470,3 +1526,107 @@ SELECT 'partner_unit_default'::text AS source,
}
return queries
}
// ---------------------------------------------------------------------------
// Org-scope sheet registry (Slice 3 / Backup Mode — t-paliad-246).
// ---------------------------------------------------------------------------
//
// Full-schema dump. Bypasses paliad.can_see_project — admin-only,
// gated at the handler layer (BackupRunner trusts the caller).
//
// Sheet ordering: entity sheets first (alphabetical), then ref__*
// reference sheets (alphabetical). The xlsx writer iterates the slice
// in order; downstream consumers get the same order across runs.
//
// Hard exclusions (per design §5.2 / m's Q3 decision):
//
// - paliadin_turns
// - paliadin_aichat_conversation
//
// AI conversation history is the most-sensitive personal data paliad
// carries; m's prior Q5 decision in t-paliad-214 made the exclusion
// structural. The two tables are absent from the registry — not just
// column-level redacted — so a future schema addition cannot
// accidentally re-include them.
//
// Also excluded unconditionally (operational / shadow):
//
// - *_pre_NNN shadow tables (CREATE TABLE … AS SELECT backups
// written by destructive migrations)
// - paliad_schema_migrations (operational)
// - auth.* (Supabase Auth schema — not ours)
//
// The PII column deny-regex (piiColumnDenyRegex) catches
// secret|token|password|api_key|private_key on every sheet as a
// belt-and-braces filter. user_caldav_config.password_encrypted is
// explicitly named in DropColumns too.
func orgSheetQueries() []sheetQuery {
return []sheetQuery{
// --- entity sheets (alphabetical) ---
{SheetName: "appointment_caldav_targets", SQL: `SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id, calendar_binding_id`},
{SheetName: "appointments", SQL: `SELECT * FROM paliad.appointments ORDER BY id`},
{SheetName: "approval_policies", SQL: `SELECT * FROM paliad.approval_policies ORDER BY id`},
{SheetName: "approval_requests", SQL: `SELECT * FROM paliad.approval_requests ORDER BY id`},
// backups is self-reflexive — including it makes "what backups
// have we taken" recoverable from any prior backup. Tiny table.
{SheetName: "backups", SQL: `SELECT * FROM paliad.backups ORDER BY started_at, id`},
{SheetName: "caldav_sync_log", SQL: `SELECT * FROM paliad.caldav_sync_log ORDER BY occurred_at, id`},
{SheetName: "checklist_instances", SQL: `SELECT * FROM paliad.checklist_instances ORDER BY id`},
{SheetName: "checklist_shares", SQL: `SELECT * FROM paliad.checklist_shares ORDER BY id`},
{SheetName: "checklists", SQL: `SELECT * FROM paliad.checklists ORDER BY id`},
{SheetName: "deadline_rule_audit", SQL: `SELECT * FROM paliad.deadline_rule_audit ORDER BY changed_at, id`},
{SheetName: "deadlines", SQL: `SELECT * FROM paliad.deadlines ORDER BY id`},
// documents: ai_extracted jsonb dropped (verbose AI prompts;
// matches the personal/project precedent). Binaries are not in
// the export — only metadata.
{
SheetName: "documents",
SQL: `SELECT id, project_id, title, doc_type, file_path, file_size, mime_type, uploaded_by, created_at, updated_at
FROM paliad.documents
ORDER BY id`,
},
{SheetName: "email_broadcasts", SQL: `SELECT * FROM paliad.email_broadcasts ORDER BY id`},
{SheetName: "email_template_versions", SQL: `SELECT * FROM paliad.email_template_versions ORDER BY id`},
{SheetName: "email_templates", SQL: `SELECT * FROM paliad.email_templates ORDER BY id`},
{SheetName: "firm_dashboard_default", SQL: `SELECT * FROM paliad.firm_dashboard_default ORDER BY id`},
{SheetName: "invitations", SQL: `SELECT * FROM paliad.invitations ORDER BY sent_at, id`},
{SheetName: "notes", SQL: `SELECT * FROM paliad.notes ORDER BY id`},
{SheetName: "parties", SQL: `SELECT * FROM paliad.parties ORDER BY id`},
{SheetName: "partner_unit_events", SQL: `SELECT * FROM paliad.partner_unit_events ORDER BY id`},
{SheetName: "partner_unit_members", SQL: `SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id`},
{SheetName: "partner_units", SQL: `SELECT * FROM paliad.partner_units ORDER BY id`},
{SheetName: "policy_audit_log", SQL: `SELECT * FROM paliad.policy_audit_log ORDER BY changed_at, id`},
{SheetName: "project_events", SQL: `SELECT * FROM paliad.project_events ORDER BY id`},
{SheetName: "project_partner_units", SQL: `SELECT * FROM paliad.project_partner_units ORDER BY project_id, partner_unit_id`},
{SheetName: "project_teams", SQL: `SELECT * FROM paliad.project_teams ORDER BY project_id, user_id`},
{SheetName: "projects", SQL: `SELECT * FROM paliad.projects ORDER BY id`},
{SheetName: "reminder_log", SQL: `SELECT * FROM paliad.reminder_log ORDER BY sent_at, id`},
{SheetName: "submission_drafts", SQL: `SELECT * FROM paliad.submission_drafts ORDER BY id`},
{SheetName: "system_audit_log", SQL: `SELECT * FROM paliad.system_audit_log ORDER BY created_at, id`},
{
SheetName: "user_caldav_config",
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
DropColumns: []string{"password_encrypted"}, // belt-and-braces; piiColumnDenyRegex also catches it
},
{SheetName: "user_calendar_bindings", SQL: `SELECT * FROM paliad.user_calendar_bindings ORDER BY user_id, calendar_path`},
{SheetName: "user_card_layouts", SQL: `SELECT * FROM paliad.user_card_layouts ORDER BY id`},
{SheetName: "user_dashboard_layouts", SQL: `SELECT * FROM paliad.user_dashboard_layouts ORDER BY user_id`},
{SheetName: "user_pinned_projects", SQL: `SELECT * FROM paliad.user_pinned_projects ORDER BY user_id, project_id`},
{SheetName: "user_views", SQL: `SELECT * FROM paliad.user_views ORDER BY id`},
{SheetName: "users", SQL: `SELECT * FROM paliad.users ORDER BY id`},
// --- reference data (alphabetical, prefixed ref__) ---
{SheetName: "ref__countries", SQL: `SELECT * FROM paliad.countries ORDER BY code`},
{SheetName: "ref__courts", SQL: `SELECT * FROM paliad.courts ORDER BY id`},
{SheetName: "ref__deadline_concept_event_types", SQL: `SELECT * FROM paliad.deadline_concept_event_types ORDER BY concept_id, event_type_id`},
{SheetName: "ref__deadline_concepts", SQL: `SELECT * FROM paliad.deadline_concepts ORDER BY id`},
{SheetName: "ref__deadline_event_types", SQL: `SELECT * FROM paliad.deadline_event_types ORDER BY rule_id, event_type_id`},
{SheetName: "ref__deadline_rules", SQL: `SELECT * FROM paliad.deadline_rules ORDER BY id`},
{SheetName: "ref__event_categories", SQL: `SELECT * FROM paliad.event_categories ORDER BY id`},
{SheetName: "ref__event_category_concepts", SQL: `SELECT * FROM paliad.event_category_concepts ORDER BY category_id, concept_id`},
{SheetName: "ref__event_types", SQL: `SELECT * FROM paliad.event_types ORDER BY id`},
{SheetName: "ref__holidays", SQL: `SELECT * FROM paliad.holidays ORDER BY date, country`},
{SheetName: "ref__proceeding_types", SQL: `SELECT * FROM paliad.proceeding_types ORDER BY id`},
{SheetName: "ref__trigger_events", SQL: `SELECT * FROM paliad.trigger_events ORDER BY id`},
}
}

View File

@@ -47,33 +47,6 @@ type PlaceholderMap map[string]string
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
type MissingPlaceholderFn func(key string) string
// valueWrapperFn wraps a substituted value with a marker the HTML
// preview emitter can recognise — used by RenderHTML to turn each
// substituted value into a clickable <span class="draft-var" …>
// (t-paliad-261, click-variable-in-preview → jump-to-field). nil means
// no wrapping; the .docx export path uses nil so its output is
// byte-identical to the wrapper-free build. The wrapper is invoked for
// both resolved values and missing-marker text so clicking a missing
// placeholder still jumps to the corresponding sidebar input.
type valueWrapperFn func(key, value string) string
// Private-Use-Area sentinels for the HTML preview wrap. PUA characters
// are valid in XML 1.0 content, never appear in legitimate template
// text, pass unchanged through xmlEncode/xmlDecode/htmlEscape, and are
// stripped by emitTextWithDraftVars when the preview HTML is assembled.
const (
previewVarBegin = ""
previewVarMid = ""
previewVarEnd = ""
)
// htmlPreviewWrapper wraps a substituted value with the PUA sentinels
// emitTextWithDraftVars recognises. Used only by RenderHTML; the .docx
// Render path uses nil so its output is identical to the pre-261 build.
func htmlPreviewWrapper(key, value string) string {
return previewVarBegin + key + previewVarMid + value + previewVarEnd
}
// DefaultMissingMarker returns the standard missing-value marker for
// the given UI language.
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
@@ -134,7 +107,7 @@ func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, m
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
}
if isWordXMLEntry(entry.Name) {
body = substituteInDocumentXML(body, vars, missing, nil)
body = substituteInDocumentXML(body, vars, missing)
}
w, err := zw.CreateHeader(&zip.FileHeader{
Name: entry.Name,
@@ -192,7 +165,7 @@ func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMa
if docXML == nil {
return "", fmt.Errorf("submission render html: word/document.xml missing")
}
merged := substituteInDocumentXML(docXML, vars, missing, htmlPreviewWrapper)
merged := substituteInDocumentXML(docXML, vars, missing)
return docXMLToHTML(merged), nil
}
@@ -241,12 +214,12 @@ func readMergeZipEntry(f *zip.File) ([]byte, error) {
// paragraph, run the replacement on the merged text, and rewrite
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
// the formatting properties of the first run.
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
replaced := substituteInTextNodes(body, vars, missing, wrap)
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
replaced := substituteInTextNodes(body, vars, missing)
if !needsCrossRunMerge(replaced) {
return replaced
}
return substituteAcrossRuns(replaced, vars, missing, wrap)
return substituteAcrossRuns(replaced, vars, missing)
}
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
@@ -256,12 +229,12 @@ var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
// substituteInTextNodes runs the placeholder replacement inside each
// <w:t> text node independently. Format-preserving for single-run
// placeholders.
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
sub := wTextNodeRegex.FindSubmatch(match)
attrs := string(sub[1])
contents := xmlDecode(string(sub[2]))
replaced := replacePlaceholders(contents, vars, missing, wrap)
replaced := replacePlaceholders(contents, vars, missing)
if replaced == contents {
return match
}
@@ -297,7 +270,7 @@ var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
// substituteAcrossRuns is pass 2: concatenate every text node in a
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) []byte {
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
if len(textNodes) == 0 {
@@ -311,7 +284,7 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
if !strings.Contains(original, "{{") {
return para
}
replaced := replacePlaceholders(original, vars, missing, wrap)
replaced := replacePlaceholders(original, vars, missing)
if replaced == original {
return para
}
@@ -334,29 +307,18 @@ func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlace
}
// replacePlaceholders performs the actual substitution on a plain
// string. Unbound placeholders render the missing marker. When wrap is
// non-nil, both the resolved value AND the missing-marker text are
// passed through wrap(key, value) — the HTML preview path uses this to
// emit clickable spans around every substituted placeholder, including
// missing ones (clicking a missing marker jumps to the corresponding
// sidebar input).
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn, wrap valueWrapperFn) string {
// string. Unbound placeholders render the missing marker.
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
sub := placeholderRegex.FindStringSubmatch(match)
if len(sub) < 2 {
return match
}
key := sub[1]
var value string
if v, ok := vars[key]; ok {
value = v
} else {
value = missing(key)
if value, ok := vars[key]; ok {
return value
}
if wrap != nil {
return wrap(key, value)
}
return value
return missing(key)
})
}
@@ -439,7 +401,7 @@ func paragraphToHTML(para []byte) string {
if italic {
out.WriteString("<em>")
}
out.WriteString(emitTextWithDraftVars(text))
out.WriteString(htmlEscape(text))
if italic {
out.WriteString("</em>")
}
@@ -450,52 +412,6 @@ func paragraphToHTML(para []byte) string {
return out.String()
}
// emitTextWithDraftVars HTML-escapes run text while converting any
// preview-only sentinels emitted by htmlPreviewWrapper into
// <span class="draft-var" data-var="<key>">…</span>. The key is
// restricted to [A-Za-z][A-Za-z0-9_.]* by placeholderRegex, so no
// attribute-escaping is needed on the key; the value is HTML-escaped
// normally. Sentinel-free text (the Render path output, or template
// text outside placeholders) is passed straight through htmlEscape, so
// callers that never invoked wrap see byte-identical HTML.
//
// t-paliad-261: makes substituted variables clickable in the preview
// pane so the user can jump to the matching input in the sidebar.
func emitTextWithDraftVars(text string) string {
if !strings.Contains(text, previewVarBegin) {
return htmlEscape(text)
}
var out strings.Builder
rest := text
for {
i := strings.Index(rest, previewVarBegin)
if i < 0 {
out.WriteString(htmlEscape(rest))
return out.String()
}
out.WriteString(htmlEscape(rest[:i]))
body := rest[i+len(previewVarBegin):]
mid := strings.Index(body, previewVarMid)
end := strings.Index(body, previewVarEnd)
if mid < 0 || end < 0 || mid > end {
// Malformed sentinel — emit the marker as plain escaped
// text and continue past it so the rest of the run still
// renders.
out.WriteString(htmlEscape(previewVarBegin))
rest = body
continue
}
key := body[:mid]
value := body[mid+len(previewVarMid) : end]
out.WriteString(`<span class="draft-var" data-var="`)
out.WriteString(key)
out.WriteString(`">`)
out.WriteString(htmlEscape(value))
out.WriteString(`</span>`)
rest = body[end+len(previewVarEnd):]
}
}
// extractRunText concatenates every <w:t> inside a run, XML-decoding
// the content as it goes.
func extractRunText(run []byte) string {

View File

@@ -265,9 +265,7 @@ func TestPatentNumberUPC(t *testing.T) {
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
// bold/italic through to <strong>/<em>. Substituted placeholders are
// wrapped in <span class="draft-var" data-var="…"> so the client can
// make them clickable (t-paliad-261).
// bold/italic through to <strong>/<em>.
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
doc := `<w:document><w:body>` +
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
@@ -280,8 +278,8 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
if err != nil {
t.Fatalf("render html: %v", err)
}
if !strings.Contains(html, `<p>Hello <span class="draft-var" data-var="firm.name">HLC</span></p>`) {
t.Errorf("expected merged paragraph with draft-var span, got %q", html)
if !strings.Contains(html, "<p>Hello HLC</p>") {
t.Errorf("expected merged paragraph, got %q", html)
}
if !strings.Contains(html, "<strong>Bold line</strong>") {
t.Errorf("expected bold span, got %q", html)
@@ -292,8 +290,7 @@ func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
}
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
// special characters in placeholder values even inside the draft-var
// span wrapper.
// special characters in placeholder values.
func TestRenderHTML_EscapesContent(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
@@ -304,50 +301,7 @@ func TestRenderHTML_EscapesContent(t *testing.T) {
if err != nil {
t.Fatalf("render html: %v", err)
}
want := `<span class="draft-var" data-var="user.display_name">M&amp;S &lt;Inc&gt; &quot;X&quot;</span>`
if !strings.Contains(html, want) {
t.Errorf("expected escaped value inside draft-var span, got %q", html)
}
}
// TestRenderHTML_WrapsMissingMarker confirms that an unbound placeholder
// is still rendered as a clickable draft-var span so the user can click
// the [KEIN WERT: …] marker in the preview and jump to the field.
func TestRenderHTML_WrapsMissingMarker(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
html, err := r.RenderHTML(tmpl, PlaceholderMap{}, nil)
if err != nil {
t.Fatalf("render html: %v", err)
}
want := `<span class="draft-var" data-var="project.case_number">[KEIN WERT: project.case_number]</span>`
if !strings.Contains(html, want) {
t.Errorf("expected missing marker wrapped in draft-var span, got %q", html)
}
}
// TestRender_DocxOutputUnchangedByPreviewWrap asserts the hard rule from
// t-paliad-261: the .docx export path must NOT carry the preview-only
// draft-var sentinels or any draft-var span markup. Renders the same
// template through Render (.docx) and asserts the merged document.xml
// has only the resolved value, not a wrapped one.
func TestRender_DocxOutputUnchangedByPreviewWrap(t *testing.T) {
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
tmpl := minimalMergeDOCX(t, doc)
r := NewSubmissionRenderer()
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
if err != nil {
t.Fatalf("render docx: %v", err)
}
body := readMergeDocumentXML(t, out)
if !strings.Contains(body, `<w:t>HLC</w:t>`) {
t.Errorf("expected raw resolved value in .docx, got %q", body)
}
// PUA sentinels and any span markup must NOT appear in the .docx.
for _, forbidden := range []string{"draft-var", "data-var", previewVarBegin, previewVarMid, previewVarEnd} {
if strings.Contains(body, forbidden) {
t.Errorf("docx output unexpectedly contains %q: %q", forbidden, body)
}
if !strings.Contains(html, "M&amp;S &lt;Inc&gt; &quot;X&quot;") {
t.Errorf("expected escaped value in HTML, got %q", html)
}
}