Compare commits
26 Commits
mai/hermes
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| 99c9d89daa | |||
| ef21e43375 | |||
| 4cb99fb627 | |||
| 452ccdf127 | |||
| 045accc6d9 | |||
| e6b61b4d2e | |||
| 940df95418 | |||
| 538c2d2da9 | |||
| a9a9adbd2a | |||
| f24a90b722 | |||
| 55bfe439f2 | |||
| 0ac26fe0ee | |||
| 72b64140e9 | |||
| 50cd80a4a6 | |||
| 716f6d7ece | |||
| 1bf62c78e3 | |||
| 9a774ba3ad | |||
| 8caaf6a631 | |||
| 228ae1b263 | |||
| cdd3747c2b | |||
| 02255c4234 | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b |
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
96
frontend/src/admin-backups.tsx
Normal file
96
frontend/src/admin-backups.tsx
Normal 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 — 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ändige Snapshots aller Daten — 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öß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 …</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äteren Slice aktiviert. Manuelle Backups stehen jetzt zur Verfügung.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/admin-backups.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
192
frontend/src/client/admin-backups.ts
Normal file
192
frontend/src/client/admin-backups.ts
Normal 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 "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
case "'": return "'";
|
||||
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");
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { initNotes } from "./notes";
|
||||
import { projectIndent } from "./project-indent";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
|
||||
interface Appointment {
|
||||
id: string;
|
||||
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
interface Me {
|
||||
@@ -43,6 +47,10 @@ let project: Project | null = null;
|
||||
let allProjects: Project[] = [];
|
||||
let pendingRequest: PendingApprovalRequest | null = null;
|
||||
let me: Me | null = null;
|
||||
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
|
||||
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
|
||||
// "Termin bearbeiten" in the withdraw warning modal.
|
||||
let pendingEditMode = false;
|
||||
|
||||
function parseAppointmentID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
@@ -207,10 +215,14 @@ function renderHeader() {
|
||||
}
|
||||
|
||||
// Freeze the edit form + delete button while a request is in flight.
|
||||
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
|
||||
// withdraw modal, pendingEditMode unfreezes the form so Save can route
|
||||
// to /edit-entity (which keeps the request pending + merges payload).
|
||||
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
|
||||
if (form) {
|
||||
const freeze = isPending && !pendingEditMode;
|
||||
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
|
||||
.forEach((el) => { el.disabled = isPending; });
|
||||
.forEach((el) => { el.disabled = freeze; });
|
||||
}
|
||||
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
|
||||
if (deleteBtn) deleteBtn.disabled = isPending;
|
||||
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
|
||||
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
// t-paliad-252 — pending-edit mode routes through /edit-entity which
|
||||
// keeps the request pending + merges fields into payload. clear_project
|
||||
// and project_id are NOT in the counter-allowlist (yet) — the requester
|
||||
// can't move projects on a pending request from this surface.
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const editFields = { ...payload };
|
||||
delete editFields.clear_project;
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: editFields }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/appointments/${appointment.id}`);
|
||||
if (fresh.ok) appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
// Exit pending-edit mode so the form re-freezes (still pending).
|
||||
pendingEditMode = false;
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
msg.textContent = t("appointments.detail.saved");
|
||||
msg.className = "form-msg form-msg-ok";
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
|
||||
msg.textContent = data.message || data.error || t("appointments.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/appointments/${appointment.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -312,12 +357,37 @@ async function deleteAppointment() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-252 — withdraw warning modal replaces the old confirm().
|
||||
// Returns:
|
||||
// "edit" → unfreeze the edit form (pending-edit mode); Save will
|
||||
// route through /api/approval-requests/{id}/edit-entity
|
||||
// "withdraw" → destructive: the existing /revoke endpoint
|
||||
// null → user cancelled
|
||||
async function withdrawAppointmentRequest() {
|
||||
if (!appointment || !pendingRequest) return;
|
||||
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "appointment",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
if (btn) btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
pendingEditMode = true;
|
||||
if (btn) btn.disabled = false;
|
||||
// renderHeader re-evaluates the freeze and unfreezes the form now
|
||||
// that pendingEditMode is set. Focus the first editable field so the
|
||||
// user can type immediately.
|
||||
renderHeader();
|
||||
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
|
||||
titleEl?.focus();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
|
||||
if (fresh.ok) {
|
||||
appointment = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
// CREATE lifecycle: entity gone → back to the list.
|
||||
window.location.href = "/events?type=appointment";
|
||||
}
|
||||
renderHeader();
|
||||
fillEditForm();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
|
||||
const msg = document.getElementById("appointment-edit-msg")!;
|
||||
|
||||
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
149
frontend/src/client/components/withdraw-warning-modal.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
|
||||
//
|
||||
// Before t-paliad-252 the deadline + appointment detail pages did a
|
||||
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
|
||||
// For pending CREATE lifecycles that endpoint silently DELETES the
|
||||
// underlying entity row — m's "withdrawing the approval deletes the event"
|
||||
// surprise.
|
||||
//
|
||||
// This modal replaces the confirm() with three explicit paths:
|
||||
//
|
||||
// 1. Cancel — does nothing
|
||||
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
|
||||
// through POST /approval-requests/{id}/
|
||||
// edit-entity which keeps the request
|
||||
// pending and merges the new fields
|
||||
// into approval_request.payload
|
||||
// 3. Endgültig zurückziehen + — destructive; current /revoke
|
||||
// löschen behaviour (delete for CREATE, revert
|
||||
// for UPDATE/COMPLETE, cancel for
|
||||
// DELETE-lifecycle requests)
|
||||
//
|
||||
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
|
||||
// three-button row sits cleanly inside the body — the primitive only
|
||||
// supports one secondary action, but we paint the destructive button as a
|
||||
// separate row above the footer.
|
||||
|
||||
import { t } from "../i18n";
|
||||
import { openModal } from "./modal";
|
||||
|
||||
export type WithdrawAction = "edit" | "withdraw";
|
||||
|
||||
export interface WithdrawWarningArgs {
|
||||
// entityType drives the copy ("event" vs "appointment" labels).
|
||||
entityType: "deadline" | "appointment";
|
||||
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
|
||||
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
|
||||
// cancelling the deletion request).
|
||||
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
|
||||
}
|
||||
|
||||
// openWithdrawWarningModal resolves with the chosen action, or null if the
|
||||
// user dismissed via Cancel / Esc / backdrop / browser back-button.
|
||||
export async function openWithdrawWarningModal(
|
||||
args: WithdrawWarningArgs,
|
||||
): Promise<WithdrawAction | null> {
|
||||
const body = document.createElement("div");
|
||||
body.className = "withdraw-warning-body";
|
||||
|
||||
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
|
||||
// knows what the destructive button will actually do. The /revoke
|
||||
// backend behaviour:
|
||||
// - create → DELETE the entity (the "surprise" m flagged)
|
||||
// - update → revert to pre_image
|
||||
// - complete → revert to pre-complete state
|
||||
// - delete → cancel the delete request (entity stays alive)
|
||||
const intro = document.createElement("p");
|
||||
intro.className = "withdraw-warning-intro";
|
||||
intro.textContent = leadCopyFor(args);
|
||||
body.appendChild(intro);
|
||||
|
||||
const sub = document.createElement("p");
|
||||
sub.className = "withdraw-warning-sub muted";
|
||||
sub.textContent = subCopyFor(args);
|
||||
body.appendChild(sub);
|
||||
|
||||
// The destructive button lives inside the body — the openModal primitive
|
||||
// only exposes one secondary button slot, and we want the safe "Edit"
|
||||
// path to be the primary CTA. Painting it in red here, separated from
|
||||
// the footer, signals "this is the dangerous option" without competing
|
||||
// visually with the primary CTA.
|
||||
const destructiveRow = document.createElement("div");
|
||||
destructiveRow.className = "withdraw-warning-destructive-row";
|
||||
const destructiveBtn = document.createElement("button");
|
||||
destructiveBtn.type = "button";
|
||||
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
|
||||
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
|
||||
destructiveRow.appendChild(destructiveBtn);
|
||||
body.appendChild(destructiveRow);
|
||||
|
||||
return new Promise<WithdrawAction | null>((resolve) => {
|
||||
let chosen: WithdrawAction | null = null;
|
||||
|
||||
// The destructive button has to close the modal and return "withdraw".
|
||||
// We need access to the modal's internal close() — fortunately openModal
|
||||
// exposes it via the primary handler's first arg. We pass through the
|
||||
// outer resolve and let the primary handler (Edit) own the close-fn
|
||||
// route. For the destructive button we resolve the outer promise
|
||||
// directly and then synthesise an ESC keypress so the modal dismisses
|
||||
// — or, simpler, set chosen and use the secondary "Cancel" path that
|
||||
// the modal already supports. (openModal's onClose fires on every
|
||||
// dismiss path including the primary handler resolution.)
|
||||
destructiveBtn.addEventListener("click", () => {
|
||||
chosen = "withdraw";
|
||||
// The unified openModal primitive (modal.ts) wires its dismiss path
|
||||
// through the native <dialog>'s `cancel` event. Dispatching it on
|
||||
// the parent <dialog> runs the same finish() → onClose → resolve
|
||||
// sequence as ESC / backdrop. We then map the resolved `null` back
|
||||
// to "withdraw" via the captured `chosen` in onClose below.
|
||||
const dialogEl = body.closest("dialog");
|
||||
dialogEl?.dispatchEvent(new Event("cancel"));
|
||||
});
|
||||
|
||||
void openModal<WithdrawAction>({
|
||||
title: t("approvals.withdraw.modal.title"),
|
||||
body,
|
||||
size: "md",
|
||||
classNames: "withdraw-warning-modal",
|
||||
primary: {
|
||||
label: t("approvals.withdraw.primary.label"),
|
||||
handler: (close) => {
|
||||
chosen = "edit";
|
||||
close("edit");
|
||||
},
|
||||
},
|
||||
secondary: { label: t("approvals.withdraw.cancel") },
|
||||
onClose: () => {
|
||||
// Resolves whatever was chosen via the destructive button OR the
|
||||
// primary handler. ESC / backdrop / secondary clear `chosen` to
|
||||
// null which is the right "cancel" semantics.
|
||||
resolve(chosen);
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function leadCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return args.entityType === "appointment"
|
||||
? t("approvals.withdraw.lead.create.appointment")
|
||||
: t("approvals.withdraw.lead.create.deadline");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.lead.delete");
|
||||
default:
|
||||
// update / complete / unknown → revert semantics
|
||||
return t("approvals.withdraw.lead.update");
|
||||
}
|
||||
}
|
||||
|
||||
function subCopyFor(args: WithdrawWarningArgs): string {
|
||||
switch (args.lifecycleEvent) {
|
||||
case "create":
|
||||
return t("approvals.withdraw.sub.create");
|
||||
case "delete":
|
||||
return t("approvals.withdraw.sub.delete");
|
||||
default:
|
||||
return t("approvals.withdraw.sub.update");
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
|
||||
import { formatRuleLabel, formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||||
|
||||
interface Deadline {
|
||||
id: string;
|
||||
@@ -20,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;
|
||||
@@ -38,6 +43,9 @@ interface PendingApprovalRequest {
|
||||
requested_at: string;
|
||||
required_role: string;
|
||||
requester_name?: string;
|
||||
// t-paliad-252 — used by the withdraw warning modal to pick the right
|
||||
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
|
||||
lifecycle_event?: string;
|
||||
}
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
@@ -54,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 {
|
||||
@@ -70,6 +92,30 @@ 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
|
||||
// pending + merges the new fields into payload) instead of the regular
|
||||
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
|
||||
// from edit mode + after a successful save.
|
||||
let pendingEditMode = false;
|
||||
|
||||
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
|
||||
// modal handler (initWithdraw) can route into pending-edit mode without
|
||||
// duplicating the edit-mode toggle logic.
|
||||
let pendingEnterEdit: (() => void) | null = null;
|
||||
|
||||
function parseDeadlineID(): string | null {
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "deadlines" || !parts[1]) return null;
|
||||
@@ -165,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");
|
||||
@@ -227,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).
|
||||
@@ -353,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;
|
||||
@@ -366,6 +509,11 @@ function initEdit() {
|
||||
const etEdit = document.getElementById("deadline-event-types-edit");
|
||||
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";
|
||||
@@ -381,6 +529,20 @@ function initEdit() {
|
||||
projectEdit.style.display = "";
|
||||
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();
|
||||
@@ -399,12 +561,71 @@ function initEdit() {
|
||||
projectEdit.style.display = "none";
|
||||
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).
|
||||
pendingEnterEdit = () => {
|
||||
pendingEditMode = true;
|
||||
enterEdit();
|
||||
};
|
||||
|
||||
editBtn.addEventListener("click", enterEdit);
|
||||
|
||||
// t-paliad-251 Part 4 — Standardtitel button.
|
||||
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
|
||||
// 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", () => {
|
||||
if (!deadline) return;
|
||||
let head = "";
|
||||
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
|
||||
if (ids.length === 1) {
|
||||
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) {
|
||||
head = formatRuleLabel(rule);
|
||||
}
|
||||
if (!head && deadline.rule_code) {
|
||||
head = deadline.rule_code;
|
||||
}
|
||||
if (!head) head = t("deadlines.field.title.default_fallback");
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) head = `${head} — ${ref}`;
|
||||
titleEdit.value = head;
|
||||
titleEdit.focus();
|
||||
});
|
||||
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!deadline) return;
|
||||
const newTitle = titleEdit.value.trim();
|
||||
@@ -424,6 +645,48 @@ 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
|
||||
// approval_request. Outside pending-edit mode the regular PATCH
|
||||
// path remains the authoritative one (with its existing 409-on-
|
||||
// pending guard).
|
||||
if (pendingEditMode && pendingRequest) {
|
||||
const resp = await fetch(
|
||||
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ fields: payload }),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (fresh.ok) deadline = await fresh.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
const body = await resp.json().catch(() => null);
|
||||
const msg = (body && (body.message || body.error))
|
||||
|| (t("approvals.withdraw.error") || "Fehler");
|
||||
window.alert(msg);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -501,19 +764,39 @@ function initReopen() {
|
||||
});
|
||||
}
|
||||
|
||||
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
|
||||
// /api/approval-requests/{id}/revoke endpoint (no new server route
|
||||
// needed). After the revoke lands, the entity goes back to
|
||||
// approval_status='approved' and the page reloads to refresh the
|
||||
// in-memory state cleanly.
|
||||
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
|
||||
//
|
||||
// Click flow: open the withdraw warning modal (replaces the old
|
||||
// confirm()). The modal returns one of:
|
||||
//
|
||||
// "edit" — open the edit form in pending-edit mode; Save calls
|
||||
// /api/approval-requests/{id}/edit-entity which keeps the
|
||||
// request pending + merges the new fields into payload
|
||||
// "withdraw" — destructive: call the existing /revoke endpoint
|
||||
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
|
||||
// cancel-delete for DELETE lifecycle)
|
||||
// null — user cancelled; nothing happens
|
||||
function initWithdraw() {
|
||||
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!deadline || !pendingRequest) return;
|
||||
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const action = await openWithdrawWarningModal({
|
||||
entityType: "deadline",
|
||||
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
|
||||
});
|
||||
if (action === null) {
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
if (action === "edit") {
|
||||
btn.disabled = false;
|
||||
pendingEnterEdit?.();
|
||||
return;
|
||||
}
|
||||
// action === "withdraw" → existing destructive path.
|
||||
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
@@ -521,14 +804,16 @@ function initWithdraw() {
|
||||
});
|
||||
if (resp.ok) {
|
||||
// Re-fetch the entity so approval_status flips back to 'approved'
|
||||
// and the badge / buttons rerender accordingly.
|
||||
// and the badge / buttons rerender accordingly. For CREATE
|
||||
// lifecycle the entity is gone, so the 404 surfaces as a reload.
|
||||
const r = await fetch(`/api/deadlines/${deadline.id}`);
|
||||
if (r.ok) {
|
||||
deadline = await r.json();
|
||||
await loadPendingRequest();
|
||||
render();
|
||||
} else {
|
||||
window.location.reload();
|
||||
// CREATE lifecycle deleted the entity — bounce to the list.
|
||||
window.location.href = "/events?type=deadline";
|
||||
}
|
||||
} else {
|
||||
btn.disabled = false;
|
||||
@@ -592,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
|
||||
@@ -614,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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initI18n, t, tDyn, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
@@ -8,22 +8,21 @@ 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;
|
||||
// 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;
|
||||
}
|
||||
|
||||
interface DeadlineRule {
|
||||
@@ -32,23 +31,37 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// 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.
|
||||
legal_source?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
sequence_order?: number;
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
interface ProceedingType {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en?: string;
|
||||
jurisdiction: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
// 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;
|
||||
// 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>();
|
||||
|
||||
let preselectedProjectID = "";
|
||||
let preselectedProjectIDLocal = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
@@ -62,6 +75,13 @@ function showError(msg: string) {
|
||||
el.className = "form-msg form-msg-error";
|
||||
}
|
||||
|
||||
function proceedingLabel(pt: ProceedingType | undefined): string {
|
||||
if (!pt) return "";
|
||||
const lang = getLang();
|
||||
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
|
||||
return `${pt.jurisdiction} — ${name}`;
|
||||
}
|
||||
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
|
||||
const hint = document.getElementById("deadline-project-empty-hint")!;
|
||||
@@ -69,6 +89,7 @@ async function loadProjects() {
|
||||
const resp = await fetch("/api/projects");
|
||||
if (!resp.ok) return;
|
||||
const projects: Project[] = await resp.json();
|
||||
projectsByID = new Map(projects.map((p) => [p.id, p]));
|
||||
if (projects.length === 0) {
|
||||
hint.style.display = "";
|
||||
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
|
||||
@@ -82,7 +103,7 @@ async function loadProjects() {
|
||||
const ref = p.reference || "";
|
||||
const indent = projectIndent(p.path);
|
||||
options.push(
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
|
||||
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} — ${esc(p.title)}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = options.join("");
|
||||
@@ -91,122 +112,166 @@ async function loadProjects() {
|
||||
}
|
||||
}
|
||||
|
||||
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 */
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRules() {
|
||||
// Optional: load rules so user can attach. We pull all rules; small set.
|
||||
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
|
||||
try {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
for (const r of rules) {
|
||||
const code = r.rule_code || r.code || "";
|
||||
const label = code ? `${code} \u2014 ${r.name}` : r.name;
|
||||
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
allRules = (await resp.json()) as DeadlineRule[];
|
||||
rulesByID = new Map(allRules.map((r) => [r.id, r]));
|
||||
} catch {
|
||||
/* non-fatal — rule select stays at "no rule" */
|
||||
/* non-fatal — rule display falls back to "—" */
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
// 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 the first candidate in canonical sequence_order.
|
||||
//
|
||||
// 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;
|
||||
// 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;
|
||||
if (candidates.length === 1) return candidates[0];
|
||||
|
||||
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 project = projectID ? projectsByID.get(projectID) : undefined;
|
||||
if (project?.proceeding_type_id) {
|
||||
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
|
||||
if (exact) return exact;
|
||||
}
|
||||
|
||||
const et = eventTypesByID.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];
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
// 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 picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
let head = "";
|
||||
if (picked.length === 1) {
|
||||
const et = eventTypesByID.get(picked[0]);
|
||||
if (et) head = eventTypeLabel(et);
|
||||
}
|
||||
|
||||
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;
|
||||
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;
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
if (!head && project?.proceeding_type_id) {
|
||||
const pt = proceedingTypesByID.get(project.proceeding_type_id);
|
||||
if (pt) head = proceedingLabel(pt);
|
||||
}
|
||||
if (!head) {
|
||||
head = t("deadlines.field.title.default_fallback");
|
||||
}
|
||||
|
||||
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`;
|
||||
const ref = project?.reference?.trim() || "";
|
||||
if (ref && !head.includes(ref)) {
|
||||
return `${head} — ${ref}`;
|
||||
}
|
||||
return head;
|
||||
}
|
||||
|
||||
async function submitForm(e: Event) {
|
||||
@@ -217,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) {
|
||||
@@ -234,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;
|
||||
@@ -252,8 +324,8 @@ async function submitForm(e: Event) {
|
||||
return;
|
||||
}
|
||||
const created = await resp.json();
|
||||
if (preselectedProjectID) {
|
||||
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
|
||||
if (preselectedProjectIDLocal) {
|
||||
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
|
||||
} else {
|
||||
window.location.href = `/deadlines/${created.id}`;
|
||||
}
|
||||
@@ -275,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");
|
||||
@@ -288,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");
|
||||
@@ -308,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;
|
||||
@@ -343,44 +422,51 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
// Default due to today
|
||||
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
|
||||
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
|
||||
await Promise.all([loadProjects(), loadRules(), loadMe()]);
|
||||
|
||||
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
|
||||
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
onChange: () => {
|
||||
// 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();
|
||||
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.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
.catch(() => {/* non-fatal */});
|
||||
|
||||
// Rule mode toggle.
|
||||
document.getElementById("deadline-rule-mode-toggle")?.addEventListener("click", () => {
|
||||
setRuleMode(ruleMode === "auto" ? "custom" : "auto");
|
||||
});
|
||||
// "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.
|
||||
|
||||
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;
|
||||
const derived = computeDefaultTitle();
|
||||
if (derived) titleInput.value = derived;
|
||||
titleInput.focus();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
|
||||
return new Promise<string[] | null>((resolve) => {
|
||||
let selected = new Set<string>(opts.initialIDs);
|
||||
let searchQuery = "";
|
||||
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
|
||||
// jurisdiction). Any non-null value matches event_types.jurisdiction;
|
||||
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
|
||||
let activeJurisdiction: string | null = null;
|
||||
|
||||
// Surface every jurisdiction present in the data — "any" stays bucketed
|
||||
// separately so users still have a "show generic-only" chip. EPA is
|
||||
// canonicalised to EPO in event_types (see mig 074); the chip label
|
||||
// shows EPA to match the legal vocabulary the lawyers use.
|
||||
const jurisdictionsPresent = new Set<string>();
|
||||
for (const et of opts.types) {
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
if (j) jurisdictionsPresent.add(j);
|
||||
}
|
||||
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
|
||||
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
|
||||
// Any jurisdiction in the data that isn't in our ordered list lands at
|
||||
// the end so the chip row never silently drops a court flavour.
|
||||
for (const j of jurisdictionsPresent) {
|
||||
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
|
||||
}
|
||||
|
||||
function chipLabel(j: string): string {
|
||||
if (j === "EPO") return "EPA";
|
||||
if (j === "any") return t("event_types.browse.jurisdiction.none");
|
||||
return j;
|
||||
}
|
||||
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-browse-overlay";
|
||||
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
|
||||
<div class="event-type-browse-header">
|
||||
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
|
||||
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
|
||||
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
|
||||
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
|
||||
${chipJurisdictions
|
||||
.map(
|
||||
(j) =>
|
||||
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>
|
||||
</div>
|
||||
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
|
||||
<div class="event-type-browse-actions">
|
||||
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
|
||||
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
|
||||
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
|
||||
|
||||
const groups = groupByCategory(opts.types);
|
||||
|
||||
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
|
||||
return j;
|
||||
}
|
||||
|
||||
function jurisdictionMatches(et: EventType): boolean {
|
||||
if (activeJurisdiction === null) return true;
|
||||
const j = (et.jurisdiction ?? "").trim();
|
||||
return j === activeJurisdiction;
|
||||
}
|
||||
|
||||
function updateCount() {
|
||||
countEl.textContent = t("event_types.browse.selected_count").replace(
|
||||
"{n}",
|
||||
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
|
||||
function renderList() {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
const matches = (et: EventType) => {
|
||||
if (!jurisdictionMatches(et)) return false;
|
||||
if (!q) return true;
|
||||
return (
|
||||
et.label_de.toLowerCase().includes(q) ||
|
||||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
|
||||
renderList();
|
||||
});
|
||||
|
||||
chipButtons.forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const raw = btn.dataset.jurisdiction ?? "";
|
||||
activeJurisdiction = raw === "" ? null : raw;
|
||||
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
|
||||
btn.classList.add("event-type-browse-chip--active");
|
||||
renderList();
|
||||
});
|
||||
});
|
||||
|
||||
function close(value: string[] | null) {
|
||||
document.removeEventListener("keydown", onKey);
|
||||
overlay.remove();
|
||||
|
||||
@@ -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 "—";
|
||||
}
|
||||
|
||||
|
||||
@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
// Pass the chip-strip perspective through as `side` so the column
|
||||
// bucketer keeps the user's own party on the left (Unsere Seite) —
|
||||
// t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user
|
||||
// was on the defendant side, the new labels demand we route the
|
||||
// user's party into the `ours` column.
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
|
||||
@@ -302,9 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Zeitstrahl",
|
||||
"deadlines.view.columns": "Spalten",
|
||||
"deadlines.notes.show": "Hinweise anzeigen",
|
||||
"deadlines.col.proactive": "Proaktiv",
|
||||
"deadlines.col.ours": "Unsere Seite",
|
||||
"deadlines.col.court": "Gericht",
|
||||
"deadlines.col.reactive": "Reaktiv",
|
||||
"deadlines.col.opponent": "Gegnerseite",
|
||||
"deadlines.col.both": "Beide Parteien",
|
||||
// Trigger-event mode (PR-2 \u2014 youpc-parity)
|
||||
"deadlines.mode.procedure": "Verfahrensablauf",
|
||||
@@ -417,6 +417,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.side.label": "Seite:",
|
||||
"deadlines.side.claimant": "Klägerseite",
|
||||
"deadlines.side.defendant": "Beklagtenseite",
|
||||
"deadlines.side.both": "Beide",
|
||||
"deadlines.appellant.label": "Berufung durch:",
|
||||
"deadlines.appellant.claimant": "Klägerseite",
|
||||
"deadlines.appellant.defendant": "Beklagtenseite",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -874,11 +882,15 @@ 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.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)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -1426,8 +1438,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.tab.settings": "Verwaltung",
|
||||
"projects.detail.export.button": "Daten exportieren",
|
||||
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
|
||||
"projects.detail.settings.export.heading": "Daten exportieren",
|
||||
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
|
||||
"projects.detail.settings.archive.heading": "Projekt archivieren",
|
||||
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
|
||||
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
|
||||
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
|
||||
@@ -2332,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.",
|
||||
@@ -2431,6 +2474,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Abbrechen",
|
||||
"event_types.browse.selected_count": "{n} ausgewählt",
|
||||
"event_types.browse.jurisdiction.none": "Allgemein",
|
||||
"event_types.browse.jurisdiction.all": "Alle Gerichte",
|
||||
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
|
||||
"event_types.filter.all": "Alle Typen",
|
||||
"event_types.filter.untyped": "— Ohne Typ —",
|
||||
"event_types.filter.search": "Typ suchen…",
|
||||
@@ -2576,6 +2621,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
|
||||
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
|
||||
"approvals.withdraw.error": "Fehler beim Zurückziehen",
|
||||
"approvals.withdraw.cancel": "Abbrechen",
|
||||
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
|
||||
"approvals.withdraw.primary.label": "Termin bearbeiten",
|
||||
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
|
||||
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
|
||||
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
|
||||
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
|
||||
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
|
||||
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
|
||||
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
|
||||
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
|
||||
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
|
||||
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
|
||||
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
|
||||
@@ -3242,9 +3298,9 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.view.timeline": "Timeline",
|
||||
"deadlines.view.columns": "Columns",
|
||||
"deadlines.notes.show": "Show details",
|
||||
"deadlines.col.proactive": "Proactive",
|
||||
"deadlines.col.ours": "Client Side",
|
||||
"deadlines.col.court": "Court",
|
||||
"deadlines.col.reactive": "Reactive",
|
||||
"deadlines.col.opponent": "Opponent Side",
|
||||
"deadlines.col.both": "Both parties",
|
||||
"deadlines.adjusted": "Adjusted",
|
||||
"deadlines.adjusted.reason": "weekend/holiday",
|
||||
@@ -3364,6 +3420,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.side.label": "Side:",
|
||||
"deadlines.side.claimant": "Claimant",
|
||||
"deadlines.side.defendant": "Defendant",
|
||||
"deadlines.side.both": "Both",
|
||||
"deadlines.appellant.label": "Appeal filed by:",
|
||||
"deadlines.appellant.claimant": "Claimant",
|
||||
"deadlines.appellant.defendant": "Defendant",
|
||||
"deadlines.appellant.none": "—",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -3814,11 +3878,15 @@ 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.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)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -4347,8 +4415,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.tab.settings": "Settings",
|
||||
"projects.detail.export.button": "Export data",
|
||||
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
|
||||
"projects.detail.settings.export.heading": "Export data",
|
||||
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
|
||||
"projects.detail.settings.archive.heading": "Archive project",
|
||||
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
|
||||
"projects.detail.settings.archive.cta": "Open edit dialog",
|
||||
"projects.detail.submissions.empty": "No submission templates are configured yet.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
|
||||
@@ -5244,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.",
|
||||
@@ -5343,6 +5442,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event_types.browse.cancel": "Cancel",
|
||||
"event_types.browse.selected_count": "{n} selected",
|
||||
"event_types.browse.jurisdiction.none": "Any",
|
||||
"event_types.browse.jurisdiction.all": "All courts",
|
||||
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
|
||||
"event_types.filter.all": "All types",
|
||||
"event_types.filter.untyped": "— Untyped —",
|
||||
"event_types.filter.search": "Search type…",
|
||||
@@ -5488,6 +5589,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"approvals.withdraw.cta": "Withdraw approval request",
|
||||
"approvals.withdraw.confirm": "Withdraw the approval request?",
|
||||
"approvals.withdraw.error": "Failed to withdraw",
|
||||
"approvals.withdraw.cancel": "Cancel",
|
||||
"approvals.withdraw.modal.title": "Withdraw approval request?",
|
||||
"approvals.withdraw.primary.label": "Edit event",
|
||||
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
|
||||
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
|
||||
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
|
||||
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
|
||||
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
|
||||
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
|
||||
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
|
||||
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
|
||||
"approvals.pending_create.label": "Awaits approval (creation)",
|
||||
"approvals.pending_update.label": "Awaits approval (change)",
|
||||
"approvals.pending_complete.label": "Awaits approval (completion)",
|
||||
|
||||
@@ -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).
|
||||
@@ -175,7 +181,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -187,6 +194,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -803,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;
|
||||
@@ -830,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 {
|
||||
@@ -999,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();
|
||||
@@ -1037,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>`;
|
||||
})
|
||||
@@ -1185,13 +1220,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -2045,6 +2083,17 @@ function initEditModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2991,17 +3040,21 @@ function canExportProject(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
@@ -3012,6 +3065,17 @@ function wireExportButton(projectID: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
|
||||
87
frontend/src/client/rule-label.ts
Normal file
87
frontend/src/client/rule-label.ts
Normal 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}`;
|
||||
}
|
||||
@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
|
||||
const SIDEBAR_WIDTH_MIN = 180;
|
||||
const SIDEBAR_WIDTH_MAX = 480;
|
||||
const SIDEBAR_WIDTH_DEFAULT = 240;
|
||||
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
|
||||
// on every scroll event, restored on initSidebar() so a full-page nav
|
||||
// click doesn't bounce the user back to the top of a long sidebar
|
||||
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
|
||||
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
|
||||
// starts that tab fresh at the top, which matches user expectation.
|
||||
const SCROLL_KEY = "paliad.sidebar.scroll";
|
||||
|
||||
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
|
||||
// BottomNav menu slot can call it without duplicating the open/close
|
||||
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
|
||||
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
|
||||
}
|
||||
|
||||
// readStoredScroll returns the persisted scrollTop or 0 when missing /
|
||||
// malformed. Bounds are checked at apply time against the actual
|
||||
// scrollHeight, so a stale value pointing past the current scroll range
|
||||
// is harmless (the browser clamps assignments to [0, max]).
|
||||
function readStoredScroll(): number {
|
||||
const raw = sessionStorage.getItem(SCROLL_KEY);
|
||||
if (raw === null) return 0;
|
||||
const n = parseInt(raw, 10);
|
||||
if (!Number.isFinite(n) || n < 0) return 0;
|
||||
return n;
|
||||
}
|
||||
|
||||
function applySidebarScroll(nav: HTMLElement, px: number): void {
|
||||
if (px <= 0) return;
|
||||
nav.scrollTop = px;
|
||||
}
|
||||
|
||||
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
|
||||
// first load and removes the stale entry. Drop this fallback once the rename
|
||||
// grace period is over.
|
||||
@@ -79,6 +103,7 @@ export function initSidebar() {
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
initSidebarScrollRestore(sidebar);
|
||||
|
||||
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
|
||||
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
|
||||
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
|
||||
// sessionStorage so the user's scroll position survives a full-page
|
||||
// navigation (every sidebar link click is a real reload — see m/paliad#85).
|
||||
// Restore is synchronous on init so the first paint is already at the
|
||||
// right offset; the passive scroll listener persists subsequent moves.
|
||||
// reapplySidebarScroll() exists so callers that mutate sidebar content
|
||||
// async (initUserViewsGroup appending /api/user-views into the Ansichten
|
||||
// group) can nudge the scroll back to where it was after the layout shift.
|
||||
function initSidebarScrollRestore(sidebar: HTMLElement): void {
|
||||
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
nav.addEventListener("scroll", () => {
|
||||
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
|
||||
}, { passive: true });
|
||||
}
|
||||
|
||||
function reapplySidebarScroll(): void {
|
||||
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
|
||||
if (!nav) return;
|
||||
applySidebarScroll(nav, readStoredScroll());
|
||||
}
|
||||
|
||||
// Changelog badge — fetches the count of entries newer than the locally
|
||||
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
|
||||
// link. Skipped on the changelog page itself because changelog.ts stamps
|
||||
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
|
||||
for (const view of views) {
|
||||
items.appendChild(renderUserViewItem(view, currentPath));
|
||||
}
|
||||
// The synchronous restore in initSidebarScrollRestore() happened
|
||||
// before these views were appended, so a saved scrollTop that
|
||||
// pointed below the Ansichten group would now sit on the wrong
|
||||
// row. Re-apply once the layout has stabilised.
|
||||
reapplySidebarScroll();
|
||||
// After rendering, kick off count refresh for views that opted in.
|
||||
for (const view of views) {
|
||||
if (view.show_count) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
type Side,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
@@ -24,6 +25,70 @@ import {
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
|
||||
// view is shareable and survives reload:
|
||||
// ?side=claimant|defendant → swaps which column owns the user's
|
||||
// side (proactive vs reactive label).
|
||||
// Default null = claimant-on-the-left.
|
||||
// ?appellant=claimant|defendant → collapses party=both rows into the
|
||||
// appellant's column (no mirror).
|
||||
// Only meaningful for role-swap
|
||||
// proceedings (Appeal etc.). Default
|
||||
// null = legacy mirror behaviour.
|
||||
let currentSide: Side = null;
|
||||
let currentAppellant: Side = null;
|
||||
|
||||
// Proceedings where one party initiates and "both" rows are role-swap
|
||||
// (i.e. either party files depending on who acted at the lower
|
||||
// instance). For these proceedings the appellant selector is meaningful
|
||||
// — when set, "both" rows collapse to a single row in the appellant's
|
||||
// column. For first-instance proceedings (Inf, Rev, …) the selector is
|
||||
// hidden because there's no appellant axis.
|
||||
//
|
||||
// Today: every upc.apl.* family member plus dpma.appeal.* and
|
||||
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
|
||||
// Conservative — false negatives just hide a control; false positives
|
||||
// would show an irrelevant control.
|
||||
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
||||
"upc.apl.merits",
|
||||
"upc.apl.cost",
|
||||
"upc.apl.order",
|
||||
"de.inf.olg",
|
||||
"de.inf.bgh",
|
||||
"de.null.bgh",
|
||||
"dpma.appeal.bpatg",
|
||||
"dpma.appeal.bgh",
|
||||
"epa.opp.boa",
|
||||
]);
|
||||
|
||||
function hasAppellantAxis(proceedingType: string): boolean {
|
||||
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
||||
}
|
||||
|
||||
function readSideFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("side");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function readAppellantFromURL(): Side {
|
||||
const raw = new URLSearchParams(window.location.search).get("appellant");
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
function writeSideToURL(s: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (s === null) url.searchParams.delete("side");
|
||||
else url.searchParams.set("side", s);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
function writeAppellantToURL(a: Side) {
|
||||
const url = new URL(window.location.href);
|
||||
if (a === null) url.searchParams.delete("appellant");
|
||||
else url.searchParams.set("appellant", a);
|
||||
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
}
|
||||
|
||||
// Per-rule anchor overrides set by the click-to-edit affordance on
|
||||
// timeline / column date cells. Posted as `anchorOverrides` to the
|
||||
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
||||
@@ -154,20 +219,31 @@ async function doCalc() {
|
||||
}
|
||||
|
||||
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
||||
// label from the calc response. The root rule (isRootEvent=true) is
|
||||
// the first event in the proceeding — e.g. Klageerhebung for
|
||||
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
|
||||
// active proceeding name if no root rule fires (shouldn't happen for
|
||||
// healthy data, but safer than a blank). Fallback respects language —
|
||||
// proceedingNameEN is consulted on EN before the DE proceedingName
|
||||
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
// label from the calc response. Precedence:
|
||||
//
|
||||
// 1. Server-supplied triggerEventLabel from proceeding_types
|
||||
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
||||
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
||||
// all carry a non-zero duration off the trigger date so none is
|
||||
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
||||
// misnamed the input as the proceeding itself.
|
||||
// 2. Root rule (isRootEvent=true) — the first event in the
|
||||
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
||||
// Nichtigkeitsklage for upc.rev.cfi.
|
||||
// 3. Active proceeding name — last-resort fallback. Language-aware
|
||||
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
||||
// proceedings like upc.ccr.cfi which had no rules → no root).
|
||||
function triggerEventLabelFor(data: DeadlineResponse): string {
|
||||
const lang = getLang();
|
||||
const curated = lang === "en"
|
||||
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
||||
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
||||
if (curated) return curated;
|
||||
const root = data.deadlines.find((d) => d.isRootEvent);
|
||||
if (root) {
|
||||
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
||||
}
|
||||
if (getLang() === "en") {
|
||||
if (lang === "en") {
|
||||
return data.proceedingNameEN || data.proceedingName || "";
|
||||
}
|
||||
return data.proceedingName || data.proceedingNameEN || "";
|
||||
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
|
||||
: "";
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data, { editable: true, showNotes })
|
||||
? renderColumnsBody(data, {
|
||||
editable: true,
|
||||
showNotes,
|
||||
side: currentSide,
|
||||
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
|
||||
})
|
||||
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||
|
||||
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
||||
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
syncFlagRows();
|
||||
syncAppellantRowVisibility();
|
||||
|
||||
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
||||
|
||||
@@ -283,6 +365,29 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
// syncAppellantRowVisibility hides the appellant selector for
|
||||
// proceedings that have no appellant axis (first-instance Inf, Rev,
|
||||
// …). Clears the in-memory state and the URL param when hidden so a
|
||||
// shared link with ?appellant= doesn't leak into an unrelated
|
||||
// proceeding's render.
|
||||
function syncAppellantRowVisibility() {
|
||||
const row = document.getElementById("appellant-row");
|
||||
if (!row) return;
|
||||
const visible = hasAppellantAxis(selectedType);
|
||||
row.style.display = visible ? "" : "none";
|
||||
if (!visible && currentAppellant !== null) {
|
||||
currentAppellant = null;
|
||||
writeAppellantToURL(null);
|
||||
syncRadioGroup("appellant", "");
|
||||
}
|
||||
}
|
||||
|
||||
function syncRadioGroup(name: string, value: string) {
|
||||
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
||||
input.checked = input.value === value;
|
||||
});
|
||||
}
|
||||
|
||||
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
||||
// Mirrors the events.ts pattern (body.events-view-*). The print
|
||||
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
||||
@@ -321,6 +426,38 @@ function initViewToggle() {
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
// initPerspectiveControls hydrates side+appellant from the URL,
|
||||
// reflects state into the radio inputs, and wires onchange handlers
|
||||
// that update state + URL + re-render. Re-render path skips the
|
||||
// /api/tools/fristenrechner round-trip — perspective is a pure
|
||||
// projection of the last response, no backend involved.
|
||||
function initPerspectiveControls() {
|
||||
currentSide = readSideFromURL();
|
||||
currentAppellant = readAppellantFromURL();
|
||||
syncRadioGroup("side", currentSide ?? "");
|
||||
syncRadioGroup("appellant", currentAppellant ?? "");
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeSideToURL(currentSide);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
const v = input.value;
|
||||
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
|
||||
writeAppellantToURL(currentAppellant);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
@@ -390,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
}
|
||||
|
||||
initViewToggle();
|
||||
initPerspectiveControls();
|
||||
|
||||
onLangChange(() => {
|
||||
// Active-button name updates with language change (the data-i18n
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
bucketDeadlinesIntoColumns,
|
||||
deadlineCardHtml,
|
||||
} from "./verfahrensablauf-core";
|
||||
|
||||
@@ -65,3 +66,141 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||
expect(html).not.toContain("data-rule-code=");
|
||||
});
|
||||
});
|
||||
|
||||
// Pure column-routing behaviour. Originally pinned by m/paliad#81
|
||||
// (side + appellant axes), re-framed by m/paliad#88: the column
|
||||
// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the
|
||||
// left") instead of the misleading Proaktiv/Reaktiv pair.
|
||||
// Hits bucketDeadlinesIntoColumns directly so the assertions stay
|
||||
// in pure-Node territory (renderColumnsBody goes through escHtml ->
|
||||
// document.createElement which isn't available in plain bun test).
|
||||
//
|
||||
// Scenario fixture mirrors the UPC Appeal "both parties" case m
|
||||
// pasted into #81: every filing rule carries party='both' so the
|
||||
// legacy mirror path duplicates every row across both columns.
|
||||
// With ?appellant= set, the duplicate must collapse to a single
|
||||
// row in the appellant's column.
|
||||
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => {
|
||||
const both = (name: string, due: string): CalculatedDeadline => ({
|
||||
code: name,
|
||||
name,
|
||||
nameEN: name,
|
||||
party: "both",
|
||||
priority: "mandatory",
|
||||
ruleRef: "",
|
||||
dueDate: due,
|
||||
originalDate: due,
|
||||
wasAdjusted: false,
|
||||
isRootEvent: false,
|
||||
isCourtSet: false,
|
||||
});
|
||||
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
|
||||
...both(name, due),
|
||||
party,
|
||||
});
|
||||
|
||||
test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].court).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
]);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
});
|
||||
|
||||
test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
|
||||
{ appellant: "claimant" },
|
||||
);
|
||||
expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([
|
||||
["Notice of Appeal"],
|
||||
["Statement of Grounds"],
|
||||
]);
|
||||
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
|
||||
});
|
||||
|
||||
test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => {
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
});
|
||||
|
||||
test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => {
|
||||
// User is on the defendant side: defendant filings land in 'ours'
|
||||
// (left), claimant filings land in 'opponent' (right). Court rules
|
||||
// stay in court regardless of side.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[
|
||||
partySpecific("claimant", "Klageschrift", "2026-01-01"),
|
||||
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
|
||||
partySpecific("court", "Urteil", "2026-10-01"),
|
||||
],
|
||||
{ side: "defendant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]);
|
||||
expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]);
|
||||
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => {
|
||||
// The user is the defendant AND the appellant, so the appellant's
|
||||
// column == the user's own column == ours after the swap.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "defendant" },
|
||||
);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].opponent).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => {
|
||||
// Side flip + appellant axis combined: the claimant is the appellant
|
||||
// but NOT us, so the collapsed 'both' row lands in the opponent
|
||||
// column (right). This is the UPC Appeal "they appealed, we
|
||||
// respond" scenario.
|
||||
const rows = bucketDeadlinesIntoColumns(
|
||||
[both("Notice of Appeal", "2026-07-23")],
|
||||
{ side: "defendant", appellant: "claimant" },
|
||||
);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
|
||||
expect(rows[0].ours).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
|
||||
const sameDate = "2026-07-23";
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("claimant", "A", sameDate),
|
||||
partySpecific("defendant", "B", sameDate),
|
||||
partySpecific("court", "C", sameDate),
|
||||
]);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]);
|
||||
expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]);
|
||||
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
|
||||
});
|
||||
|
||||
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
|
||||
const rows = bucketDeadlinesIntoColumns([
|
||||
partySpecific("court", "Oral Hearing", ""),
|
||||
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
|
||||
partySpecific("court", "Decision", ""),
|
||||
]);
|
||||
expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([
|
||||
["Statement of Claim"],
|
||||
["Oral Hearing"],
|
||||
["Decision"],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -110,6 +110,16 @@ export interface DeadlineResponse {
|
||||
// explains the framing. (m/paliad#58)
|
||||
contextualNote?: string;
|
||||
contextualNoteEN?: string;
|
||||
// triggerEventLabel / triggerEventLabelEN: optional caption for the
|
||||
// "Auslösendes Ereignis" / "Triggering event" field on
|
||||
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
|
||||
// when set (mig 121). The page prefers this over the proceedingName
|
||||
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
|
||||
// uses this so the field reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// (m/paliad#81)
|
||||
triggerEventLabel?: string;
|
||||
triggerEventLabelEN?: string;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -412,42 +422,124 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite.
|
||||
//
|
||||
// The columns are user-perspective ("WE are always on the left", per
|
||||
// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied:
|
||||
// Klägerseite is sometimes proactive (filing the claim) and sometimes
|
||||
// reactive (responding to a counterclaim), so the static "Proaktiv =
|
||||
// Klägerseite" label-pair was wrong half the time. The new axis is
|
||||
// "ours vs opponent" — the side toggle picks who WE are in this
|
||||
// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged
|
||||
// infringer / Einsprechender vs Patentinhaber, etc.), and rule
|
||||
// placement re-resolves around that pick.
|
||||
//
|
||||
// Column assignment per deadline (default opts.side === null keeps
|
||||
// the legacy claimant-on-the-left layout — i.e. "we are claimant"):
|
||||
//
|
||||
// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent
|
||||
// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours
|
||||
// - party=court → court (independent of side)
|
||||
// - party=both → BOTH ours AND opponent (mirror)
|
||||
//
|
||||
// When `opts.appellant` is set (claimant|defendant), "both" rows
|
||||
// collapse to a single row in the appellant's column — the intent is
|
||||
// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both"
|
||||
// really means "either party files, depending on who initiated".
|
||||
// Appellant axis is independent of `side`: in an Appeal CoA, the
|
||||
// appellant selector pins which party appealed; the side toggle
|
||||
// still picks which of those is us.
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
// Internal column-position alias. "ours" is always rendered in the
|
||||
// left grid column ("Unsere Seite"); "opponent" is always the right
|
||||
// column ("Gegnerseite"). Field names mirror the labels so the
|
||||
// bucketing primitive reads as a direct mapping.
|
||||
type ColumnPosition = "ours" | "opponent";
|
||||
|
||||
export interface ColumnsBodyOpts {
|
||||
editable?: boolean;
|
||||
showNotes?: boolean;
|
||||
// side: which side the user is on. Drives column placement;
|
||||
// does NOT filter rows. Default null = claimant-on-the-left
|
||||
// (i.e. "ours = claimant", legacy default).
|
||||
side?: Side;
|
||||
// appellant: which side initiated the appeal / counterclaim.
|
||||
// When set, party=both rows go to the appellant's column ONLY
|
||||
// (no mirror). Default null = mirror "both" into both cells
|
||||
// (legacy behaviour). Independent of `side`.
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
|
||||
// so unit tests can hit the pure routing logic without going through
|
||||
// document.createElement (no jsdom in this repo).
|
||||
export interface ColumnsRow {
|
||||
key: string;
|
||||
ours: CalculatedDeadline[];
|
||||
court: CalculatedDeadline[];
|
||||
opponent: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface BucketingOpts {
|
||||
side?: Side;
|
||||
appellant?: Side;
|
||||
}
|
||||
|
||||
// bucketDeadlinesIntoColumns is the pure routing primitive that
|
||||
// renderColumnsBody uses. Extracted as its own export so the per-row
|
||||
// column placement (including the side-swap + appellant-collapse
|
||||
// logic from m/paliad#81 and the user-perspective re-frame from
|
||||
// m/paliad#88) is unit-testable without a DOM. The returned rows are
|
||||
// sorted: dated rows ascending by dueDate, then unscheduled rows in
|
||||
// declaration order (each keyed by sequence).
|
||||
export function bucketDeadlinesIntoColumns(
|
||||
deadlines: CalculatedDeadline[],
|
||||
opts: BucketingOpts = {},
|
||||
): ColumnsRow[] {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
// Default (side=null) treats the user as claimant — keeps the
|
||||
// legacy claimant-on-the-left layout when no perspective is picked.
|
||||
const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours";
|
||||
const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours";
|
||||
const appellantColumn: ColumnPosition | null =
|
||||
opts.appellant === "claimant" ? claimantColumn
|
||||
: opts.appellant === "defendant" ? defendantColumn
|
||||
: null;
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
const rowsMap = new Map<string, ColumnsRow>();
|
||||
const ensureRow = (key: string): ColumnsRow => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
r = { key, ours: [], court: [], opponent: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
row[claimantColumn].push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
row[defendantColumn].push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
if (appellantColumn !== null) {
|
||||
// Role-swap collapse: appellant initiated → both → one row
|
||||
// in appellant's column. Mirror suppressed.
|
||||
row[appellantColumn].push(dl);
|
||||
} else {
|
||||
row.ours.push(dl);
|
||||
row.opponent.push(dl);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
@@ -462,17 +554,28 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
|
||||
}
|
||||
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
|
||||
const userSide: Side = opts.side ?? null;
|
||||
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
|
||||
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
|
||||
|
||||
// Collapsed "both" rows lose their mirror tag — there's no longer
|
||||
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
|
||||
// be misleading. Keep it for the legacy mirror path.
|
||||
const showMirrorTag = !appellantPinned;
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
const mirrorTag = showMirrorTag && dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
@@ -487,16 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
// Static labels — "Unsere Seite" is always the left column, regardless
|
||||
// of which physical party (claimant vs defendant) occupies it. The
|
||||
// bucketing primitive already routes the user's side into the `ours`
|
||||
// bucket, so the header truth-fully describes the column contents.
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.ours"), "fr-col-ours");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
for (const row of rows) {
|
||||
html += renderCell(row.ours);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
html += renderCell(row.opponent);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
|
||||
@@ -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" : ""}`}
|
||||
|
||||
@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
|
||||
<div className="entity-detail-title-col">
|
||||
<h1 id="deadline-title-display" />
|
||||
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
|
||||
{/* t-paliad-251 Part 4 — Standardtitel button only
|
||||
visible in edit mode; clicking replaces the
|
||||
title with a default derived from the project
|
||||
and the deadline's event types / rule. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
<div className="entity-detail-meta">
|
||||
<span id="deadline-due-chip" className="frist-due-chip" />
|
||||
<span id="deadline-status-chip" className="entity-status-chip" />
|
||||
@@ -95,7 +108,36 @@ export function renderDeadlinesDetail(): string {
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
<dd>
|
||||
<span id="deadline-rule-display">—</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">—</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" />
|
||||
|
||||
@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
<div className="form-field-label-row">
|
||||
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
|
||||
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
|
||||
currently-known context (event type → rule → proceeding
|
||||
type → fallback) with the project reference as suffix.
|
||||
Always replaces the title; no destructive confirmation
|
||||
because the user invoked it explicitly. */}
|
||||
<button
|
||||
type="button"
|
||||
id="deadline-title-default-btn"
|
||||
className="btn-link-action"
|
||||
data-i18n="deadlines.field.title.default_btn"
|
||||
>
|
||||
Standardtitel
|
||||
</button>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="deadline-title"
|
||||
@@ -57,58 +72,42 @@ 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"
|
||||
>
|
||||
(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ä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 — Sie haben den Typ ü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">
|
||||
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
|
||||
<select id="deadline-rule">
|
||||
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
|
||||
</select>
|
||||
<div className="form-field-label-row">
|
||||
<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>
|
||||
<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">—</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>
|
||||
|
||||
<div className="form-field">
|
||||
|
||||
@@ -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"
|
||||
@@ -682,9 +704,20 @@ export type I18nKey =
|
||||
| "approvals.tab.mine"
|
||||
| "approvals.tab.pending_mine"
|
||||
| "approvals.title"
|
||||
| "approvals.withdraw.cancel"
|
||||
| "approvals.withdraw.confirm"
|
||||
| "approvals.withdraw.cta"
|
||||
| "approvals.withdraw.destructive.label"
|
||||
| "approvals.withdraw.error"
|
||||
| "approvals.withdraw.lead.create.appointment"
|
||||
| "approvals.withdraw.lead.create.deadline"
|
||||
| "approvals.withdraw.lead.delete"
|
||||
| "approvals.withdraw.lead.update"
|
||||
| "approvals.withdraw.modal.title"
|
||||
| "approvals.withdraw.primary.label"
|
||||
| "approvals.withdraw.sub.create"
|
||||
| "approvals.withdraw.sub.delete"
|
||||
| "approvals.withdraw.sub.update"
|
||||
| "bottomnav.add"
|
||||
| "bottomnav.add.appointment"
|
||||
| "bottomnav.add.appointment.sub"
|
||||
@@ -1112,6 +1145,10 @@ export type I18nKey =
|
||||
| "deadlines.adjusted.weekend"
|
||||
| "deadlines.adjusted.weekend.saturday"
|
||||
| "deadlines.adjusted.weekend.sunday"
|
||||
| "deadlines.appellant.claimant"
|
||||
| "deadlines.appellant.defendant"
|
||||
| "deadlines.appellant.label"
|
||||
| "deadlines.appellant.none"
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.card.calc.add_to_project"
|
||||
| "deadlines.card.calc.add_to_project.disabled"
|
||||
@@ -1138,8 +1175,8 @@ export type I18nKey =
|
||||
| "deadlines.col.court"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.proactive"
|
||||
| "deadlines.col.reactive"
|
||||
| "deadlines.col.opponent"
|
||||
| "deadlines.col.ours"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -1227,12 +1264,16 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.rule.auto_badge"
|
||||
| "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"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
| "deadlines.filter.akte.all"
|
||||
@@ -1366,6 +1407,10 @@ export type I18nKey =
|
||||
| "deadlines.search.placeholder"
|
||||
| "deadlines.search.results.count"
|
||||
| "deadlines.search.results.count_one"
|
||||
| "deadlines.side.both"
|
||||
| "deadlines.side.claimant"
|
||||
| "deadlines.side.defendant"
|
||||
| "deadlines.side.label"
|
||||
| "deadlines.source.caldav"
|
||||
| "deadlines.source.fristenrechner"
|
||||
| "deadlines.source.imported"
|
||||
@@ -1574,6 +1619,8 @@ export type I18nKey =
|
||||
| "event_types.browse.apply"
|
||||
| "event_types.browse.cancel"
|
||||
| "event_types.browse.empty"
|
||||
| "event_types.browse.jurisdiction.all"
|
||||
| "event_types.browse.jurisdiction.filter_label"
|
||||
| "event_types.browse.jurisdiction.none"
|
||||
| "event_types.browse.search"
|
||||
| "event_types.browse.selected_count"
|
||||
@@ -1869,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"
|
||||
@@ -2188,6 +2236,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2277,6 +2330,7 @@ export type I18nKey =
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
|
||||
@@ -89,20 +89,9 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Sits
|
||||
last in the tab list per t-paliad-245. */}
|
||||
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -666,6 +655,39 @@ export function renderProjectsDetail(): string {
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Verwaltung — rare admin actions (export, archive). Each
|
||||
sub-section hides itself if the caller is not entitled
|
||||
(export: §4 gate; archive: global_admin). */}
|
||||
<section className="entity-tab-panel" id="tab-settings" style="display:none">
|
||||
<div className="settings-section" id="project-settings-export" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
|
||||
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" id="project-settings-archive" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
|
||||
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-settings-archive-link"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.settings.archive.cta">
|
||||
Bearbeiten öffnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Full edit modal — same form as /projects/new, pre-filled. */}
|
||||
|
||||
@@ -3548,6 +3548,30 @@ input[type="range"]::-moz-range-thumb {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Verfahrensablauf — perspective strip (side + appellant selectors,
|
||||
t-paliad-250 / m/paliad#81). Two rows so the labels stack cleanly on
|
||||
narrow viewports; each row reuses .fristen-view-toggle for the
|
||||
chip-radio cluster so the visual language matches the view-toggle
|
||||
above it. The appellant row hides for proceedings without an
|
||||
appellant axis (Inf / Rev first-instance). */
|
||||
.verfahrensablauf-perspective {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.verfahrensablauf-perspective-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.verfahrensablauf-perspective-row .fristen-view-toggle {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Compact note hint — sits in the timeline-meta line when the notes
|
||||
toggle is off. Native browser tooltip via title= attribute carries
|
||||
the full text on hover; tabindex=0 + aria-label make it
|
||||
@@ -3605,7 +3629,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.fr-col-header.fr-col-proactive {
|
||||
.fr-col-header.fr-col-ours {
|
||||
background: var(--status-blue-bg);
|
||||
color: var(--status-blue-fg);
|
||||
}
|
||||
@@ -3615,7 +3639,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
color: var(--status-blue-soft-fg);
|
||||
}
|
||||
|
||||
.fr-col-header.fr-col-reactive {
|
||||
.fr-col-header.fr-col-opponent {
|
||||
background: var(--status-amber-bg);
|
||||
color: var(--status-amber-fg);
|
||||
}
|
||||
@@ -5701,6 +5725,21 @@ dialog.modal::backdrop {
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* t-paliad-260 — at single-column widths, drop the sticky/max-height
|
||||
constraints on the variable editor so it reflows above the preview
|
||||
and scrolls away naturally instead of overlaying the preview pane
|
||||
(sticky + calc(100vh - 2rem) keep the form pinned at the top of the
|
||||
viewport while the user scrolls down to read the preview). Must come
|
||||
after the unscoped .submission-draft-sidebar block to win source
|
||||
order at equal specificity. */
|
||||
@media (max-width: 900px) {
|
||||
.submission-draft-sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
}
|
||||
}
|
||||
|
||||
.submission-draft-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -6545,12 +6584,18 @@ dialog.modal::backdrop {
|
||||
|
||||
/* Each filter is a label-above-control cell so the caption sits on top of
|
||||
its select / button. The whole filter-row stays a horizontal flex-wrap
|
||||
of these column-cells (t-paliad-117). */
|
||||
of these column-cells (t-paliad-117).
|
||||
|
||||
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
|
||||
container and prevents a native <select> with long option text from
|
||||
blowing the cell wider than the viewport (t-paliad-255). */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@@ -6564,6 +6609,10 @@ dialog.modal::backdrop {
|
||||
.filter-group .entity-select { width: 100%; }
|
||||
}
|
||||
|
||||
/* max-width: 100% caps the intrinsic width of a native <select> at its
|
||||
parent — without it, browsers size the select to the longest <option>
|
||||
text and a very long project title overflows the viewport on tablet
|
||||
widths above the 480px breakpoint (t-paliad-255). */
|
||||
.entity-select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
@@ -6572,6 +6621,8 @@ dialog.modal::backdrop {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-select:focus {
|
||||
@@ -7291,6 +7342,20 @@ dialog.modal::backdrop {
|
||||
padding: 0.5rem 0 2rem;
|
||||
}
|
||||
|
||||
/* Verwaltung tab — rare admin actions (export, archive) live here as
|
||||
stacked sections. No accent, no oversized buttons (t-paliad-245). */
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section .tool-subtitle {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.entity-events {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
@@ -7506,6 +7571,126 @@ dialog.modal::backdrop {
|
||||
border-left: 2px solid #b88800;
|
||||
}
|
||||
|
||||
/* t-paliad-251 — Auto-derived hint variant. Lime-tint, sibling of the
|
||||
yellow warning variant. Carries a small pill-badge in front (the
|
||||
"Auto" label) followed by the derived rule name. */
|
||||
.form-hint--auto {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: var(--radius-sm, 4px);
|
||||
border-left: 2px solid var(--color-accent);
|
||||
}
|
||||
.form-hint-badge {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* t-paliad-251 — label row that hosts both the form label and an
|
||||
inline action (Standardtitel button, Rule-sort dropdown). The label
|
||||
keeps growing to push the action to the right edge. */
|
||||
.form-field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.form-field-label-row > label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Inline action button rendered next to a form label (Standardtitel).
|
||||
Text-link styling so it doesn't compete with the primary CTA. */
|
||||
.btn-link-action {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--color-link, var(--color-text));
|
||||
padding: 0;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
.btn-link-action:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
/* 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);
|
||||
color: var(--color-text);
|
||||
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;
|
||||
@@ -7613,6 +7798,42 @@ dialog.modal::backdrop {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
/* t-paliad-252 — withdraw warning modal body. The destructive button sits
|
||||
inside the body (above the footer's Cancel + Edit primary) so the safe
|
||||
"Edit event" path stays visually primary. The intro paragraph leads,
|
||||
the muted sub-line explains consequences, then the red row makes the
|
||||
destructive option discoverable without competing with the CTA. */
|
||||
.withdraw-warning-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.withdraw-warning-intro {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.withdraw-warning-sub {
|
||||
margin: 0;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.withdraw-warning-destructive-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
.withdraw-warning-destructive-btn {
|
||||
/* Inherits .btn .btn-danger, but bump the font size down a touch so
|
||||
the body button doesn't crowd the footer's primary CTA. */
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 1rem;
|
||||
}
|
||||
|
||||
.entity-soon {
|
||||
text-align: center;
|
||||
padding: 3rem 1.5rem;
|
||||
@@ -12073,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 {
|
||||
@@ -12503,6 +12692,36 @@ dialog.quick-add-sheet::backdrop {
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
.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. */
|
||||
.event-type-browse-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.event-type-browse-chip {
|
||||
padding: 0.2rem 0.7rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
|
||||
}
|
||||
.event-type-browse-chip:hover {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.event-type-browse-chip--active {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.event-type-browse-list {
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
|
||||
@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
|
||||
swaps the column LABELS so the user's own side is
|
||||
proactive (= "your filings"). Appellant collapses
|
||||
party=both rows to a single column when set — only
|
||||
relevant for role-swap proceedings (Appeal etc.);
|
||||
the row hides itself when the picked proceeding has
|
||||
no appellant axis (see hasAppellantAxis() in the
|
||||
client). Both selectors are URL-driven (?side= +
|
||||
?appellant=) so the perspective survives reload
|
||||
and is shareable. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.both">Beide</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="claimant" />
|
||||
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="defendant" />
|
||||
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appellant" value="" checked />
|
||||
<span data-i18n="deadlines.appellant.none">—</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
-- Drop the optional trigger-event label columns added in
|
||||
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
|
||||
-- their override; the frontend falls back to proceedingName.
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
DROP COLUMN IF EXISTS trigger_event_label_en,
|
||||
DROP COLUMN IF EXISTS trigger_event_label_de;
|
||||
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
|
||||
--
|
||||
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
|
||||
-- to `paliad.proceeding_types.name` whenever the calculator finds no
|
||||
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
|
||||
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
|
||||
-- the trigger date, so the caption reads "Berufungsverfahren" /
|
||||
-- "Appeal" — the proceeding itself — instead of the appealable
|
||||
-- decision that actually starts the clock.
|
||||
--
|
||||
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
|
||||
-- pair on proceeding_types. When set, the calculator surfaces it on the
|
||||
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
|
||||
-- proceedingName. No deadline-rule additions, no slug changes; existing
|
||||
-- proceeding_type.code stays stable (hard rule from the issue).
|
||||
|
||||
ALTER TABLE paliad.proceeding_types
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
|
||||
|
||||
-- UPC Appeal: the trigger date is the date of the appealable first-instance
|
||||
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
|
||||
-- service of the decision per R.220.1(a)/(b)).
|
||||
UPDATE paliad.proceeding_types
|
||||
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
|
||||
trigger_event_label_en = 'Appealable Decision'
|
||||
WHERE code = 'upc.apl.merits';
|
||||
@@ -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;
|
||||
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal file
26
internal/db/migrations/122_deadlines_custom_rule_text.up.sql
Normal 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.';
|
||||
11
internal/db/migrations/123_backups.down.sql
Normal file
11
internal/db/migrations/123_backups.down.sql
Normal 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;
|
||||
86
internal/db/migrations/123_backups.up.sql
Normal file
86
internal/db/migrations/123_backups.up.sql
Normal 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.';
|
||||
@@ -326,6 +326,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
|
||||
handleApprovalDecision(w, r, "revoke")
|
||||
}
|
||||
|
||||
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
|
||||
//
|
||||
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
|
||||
// pending create) without withdrawing the request. The non-destructive
|
||||
// sibling of /revoke that m asked for after noticing that withdraw silently
|
||||
// deletes the underlying event.
|
||||
//
|
||||
// Body: {"fields": {<entity-shape>}}
|
||||
// 200: {"status": "ok"}
|
||||
//
|
||||
// Status mapping (mapApprovalError):
|
||||
//
|
||||
// 400 suggestion_requires_change — payload has no allowlisted fields
|
||||
// 403 not_authorized — caller isn't the requested_by
|
||||
// 404 — request not found / not visible
|
||||
// 409 request_not_pending — request already decided / revoked
|
||||
type editPendingEntityBody struct {
|
||||
Fields map[string]any `json:"fields"`
|
||||
}
|
||||
|
||||
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
requestID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
|
||||
return
|
||||
}
|
||||
var body editPendingEntityBody
|
||||
if r.Body != nil && r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"code": "invalid_body",
|
||||
"message": "Ungültiger Body.",
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
|
||||
writeApprovalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||
}
|
||||
|
||||
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
|
||||
// counter_payload is an entity-shaped jsonb of the approver's edited
|
||||
// values (allowlist enforced server-side); note is the optional free-text
|
||||
|
||||
247
internal/handlers/backups.go
Normal file
247
internal/handlers/backups.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -65,8 +65,28 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
// Universal skeleton (t-paliad-259). Code-agnostic Schriftsatz starter
|
||||
// that carries every placeholder SubmissionVarsService resolves but no
|
||||
// submission_code-specific body structure. Slot between the per-firm
|
||||
// per-code template and the bare HL Patents Style .dotm fallback: every
|
||||
// submission_code without a dedicated template still renders with
|
||||
// variables substituted instead of the macro-only letterhead.
|
||||
skeletonSubmissionSlug: {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
DownloadName: branding.Name + " — Schriftsatz-Skelett.docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// skeletonSubmissionSlug names the universal skeleton template inside
|
||||
// the shared fileRegistry cache. Exported via a const so handler code
|
||||
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
|
||||
// the same string the registry uses.
|
||||
const skeletonSubmissionSlug = "submission/_skeleton.docx"
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
@@ -189,6 +209,46 @@ func handleFileRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"ok": "true", "message": "Cache cleared"})
|
||||
}
|
||||
|
||||
// fetchSubmissionSkeletonBytes returns the cached universal skeleton
|
||||
// template bytes plus its provenance SHA. Sits between the per-firm
|
||||
// per-submission_code template (fetchSubmissionTemplateBytes) and the
|
||||
// bare universal HL Patents Style .dotm (fetchHLPatentsStyleBytes) in
|
||||
// resolveSubmissionTemplate's fallback chain — used for every
|
||||
// submission_code that has no dedicated template registered. Same
|
||||
// stale-while-revalidate semantics as the rest of the file proxy: first
|
||||
// call warms the cache synchronously from mWorkRepo via Gitea; later
|
||||
// calls return immediately while a background refresh runs.
|
||||
func fetchSubmissionSkeletonBytes(ctx context.Context) ([]byte, string, error) {
|
||||
entry, ok := fileRegistry[skeletonSubmissionSlug]
|
||||
if !ok {
|
||||
return nil, "", fmt.Errorf("file proxy: %s not registered", skeletonSubmissionSlug)
|
||||
}
|
||||
ce := getCacheEntry(skeletonSubmissionSlug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", fmt.Errorf("file proxy: %s cache empty after fetch", skeletonSubmissionSlug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, nil
|
||||
}
|
||||
|
||||
// fetchHLPatentsStyleBytes returns the cached HL Patents Style .dotm
|
||||
// bytes. Shared accessor used by both the /files/{slug} download path
|
||||
// (Word auto-update channel) and the submission generator
|
||||
|
||||
@@ -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))
|
||||
@@ -658,6 +675,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
|
||||
// t-paliad-252 — non-destructive sibling of /revoke: lets the
|
||||
// requester revise the in-flight entity without withdrawing.
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
|
||||
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
|
||||
|
||||
// t-paliad-154 — form-time effective policy lookup. Reachable by
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -904,16 +904,33 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
// submission code. Lookup order matches the cronus design fallback chain
|
||||
// §8: per-firm template registered in submissionTemplateRegistry first,
|
||||
// then the universal HL Patents Style as the global fallback. The
|
||||
// returned SHA is the cache entry's commit SHA so the export audit row
|
||||
// can record provenance.
|
||||
// §8 plus the t-paliad-259 universal-skeleton slot:
|
||||
//
|
||||
// 1. per-firm per-submission_code template registered in
|
||||
// submissionTemplateRegistry (e.g. de.inf.lg.erwidg.docx) — code-
|
||||
// specific structure plus the full variable bag.
|
||||
// 2. universal _skeleton.docx — same variable bag, no submission_code-
|
||||
// specific prose. Catches every code without a dedicated template
|
||||
// so the editor preview / generate flow still has variables to
|
||||
// substitute instead of falling through to the bare letterhead.
|
||||
// 3. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Final fallback when even the skeleton is unreachable
|
||||
// (mWorkRepo outage etc.). Preserves the pre-t-paliad-259 behaviour
|
||||
// for resilience.
|
||||
//
|
||||
// The returned SHA is the cache entry's commit SHA so the export audit
|
||||
// row can record provenance.
|
||||
func resolveSubmissionTemplate(ctx context.Context, submissionCode string) ([]byte, string, error) {
|
||||
if data, sha, found, err := fetchSubmissionTemplateBytes(ctx, submissionCode); err != nil {
|
||||
return nil, "", err
|
||||
} else if found {
|
||||
return data, sha, nil
|
||||
}
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
return data, sha, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: skeleton fetch failed for code=%s, falling back to HL Patents Style: %v", submissionCode, err)
|
||||
}
|
||||
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
|
||||
@@ -2,7 +2,8 @@ package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
||||
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
|
||||
// to the full cross-proceeding catalog).
|
||||
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
|
||||
// from format-only to the same merge engine the draft editor uses).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
@@ -15,17 +16,17 @@ package handlers
|
||||
// editor falls back to the universal HL Patents Style.
|
||||
//
|
||||
// POST /api/projects/{id}/submissions/{code}/generate
|
||||
// Fetches the cached HL Patents Style .dotm (same proxy used
|
||||
// by /files/hl-patents-style.dotm), converts it to a clean
|
||||
// .docx via services.ConvertDotmToDocx, writes one
|
||||
// paliad.system_audit_log row, and streams the result as an
|
||||
// attachment download.
|
||||
//
|
||||
// No variable substitution, no per-submission templates, no
|
||||
// project_events/documents writes. Those layers are deferred to a
|
||||
// future "merge engine" slice; today's generator hands the lawyer a
|
||||
// clean .docx of the firm style and lets them edit and save under
|
||||
// their own filename.
|
||||
// Resolves the template through the cronus fallback chain
|
||||
// (per-firm `submissionTemplateRegistry[code]` first, HL
|
||||
// Patents Style as the universal fallback), builds a fresh
|
||||
// variable bag via SubmissionVarsService.Build, and runs the
|
||||
// SubmissionRenderer merge so every {{placeholder}} resolves
|
||||
// to project state (or `[KEIN WERT: key]` for empties). Writes
|
||||
// one paliad.system_audit_log row and streams the .docx as an
|
||||
// attachment download. The HL Patents Style fallback has no
|
||||
// placeholders today, so for codes without a per-firm template
|
||||
// the renderer is a no-op on substitution but still runs the
|
||||
// .dotm→.docx pre-pass.
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
|
||||
@@ -265,10 +266,16 @@ func hasPerSubmissionTemplate(submissionCode string) bool {
|
||||
return ok
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission fetches the universal HL Patents
|
||||
// Style .dotm, converts it to a clean .docx, writes one audit row, and
|
||||
// streams the result. No variable substitution; the bytes that go down
|
||||
// the wire are the firm style template with macros stripped.
|
||||
// handleGenerateProjectSubmission resolves the per-submission template
|
||||
// (per-firm first, HL Patents Style fallback), builds a fresh variable
|
||||
// bag from project state via SubmissionVarsService, runs the merge
|
||||
// engine so every {{placeholder}} substitutes, writes one audit row,
|
||||
// and streams the result. Pre-t-paliad-253 this handler ignored the
|
||||
// per-firm registry and returned the bare HL Patents Style .dotm with
|
||||
// no substitution — the "Generieren" button on the Schriftsätze tab
|
||||
// therefore produced a generic firm-style .docx instead of a
|
||||
// project-merged Klageerwiderung, which is what m noticed in
|
||||
// m/paliad#84.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -277,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submissions not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
@@ -291,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
|
||||
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRuleNotFound) {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: load rule %q: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
// ErrNotVisible / project ErrNotFound from the visibility gate
|
||||
// surface through writeServiceError as 404, matching the rest
|
||||
// of the project surfaces.
|
||||
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
dotm, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "template upstream unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
docx, err := services.ConvertDotmToDocx(dotm)
|
||||
if err != nil {
|
||||
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "convert failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
log.Printf("submissions: load user %s: %v", uid, err)
|
||||
}
|
||||
lang := "de"
|
||||
if user != nil && user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
|
||||
filename := submissionFileName(rule, project, lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
// affects the system_audit_log feed — never the user's response.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
|
||||
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
@@ -356,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// errRuleNotFound is the sentinel for "no published rule with that
|
||||
// submission_code" — distinguished from a generic DB error so the
|
||||
// handler returns 404 instead of 500.
|
||||
var errRuleNotFound = errors.New("submission rule not found")
|
||||
|
||||
// loadPublishedRuleByCode fetches the rule the user requested. Only
|
||||
// published+active rows resolve; drafts and archived rules never feed
|
||||
// a real submission.
|
||||
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := dbSvc.projects.DB().GetContext(ctx, &rule,
|
||||
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value, duration_unit,
|
||||
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at, lifecycle_state
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// submissionFileName produces the user-facing download name per
|
||||
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
||||
// Empty case_number drops the segment entirely (no fallback hash —
|
||||
|
||||
@@ -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"`
|
||||
@@ -721,6 +729,14 @@ type ProceedingType struct {
|
||||
DefaultColor string `db:"default_color" json:"default_color"`
|
||||
SortOrder int `db:"sort_order" json:"sort_order"`
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
|
||||
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
|
||||
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
|
||||
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
|
||||
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
|
||||
// NULL on most proceedings — they already carry a root rule.
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
|
||||
}
|
||||
|
||||
// TriggerEvent is a UPC procedural event that can start one or more deadlines
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -364,6 +365,135 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
|
||||
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
|
||||
}
|
||||
|
||||
// EditPendingEntity lets the REQUESTER of a pending approval_request revise
|
||||
// the in-flight entity (e.g. tweak the title or due_date on a pending
|
||||
// create) without withdrawing the request. t-paliad-252 / m/paliad#83 added
|
||||
// this as the non-destructive sibling of Revoke — m's mental model is
|
||||
// "withdraw deletes the event; let me edit the event instead, keep the
|
||||
// approval request alive".
|
||||
//
|
||||
// Authorization: caller MUST be the original requested_by (no approver can
|
||||
// edit on the requester's behalf — that would collapse into SuggestChanges).
|
||||
// Request status MUST be pending.
|
||||
//
|
||||
// Allowlist: uses the WIDER counter-allowlist already maintained for
|
||||
// SuggestChanges (buildCounterSetClauses) — every editable field on the
|
||||
// entity, not just the date-bearing approval triggers. Unknown keys are
|
||||
// silently dropped. Returns ErrSuggestionRequiresChange when fields carries
|
||||
// no allowlisted key for the entity_type (would be a no-op write).
|
||||
//
|
||||
// Side effects in one tx: entity columns updated (and event_type_ids junction
|
||||
// rewritten for deadlines), approval_request.payload merged with the new
|
||||
// values so the approver sees what was revised, and a distinct
|
||||
// `<entity>_approval_edited_by_requester` project_event emitted so the
|
||||
// Verlauf shows the revision separately from the original *_requested row.
|
||||
//
|
||||
// The approval_request stays pending; entity.approval_status stays pending.
|
||||
// The approver inbox sees a fresh updated_at + the merged payload.
|
||||
func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error {
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback() //nolint:errcheck
|
||||
|
||||
req, err := s.getRequestForUpdate(ctx, tx, requestID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Status != RequestStatusPending {
|
||||
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
|
||||
}
|
||||
if callerID != req.RequestedBy {
|
||||
return ErrNotApprover
|
||||
}
|
||||
|
||||
// Validate the counter-allowlist intersect produces at least one
|
||||
// settable column. applyEntityUpdate also wraps this check; pre-checking
|
||||
// here lets us emit a cleaner error before opening the entity-write.
|
||||
if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil {
|
||||
// Already wraps ErrSuggestionRequiresChange for empty / title-cleared
|
||||
// cases. Propagate verbatim.
|
||||
return err
|
||||
}
|
||||
|
||||
// Apply the field updates to the entity row via the shared
|
||||
// counter-allowlist path (same as SuggestChanges).
|
||||
if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Merge new fields into the request payload so the approver's inbox
|
||||
// reflects what the requester revised to. Keys overwrite; event_type_ids
|
||||
// is replaced wholesale per the same semantics applyEntityUpdate uses
|
||||
// for the junction rewrite.
|
||||
var existing map[string]any
|
||||
if len(req.Payload) > 0 {
|
||||
if err := json.Unmarshal(req.Payload, &existing); err != nil {
|
||||
return fmt.Errorf("unmarshal payload: %w", err)
|
||||
}
|
||||
}
|
||||
if existing == nil {
|
||||
existing = map[string]any{}
|
||||
}
|
||||
maps.Copy(existing, fields)
|
||||
merged, err := json.Marshal(existing)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal merged payload: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.approval_requests
|
||||
SET payload = $1, updated_at = $2
|
||||
WHERE id = $3`,
|
||||
merged, now, requestID); err != nil {
|
||||
return fmt.Errorf("update payload: %w", err)
|
||||
}
|
||||
|
||||
// Audit emit. Distinct event_type so the Verlauf surfaces the revision
|
||||
// separately from the original *_requested or any decision row.
|
||||
verlaufKind := "edited_by_requester"
|
||||
eventType := approvalEventType(req.EntityType, verlaufKind)
|
||||
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
|
||||
editedKeys := sortedKeys(fields)
|
||||
meta := map[string]any{
|
||||
"approval_request_id": req.ID.String(),
|
||||
"lifecycle_event": req.LifecycleEvent,
|
||||
req.EntityType + "_id": req.EntityID.String(),
|
||||
"edited_fields": editedKeys,
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// sortedKeys returns m's keys in stable alphabetical order so the audit-log
|
||||
// metadata is byte-for-byte stable across calls (helps when diffing audit
|
||||
// logs or asserting on them in tests).
|
||||
func sortedKeys(m map[string]any) []string {
|
||||
out := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
// Use the stdlib sort; the slice is small (≤ counter-allowlist size).
|
||||
sortStrings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// sortStrings: indirection so we don't add a new top-level import group.
|
||||
// In Go 1.21+ slices.Sort exists; this package is currently importing
|
||||
// strings + standard libs and adding "sort" would re-fan the imports.
|
||||
// Kept as a one-line wrapper to localise the dependency if a later move
|
||||
// to slices.Sort feels right.
|
||||
func sortStrings(s []string) {
|
||||
for i := 1; i < len(s); i++ {
|
||||
for j := i; j > 0 && s[j-1] > s[j]; j-- {
|
||||
s[j-1], s[j] = s[j], s[j-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
|
||||
// proposes a counter-payload + optional free-text note; in one transaction
|
||||
// we close the old request as 'changes_requested', revert the entity from
|
||||
|
||||
555
internal/services/backup_service.go
Normal file
555
internal/services/backup_service.go
Normal 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)
|
||||
}
|
||||
193
internal/services/backup_service_test.go
Normal file
193
internal/services/backup_service_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +115,16 @@ type UIResponse struct {
|
||||
// note explaining the framing.
|
||||
ContextualNote string `json:"contextualNote,omitempty"`
|
||||
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
|
||||
// TriggerEventLabel / TriggerEventLabelEN: optional caption for the
|
||||
// /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated
|
||||
// from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121).
|
||||
// The frontend prefers this over the proceedingName fallback that
|
||||
// fires when no rule has IsRootEvent=true — UPC Appeal needed it
|
||||
// because all its rules carry a non-zero duration off the trigger
|
||||
// date so no rule is the "anchor". The trigger event for UPC Appeal
|
||||
// is the appealable first-instance decision (m/paliad#81).
|
||||
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
|
||||
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
|
||||
}
|
||||
|
||||
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
|
||||
@@ -237,14 +247,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
|
||||
// Look up proceeding type metadata.
|
||||
var pt struct {
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
Jurisdiction *string `db:"jurisdiction"`
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
Jurisdiction *string `db:"jurisdiction"`
|
||||
TriggerEventLabelDE *string `db:"trigger_event_label_de"`
|
||||
TriggerEventLabelEN *string `db:"trigger_event_label_en"`
|
||||
}
|
||||
err = s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT id, code, name, name_en, jurisdiction
|
||||
`SELECT id, code, name, name_en, jurisdiction,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true`, proceedingCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -271,7 +284,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
hasSubTrackNote = true
|
||||
// Re-resolve to the parent proceeding for rule lookup.
|
||||
err = s.rules.db.GetContext(ctx, &pt,
|
||||
`SELECT id, code, name, name_en, jurisdiction
|
||||
`SELECT id, code, name, name_en, jurisdiction,
|
||||
trigger_event_label_de, trigger_event_label_en
|
||||
FROM paliad.proceeding_types
|
||||
WHERE code = $1 AND is_active = true`, route.ParentCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
@@ -604,6 +618,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
}
|
||||
// Sub-track routing keeps the user-picked proceeding's identity,
|
||||
// so the trigger-event label rides on `pickedProceeding` (e.g.
|
||||
// upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not
|
||||
// upc.ccr.cfi's own — which is fine: the sub-track note already
|
||||
// explains the framing).
|
||||
if pickedProceeding.TriggerEventLabelDE != nil {
|
||||
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
|
||||
}
|
||||
if pickedProceeding.TriggerEventLabelEN != nil {
|
||||
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
|
||||
}
|
||||
if hasSubTrackNote {
|
||||
resp.ContextualNote = subTrackNote.NoteDE
|
||||
resp.ContextualNoteEN = subTrackNote.NoteEN
|
||||
|
||||
@@ -519,6 +519,34 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// RenderProjectSubmission renders the given .docx template with a fresh
|
||||
// variable bag for (user, project, submissionCode). No lawyer overrides
|
||||
// — the output reflects exactly what SubmissionVarsService resolves
|
||||
// from project state. Used by the one-click /api/projects/{id}/
|
||||
// submissions/{code}/generate path which has no saved draft row.
|
||||
//
|
||||
// Returns the merged bytes plus the resolved bag (for audit row + file
|
||||
// naming). Visibility is enforced by SubmissionVarsService.Build via
|
||||
// ProjectService.GetByID — callers get ErrNotFound on no-access.
|
||||
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
|
||||
// requested submission_code.
|
||||
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
pid := projectID
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: userID,
|
||||
ProjectID: &pid,
|
||||
SubmissionCode: submissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
// Called by every fetch path so the caller sees a populated Variables.
|
||||
func (d *SubmissionDraft) decodeVariables() error {
|
||||
|
||||
@@ -232,12 +232,15 @@ func buildDocumentXML() string {
|
||||
|
||||
// English-locale exercise — lets the lawyer verify the EN long-form
|
||||
// date and EN proceeding name resolve correctly when the user's
|
||||
// preference is en.
|
||||
// preference is en. Also exercises the bare {{today}} alias
|
||||
// (identical to {{today.iso}}; included so every key the variable
|
||||
// bag carries appears at least once in this demo template).
|
||||
heading2(&b, "Locale-aware variants (DEMO)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
plain(&b, "Today (bare alias): {{today}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
|
||||
303
scripts/gen-skeleton-submission-template/main.go
Normal file
303
scripts/gen-skeleton-submission-template/main.go
Normal file
@@ -0,0 +1,303 @@
|
||||
// Universal-skeleton submission template generator (t-paliad-259).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves — without baking in any submission_code-specific prose.
|
||||
//
|
||||
// Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx
|
||||
//
|
||||
// so paliad's submission generator picks it up via the fallback chain
|
||||
// slotted between the per-submission_code template and the bare
|
||||
// universal HL Patents Style .dotm. Any submission_code that has no
|
||||
// per-firm template still gets a draft populated with variables
|
||||
// instead of the macro-only letterhead.
|
||||
//
|
||||
// Why a separate file from de.inf.lg.erwidg.docx: that one is a
|
||||
// Klageerwiderung skeleton (DE LG, "I. Anträge / II. Sachverhalt /
|
||||
// III. Rechtsausführungen"). For a UPC SoC, an EPO opposition, a DPMA
|
||||
// appeal, that body structure is wrong. The universal skeleton drops
|
||||
// the structure and leaves a single neutral body block the lawyer
|
||||
// replaces — every variable still resolves regardless of code.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-skeleton-submission-template -out /tmp/_skeleton.docx
|
||||
//
|
||||
// Output is byte-reproducible (zip mtimes pinned to a fixed UTC
|
||||
// timestamp).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "_skeleton.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-skeleton-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
var fixedTime = time.Date(2026, 5, 25, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
|
||||
// case caption + parties + submission heading + deadline + a single
|
||||
// neutral body block. Mirrors the variable bag from SubmissionVarsService
|
||||
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
|
||||
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
|
||||
// structure. A lawyer customising this template for a UPC SoC, EPO
|
||||
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
|
||||
// renames the party labels — every placeholder still resolves regardless
|
||||
// of the submission_code chosen.
|
||||
//
|
||||
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
|
||||
// (format-preserving, single-run) substitution catches it. The
|
||||
// DEMO/SKELETON banner makes it obvious this is a starter template and
|
||||
// not approved firm content.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
skeletonBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}}")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Sache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin / Patentinhaberin / Anmelderin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte / Einsprechende / Beschwerdegegnerin —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "Schriftsatztext")
|
||||
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
|
||||
plain(&b, "")
|
||||
plain(&b, "[Body of the submission goes here. This skeleton template carries no pre-baked structure — fill in according to submission type ({{rule.name_en}}).]")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
|
||||
// Locale-aware verification block — exercises every EN/DE alias the
|
||||
// variable bag carries (today.long_en, deadline.due_date_long_en,
|
||||
// project.our_side_en, project.proceeding.name_en, rule.name_en) and
|
||||
// the bare {{today}} alias. A lawyer customising the template can
|
||||
// delete this block; the renderer round-trips it cleanly today.
|
||||
heading2(&b, "Locale-aware variants (SKELETON)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
plain(&b, "Today (bare alias): {{today}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func skeletonBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">SKELETON — universelle Vorlage (Schriftsatz-Typ-unabhängig, nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
568
scripts/seed-example-projects/main.go
Normal file
568
scripts/seed-example-projects/main.go
Normal file
@@ -0,0 +1,568 @@
|
||||
// Seed Example Projects (t-paliad-256 / m/paliad#87).
|
||||
//
|
||||
// Re-runnable test-data reset:
|
||||
//
|
||||
// 1. Wipes every row in paliad.projects (FK CASCADE handles the
|
||||
// dependent rows: deadlines, appointments, parties, notes,
|
||||
// project_events, project_teams, submission_drafts, approval_*,
|
||||
// project_partner_units, user_pinned_projects, documents,
|
||||
// user_calendar_bindings).
|
||||
//
|
||||
// 2. Inserts a small but realistic example tree (3 clients, 4
|
||||
// litigations, 4 patents, 8 cases — 19 projects total) that
|
||||
// exercises the auto-derived chain code: Client.Litigation.Patent.Case
|
||||
// → e.g. SIEMENS.HUAW.789.INF.CFI.
|
||||
//
|
||||
// 3. Re-reads the projects and prints each row's chain code so the
|
||||
// operator can eyeball the result without bouncing to SQL.
|
||||
//
|
||||
// Reference tables (proceeding_types, deadline_rules, event_types,
|
||||
// gerichte, checklists templates, firms, profiles) are untouched.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// DATABASE_URL='postgres://...' go run ./scripts/seed-example-projects
|
||||
//
|
||||
// One transaction wraps both wipe and seed so the DB is never in a
|
||||
// half-wiped state. Re-running drops the previous example tree and
|
||||
// reseeds fresh UUIDs — handy when project-code semantics change.
|
||||
//
|
||||
// Owner: m (matthias.siebels@hoganlovells.com). The script looks the
|
||||
// auth user up by email so it works on any environment where that
|
||||
// account exists; on a brand-new DB it falls back to NULL created_by.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// ownerEmail is the auth.users email the seed assigns as created_by.
|
||||
// Living in code (not a flag) because the example tree is m-owned by
|
||||
// convention; flip if the example data ever needs a service-account
|
||||
// owner.
|
||||
const ownerEmail = "matthias.siebels@hoganlovells.com"
|
||||
|
||||
// Proceeding-type IDs used by the seed. Resolved by code (not pinned
|
||||
// to integer IDs in source) to survive DB renumbering. Loaded once at
|
||||
// startup; missing codes fail fast with a clear message.
|
||||
var proceedingCodes = []string{
|
||||
"upc.inf.cfi",
|
||||
"upc.ccr.cfi",
|
||||
"upc.apl.merits",
|
||||
"de.inf.lg",
|
||||
"epa.opp.opd",
|
||||
"de.null.bpatg",
|
||||
"dpma.opp.dpma",
|
||||
}
|
||||
|
||||
func main() {
|
||||
dsn := flag.String("dsn", os.Getenv("DATABASE_URL"), "Postgres DSN (defaults to $DATABASE_URL)")
|
||||
dryRun := flag.Bool("dry-run", false, "print intended actions, roll back transaction")
|
||||
flag.Parse()
|
||||
|
||||
if *dsn == "" {
|
||||
fmt.Fprintln(os.Stderr, "seed-example-projects: DATABASE_URL not set and -dsn empty")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
db, err := sqlx.Connect("postgres", *dsn)
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "connect:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
if err := run(ctx, db, *dryRun); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "seed-example-projects:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, db *sqlx.DB, dryRun bool) error {
|
||||
ownerID, err := lookupOwner(ctx, db, ownerEmail)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup owner: %w", err)
|
||||
}
|
||||
if ownerID == uuid.Nil {
|
||||
fmt.Printf("note: %s not found in auth.users — created_by will be NULL\n", ownerEmail)
|
||||
} else {
|
||||
fmt.Printf("owner resolved: %s = %s\n", ownerEmail, ownerID)
|
||||
}
|
||||
|
||||
procIDs, err := lookupProceedingTypes(ctx, db, proceedingCodes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("lookup proceeding_types: %w", err)
|
||||
}
|
||||
|
||||
tx, err := db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }() // no-op if Commit ran first
|
||||
|
||||
if err := wipe(ctx, tx); err != nil {
|
||||
return fmt.Errorf("wipe: %w", err)
|
||||
}
|
||||
|
||||
tree, err := seed(ctx, tx, ownerID, procIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("seed: %w", err)
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
fmt.Println("\n--- DRY RUN — rolling back ---")
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("commit: %w", err)
|
||||
}
|
||||
fmt.Println("seed committed.")
|
||||
|
||||
if err := report(ctx, db, tree); err != nil {
|
||||
return fmt.Errorf("report: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func lookupOwner(ctx context.Context, db *sqlx.DB, email string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := db.GetContext(ctx, &id, `SELECT id FROM auth.users WHERE email = $1`, email)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return uuid.Nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func lookupProceedingTypes(ctx context.Context, db *sqlx.DB, codes []string) (map[string]int, error) {
|
||||
rows, err := db.QueryxContext(ctx,
|
||||
`SELECT id, code FROM paliad.proceeding_types WHERE code = ANY($1)`,
|
||||
pgTextArray(codes))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := make(map[string]int, len(codes))
|
||||
for rows.Next() {
|
||||
var id int
|
||||
var code string
|
||||
if err := rows.Scan(&id, &code); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[code] = id
|
||||
}
|
||||
for _, c := range codes {
|
||||
if _, ok := out[c]; !ok {
|
||||
return nil, fmt.Errorf("proceeding_types row missing for code=%q", c)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pgTextArray is the lib/pq array adapter, repackaged inline so the
|
||||
// script doesn't need a separate util import.
|
||||
func pgTextArray(xs []string) any {
|
||||
type arr = []string
|
||||
return arr(xs)
|
||||
}
|
||||
|
||||
// wipe deletes every paliad.projects row. FK CASCADE handles the
|
||||
// dependent tables (verified live 2026-05-25 against information_schema:
|
||||
// appointments, approval_requests, approval_policies, deadlines,
|
||||
// documents, notes, parties, project_events, project_partner_units,
|
||||
// project_teams, submission_drafts, user_pinned_projects,
|
||||
// user_calendar_bindings, checklist_shares all cascade; projects.
|
||||
// counterclaim_of and checklist_instances SET NULL; policy_audit_log
|
||||
// SET NULL).
|
||||
//
|
||||
// Reference tables (proceeding_types, deadline_rules, event_types,
|
||||
// gerichte, checklists, firms, partner_units, profiles) are not
|
||||
// referenced from this delete.
|
||||
func wipe(ctx context.Context, tx *sqlx.Tx) error {
|
||||
res, err := tx.ExecContext(ctx, `DELETE FROM paliad.projects`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
fmt.Printf("wiped: %d project rows (FK CASCADE handled dependents)\n", n)
|
||||
return nil
|
||||
}
|
||||
|
||||
// seededNode is one row of the seed result, kept so we can print the
|
||||
// chain code after commit without re-querying for IDs.
|
||||
type seededNode struct {
|
||||
id uuid.UUID
|
||||
title string
|
||||
}
|
||||
|
||||
// seed inserts the example tree. Order matters because parent_id FKs
|
||||
// must already exist — clients first, then litigations under them, then
|
||||
// patents, then cases (with the CCR case referencing its sibling
|
||||
// Klage case via counterclaim_of).
|
||||
func seed(ctx context.Context, tx *sqlx.Tx, ownerID uuid.UUID, procIDs map[string]int) ([]seededNode, error) {
|
||||
var nodes []seededNode
|
||||
|
||||
insertProject := func(p projectInsert) (uuid.UUID, error) {
|
||||
id := uuid.New()
|
||||
var createdBy any
|
||||
if ownerID != uuid.Nil {
|
||||
createdBy = ownerID
|
||||
}
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO paliad.projects (
|
||||
id, type, parent_id, title, reference, description, status,
|
||||
created_by, industry, country, client_number, matter_number,
|
||||
patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id,
|
||||
our_side, opponent_code, instance_level, counterclaim_of
|
||||
) VALUES (
|
||||
$1, $2, $3, $4, $5, $6, 'active',
|
||||
$7, $8, $9, $10, $11,
|
||||
$12, $13, $14,
|
||||
$15, $16, $17,
|
||||
$18, $19, $20, $21
|
||||
)`,
|
||||
id, p.Type, nullUUID(p.ParentID), p.Title, nullStr(p.Reference), nullStr(p.Description),
|
||||
createdBy, nullStr(p.Industry), nullStr(p.Country), nullStr(p.ClientNumber), nullStr(p.MatterNumber),
|
||||
nullStr(p.PatentNumber), nullDate(p.FilingDate), nullDate(p.GrantDate),
|
||||
nullStr(p.Court), nullStr(p.CaseNumber), nullInt(p.ProceedingTypeID),
|
||||
nullStr(p.OurSide), nullStr(p.OpponentCode), nullStr(p.InstanceLevel), nullUUID(p.CounterclaimOf),
|
||||
)
|
||||
if err != nil {
|
||||
return uuid.Nil, fmt.Errorf("insert %s %q: %w", p.Type, p.Title, err)
|
||||
}
|
||||
nodes = append(nodes, seededNode{id: id, title: p.Title})
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// --- Client 1: Siemens AG ----------------------------------------
|
||||
siemens, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Siemens AG", Reference: "SIEMENS",
|
||||
Industry: "Telekommunikation / Industrieelektronik", Country: "DE",
|
||||
Description: "Beispiel-Mandant — Telekommunikation & Halbleiter.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensHuawei, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: siemens,
|
||||
Title: "Siemens ./. Huawei Technologies", OpponentCode: "HUAW",
|
||||
Description: "Patentstreit Mobilfunk-Standardpatent.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensHuaweiPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: siemensHuawei,
|
||||
Title: "EP3456789 — Funkkommunikationssystem mit Mehrfachantenne",
|
||||
PatentNumber: "EP3456789",
|
||||
FilingDate: "2018-03-12", GrantDate: "2022-11-09",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
upcInfCFI, err := insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC CFI München — Klage Siemens ./. Huawei (EP3456789)",
|
||||
Court: "UPC Lokalkammer München",
|
||||
CaseNumber: "UPC_CFI_123/2026",
|
||||
ProceedingTypeID: procIDs["upc.inf.cfi"],
|
||||
OurSide: "claimant",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC CFI München — Widerklage Huawei ./. Siemens (EP3456789)",
|
||||
Court: "UPC Lokalkammer München",
|
||||
CaseNumber: "UPC_CFI_123/2026 (CCR)",
|
||||
ProceedingTypeID: procIDs["upc.ccr.cfi"],
|
||||
OurSide: "defendant", // we're respondent on the CCR
|
||||
InstanceLevel: "first",
|
||||
CounterclaimOf: upcInfCFI,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensHuaweiPatent,
|
||||
Title: "UPC Berufungsgericht — Berufung Huawei (EP3456789)",
|
||||
Court: "UPC Court of Appeal",
|
||||
CaseNumber: "UPC_CoA_45/2027",
|
||||
ProceedingTypeID: procIDs["upc.apl.merits"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "appeal",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensBosch, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: siemens,
|
||||
Title: "Siemens ./. Robert Bosch GmbH", OpponentCode: "BOSCH",
|
||||
Description: "Sensorik / autonomes Fahren.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
siemensBoschPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: siemensBosch,
|
||||
Title: "EP1111222 — Sensoreinrichtung für autonomes Fahren",
|
||||
PatentNumber: "EP1111222",
|
||||
FilingDate: "2017-06-21", GrantDate: "2021-08-04",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: siemensBoschPatent,
|
||||
Title: "LG München I — Klage Siemens ./. Bosch (EP1111222)",
|
||||
Court: "Landgericht München I",
|
||||
CaseNumber: "7 O 12345/26",
|
||||
ProceedingTypeID: procIDs["de.inf.lg"],
|
||||
OurSide: "claimant",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Client 2: Bayer AG ------------------------------------------
|
||||
bayer, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Bayer AG", Reference: "BAYER",
|
||||
Industry: "Pharma / Life Sciences", Country: "DE",
|
||||
Description: "Beispiel-Mandant — pharmazeutische Wirkstoffe.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bayerNova, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: bayer,
|
||||
Title: "Bayer ./. Novartis Pharma", OpponentCode: "NOVA",
|
||||
Description: "Wirkstoffverbindung X — Einspruch + Nichtigkeit.", OurSide: "claimant",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
bayerNovaPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: bayerNova,
|
||||
Title: "EP2222333 — Wirkstoffverbindung X",
|
||||
PatentNumber: "EP2222333",
|
||||
FilingDate: "2015-09-30", GrantDate: "2020-04-22",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: bayerNovaPatent,
|
||||
Title: "EPA Einspruch — Novartis ./. EP2222333",
|
||||
Court: "Europäisches Patentamt — Einspruchsabteilung",
|
||||
CaseNumber: "OPP-2026-0042",
|
||||
ProceedingTypeID: procIDs["epa.opp.opd"],
|
||||
OurSide: "respondent", // Bayer is patent owner defending the patent
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: bayerNovaPatent,
|
||||
Title: "BPatG — Nichtigkeitsklage Novartis ./. EP2222333",
|
||||
Court: "Bundespatentgericht",
|
||||
CaseNumber: "5 Ni 12/26",
|
||||
ProceedingTypeID: procIDs["de.null.bpatg"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// --- Client 3: Beispiel AG (intentionally sparse) ----------------
|
||||
// Demonstrates the empty-segment skip in BuildProjectCode — the
|
||||
// case row has a proceeding_type set so the tail is present, but
|
||||
// no instance_level / our_side, and the patent's number is national
|
||||
// (DE) so the last-3-digits segment shows DE-style behaviour.
|
||||
beispiel, err := insertProject(projectInsert{
|
||||
Type: "client", Title: "Beispiel AG", Reference: "BEISPL",
|
||||
Industry: "Unspezifiziert", Country: "DE",
|
||||
Description: "Sparse-Beispiel — zeigt, wie fehlende Segmente übersprungen werden.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beispielWtb, err := insertProject(projectInsert{
|
||||
Type: "litigation", ParentID: beispiel,
|
||||
Title: "Beispiel ./. Wettbewerber GmbH", OpponentCode: "WTB",
|
||||
Description: "Demo-Litigation ohne große Detailtiefe.",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
beispielWtbPatent, err := insertProject(projectInsert{
|
||||
Type: "patent", ParentID: beispielWtb,
|
||||
Title: "DE10987654 — Demo-Erfindung",
|
||||
PatentNumber: "DE10987654",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = insertProject(projectInsert{
|
||||
Type: "case", ParentID: beispielWtbPatent,
|
||||
Title: "DPMA Einspruch — Wettbewerber ./. DE10987654",
|
||||
Court: "Deutsches Patent- und Markenamt",
|
||||
CaseNumber: "DPMA-EIN-987/26",
|
||||
ProceedingTypeID: procIDs["dpma.opp.dpma"],
|
||||
OurSide: "respondent",
|
||||
InstanceLevel: "first",
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fmt.Printf("seeded: %d projects\n", len(nodes))
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// projectInsert is the typed input for one insertProject call. Pointer
|
||||
// fields are kept as plain strings here and converted via nullStr at
|
||||
// bind time; keeps the call sites readable.
|
||||
type projectInsert struct {
|
||||
Type string
|
||||
ParentID uuid.UUID
|
||||
Title string
|
||||
Reference string
|
||||
Description string
|
||||
Industry string
|
||||
Country string
|
||||
ClientNumber string
|
||||
MatterNumber string
|
||||
PatentNumber string
|
||||
FilingDate string // YYYY-MM-DD
|
||||
GrantDate string
|
||||
Court string
|
||||
CaseNumber string
|
||||
ProceedingTypeID int
|
||||
OurSide string
|
||||
OpponentCode string
|
||||
InstanceLevel string
|
||||
CounterclaimOf uuid.UUID
|
||||
}
|
||||
|
||||
func nullStr(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func nullInt(i int) any {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
func nullUUID(u uuid.UUID) any {
|
||||
if u == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func nullDate(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return t
|
||||
}
|
||||
|
||||
// reportRow is one row of the post-seed report — only the fields the
|
||||
// printout needs.
|
||||
type reportRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Type string `db:"type"`
|
||||
Title string `db:"title"`
|
||||
Path string `db:"path"`
|
||||
}
|
||||
|
||||
// report prints the seeded tree with the auto-derived chain code for
|
||||
// each row. Uses services.BuildProjectCode so the script verifies the
|
||||
// same helper the live app uses (catches drift if the algorithm
|
||||
// changes).
|
||||
func report(ctx context.Context, db *sqlx.DB, _ []seededNode) error {
|
||||
var rows []reportRow
|
||||
err := db.SelectContext(ctx, &rows, `
|
||||
SELECT id, type, title, path
|
||||
FROM paliad.projects
|
||||
ORDER BY path
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
fmt.Println("\nresulting chain codes:")
|
||||
tw := tabwriter.NewWriter(os.Stdout, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "TYPE\tTITLE\tCODE")
|
||||
for _, r := range rows {
|
||||
code, err := services.BuildProjectCode(ctx, db, r.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build code for %s: %w", r.ID, err)
|
||||
}
|
||||
indent := strings.Repeat(" ", pathDepth(r.Path)-1)
|
||||
fmt.Fprintf(tw, "%s\t%s%s\t%s\n", r.Type, indent, r.Title, code)
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func pathDepth(p string) int {
|
||||
if p == "" {
|
||||
return 1
|
||||
}
|
||||
d := 1
|
||||
for _, c := range p {
|
||||
if c == '.' {
|
||||
d++
|
||||
}
|
||||
}
|
||||
return d
|
||||
}
|
||||
Reference in New Issue
Block a user