Compare commits

..

4 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
mAi
ef21e43375 Merge: t-paliad-260 — submission-draft mobile layout (m/paliad#91) 2026-05-25 14:59:53 +02:00
mAi
452ccdf127 Merge: t-paliad-258 — Deadline form Auto/Custom rule field + canonical rule-label display (m/paliad#89) 2026-05-25 14:56:18 +02:00
mAi
045accc6d9 mAi: #89 - deadline rule field binary Auto/Custom + canonical rule-label display
t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.

Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
  rule renders read-only as 'Auto | <Name · Citation>' next to the
  field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
  nullable column, migration 122). Mutually exclusive with rule_id at
  the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
  the current Type — no stale state.

Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
  Existing rows: empty custom_rule_text + non-null rule_id = Auto-
  equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
  when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
  RuleSet=true is the discriminator so absent fields don't overwrite
  the row (PATCH semantics). RuleID and CustomRuleText are mutually
  exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
  surfaces can render it.

Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
  dropdown, the override-warning slot, and the collapsed-by-Regel Typ
  view. Strip the (Rule→Type) auto-fill machinery — direction is now
  one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
  rule by project's proceeding, then jurisdiction match, then first
  candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
  Custom text → proceeding → fallback) so the recipe still produces a
  sensible title even when Custom is used.

Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
  custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
  enterEdit initialises the mode from the persisted deadline; Save
  PATCHes with rule_set:true + the chosen rule pointer.

Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
  ("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
  with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
  formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
  shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
  Standardtitel, events.ts ruleDisplay (REGEL column on /events),
  projects-detail.ts Fristen table, views/shape-list.ts generic
  rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
  name + citation chip separately and matches the canonical pattern;
  no change needed. Schriftsätze table is column-shaped (name + code
  in distinct columns) and out of scope per the addendum.

CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
  family (retired with the catalog dropdown).

i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
  mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
  auto_pick_type, custom_badge, custom_placeholder,
  mode.toggle_to_auto, mode.toggle_to_custom).

Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).

Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).

Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
2026-05-25 14:54:51 +02:00
29 changed files with 2401 additions and 632 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

@@ -10,6 +10,7 @@ import {
type PickerHandle,
} from "./event-types";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Deadline {
id: string;
@@ -21,6 +22,9 @@ interface Deadline {
source: string;
rule_id?: string;
rule_code?: string;
// t-paliad-258 — lawyer's free-text rule label when the deadline was
// saved in Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
notes?: string;
created_at: string;
completed_at?: string;
@@ -58,7 +62,21 @@ interface DeadlineRule {
id: string;
code?: string;
name: string;
name_en?: string;
rule_code?: string;
legal_source?: string | null;
// t-paliad-258 — canonical event_type for Auto-mode rule resolution
// when the user flips to Auto on the edit form.
concept_default_event_type_id?: string | null;
proceeding_type_id?: number | null;
}
interface ProceedingType {
id: number;
jurisdiction: string;
name: string;
name_en?: string;
sort_order?: number;
}
interface Me {
@@ -73,6 +91,18 @@ let rule: DeadlineRule | null = null;
let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
// t-paliad-258 — Auto/Custom rule editor state. Mirrors the create form.
// On enterEdit we initialise the mode from the persisted deadline:
// rule_id set → "auto"
// custom_rule_text set, no rule_id → "custom"
// neither set → "auto" (so the Type-driven
// resolver fills in immediately).
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let allRules: DeadlineRule[] = [];
let rulesByID = new Map<string, DeadlineRule>();
let proceedingTypesByID = new Map<number, ProceedingType>();
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
// modal, the entity is still in approval_status='pending'. Save must POST
// to /api/approval-requests/{id}/edit-entity (which keeps the request
@@ -181,17 +211,66 @@ function populateProjectPicker() {
sel.value = deadline.project_id;
}
async function loadRule(ruleID: string) {
async function loadAllRules() {
try {
const resp = await fetch(`/api/deadline-rules`);
if (!resp.ok) return;
const all: DeadlineRule[] = await resp.json();
rule = all.find((r) => r.id === ruleID) || null;
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
} catch {
/* non-fatal */
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal */
}
}
function lookupRule(ruleID: string): DeadlineRule | null {
return rulesByID.get(ruleID) || null;
}
// resolveAutoRuleForType mirrors the create-form resolver: pick the
// canonical rule for the chosen event_type, prioritising the project's
// proceeding then jurisdiction match.
function resolveAutoRuleForType(eventTypeID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const projID = deadline?.project_id;
const proj = projID ? allProjects.find((p) => p.id === projID) as (Project & { proceeding_type_id?: number | null }) | undefined : undefined;
if (proj && proj.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === proj.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypeByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
return resolveAutoRuleForType(picked[0]);
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -243,9 +322,15 @@ function render() {
}
const ruleEl = document.getElementById("deadline-rule-display")!;
// t-paliad-258 — display priority:
// 1. catalog rule (canonical Name · Citation pattern)
// 2. custom_rule_text + Custom badge
// 3. legacy rule_code-only (Fristenrechner saves)
// 4. "—"
if (rule) {
const code = rule.rule_code || rule.code || "";
ruleEl.textContent = code ? `${code}${rule.name}` : rule.name;
ruleEl.innerHTML = formatRuleLabelHTML(rule, esc);
} else if (deadline.custom_rule_text && deadline.custom_rule_text.trim()) {
ruleEl.innerHTML = formatCustomRuleLabelHTML(deadline.custom_rule_text, esc);
} else if (deadline.rule_code) {
// Fristenrechner-saved deadlines carry rule_code directly without
// a rule_id (no rule UUID round-trips through the public API).
@@ -369,6 +454,48 @@ function render() {
}
}
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const r = currentAutoRule();
if (r) {
text.textContent = formatRuleLabel(r);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function initEdit() {
const titleDisplay = document.getElementById("deadline-title-display")!;
const titleEdit = document.getElementById("deadline-title-edit") as HTMLInputElement;
@@ -383,6 +510,10 @@ function initEdit() {
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
const ruleDisplay = document.getElementById("deadline-rule-display");
const ruleEdit = document.getElementById("deadline-rule-edit");
const ruleCustomInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const ruleToggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -399,6 +530,19 @@ function initEdit() {
projectEdit.value = deadline.project_id;
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
// t-paliad-258 — show the Auto/Custom rule editor + initialise mode
// from the persisted deadline. Display element stays visible so the
// user keeps "before / after" context while editing.
if (ruleEdit) ruleEdit.style.display = "";
if (ruleDisplay) ruleDisplay.style.display = "none";
if (deadline?.custom_rule_text && !deadline.rule_id) {
ruleMode = "custom";
if (ruleCustomInput) ruleCustomInput.value = deadline.custom_rule_text;
} else {
ruleMode = "auto";
if (ruleCustomInput) ruleCustomInput.value = "";
}
applyRuleModeUI();
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -418,11 +562,22 @@ function initEdit() {
projectLink.style.display = "";
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
if (ruleEdit) ruleEdit.style.display = "none";
if (ruleDisplay) ruleDisplay.style.display = "";
saveBtn.style.display = "none";
editBtn.style.display = "";
pendingEditMode = false;
}
// Rule mode toggle (Auto ↔ Custom). The Auto resolver re-runs every
// time the Type picker changes, so just-toggling-to-Auto immediately
// surfaces a fresh resolution.
ruleToggleBtn?.addEventListener("click", () => {
ruleMode = ruleMode === "auto" ? "custom" : "auto";
applyRuleModeUI();
if (ruleMode === "custom") ruleCustomInput?.focus();
});
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
// route into pending-edit mode without re-running the edit-button
// visibility gate (which hides the button during pending).
@@ -435,8 +590,11 @@ function initEdit() {
// t-paliad-251 Part 4 — Standardtitel button.
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
// head = event_type label (if exactly one Typ chip is in edit)
// || rule code+name (when deadline carries a rule)
// head = event_type label (if exactly one Typ chip in edit)
// || Auto-resolved rule's canonical label (Name · Citation)
// || saved rule's canonical label
// || custom_rule_text (when in Custom mode + non-empty)
// || rule_code-only legacy fallback
// || "Neue Frist" fallback
// suffix = " — <project.reference>" when not already in head
titleDefaultBtn?.addEventListener("click", () => {
@@ -447,9 +605,16 @@ function initEdit() {
const et = eventTypeByID.get(ids[0]);
if (et) head = eventTypeLabel(et);
}
if (!head) {
const r = ruleMode === "auto" ? (currentAutoRule() ?? rule) : null;
if (r) head = formatRuleLabel(r);
}
if (!head && ruleMode === "custom") {
const txt = ruleCustomInput?.value.trim() || "";
if (txt) head = txt;
}
if (!head && rule) {
const code = rule.rule_code || rule.code || "";
head = code ? `${code}${rule.name}` : rule.name;
head = formatRuleLabel(rule);
}
if (!head && deadline.rule_code) {
head = deadline.rule_code;
@@ -480,6 +645,19 @@ function initEdit() {
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
// t-paliad-258 — rule_set discriminator tells the service this
// PATCH carries an Auto/Custom rule change. Both columns are
// mutually exclusive at the persistence boundary.
payload.rule_set = true;
if (ruleMode === "auto") {
const r = currentAutoRule();
payload.rule_id = r ? r.id : null;
payload.custom_rule_text = null;
} else {
const txt = ruleCustomInput?.value.trim() || "";
payload.rule_id = null;
payload.custom_rule_text = txt || null;
}
// t-paliad-252 — pending-edit mode routes through the new endpoint
// that updates the entity + merges payload into the still-pending
@@ -699,8 +877,14 @@ async function main() {
notfound.style.display = "block";
return;
}
await Promise.all([loadProject(deadline.project_id), loadAllProjects(), loadPendingRequest()]);
if (deadline.rule_id) await loadRule(deadline.rule_id);
await Promise.all([
loadProject(deadline.project_id),
loadAllProjects(),
loadPendingRequest(),
loadAllRules(),
loadProceedingTypes(),
]);
if (deadline.rule_id) rule = lookupRule(deadline.rule_id);
// Load event types in parallel; render once ready (the picker re-renders
// chips off the cached map, and the display element re-renders on the
@@ -721,6 +905,11 @@ async function main() {
eventTypePicker = attachEventTypePicker(pickerHost, {
initialIDs: deadline.event_type_ids ?? [],
currentUserAdmin: me?.global_role === "global_admin",
onChange: () => {
// Type change shifts the Auto-resolved rule. Refresh the
// read-only display panel (no-op outside edit mode / Custom).
refreshRuleAutoDisplay();
},
});
}

View File

@@ -8,25 +8,20 @@ import {
type PickerHandle,
} from "./event-types";
import { projectIndent } from "./project-indent";
import { formatRuleLabel } from "./rule-label";
let eventTypePicker: PickerHandle | null = null;
let currentUserAdmin = false;
let eventTypesByID = new Map<string, EventType>();
// expandedOverride flips to true when the user clicks "Anderen Typ
// wählen" on the collapsed inline summary. Sticky for the rest of the
// form session — cleared only when the user reverts the rule to "Keine
// Regel". When true, the picker stays visible regardless of whether
// the chip matches the rule's canonical default.
let expandedOverride = false;
interface Project {
id: string;
reference?: string | null;
title: string;
path: string;
// t-paliad-251 — used by Type→Rule autofill to narrow rule candidates
// to the project's own proceeding. Optional because not every project
// is a case/proceeding (clients + matters carry no proceeding type).
// Used by the Type→Rule resolver to narrow rule candidates to the
// project's own proceeding when one applies. Optional because clients
// and matter-level projects don't carry a proceeding type.
proceeding_type_id?: number | null;
}
@@ -36,14 +31,11 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
legal_source?: string | null;
proceeding_type_id?: number | null;
sequence_order?: number;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule,
// AND is inverted to power Typ→Regel auto-fill (t-paliad-251 Part 2):
// given a chosen event_type X, candidate rules are those whose
// concept_default_event_type_id === X.
// t-paliad-165 — canonical event_type for the rule's concept. The
// catalog is indexed by it so we can resolve Type → canonical Rule.
concept_default_event_type_id?: string | null;
}
@@ -56,31 +48,20 @@ interface ProceedingType {
sort_order?: number;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
// Rule mode (t-paliad-258 / m/paliad#89). The form has two states:
// auto — rule_id resolved from the chosen event_type, rendered
// read-only as "Auto: Name · Citation".
// custom — free-text input; submits as custom_rule_text on the API.
type RuleMode = "auto" | "custom";
let ruleMode: RuleMode = "auto";
let rulesByID = new Map<string, DeadlineRule>();
let allRules: DeadlineRule[] = [];
let proceedingTypesByID = new Map<number, ProceedingType>();
let projectsByID = new Map<string, Project>();
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
// new rule pick) or whether the user has manually edited (leave alone,
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
// t-paliad-251 — symmetric flag for the inverse direction. Tracks the
// rule ID we most recently injected as the Auto-derived default for the
// chosen event_type, so we can replace it silently when the user picks
// a different type but leave manual rule picks alone.
let lastAutoFilledRuleID: string | null = null;
// Current sort mode for the Rule select. Persisted to localStorage so
// repeat-form users don't have to re-pick their preferred ordering.
type RuleSort = "by_proceeding" | "by_court" | "alpha";
const RULE_SORT_KEY = "paliad.deadline.rule.sort";
let preselectedProjectID = "";
let preselectedProjectIDLocal = "";
function esc(s: string): string {
const d = document.createElement("div");
@@ -94,13 +75,6 @@ function showError(msg: string) {
el.className = "form-msg form-msg-error";
}
function ruleLabel(r: DeadlineRule): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const code = r.rule_code || r.code || "";
return code ? `${code}${name}` : name;
}
function proceedingLabel(pt: ProceedingType | undefined): string {
if (!pt) return "";
const lang = getLang();
@@ -145,143 +119,30 @@ async function loadProceedingTypes() {
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal — rule sort falls back to alpha when proceeding-type
metadata is missing */
/* non-fatal */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
renderRuleSelect();
} catch {
/* non-fatal — rule select stays at "no rule" */
/* non-fatal — rule display falls back to "—" */
}
}
// renderRuleSelect rebuilds the Rule <select> from the current sort
// mode + the cached rule set. Called whenever the user changes the sort
// dropdown, when the language flips, or after rules + proceeding types
// finish loading. The "Keine Regel" sentinel always stays at the top.
function renderRuleSelect(): void {
const sel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
if (!sel) return;
const previous = sel.value;
const sort = readRuleSort();
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
if (sort === "alpha") {
const sorted = [...allRules].sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
for (const r of sorted) {
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
}
} else if (sort === "by_court") {
// Group by proceeding_type.jurisdiction (UPC / EPA / DPMA / DE /
// other). Within each group, sort alpha by rule label so the user
// can scan a court's rules in stable order.
const byJurisdiction = new Map<string, DeadlineRule[]>();
for (const r of allRules) {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
const j = pt?.jurisdiction || t("event_types.browse.jurisdiction.none");
const list = byJurisdiction.get(j) ?? [];
list.push(r);
byJurisdiction.set(j, list);
}
const order = ["UPC", "EPA", "EPO", "DPMA", "DE"];
const keys = [...byJurisdiction.keys()].sort((a, b) => {
const ai = order.indexOf(a);
const bi = order.indexOf(b);
if (ai === -1 && bi === -1) return a.localeCompare(b);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
for (const k of keys) {
const list = byJurisdiction.get(k)!.sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
opts.push(`<optgroup label="${esc(k === "EPO" ? "EPA" : k)}">`);
for (const r of list) {
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
}
opts.push(`</optgroup>`);
}
} else {
// by_proceeding — group by proceeding_type, within each preserve the
// canonical sequence_order so the user reads "Klageerwiderung →
// Replik → Duplik → Verhandlung" in chronological order.
const byProceeding = new Map<number | string, DeadlineRule[]>();
const noProceedingKey = "__none__";
for (const r of allRules) {
const k: number | string = r.proceeding_type_id ?? noProceedingKey;
const list = byProceeding.get(k) ?? [];
list.push(r);
byProceeding.set(k, list);
}
const keys = [...byProceeding.keys()].sort((a, b) => {
if (a === noProceedingKey) return 1;
if (b === noProceedingKey) return -1;
const pa = proceedingTypesByID.get(a as number);
const pb = proceedingTypesByID.get(b as number);
const sa = pa?.sort_order ?? 9999;
const sb = pb?.sort_order ?? 9999;
if (sa !== sb) return sa - sb;
return (pa?.code ?? "").localeCompare(pb?.code ?? "");
});
for (const k of keys) {
const list = (byProceeding.get(k)!).slice().sort(
(a, b) => (a.sequence_order ?? 0) - (b.sequence_order ?? 0),
);
const pt = typeof k === "number" ? proceedingTypesByID.get(k) : undefined;
const groupLabel = pt ? proceedingLabel(pt) : t("deadlines.field.rule.sort.other_proceeding");
opts.push(`<optgroup label="${esc(groupLabel)}">`);
for (const r of list) {
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
}
opts.push(`</optgroup>`);
}
}
sel.innerHTML = opts.join("");
// Restore previous selection if it still exists in the new order.
if (previous && rulesByID.has(previous)) {
sel.value = previous;
}
}
function readRuleSort(): RuleSort {
try {
const raw = localStorage.getItem(RULE_SORT_KEY);
if (raw === "by_proceeding" || raw === "by_court" || raw === "alpha") return raw;
} catch {
/* non-fatal */
}
return "by_court";
}
function writeRuleSort(s: RuleSort): void {
try {
localStorage.setItem(RULE_SORT_KEY, s);
} catch {
/* non-fatal */
}
}
// resolveAutoRuleForType picks the best-match rule for the chosen event
// type, scoring by:
// resolveAutoRuleForType picks the best-match catalog rule for the
// chosen event type, scoring by:
// 1. project's proceeding_type_id (if known) — exact match wins,
// 2. otherwise event_type.jurisdiction matches the rule's
// proceeding's jurisdiction (EPA→EPO canonicalised),
// 3. otherwise just the first candidate in the canonical ordering.
// 2. otherwise event_type.jurisdiction matches the rule's proceeding's
// jurisdiction (EPA→EPO canonicalised),
// 3. otherwise the first candidate in canonical sequence_order.
//
// Returns null when no rule maps to this event_type. The caller surfaces
// this as "no Auto rule available — pick one manually" rather than
// silently leaving the dropdown stuck on whatever the user picked before.
// Returns null when no rule maps. Callers render that as "no Auto rule
// available" so the user can flip to Custom or pick a different Type.
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
@@ -306,231 +167,81 @@ function resolveAutoRuleForType(eventTypeID: string, projectID: string): Deadlin
return candidates[0];
}
let preselectedProjectIDLocal = "";
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
// currentAutoRule returns the catalog rule the Auto mode would resolve
// to for the current form state, or null when no Type is picked or no
// rule maps. Centralised so the Auto display, submitForm, and the
// Standardtitel button all agree on the same resolution.
function currentAutoRule(): DeadlineRule | null {
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) return null;
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
return resolveAutoRuleForType(picked[0], projectID);
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
//
// collapsed: rule selected + canonical event_type known + picker
// contains exactly [default] + user hasn't clicked "Anderen Typ
// wählen". Hides the chip cluster, surfaces a single inline
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
// override link.
//
// expanded: every other case — no rule, no default for the rule,
// picker has been edited, or expandedOverride is sticky after the
// user clicked the override link. Picker visible; mismatch warning
// surfaces yellow when the rule expected a different event_type.
function refreshRuleView(): void {
const collapsed = document.getElementById("deadline-event-type-collapsed");
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
const pickerHost = document.getElementById("deadline-event-types");
const warn = document.getElementById("deadline-event-type-rule-mismatch");
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const picked = eventTypePicker?.getIDs() ?? [];
const pickerMatchesDefault =
expected !== null && picked.length === 1 && picked[0] === expected;
// t-paliad-251 — when the rule was auto-derived from a user-picked
// type (Typ→Regel direction), the collapsed "vorgegeben durch Regel"
// copy reads backwards. Show the picker explicitly + surface the
// Auto badge on the Rule field instead.
const ruleWasAutoDerivedFromType =
lastAutoFilledRuleID !== null && ruleID === lastAutoFilledRuleID;
const wantsCollapsed =
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault && !ruleWasAutoDerivedFromType;
if (wantsCollapsed) {
const et = eventTypesByID.get(expected!);
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
collapsed.style.display = "";
pickerHost.style.display = "none";
warn.style.display = "none";
// refreshRuleAutoDisplay updates the read-only Auto display panel to
// reflect the rule that would be saved in Auto mode. Hides itself when
// the user is in Custom mode (the input takes its place).
function refreshRuleAutoDisplay(): void {
const panel = document.getElementById("deadline-rule-auto-display");
const text = document.getElementById("deadline-rule-auto-text");
if (!panel || !text) return;
if (ruleMode !== "auto") {
panel.style.display = "none";
return;
}
panel.style.display = "";
const rule = currentAutoRule();
if (rule) {
text.textContent = formatRuleLabel(rule);
text.classList.remove("rule-auto-text--empty");
return;
}
const picked = eventTypePicker?.getIDs() ?? [];
const fallback = picked.length === 1
? (t("deadlines.field.rule.auto_no_match") || "Keine Regel zur gewählten Verfahrenshandlung")
: (t("deadlines.field.rule.auto_pick_type") || "Wählen Sie zuerst eine Verfahrenshandlung");
text.textContent = fallback;
text.classList.add("rule-auto-text--empty");
}
collapsed.style.display = "none";
pickerHost.style.display = "";
// Mismatch warning: rule expected an event_type AND the picker
// doesn't contain it. (When the picker is empty + no override, no
// warning — user is free to leave it blank.)
if (expected && picked.length > 0 && !picked.includes(expected)) {
warn.style.display = "";
function applyRuleModeUI(): void {
const toggleBtn = document.getElementById("deadline-rule-mode-toggle") as HTMLButtonElement | null;
const autoPanel = document.getElementById("deadline-rule-auto-display");
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
if (!toggleBtn || !autoPanel || !customInput) return;
if (ruleMode === "auto") {
autoPanel.style.display = "";
customInput.style.display = "none";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_custom") || "Eigene Regel eingeben";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_custom");
} else {
warn.style.display = "none";
autoPanel.style.display = "none";
customInput.style.display = "";
toggleBtn.textContent = t("deadlines.field.rule.mode.toggle_to_auto") || "Zurück zu Auto";
toggleBtn.setAttribute("data-i18n", "deadlines.field.rule.mode.toggle_to_auto");
}
refreshRuleAutoDisplay();
}
function setRuleMode(mode: RuleMode): void {
ruleMode = mode;
applyRuleModeUI();
if (mode === "custom") {
const input = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
input?.focus();
}
}
// refreshRuleAutoBadgeAndWarning surfaces the Auto badge whenever the
// Rule was derived from the Typ (i.e. lastAutoFilledRuleID is currently
// selected) AND the warning whenever the user has manually picked a
// non-Auto rule that contradicts the Type's derived rule. Both end up
// inert when there's no Type chosen.
function refreshRuleAutoBadgeAndWarning(): void {
const autoEl = document.getElementById("deadline-rule-auto-hint");
const autoTextEl = document.getElementById("deadline-rule-auto-hint-text");
const warnEl = document.getElementById("deadline-rule-override-warn");
if (!autoEl || !autoTextEl || !warnEl) return;
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
if (!ruleSel) return;
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) {
autoEl.style.display = "none";
warnEl.style.display = "none";
return;
}
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const derived = resolveAutoRuleForType(picked[0], projectID);
const currentRuleID = ruleSel.value || "";
if (currentRuleID && currentRuleID === lastAutoFilledRuleID) {
// The current rule was auto-derived (and the user hasn't touched it).
autoEl.style.display = "";
autoTextEl.textContent = derived ? `${ruleLabel(derived)}` : "";
warnEl.style.display = "none";
return;
}
autoEl.style.display = "none";
// Override warning: derived rule exists AND user has picked a
// different non-empty rule. The copy names BOTH so the user knows
// exactly what's happening — and which one will be applied.
if (derived && currentRuleID && currentRuleID !== derived.id) {
const current = rulesByID.get(currentRuleID);
if (current) {
const tmpl = t("deadlines.field.rule.override_warn");
const msg = tmpl
.replace("{derived}", ruleLabel(derived))
.replace("{selected}", ruleLabel(current));
warnEl.textContent = msg;
warnEl.style.display = "";
return;
}
}
warnEl.style.display = "none";
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
function applyRuleAutoFill(): void {
if (!eventTypePicker) return;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const expected = rule?.concept_default_event_type_id ?? null;
const current = eventTypePicker.getIDs();
// Reset the override on transition to "Keine Regel" — fresh form
// session. Otherwise expandedOverride stays sticky.
if (ruleID === "") {
expandedOverride = false;
}
const pickerStillReflectsLastSuggestion =
lastAutoFilledEventTypeID !== null &&
current.length === 1 &&
current[0] === lastAutoFilledEventTypeID;
const pickerIsEmpty = current.length === 0;
if (expected) {
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
eventTypePicker.setIDs([expected]);
lastAutoFilledEventTypeID = expected;
}
} else if (pickerStillReflectsLastSuggestion) {
// New rule has no canonical event_type — clear the stale auto-fill
// so the picker doesn't carry a chip from the old rule.
eventTypePicker.setIDs([]);
lastAutoFilledEventTypeID = null;
}
refreshRuleView();
}
// applyTypeAutoFillRule is the inverse direction (t-paliad-251 Part 2):
// when the user picks a single Typ chip, derive the canonical Rule and
// inject it into the Regel select. Like applyRuleAutoFill, it leaves
// manual rule picks alone — only replaces when the current rule is the
// previous auto-fill (sticky-replace pattern).
function applyTypeAutoFillRule(): void {
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
if (!ruleSel) return;
const picked = eventTypePicker?.getIDs() ?? [];
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
if (picked.length !== 1) {
// 0 or 2+ Typ chips → no canonical rule to derive. Clear the
// sticky auto-fill so a stale Auto suggestion doesn't linger.
if (lastAutoFilledRuleID && ruleSel.value === lastAutoFilledRuleID) {
ruleSel.value = "";
lastAutoFilledRuleID = null;
// Mirror to the Regel→Typ path so its mismatch warning recomputes.
applyRuleAutoFill();
}
refreshRuleAutoBadgeAndWarning();
return;
}
const derived = resolveAutoRuleForType(picked[0], projectID);
const currentRuleID = ruleSel.value || "";
const ruleStillReflectsLastSuggestion =
lastAutoFilledRuleID !== null && currentRuleID === lastAutoFilledRuleID;
const ruleIsEmpty = currentRuleID === "";
if (derived) {
if (ruleIsEmpty || ruleStillReflectsLastSuggestion) {
ruleSel.value = derived.id;
lastAutoFilledRuleID = derived.id;
// Mirror to the Regel→Typ direction — the new rule's collapsed
// view + mismatch state needs to recompute now that we changed
// the selection programmatically.
applyRuleAutoFill();
}
} else if (ruleStillReflectsLastSuggestion) {
// No derived rule for the new type — drop the stale auto-fill.
ruleSel.value = "";
lastAutoFilledRuleID = null;
applyRuleAutoFill();
}
refreshRuleAutoBadgeAndWarning();
}
// computeDefaultTitle — t-paliad-251 Part 4. Recipe (documented also in
// the commit message so future title templates can mirror it):
//
// priority order picks the head of the title:
// 1. event_type label (when exactly one Typ chip is set)
// 2. rule name (when a Rule is set — uses ruleLabel = "code — name")
// 3. proceeding type name (when project carries a proceeding_type_id)
// 4. fallback: t("deadlines.field.title.default_fallback")
//
// suffix: " — <project-reference>" when the project has a reference
// string and the title doesn't already contain it.
//
// Returns "" only when even the fallback fails (i18n unavailable) —
// callers handle that by leaving the field untouched.
// computeDefaultTitle — t-paliad-251 Part 4. Priority order picks the head:
// 1. event_type label (when exactly one Typ chip is set)
// 2. canonical rule name (when Auto resolves to a rule)
// 3. custom rule text (when in Custom mode)
// 4. proceeding type name (when project carries one)
// 5. fallback i18n key
// Suffix: " — <project-reference>" when not already in head.
function computeDefaultTitle(): string {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const project = projectID ? projectsByID.get(projectID) : undefined;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const picked = eventTypePicker?.getIDs() ?? [];
let head = "";
@@ -538,8 +249,15 @@ function computeDefaultTitle(): string {
const et = eventTypesByID.get(picked[0]);
if (et) head = eventTypeLabel(et);
}
if (!head && rule) {
head = ruleLabel(rule);
if (!head) {
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) head = formatRuleLabel(rule);
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) head = txt;
}
}
if (!head && project?.proceeding_type_id) {
const pt = proceedingTypesByID.get(project.proceeding_type_id);
@@ -564,7 +282,6 @@ async function submitForm(e: Event) {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement).value;
const title = (document.getElementById("deadline-title") as HTMLInputElement).value.trim();
const due = (document.getElementById("deadline-due") as HTMLInputElement).value;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement).value;
const notes = (document.getElementById("deadline-notes") as HTMLTextAreaElement).value.trim();
if (!projectID || !title || !due) {
@@ -581,7 +298,15 @@ async function submitForm(e: Event) {
due_date: due,
source: "manual",
};
if (ruleID) payload.rule_id = ruleID;
// Rule field: Auto resolves to rule_id, Custom sends the free text.
if (ruleMode === "auto") {
const rule = currentAutoRule();
if (rule) payload.rule_id = rule.id;
} else {
const customInput = document.getElementById("deadline-rule-custom-input") as HTMLInputElement | null;
const txt = customInput?.value.trim() || "";
if (txt) payload.custom_rule_text = txt;
}
if (notes) payload.notes = notes;
const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
@@ -622,6 +347,16 @@ function detectPreselect() {
if (fromQuery) preselectedProjectID = fromQuery;
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
@@ -635,8 +370,6 @@ async function loadMe() {
// t-paliad-154 — fetch the effective approval policy for (project,
// deadline, create) and reveal the form-time hint when it applies.
// Hidden when no policy applies. Re-runs on project change so the hint
// updates if the user picks a different project mid-form.
async function refreshApprovalHint(): Promise<void> {
const hint = document.getElementById("deadline-approval-hint");
const text = document.getElementById("deadline-approval-hint-text");
@@ -655,7 +388,6 @@ async function refreshApprovalHint(): Promise<void> {
hint.style.display = "none";
return;
}
// t-paliad-160 split-grammar (with M1 legacy fallback).
const eff = await resp.json() as {
requires_approval?: boolean;
min_role?: string | null;
@@ -691,78 +423,45 @@ document.addEventListener("DOMContentLoaded", async () => {
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
// Wire the sort dropdown to read its initial value from localStorage and
// persist user picks back.
const sortSel = document.getElementById("deadline-rule-sort") as HTMLSelectElement | null;
if (sortSel) {
sortSel.value = readRuleSort();
sortSel.addEventListener("change", () => {
writeRuleSort(sortSel.value as RuleSort);
renderRuleSelect();
});
}
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
// After both rules + proceeding types are in, re-render with the
// chosen sort so groups carry proper labels.
renderRuleSelect();
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => {
// Both directions trigger off picker change: refresh the
// Regel→Typ collapsed/expanded state AND the Typ→Regel auto-fill.
refreshRuleView();
applyTypeAutoFillRule();
// Type change shifts which Auto rule resolves; re-render the
// read-only Auto display panel.
refreshRuleAutoDisplay();
},
});
}
// t-paliad-165 follow-up — preload event_types so the collapsed
// summary can render the type's label inline without an extra round
// trip when the user picks a Regel.
// Preload event_types for the Auto display + Standardtitel resolver.
fetchEventTypes()
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
refreshRuleAutoBadgeAndWarning();
refreshRuleAutoDisplay();
})
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion. ALSO
// resets the Typ→Regel auto-fill marker since the user just made a
// manual rule pick.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
lastAutoFilledRuleID = null;
applyRuleAutoFill();
refreshRuleAutoBadgeAndWarning();
});
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
// visible even when the chip still matches the rule's default.
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
expandedOverride = true;
refreshRuleView();
// Move focus into the picker's search box so the user can type
// immediately without an extra click.
const search = document.querySelector<HTMLInputElement>(
"#deadline-event-types .event-type-search",
);
search?.focus();
});
// Wire approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
// Project change can shift which rule the Type maps to (via the
// project's proceeding_type_id), so re-run the auto-fill.
void refreshApprovalHint();
applyTypeAutoFillRule();
.catch(() => {/* non-fatal */});
// Rule mode toggle.
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
});
// t-paliad-251 Part 4 — Standardtitel button replaces the title with
// a derived default. No destructive confirmation because the user
// invoked it explicitly.
applyRuleModeUI();
// Approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
void refreshApprovalHint();
// Project change can shift which Auto rule resolves (via the
// project's proceeding_type_id).
refreshRuleAutoDisplay();
});
// t-paliad-251 Part 4 — Standardtitel button.
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
if (!titleInput) return;

View File

@@ -9,6 +9,7 @@ import {
} from "./event-types";
import { projectIndent } from "./project-indent";
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
@@ -66,6 +67,9 @@ interface EventListItem {
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was created
// via the Custom rule path. Mutually exclusive with rule_id.
custom_rule_text?: string;
event_type_ids?: string[];
// appointment-only
@@ -264,13 +268,26 @@ function urgencyClass(item: EventListItem): string {
function ruleDisplay(item: EventListItem): string {
if (item.type !== "deadline") return "";
// Prefer the saved citation (RoP.023, R.151) over the rule name —
// REGEL is meant for the legal reference, not the rule's display
// name (which is the title column's job).
if (item.rule_code && item.rule_code.trim()) return esc(item.rule_code);
const lang = getLang();
const localized = lang === "en" ? item.rule_name_en : item.rule_name;
if (localized && localized.trim()) return esc(localized);
// t-paliad-258 addendum — canonical display contract: Name primary,
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
// Custom rules render the lawyer's free text + a "Custom" badge.
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
// show the bare citation as last-resort fallback.
const hasName = (item.rule_name && item.rule_name.trim()) ||
(item.rule_name_en && item.rule_name_en.trim());
if (hasName || (item.rule_code && item.rule_code.trim())) {
return formatRuleLabelHTML(
{
name: item.rule_name || "",
name_en: item.rule_name_en,
rule_code: item.rule_code,
},
esc,
);
}
if (item.custom_rule_text && item.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
}
return "&mdash;";
}

View File

@@ -882,17 +882,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.title.placeholder": "z.\u202fB. Klageerwiderung einreichen",
"deadlines.field.due": "F\u00e4lligkeitsdatum",
"deadlines.field.rule": "Regel (optional)",
"deadlines.field.rule.none": "Keine Regel",
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.rule.override": "Anderen Typ wählen",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.override_warn": "Typ ergibt Regel: {derived}. Gewählte Regel: {selected}. Es wird {selected} angewendet.",
"deadlines.field.rule.sort.by_proceeding": "Nach Verfahrensablauf",
"deadlines.field.rule.sort.by_court": "Nach Gerichtsart",
"deadlines.field.rule.sort.alpha": "Alphabetisch",
"deadlines.field.rule.sort.other_proceeding": "Sonstige Regeln",
"deadlines.field.rule.auto_no_match": "Keine Regel zur gewählten Verfahrenshandlung",
"deadlines.field.rule.auto_pick_type": "Wählen Sie zuerst eine Verfahrenshandlung",
"deadlines.field.rule.custom_badge": "Eigen",
"deadlines.field.rule.custom_placeholder": "z.B. interner Review-Termin, Mandantengespräch",
"deadlines.field.rule.mode.toggle_to_auto": "Zurück zu Auto",
"deadlines.field.rule.mode.toggle_to_custom": "Eigene Regel eingeben",
"deadlines.field.title.default_btn": "Standardtitel",
"deadlines.field.title.default_fallback": "Neue Frist",
"deadlines.field.notes": "Notizen (optional)",
@@ -2354,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.",
@@ -3857,17 +3878,13 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.title.placeholder": "e.g. File statement of defence",
"deadlines.field.due": "Due date",
"deadlines.field.rule": "Rule (optional)",
"deadlines.field.rule.none": "No rule",
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
"deadlines.field.rule.autofill_inline": " (set by rule)",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.rule.override": "Choose another type",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.override_warn": "Type derives rule: {derived}. Selected rule: {selected}. {selected} will be applied.",
"deadlines.field.rule.sort.by_proceeding": "By proceeding sequence",
"deadlines.field.rule.sort.by_court": "By court type",
"deadlines.field.rule.sort.alpha": "Alphabetical",
"deadlines.field.rule.sort.other_proceeding": "Other rules",
"deadlines.field.rule.auto_no_match": "No rule maps to the chosen Type",
"deadlines.field.rule.auto_pick_type": "Pick a Type first",
"deadlines.field.rule.custom_badge": "Custom",
"deadlines.field.rule.custom_placeholder": "e.g. internal review meeting, client call",
"deadlines.field.rule.mode.toggle_to_auto": "Back to Auto",
"deadlines.field.rule.mode.toggle_to_custom": "Enter custom rule",
"deadlines.field.title.default_btn": "Default title",
"deadlines.field.title.default_fallback": "New deadline",
"deadlines.field.notes": "Notes (optional)",
@@ -5301,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

@@ -16,6 +16,7 @@ import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
interface Project {
id: string;
@@ -142,6 +143,11 @@ interface Deadline {
status: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
// t-paliad-258 — free-text rule label when the deadline was saved in
// Custom mode. Mutually exclusive with rule_id.
custom_rule_text?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
@@ -805,6 +811,9 @@ interface UnionEvent {
status?: string;
rule_id?: string;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
custom_rule_text?: string;
start_at?: string;
end_at?: string;
location?: string;
@@ -832,6 +841,9 @@ async function loadDeadlines(id: string) {
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
rule_name: it.rule_name,
rule_name_en: it.rule_name_en,
custom_rule_text: it.custom_rule_text,
project_title: it.project_title,
}));
} else {
@@ -1001,6 +1013,27 @@ function fmtDateOnly(iso: string): string {
}
}
// formatDeadlineRuleCell renders the REGEL column for the project
// detail Fristen table using the canonical t-paliad-258 contract:
// 1. catalog rule (rule_name / rule_name_en + rule_code) → "Name · Code"
// 2. custom_rule_text → text + "Custom" badge
// 3. legacy rule_code-only saves → bare citation
// 4. otherwise "—"
function formatDeadlineRuleCell(f: Deadline): string {
const hasName = (f.rule_name && f.rule_name.trim()) ||
(f.rule_name_en && f.rule_name_en.trim());
if (hasName || (f.rule_code && f.rule_code.trim())) {
return formatRuleLabelHTML(
{ name: f.rule_name || "", name_en: f.rule_name_en, rule_code: f.rule_code },
esc,
);
}
if (f.custom_rule_text && f.custom_rule_text.trim()) {
return formatCustomRuleLabelHTML(f.custom_rule_text, esc);
}
return "—";
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
@@ -1039,7 +1072,7 @@ function renderDeadlines() {
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td class="frist-col-rule">${formatDeadlineRuleCell(f)}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})

View File

@@ -0,0 +1,87 @@
// rule-label — canonical display contract for deadline rules.
//
// t-paliad-258 / m/paliad#89 addendum. Previously each surface (deadline
// form, list rows, detail header, Schriftsätze tab, browse-a-proceeding)
// invented its own pattern: sometimes citation-only, sometimes name-only,
// sometimes "code — name". m flagged this on the first submissions in a
// proceeding sequence where the inconsistency was most visible.
//
// Canonical pattern: **Name primary, Citation muted secondary**.
// Text: "Notice of Appeal · UPC.RoP.220.1"
// HTML: <span class="rule-label-name">Notice of Appeal</span>
// <span class="rule-label-sep"> · </span>
// <span class="rule-label-cite">UPC.RoP.220.1</span>
//
// Custom rules (t-paliad-258 — free-text label entered by the lawyer):
// formatCustomRuleLabel produces "<text>" with a "Custom" badge slot
// so list/detail surfaces can render both shapes uniformly.
import { getLang, t } from "./i18n";
export interface RuleLike {
name: string;
name_en?: string | null;
// The catalog carries multiple citation fields depending on which
// surface populated it. Order of preference: legal_source > rule_code
// > code. All three are accepted so callers don't have to normalise.
rule_code?: string | null;
code?: string | null;
legal_source?: string | null;
}
// formatRuleLabel returns the canonical plain-text label.
// Falls back gracefully when either side is missing.
export function formatRuleLabel(r: RuleLike): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) return `${name} · ${cite}`;
return name || cite || "";
}
// formatRuleLabelHTML returns the canonical HTML form with muted-citation
// styling. The caller passes the HTML-escape helper so we don't pull a
// dependency on a specific esc() module — every surface already has one.
export function formatRuleLabelHTML(r: RuleLike, esc: (s: string) => string): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const cite = ruleCitation(r);
if (name && cite) {
return (
`<span class="rule-label-name">${esc(name)}</span>` +
`<span class="rule-label-sep"> · </span>` +
`<span class="rule-label-cite">${esc(cite)}</span>`
);
}
return esc(name || cite || "");
}
// ruleCitation returns the best-available citation string for a rule.
// Exported so callers that need the bare code (e.g. CalDAV exports,
// inline data attributes) can pull it without going through the label
// formatter.
export function ruleCitation(r: RuleLike): string {
return r.legal_source || r.rule_code || r.code || "";
}
// formatCustomRuleLabelHTML — render a free-text custom rule label with
// a "Custom" badge slot. Used by surfaces that may display either a
// catalog rule (formatRuleLabelHTML) or a custom one. Returns "" when
// the text is empty so callers can fall through to "—".
export function formatCustomRuleLabelHTML(text: string | null | undefined, esc: (s: string) => string): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return (
`<span class="rule-label-name">${esc(trimmed)}</span>` +
`<span class="rule-label-badge rule-label-badge--custom">${esc(badge)}</span>`
);
}
// formatCustomRuleLabel — plain-text equivalent of the above.
export function formatCustomRuleLabel(text: string | null | undefined): string {
const trimmed = (text ?? "").trim();
if (!trimmed) return "";
const badge = t("deadlines.field.rule.custom_badge") || "Custom";
return `${trimmed} · ${badge}`;
}

View File

@@ -147,8 +147,22 @@ function formatColumn(row: ViewRow, col: string): string {
const s = (row.detail.status as string | undefined) ?? "";
return s ? t(("deadlines.status." + s) as I18nKey) : "—";
}
case "rule":
return (row.detail.rule_code as string | undefined) ?? "—";
case "rule": {
// t-paliad-258 — canonical "Name · Citation" pattern; fall back
// to custom_rule_text + " · Custom" for Custom-mode deadlines.
const lang = getLang();
const nameKey = lang === "en" ? "rule_name_en" : "rule_name";
const name = (row.detail[nameKey] as string | undefined)
|| (row.detail.rule_name as string | undefined)
|| "";
const cite = (row.detail.rule_code as string | undefined) ?? "";
if (name && cite) return `${name} · ${cite}`;
if (name) return name;
if (cite) return cite;
const custom = (row.detail.custom_rule_text as string | undefined) ?? "";
if (custom.trim()) return `${custom} · Custom`;
return "—";
}
case "event_type":
return (row.detail.event_type as string | undefined) ?? "—";
case "location":

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

@@ -108,7 +108,36 @@ export function renderDeadlinesDetail(): string {
</dd>
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
<dd>
<span id="deadline-rule-display">&mdash;</span>
{/* t-paliad-258 — Auto / Custom rule editor.
Mirrors /deadlines/new: read-only Auto display
(resolved from Type) or free-text Custom input,
with a toggle link. Hidden outside edit mode. */}
<div className="rule-edit-block" id="deadline-rule-edit" style="display:none">
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span className="form-hint-badge" data-i18n="deadlines.field.rule.auto_badge">Auto</span>
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>
</dd>
<dt data-i18n="deadlines.detail.source">Quelle</dt>
<dd id="deadline-source-display" />

View File

@@ -72,91 +72,41 @@ export function renderDeadlinesNew(): string {
<div className="form-field" id="deadline-event-type-field">
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
{/* t-paliad-165 follow-up — collapsed view: when a Regel
is selected and a default event_type is known, the
Typ chip is hidden and the type is rendered inline
as a single read-only summary with an "Anderen Typ
wählen" link that re-expands the picker. */}
<div
className="event-type-collapsed"
id="deadline-event-type-collapsed"
style="display:none"
>
<span
className="event-type-collapsed-label"
id="deadline-event-type-collapsed-label"
/>
<span
className="event-type-collapsed-source"
data-i18n="deadlines.field.rule.autofill_inline"
>
&nbsp;(vorgegeben durch Regel)
</span>
<button
type="button"
className="event-type-collapsed-override"
id="deadline-event-type-override-btn"
data-i18n="deadlines.field.rule.override"
>
Anderen Typ w&auml;hlen
</button>
</div>
<div id="deadline-event-types" className="event-type-picker-host" />
{/* Soft warning when the user is in expanded mode AND
has picked an event_type that doesn't include the
rule's canonical default. Reuses the existing
yellow form-hint--warning style; never blocking. */}
<p
className="form-hint form-hint--warning"
id="deadline-event-type-rule-mismatch"
style="display:none"
data-i18n="deadlines.field.rule.mismatch"
>
Hinweis: Typ widerspricht Regel &mdash; Sie haben den Typ &uuml;berschrieben.
</p>
</div>
{/* m/paliad#56 — Regel sits directly beneath the Typ
picker so the parent/child relationship reads at a
glance. Due date is its own row below. */}
{/* t-paliad-258 / m/paliad#89 — binary Rule field.
Auto (default): rule_id derived from the chosen
Type, displayed read-only with a canonical
"Name · Citation" label. Custom: free-text input,
no catalog FK. Toggle switches modes. */}
<div className="form-field">
<div className="form-field-label-row">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
{/* t-paliad-251 Part 2 — sort options for the Rule
select. Defaults to "by_court" so users in the
UPC bucket find UPC rules quickly. */}
<select id="deadline-rule-sort" className="rule-sort-select" aria-label="Sortierung">
<option value="by_proceeding" data-i18n="deadlines.field.rule.sort.by_proceeding">Nach Verfahrensablauf</option>
<option value="by_court" data-i18n="deadlines.field.rule.sort.by_court" selected>Nach Gerichtsart</option>
<option value="alpha" data-i18n="deadlines.field.rule.sort.alpha">Alphabetisch</option>
</select>
<label data-i18n="deadlines.field.rule">Regel</label>
<button
type="button"
id="deadline-rule-mode-toggle"
className="btn-link-action"
data-i18n="deadlines.field.rule.mode.toggle_to_custom"
>
Eigene Regel eingeben
</button>
</div>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
{/* t-paliad-251 Part 3 — explicit Auto badge surfaces
whenever the Rule was auto-derived from the Typ.
Hidden when the user has manually picked a rule. */}
<p
className="form-hint form-hint--auto"
id="deadline-rule-auto-hint"
style="display:none"
>
<div className="rule-mode-auto" id="deadline-rule-auto-display">
<span
className="form-hint-badge"
data-i18n="deadlines.field.rule.auto_badge"
>Auto</span>
<span id="deadline-rule-auto-hint-text" />
</p>
{/* t-paliad-251 Part 3 — clearer override warning that
names BOTH the type-derived rule and the actually-
applied rule. Replaces the older Regel→Typ-only
mismatch warning when the contradiction goes the
other direction. */}
<p
className="form-hint form-hint--warning"
id="deadline-rule-override-warn"
<span id="deadline-rule-auto-text" className="rule-auto-text">&mdash;</span>
</div>
<input
type="text"
id="deadline-rule-custom-input"
className="rule-mode-custom"
style="display:none"
placeholder="z.B. interner Review-Termin"
data-i18n-placeholder="deadlines.field.rule.custom_placeholder"
maxLength={200}
/>
</div>

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"
@@ -1243,16 +1265,12 @@ export type I18nKey =
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.auto_badge"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.autofill_inline"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.rule.override"
| "deadlines.field.rule.override_warn"
| "deadlines.field.rule.sort.alpha"
| "deadlines.field.rule.sort.by_court"
| "deadlines.field.rule.sort.by_proceeding"
| "deadlines.field.rule.sort.other_proceeding"
| "deadlines.field.rule.auto_no_match"
| "deadlines.field.rule.auto_pick_type"
| "deadlines.field.rule.custom_badge"
| "deadlines.field.rule.custom_placeholder"
| "deadlines.field.rule.mode.toggle_to_auto"
| "deadlines.field.rule.mode.toggle_to_custom"
| "deadlines.field.title"
| "deadlines.field.title.default_btn"
| "deadlines.field.title.default_fallback"
@@ -1898,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

@@ -7627,15 +7627,35 @@ dialog.modal::backdrop {
color: var(--color-accent);
}
/* Small dropdown rendered alongside the Rule label to switch the
ordering. Tone-down sizing so it doesn't look like a co-equal
form field. Specificity-bumped to win over `.form-field select`'s
width: 100% baseline. */
.form-field select.rule-sort-select,
select.rule-sort-select {
width: auto;
padding: 0.2rem 0.4rem;
font-size: 0.82rem;
/* t-paliad-258 — Auto/Custom Rule editor (m/paliad#89).
Replaces the t-paliad-251 catalog dropdown + sort selector with a
binary toggle:
.rule-mode-auto — read-only display, lime-tint pill + label.
.rule-mode-custom — free-text input, full-width.
Toggle button reuses .btn-link-action for the inline link styling. */
.rule-mode-auto {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border-left: 2px solid var(--color-accent);
border-radius: var(--radius-sm, 4px);
min-height: 2rem;
}
.rule-auto-text {
color: var(--color-text);
font-size: 0.95rem;
}
.rule-auto-text--empty {
color: var(--color-text-muted, #6b7280);
font-style: italic;
}
.form-field input.rule-mode-custom,
input.rule-mode-custom {
width: 100%;
padding: 0.45rem 0.6rem;
font-size: 0.95rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
@@ -7643,6 +7663,34 @@ select.rule-sort-select {
font-family: var(--font-sans);
}
/* t-paliad-258 addendum — canonical rule label display:
Name primary, Citation muted secondary ("Name · Citation").
Custom rules use a "Custom" pill instead of a citation. */
.rule-label-name {
color: var(--color-text);
}
.rule-label-sep,
.rule-label-cite {
color: var(--color-text-muted, #6b7280);
font-size: 0.9em;
}
.rule-label-cite {
margin-left: 0.15rem;
}
.rule-label-badge {
display: inline-block;
margin-left: 0.4rem;
padding: 0.02rem 0.4rem;
border-radius: 999px;
background: var(--color-bg-lime-tint);
color: var(--color-text);
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.03em;
text-transform: uppercase;
border: 1px solid var(--color-accent);
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;
@@ -12246,42 +12294,10 @@ dialog.quick-add-sheet::backdrop {
t-paliad-088 — Event Types: picker, multi-select filter, add modal
============================================================================ */
/* t-paliad-165 follow-up — collapsed read-only view used on
/deadlines/new when a Regel is selected and a default event_type is
known. Replaces the picker with a single inline label + an
"Anderen Typ wählen" override link. */
.event-type-collapsed {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
padding: 0.35rem 0.55rem;
background: var(--color-bg-lime-tint);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
font-size: 0.95rem;
line-height: 1.3;
flex-wrap: wrap;
}
.event-type-collapsed-label {
font-weight: 600;
color: var(--color-text);
}
.event-type-collapsed-source {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.event-type-collapsed-override {
margin-left: auto;
background: transparent;
border: 0;
padding: 0;
color: var(--color-link, #1d4ed8);
text-decoration: underline;
cursor: pointer;
font: inherit;
font-size: 0.85rem;
}
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
/* (t-paliad-258 — the .event-type-collapsed* "vorgegeben durch Regel"
collapsed view from t-paliad-165 was retired with the catalog
dropdown. The Auto/Custom rule editor took its place; styles for
that live under .rule-mode-auto / .rule-mode-custom above.) */
/* Picker host — chip cluster + search + suggest dropdown */
.event-type-picker {
@@ -12678,8 +12694,7 @@ dialog.quick-add-sheet::backdrop {
.event-type-browse-search:focus { border-color: var(--color-accent); }
/* t-paliad-251 — jurisdiction filter chips inside the browse modal
header. Sits below the search input, between the search and the
results list. Active chip uses the lime-tint chip palette already
established by .event-type-collapsed* (t-paliad-165). */
results list. Active chip uses the lime-tint chip palette. */
.event-type-browse-chips {
display: flex;
flex-wrap: wrap;

View File

@@ -0,0 +1,6 @@
-- t-paliad-258: revert the additive custom_rule_text column.
-- Drop the column; rows that used the Custom path lose their free-text
-- label and read as "no rule".
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS custom_rule_text;

View File

@@ -0,0 +1,26 @@
-- t-paliad-258 / m/paliad#89 — binary Auto/Custom Rule model on the
-- deadline form.
--
-- t-paliad-251 shipped the form with a full deadline_rules catalog
-- dropdown. m's verdict: too noisy (4 "Oral hearings" across UPC CFI,
-- UPC CoA, DPMA, EPO etc.). Replace with a binary model:
--
-- 1. Auto — rule_id derived from the chosen event_type, displayed
-- read-only.
-- 2. Custom — rule_id is NULL and the lawyer's free-text label is
-- stored here.
--
-- The column is additive + nullable: existing rows keep their
-- deadline_rule_id and read as Auto-equivalent. A future row with both
-- columns NULL renders as "keine Regel" (matches today's no-rule state).
ALTER TABLE paliad.deadlines
ADD COLUMN IF NOT EXISTS custom_rule_text text;
COMMENT ON COLUMN paliad.deadlines.custom_rule_text IS
'Free-text rule label entered when the lawyer chose Custom on the '
'deadline form (t-paliad-258). Mutually exclusive with rule_id at '
'the application layer: Auto path sets rule_id and leaves this '
'NULL; Custom path sets this and leaves rule_id NULL. Display '
'surfaces prefer the rule_id-joined deadline_rules.name when '
'present, else fall back to custom_rule_text + a "Custom" badge.';

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

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

@@ -313,6 +313,14 @@ type Deadline struct {
// changes to paliad.deadline_rules and accepts citations from
// outside that table.
RuleCode *string `db:"rule_code" json:"rule_code,omitempty"`
// CustomRuleText holds the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258 / m/paliad#89).
// Mutually exclusive with RuleID at the application layer: the Auto
// path sets RuleID and leaves this NULL; the Custom path sets this
// and leaves RuleID NULL. Display surfaces prefer the joined
// deadline_rules.name when RuleID is set, else fall back to this
// text + a "Custom" badge.
CustomRuleText *string `db:"custom_rule_text" json:"custom_rule_text,omitempty"`
Status string `db:"status" json:"status"`
CompletedAt *time.Time `db:"completed_at" json:"completed_at,omitempty"`
CalDAVUID *string `db:"caldav_uid" json:"caldav_uid,omitempty"`

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

@@ -66,7 +66,7 @@ func (s *DeadlineService) pendingApprovalErr(ctx context.Context, deadlineID uui
}
const deadlineColumns = `id, project_id, title, description, due_date, original_due_date,
warning_date, source, rule_id, rule_code, status, completed_at, caldav_uid, caldav_etag,
warning_date, source, rule_id, rule_code, custom_rule_text, status, completed_at, caldav_uid, caldav_etag,
notes, created_by, created_at, updated_at,
approval_status, pending_request_id, approved_by, approved_at`
@@ -81,6 +81,11 @@ type CreateDeadlineInput struct {
// Sent by the Fristenrechner save flow so the title can stay clean
// instead of carrying the citation as a prefix.
RuleCode *string `json:"rule_code,omitempty"`
// CustomRuleText is the lawyer's free-text rule label when the
// deadline form is in Custom mode (t-paliad-258). Mutually exclusive
// with RuleID at the application layer; the service trims and treats
// an all-whitespace value as nil.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
Source string `json:"source,omitempty"` // default "manual"
Notes *string `json:"notes,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
@@ -108,6 +113,20 @@ type UpdateDeadlineInput struct {
Status *string `json:"status,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
EventTypeIDs *[]uuid.UUID `json:"event_type_ids,omitempty"`
// Rule pointer pair (t-paliad-258 / m/paliad#89). Three valid
// shapes; the service rejects "both set":
// - RuleSet=true, RuleID non-nil, CustomRuleText nil → Auto:
// bind to the catalog rule, clear custom_rule_text.
// - RuleSet=true, RuleID nil, CustomRuleText non-nil → Custom:
// store free text, clear rule_id.
// - RuleSet=true, RuleID nil, CustomRuleText nil → No rule:
// clear both columns.
// RuleSet=false leaves both columns untouched (the rest of the
// PATCH body doesn't carry rule changes).
RuleSet bool `json:"rule_set,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
CustomRuleText *string `json:"custom_rule_text,omitempty"`
}
// DeadlineStatusFilter is a server-side bucket for ListVisibleForUser.
@@ -241,7 +260,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.rule_code, f.status, f.completed_at,
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
@@ -514,6 +533,23 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
}
}
// Auto/Custom rule swap (t-paliad-258). Mutually exclusive at the
// persistence boundary: setting one column NULLs the other.
if input.RuleSet {
if input.RuleID != nil && input.CustomRuleText != nil {
return nil, fmt.Errorf("%w: rule_id and custom_rule_text are mutually exclusive", ErrInvalidInput)
}
appendSet("rule_id", input.RuleID)
var customText *string
if input.CustomRuleText != nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customText = &trimmed
}
}
appendSet("custom_rule_text", customText)
}
// Project move (t-paliad-140). Visibility on the destination is enforced
// the same way as on Create — a GetByID round-trip through ProjectService
// returns ErrNotVisible if the user can't see the target. Same-project
@@ -587,7 +623,7 @@ func (s *DeadlineService) Update(ctx context.Context, userID, deadlineID uuid.UU
// Did the PATCH touch anything beyond the project move?
otherFieldsTouched := input.Title != nil || input.Description != nil ||
input.DueDate != nil || input.Notes != nil || input.Status != nil ||
input.EventTypeIDs != nil
input.EventTypeIDs != nil || input.RuleSet
if otherFieldsTouched {
auditProject := current.ProjectID
if movedFromProject != nil {
@@ -1012,15 +1048,27 @@ func (s *DeadlineService) insertTx(ctx context.Context, tx *sqlx.Tx, userID, pro
}
}
// Auto vs Custom (t-paliad-258): RuleID and CustomRuleText are
// mutually exclusive. If the caller passes both, the catalog rule
// wins and the free-text is dropped — keeps the invariant simple at
// the persistence boundary.
var customRuleText *string
if input.CustomRuleText != nil && input.RuleID == nil {
trimmed := strings.TrimSpace(*input.CustomRuleText)
if trimmed != "" {
customRuleText = &trimmed
}
}
id := uuid.New()
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(id, project_id, title, description, due_date, original_due_date,
source, rule_id, rule_code, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11, $12, $12)`,
source, rule_id, rule_code, custom_rule_text, status, notes, created_by, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 'pending', $11, $12, $13, $13)`,
id, projectID, title, input.Description, due, orig,
source, input.RuleID, ruleCode, input.Notes, userID, now,
source, input.RuleID, ruleCode, customRuleText, input.Notes, userID, now,
); err != nil {
return uuid.Nil, fmt.Errorf("insert deadline: %w", err)
}

View File

@@ -107,11 +107,15 @@ type EventListItem struct {
Status *string `json:"status,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
Source *string `json:"source,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
RuleID *uuid.UUID `json:"rule_id,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
RuleName *string `json:"rule_name,omitempty"`
RuleNameEN *string `json:"rule_name_en,omitempty"`
// CustomRuleText surfaces the lawyer's free-text rule label when the
// deadline was created via the Custom rule path (t-paliad-258).
// Display surfaces fall back to it when RuleName is absent.
CustomRuleText *string `json:"custom_rule_text,omitempty"`
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
// Appointment-only.
StartAt *time.Time `json:"start_at,omitempty"`
@@ -236,6 +240,7 @@ func projectDeadline(d models.DeadlineWithProject) EventListItem {
RuleCode: d.RuleCode,
RuleName: d.RuleName,
RuleNameEN: d.RuleNameEN,
CustomRuleText: d.CustomRuleText,
EventTypeIDs: d.EventTypeIDs,
}
}

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`},
}
}