Compare commits

..

11 Commits

Author SHA1 Message Date
mAi
8125caf49a test(backup): add TEST_DATABASE_URL-gated live smokes for org export
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two complementary live tests (both skipped without TEST_DATABASE_URL):

- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
  composer the way the backup runner does at the start of every run,
  then executes each resolved SELECT against the live DB (wrapped in
  LIMIT 1 to keep table reads cheap). A future column rename in a
  table our spec still names triggers this test and surfaces in CI
  before /admin/backups breaks.

- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
  schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
  per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
  and the zip magic bytes; doesn't materialise the full bundle to
  disk.

Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.

m/paliad#140
2026-05-26 18:19:55 +02:00
mAi
935ea23038 refactor(backup): make orgSheetQueries drift-resistant
Refactor orgSheetQueries() into orgSheetSpecs() returning declarative
(SheetName, Table, OrderBy []string) triples instead of free-form SQL,
with composeOrgSheetSQL() as a pure builder and resolveOrgSheets() as
the DB-touching orchestrator.

At backup time the resolver:
  1. probes information_schema.columns once for every spec table,
  2. composes SELECT * FROM <table> ORDER BY <columns-that-exist>,
  3. logs WARN per ORDER BY column dropped because it's gone.

A future column rename or removal can no longer break /admin/backups:
the worst case is one sheet temporarily losing sort stability, and the
WARN log surfaces which spec needs updating.

Sheets needing custom projections (documents drops ai_extracted) keep
the SQL override path. All other org-scope sheets — entity + ref__ —
declare their ORDER BY as a column list.

Tests:
  - 6 composeOrgSheetSQL unit tests cover the drift behaviour with no
    DB needed (missing column, all-missing, override bypass, declared
    order preserved, unknown table)
  - Existing registry-shape tests (no duplicates, no paliadin leakage,
    ref__ prefix, ORDER BY-for-determinism) updated to the spec API
  - Full internal/services suite green

m/paliad#140
2026-05-26 18:17:21 +02:00
mAi
da464813b7 fix(backup): repair 4 broken ORDER BY columns in orgSheetQueries
Backup export was 100% broken because four sheets referenced columns
that no longer exist (or never did) in their target tables:

- email_templates: ORDER BY id → key, lang (composite PK)
- policy_audit_log: ORDER BY changed_at → created_at
- ref__deadline_event_types: ORDER BY rule_id → deadline_id (post-rename)
- ref__event_category_concepts: ORDER BY category_id → event_category_id

Audited every entry in orgSheetQueries() against information_schema.columns;
these were the only mismatches. Patch unblocks /admin/backups → Generate.
Drift-resistant refactor (m/paliad#140 Part B) follows in a separate commit.

m/paliad#140
2026-05-26 18:13:27 +02:00
mAi
6d24fb8931 Merge: t-paliad-310 — dark-mode CSS: repoint 12 var(--color-surface-alt) sites to defined tokens (m/paliad#138)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:07:45 +02:00
mAi
446c46e5c5 fix(css): repoint 12 var(--color-surface-alt, hex) sites to defined tokens (t-paliad-310, m/paliad#138)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The --color-surface-alt token was never defined in :root or :root[data-theme="dark"],
so the var() fallback hex literal always won — leaving 12 surface sites with
zero dark-mode treatment. Same pattern as t-paliad-087 / t-paliad-150 / t-paliad-291.

Issue #138 surfaced four panels visibly broken in dark mode:
1. submission-draft no-project banner ("Kein Projekt zugeordnet…") — white-on-white
2. submission-draft preview header ("Vorschau / Read-only Vorschau…") — white-on-white
3. smart-timeline rule-chip (e.g. de.null.bpatg.berufung in Vorhersage rows) — grey-on-grey
4. submission-draft addparty manual form (Manuell / Aus DB / Name / …) — white-on-white

Eight more latent sites with the same root cause are fixed in the same pass:
.submissions-new-chip:hover, .submissions-new-project-item:hover,
.submission-draft-import-row, .submission-draft-addparty-search-projref,
.collab-invite-hint, .smart-timeline-status-icon,
.smart-timeline-kind-chip--projected, .smart-timeline-add-choice:hover.

Each site repointed to the semantically correct existing token
(--color-surface-2 for #fafafa, --color-surface-muted for #f4f4f4,
--color-bg-subtle for #f7f7f0, --color-bg-lime-tint for the lime-tinted
collab-invite-hint). All four target tokens are defined in both :root
and :root[data-theme="dark"]. No new tokens introduced.

Light-mode hex values are functionally identical (#fafafa==#fafafa,
#f4f4f4≈#f3f4f6, #f7f7f0≈#f7f3f0).

Verified: bun run build clean; Playwright screenshots of the four panels
in both light + dark modes show correct rendering.
2026-05-26 18:07:02 +02:00
mAi
d1aa0f72c0 Merge: t-paliad-305 — Slice B.3: read cutover via paliad.deadline_rules_unified view (mig 139); legacy writes retire in B.4 (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:01:25 +02:00
mAi
94f2831f3f Merge: fix(backup): export ORDER BY uses binding_id (was calendar_binding_id) — unblocks /admin/backups
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:00:37 +02:00
mAi
83be122b19 fix(backup): export ORDER BY uses binding_id, not calendar_binding_id
paliad.appointment_caldav_targets's join column is named binding_id
(mig 101). The backup sheet exporter referenced calendar_binding_id
which doesn't exist, so /admin/backups generate failed with 42703.

Single-char fix. Also flags follow-up: hardcoded ORDER BY columns on
every sheet in orgSheetQueries() are fragile under schema renames —
a separate slice (m/paliad#140) tracks making the exporter flexible
to drift (e.g. probe information_schema or use NULLS LAST id-only).
2026-05-26 18:00:17 +02:00
mAi
b6c2df95cc Merge: t-paliad-307 — Verfahrensablauf appeal mode fixes (side filter + synthetic trigger row + duration label + notes dedup) (m/paliad#136)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:57:39 +02:00
mAi
367627af0d fix(verfahrensablauf): appeal side filter + parent in duration label + notes dedup (t-paliad-307, m/paliad#136)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Frontend half of the four Verfahrensablauf appeal bugs.

Bug 1 (frontend half) — Side selector dead on appeal. The column
bucketer now reads dl.appealRole (engine-stamped under
appeal_target) and routes each "both" appeal rule via the user
side: side=claimant maps the user to the appellant, so appellant
filings land in 'ours' and appellee filings in 'opponent';
side=defendant mirrors. side=null keeps the legacy mirror so every
appeal rule renders in both columns (every-rule-visible behaviour
the brief calls out). The new appealAware opt gates the path so
non-appeal proceedings keep their existing bucketing untouched.

Removed upc.apl.unified from APPELLANT_AXIS_PROCEEDINGS — appeal
routing is now per-rule via appealRole, not a page-level appellant
collapse. Other role-swap proceedings (EPA opp, DE/DPMA appeals)
keep the appellant axis since they have no appeal_target metadata.

Bug 3 — Duration label appends parent name. formatDurationLabel now
takes an optional parent fallback and renders "<n> <unit> <timing>
<parent>". deadlineCardHtml resolves the parent per-rule
(dl.parentRuleName / EN variant), falling back to opts.trigger
EventLabel for root rules with a non-zero duration (e.g.
Berufungseinlegung 2 mo. after the Endentscheidung). renderColumns
Body + renderTimelineBody auto-derive the trigger event label from
the response via the new pickTriggerEventLabel helper unless the
caller passes one explicitly.

Bug 4 — Duration prefix stripped from deadline_notes. New
stripLeadingDurationFromNotes regex peels off leading
"Frist N <unit> <vor|nach|ab|seit> …. " (DE) and
"<N>-<unit> period from …" / "N <unit> BEFORE …" / "Period is N
<unit> from …" (EN) up to the first sentence boundary. Wired into
deadlineCardHtml so noteHint + notesBlock both render the deduped
text. Per the brief's option (a): conservative regex, composite
durations with "ODER" / "whichever is the longer" stay untouched
as a follow-up editorial cleanup. deadline_rules DB untouched.

Tests: 22 new test cases across appeal-aware bucketing,
formatDurationLabel parent append, deadlineCardHtml duration
tooltip resolution, and stripLeadingDurationFromNotes regex
(positive + negative + composite + EN/DE variants). All 209
frontend tests pass.

Engine wire fields added in the preceding commit (AppealRole,
IsTriggerEvent). Reads them from CalculatedDeadline without
breaking the wire contract for non-appeal callers.
2026-05-26 17:56:32 +02:00
mAi
7d7b20651d feat(litigationplanner): appeal-target synthetic trigger row + appeal-role stamping (t-paliad-307, m/paliad#136)
Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136.

Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set,
Calculate now prepends a synthetic TimelineEntry to the deadlines slice
dated to the trigger date, carrying the per-appeal-target label from
TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten-
entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked
IsRootEvent + IsTriggerEvent + party=court + priority=informational
so the frontend renders it as a dimmed anchor card without a save
button / choices caret / click-to-edit affordance. Empty Code so it
doesn't collide with real rule UUIDs downstream.

Bug 1 (engine half) — Side selector dead on appeal. Every appeal
filing rule carries primary_party='both' in the catalog, so the
column bucketer couldn't distinguish Berufungskläger vs Berufungs-
beklagter filings from primary_party alone. Engine now stamps the
new TimelineEntry.AppealRole field with appellant/appellee from the
rule-semantic AppealFilerRole mapping (appeal_role.go) when an
appeal_target is in scope. The frontend half of the fix (next commit)
consumes this to route each "both" rule into the user-perspective
column once the user picks a side.

Mapping covers all 12 appeal filing rules across the three
applies_to_target tracks (endentscheidung/schadensbemessung,
kostenentscheidung, anordnung/bucheinsicht). Court-issued events
(merits.decision, merits.oral, cost.decision, order.order) stay
empty — they continue to route on Party='court'. Unmapped
submission_codes return empty so a new appeal rule we forgot to map
falls through to the bucketer's legacy path rather than silently
picking a side.

Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal
SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole
stamped when target is set, (b) no synthetic row + no AppealRole
when target is unset (regression guard), (c) unknown target
short-circuits to no-op. Existing tests untouched — both behaviours
gate on opts.AppealTarget != "".

No DB migration — the bugs are calc-side. deadline_rules untouched.
2026-05-26 17:56:12 +02:00
11 changed files with 1317 additions and 107 deletions

View File

@@ -61,8 +61,14 @@ let sidePrefilledFromProject = false;
// chosen side's column). For first-instance proceedings (Inf, Rev,
// …) the side picker still narrows columns but doesn't collapse
// the "both" rows.
//
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
// timelines route via per-rule appealRole (engine-stamped under
// appeal_target) instead of the page-level appellant axis collapse.
// Adding upc.apl.unified here would short-circuit the appealAware
// path and re-introduce the dead side selector on upc.apl.unified
// (m/paliad#136 Bug 1).
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.unified",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
@@ -505,6 +511,12 @@ function renderResults(data: DeadlineResponse) {
// in the picked side's column). For non-role-swap proceedings,
// the appellant axis is irrelevant — pass null.
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
// Appeal-target proceedings get per-rule appealRole routing
// instead of the page-level appellant collapse, so the side
// selector actually splits Berufungskläger vs Berufungs-
// beklagter filings across columns. (t-paliad-307 /
// m/paliad#136 Bug 1)
appealAware: hasAppealTarget(selectedType),
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });

View File

@@ -4,7 +4,9 @@ import {
type DeadlineResponse,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
formatDurationLabel,
renderColumnsBody,
stripLeadingDurationFromNotes,
} from "./verfahrensablauf-core";
// Regression tests for the editable→click-to-edit wiring on timeline date
@@ -487,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)",
expect(html).not.toContain(">Reaktiv<");
});
});
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
// All appeal rules carry party='both' (either side could be the
// appellant). With appealAware=true + dl.appealRole set, the bucketer
// routes by (filer matches user) instead of collapsing every 'both'
// row into the user's column. Without a side picked, the bucketer
// keeps the legacy mirror so every appeal rule is visible.
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
const appeal = (
name: string,
role: "appellant" | "appellee",
due: string,
): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
appealRole: role,
});
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
side: "claimant",
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
});
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
const rows = bucketDeadlinesIntoColumns([notice, response], {
side: "defendant",
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
});
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
const rows = bucketDeadlinesIntoColumns([notice, response], {
side: null,
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
});
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
// Regression guard: a stale frontend that drops `appealAware: true`
// must not silently route via appealRole — the side selector
// would visibly change behaviour without a UI control to opt in.
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
// Legacy "side without appellant" collapse → both rows into ours.
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
});
test("appealAware respects court party — court rows always route to court column", () => {
const decision: CalculatedDeadline = {
...notice,
name: "Entscheidung",
party: "court",
appealRole: "", // court events deliberately stay empty
dueDate: "",
};
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent).toHaveLength(0);
});
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
// A future appeal rule we forgot to map: appealRole='' falls
// through the appealAware branch and lands in the legacy
// side-collapse path → ours.
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(rows[0].opponent).toHaveLength(0);
});
});
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
// parent rule name (or the proceeding's trigger event label for
// root rules) so the chip reads "4 Monate nach Endentscheidung"
// instead of the dangling "4 Monate nach".
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
code: "x",
name: "x",
nameEN: "x",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "",
originalDate: "",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 4,
durationUnit: "months",
timing: "after",
...overrides,
});
test("with parent label: appends to head", () => {
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
.toBe("4 Monate nach Endentscheidung (R.118)");
});
test("without parent label: bare head — caller decides whether to render", () => {
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
});
test("without timing: parent is not appended (degenerate phrasing)", () => {
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
// so the bare "4 Monate" head stays. Pinned to catch a future
// edit that would emit "4 Monate Endentscheidung" without a
// preposition.
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
});
test("singular value: switches to .one unit key", () => {
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
});
test("zero / missing duration: empty string", () => {
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
});
});
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
// upc.apl.merits.notice has no parent_id but a 2-month duration
// off the trigger event (the appealed decision). The duration
// tooltip must read the appeal-target label, not just "2 Monate
// nach".
const dl: CalculatedDeadline = {
code: "upc.apl.merits.notice",
name: "Berufungseinlegung",
nameEN: "Notice of Appeal",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-07-26",
originalDate: "2026-07-26",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 2,
durationUnit: "months",
timing: "after",
};
const html = deadlineCardHtml(dl, {
showParty: false,
editable: true,
triggerEventLabel: "Endentscheidung (R.118)",
});
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
});
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
// merits.response chains off merits.grounds; the duration label
// should read "3 Monate nach Berufungsbegründung", not the
// appeal-target fallback.
const dl: CalculatedDeadline = {
code: "upc.apl.merits.response",
name: "Berufungserwiderung",
nameEN: "Response to Appeal",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-12-26",
originalDate: "2026-12-26",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 3,
durationUnit: "months",
timing: "after",
parentRuleCode: "upc.apl.merits.grounds",
parentRuleName: "Berufungsbegründung",
parentRuleNameEN: "Statement of Grounds",
};
const html = deadlineCardHtml(dl, {
showParty: false,
editable: true,
triggerEventLabel: "Endentscheidung (R.118)",
});
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
});
});
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
// substring is stripped before deadline_notes renders so the new
// duration affordance and the legacy free-text don't duplicate.
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
const out = stripLeadingDurationFromNotes(
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
"de",
);
expect(out).toBe("Antrag auf Simultanübersetzung.");
});
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
const out = stripLeadingDurationFromNotes(
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
"de",
);
expect(out).toBe("");
});
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
const out = stripLeadingDurationFromNotes(
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
"de",
);
expect(out).toBe("Spätestens 1 Jahr.");
});
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
const composite =
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
});
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
expect(out).toBe("Frist vom Gericht bestimmt");
});
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
const out = stripLeadingDurationFromNotes(
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
"en",
);
expect(out).toBe("Request for simultaneous interpretation.");
});
test("EN: strips '15-day period from …'", () => {
const out = stripLeadingDurationFromNotes(
"15-day period from service of the cost decision",
"en",
);
expect(out).toBe("");
});
test("EN: strips 'Period is N <unit> from …'", () => {
const out = stripLeadingDurationFromNotes(
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
"en",
);
expect(out).toBe("Latest 12 months.");
});
test("EN: empty / non-matching notes pass through unchanged", () => {
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
.toBe("Time limit set by the court");
});
});

View File

@@ -104,19 +104,92 @@ export interface CalculatedDeadline {
durationValue?: number;
durationUnit?: string;
timing?: string;
// appealRole carries the rule's appeal-filer identity when the
// server computed the timeline under an appeal_target filter:
// "appellant" (Berufungskläger files this rule), "appellee"
// (Berufungsbeklagter files this rule), or empty for court events
// and non-appeal timelines. The column bucketer reads this in
// preference to primary_party='both' so a user-perspective `?side=`
// pick can split appeal filings into the user's column vs the
// opponent's, instead of routing every "both" rule into the
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
appealRole?: "appellant" | "appellee" | "";
// isTriggerEvent marks the synthetic row the engine prepends to the
// timeline when computing an appeal: a court-set decision dated to
// the trigger date with the per-appeal-target label
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
// carries no real rule_id — it's a UI marker so the timeline reads
// decision → appeal filings → next decision. (t-paliad-307 /
// m/paliad#136 Bug 2)
isTriggerEvent?: boolean;
}
// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for
// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302).
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely.
// stripLeadingDurationFromNotes drops the leading
// "Frist N <unit> <preposition> <subject>." (DE) /
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
// deadline_notes so it doesn't duplicate the new duration affordance
// added in m/paliad#133 (t-paliad-307 Bug 4).
//
// Pluralisation key naming mirrors the Fristenrechner event-mode
// renderer (deadlines.event.unit.<unit>.{one,many}) — the unit and
// timing translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single
// source of truth.
export function formatDurationLabel(dl: CalculatedDeadline): string {
// The duration affordance now renders the same prose as a badge on
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
// notes string that opens with the same prose reads as a verbatim
// duplicate. Only the leading-prefix shape is stripped — anything
// after the first sentence is preserved (the editorial commentary
// the lawyers actually want to read).
//
// Conservative: composite-duration prefaces with "ODER" /
// "whichever is the longer" don't match and stay untouched — those
// are the follow-up editorial cleanup (option b in the issue brief).
//
// Examples:
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
// → "Antrag …"
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
// → ""
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
// → "Spätestens …"
// "1-month period from service of the main decision"
// → ""
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
// → "Request for …"
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
// → "Latest …"
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
// → unchanged (composite — option b follow-up)
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
if (!notes) return notes;
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
// (period followed by whitespace) OR end of input. Embedded dots
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
// are skipped because the char right after them isn't whitespace.
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
// any character including newlines, non-greedy.
const re = lang === "en"
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
return notes.replace(re, "");
}
// formatDurationLabel renders the per-rule duration label for the
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
// "1 Monat vor Mündlicher Verhandlung", …
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
// m/paliad#136 Bug 3).
//
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely. Pluralisation
// key naming mirrors the Fristenrechner event-mode renderer
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
// translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single source
// of truth.
//
// `parentLabel` is the rule's anchor name (parent rule's name when
// the rule has a parent_id; otherwise the proceeding's
// triggerEventLabel from the wire). Empty falls back to bare
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
// remains the default for fixtures / tests that omit a parent.
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
const value = dl.durationValue ?? 0;
const unit = dl.durationUnit || "";
if (value <= 0 || !unit) return "";
@@ -124,7 +197,9 @@ export function formatDurationLabel(dl: CalculatedDeadline): string {
const unitStr = tDyn(unitKey);
const timing = dl.timing || "";
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
if (!timingStr || !parentLabel) return head;
return `${head} ${parentLabel}`;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -363,16 +438,34 @@ export interface CardOpts {
// flips this and re-renders; persisted via the localStorage key
// `paliad.verfahrensablauf.durations-show`. Default false.
showDurations?: boolean;
// triggerEventLabel: per-language label of the proceeding's anchor
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
// as the parent-name fallback when a rule is a root rule (no
// parent_id) but carries a non-zero duration — e.g. the
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
// already-language-resolved string. (t-paliad-307 / m/paliad#136
// Bug 3)
triggerEventLabel?: string;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
// Parent name for the duration label (t-paliad-307 / m/paliad#136
// Bug 3): use the rule's parent if set, else fall back to the
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
// Empty for rules whose anchor isn't surface-able — the duration
// label degrades to the bare "<n> <unit> <timing>" form in that case.
const parentLabelForDuration = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
// both the date-span tooltip and the inline meta-row span pull from
// the same string. Empty for rules without a usable duration.
const durationLabel = formatDurationLabel(dl);
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
// Hover affordance on the date span: prefer the duration tooltip when
// we have one, else fall back to the edit-hint when the cell is
// click-to-edit. The edit affordance still works either way — the
@@ -478,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
// Strip the leading-duration prefix so the new duration affordance
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
// for those legacy rule rows that still carry it.
// (t-paliad-307 / m/paliad#136 Bug 4)
const noteText = rawNoteText
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
: rawNoteText;
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
@@ -608,7 +708,32 @@ export function wireDateEditClicks(
});
}
// pickTriggerEventLabel returns the per-language trigger event label
// from a DeadlineResponse, used as the parent-fallback for root-rule
// duration labels. Mirrors the precedence the page-level
// triggerEventLabelFor uses (curated server label > proceedingName
// fallback). Distinct from the page helper in that it stays language-
// scoped to the current getLang() — root-rule duration labels render
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
export function pickTriggerEventLabel(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
if (curated) return curated;
return lang === "en"
? (data.proceedingNameEN || data.proceedingName || "")
: (data.proceedingName || data.proceedingNameEN || "");
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
// Resolve the trigger event label once so the duration affordance on
// root rules (no parent) can read it as the anchor fallback. Caller-
// provided value wins (lets the page override for sub-track flows).
const cardOpts: CardOpts = {
...opts,
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
};
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
const itemClasses = [
@@ -630,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, opts)}
${deadlineCardHtml(dl, cardOpts)}
</div>
</div>
`;
@@ -689,6 +814,15 @@ export interface ColumnsBodyOpts {
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
// page is rendering an appeal_target-filtered timeline. Routes
// each rule to its filer-perspective column via dl.appealRole
// instead of the legacy primary_party='both' collapse.
// (t-paliad-307 / m/paliad#136 Bug 1)
appealAware?: boolean;
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
// (t-paliad-307 / m/paliad#136 Bug 3)
triggerEventLabel?: string;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
@@ -704,6 +838,15 @@ export interface ColumnsRow {
export interface BucketingOpts {
side?: Side;
appellant?: Side;
// appealAware: when true, rules carrying a `dl.appealRole` of
// "appellant" / "appellee" route via the appeal role + user side
// axis instead of the legacy primary_party='both' collapse. With
// `side=null` the bucketer keeps the mirror semantic (both columns
// render every appeal rule); with `side` set, "appellant" rules
// land in the user's column when the user IS the appellant, in
// the opponent's column otherwise — mirror for "appellee" rules.
// (t-paliad-307 / m/paliad#136 Bug 1)
appealAware?: boolean;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
@@ -738,6 +881,8 @@ export function bucketDeadlinesIntoColumns(
return r;
};
const appealAware = opts.appealAware === true;
deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
@@ -760,6 +905,25 @@ export function bucketDeadlinesIntoColumns(
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
row[perCardCol].push(dl);
} else if (
appealAware &&
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
) {
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
// With no side picked, mirror to both columns so every rule
// is visible regardless of which side the user is on. With
// a side picked, route by (filer matches user) → ours
// column, else opponent column. side=claimant maps the
// user to "appellant" (Berufungskläger); side=defendant
// maps the user to "appellee" (Berufungsbeklagter).
if (userSide === null) {
row.ours.push(dl);
row.opponent.push(dl);
} else {
const userIsAppellant = userSide === "claimant";
const filerIsAppellant = dl.appealRole === "appellant";
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
}
} else if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
@@ -798,7 +962,11 @@ export function bucketDeadlinesIntoColumns(
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 rows = bucketDeadlinesIntoColumns(data.deadlines, {
side: userSide,
appellant: opts.appellant,
appealAware: opts.appealAware,
});
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = {
@@ -806,6 +974,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
editable: opts.editable,
showNotes: opts.showNotes,
showDurations: opts.showDurations,
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
};
// Collapsed "both" rows lose their mirror tag — there's no longer

View File

@@ -6230,7 +6230,7 @@ dialog.modal::backdrop {
align-items: baseline;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface-alt, #fafafa);
background: var(--color-surface-2);
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -6388,7 +6388,7 @@ dialog.modal::backdrop {
}
.submissions-new-chip:hover {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
}
.submissions-new-chip--active {
@@ -6426,7 +6426,7 @@ dialog.modal::backdrop {
}
.submissions-new-project-item:hover {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
}
.submissions-new-project-title {
@@ -6441,7 +6441,7 @@ dialog.modal::backdrop {
flex-wrap: wrap;
padding: 0.75rem 1rem;
margin: 0 0 1.25rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-left: 4px solid var(--color-accent, #c6f41c);
border-radius: 6px;
@@ -6464,7 +6464,7 @@ dialog.modal::backdrop {
flex-wrap: wrap;
padding: 0.5rem 0.6rem;
margin-bottom: 0.75rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 6px;
}
@@ -6592,7 +6592,7 @@ dialog.modal::backdrop {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
display: flex;
flex-direction: column;
gap: 0.5rem;
@@ -6715,7 +6715,7 @@ dialog.modal::backdrop {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
background: var(--color-bg-subtle);
color: var(--color-text-muted);
}
@@ -7922,7 +7922,7 @@ dialog.modal::backdrop {
.collab-invite-hint {
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
background: var(--color-surface-alt, var(--color-bg-lime-tint));
background: var(--color-bg-lime-tint);
border: 1px dashed var(--color-border);
border-radius: var(--radius);
font-size: 0.85rem;
@@ -16582,7 +16582,7 @@ dialog.quick-add-sheet::backdrop {
width: 1.4rem;
height: 1.4rem;
border-radius: 50%;
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-size: 0.85rem;
font-weight: 600;
@@ -16636,7 +16636,7 @@ dialog.quick-add-sheet::backdrop {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: 999px;
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-weight: 500;
letter-spacing: 0.02em;
@@ -16658,7 +16658,7 @@ dialog.quick-add-sheet::backdrop {
}
.smart-timeline-kind-chip--projected {
background: var(--color-surface-alt, #f4f4f4);
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-style: italic;
}
@@ -16725,7 +16725,7 @@ dialog.quick-add-sheet::backdrop {
.smart-timeline-add-choice:hover:not(:disabled) {
border-color: var(--color-accent-fg);
background: var(--color-surface-alt, #fafafa);
background: var(--color-surface-2);
}
.smart-timeline-add-choice--primary {

View File

@@ -0,0 +1,99 @@
package services
import (
"bytes"
"context"
"os"
"strings"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
)
// TestResolveOrgSheets_LiveSchemaSnapshot probes the live paliad schema
// the way the backup runner does at the start of every run, then asserts
// that every spec the registry declares either keeps all its ORDER BY
// columns or — if any are missing — composes a fallback SELECT that the
// DB can still execute. Catches the m/paliad#140 class of bug
// (hardcoded ORDER BY against a renamed column) before deploy.
//
// Skipped when TEST_DATABASE_URL is unset. Read-only: opens a
// REPEATABLE READ tx, never writes.
func TestResolveOrgSheets_LiveSchemaSnapshot(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
specs := orgSheetSpecs()
sheets, err := resolveOrgSheets(ctx, pool, specs)
if err != nil {
t.Fatalf("resolveOrgSheets: %v", err)
}
if len(sheets) != len(specs) {
t.Fatalf("resolved %d sheets, want %d", len(sheets), len(specs))
}
// Each resolved SELECT must run cleanly against the live schema.
// We LIMIT 1 inside a sub-SELECT so we don't materialise the full
// table (some are large) but still exercise the ORDER BY clause.
for _, sq := range sheets {
wrapped := `SELECT * FROM (` + sq.SQL + `) _wrap LIMIT 1`
if _, err := pool.QueryxContext(ctx, wrapped, sq.Args...); err != nil {
t.Errorf("sheet %q SQL failed: %v\nSQL: %s", sq.SheetName, err, sq.SQL)
}
}
}
// TestWriteOrg_LiveSmoke runs the full ExportService.WriteOrg pipeline
// against a real DB: schema probe, REPEATABLE READ tx, every sheet
// query, xlsx + json + per-sheet CSV assembly, outer zip framing.
// Discards the bytes — this is a "does it crash" smoke, the bug class
// it catches is exactly the one from m/paliad#140 (hardcoded ORDER BY
// against a missing column).
//
// Skipped when TEST_DATABASE_URL is unset.
func TestWriteOrg_LiveSmoke(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
svc := NewExportService(pool, "test-firm")
var buf bytes.Buffer
meta, err := svc.WriteOrg(context.Background(), &buf, ExportSpec{
ActorID: uuid.New(),
ActorEmail: "backup-smoke@test.local",
ActorLabel: "Backup Smoke",
})
if err != nil {
t.Fatalf("WriteOrg: %v", err)
}
if buf.Len() == 0 {
t.Fatalf("WriteOrg wrote no bytes")
}
// Spot-check meta fills.
if meta.Scope != ExportScopeOrg {
t.Errorf("meta.Scope = %q, want %q", meta.Scope, ExportScopeOrg)
}
if len(meta.RowCounts) != len(orgSheetSpecs()) {
t.Errorf("meta.RowCounts has %d entries, want %d (one per sheet)", len(meta.RowCounts), len(orgSheetSpecs()))
}
// The bytes are a zip; the first 4 bytes are PK\x03\x04 for a non-empty zip.
if buf.Len() >= 4 && !strings.HasPrefix(buf.String()[:4], "PK\x03\x04") {
t.Errorf("bundle bytes don't look like a zip (first bytes: %x)", buf.Bytes()[:4])
}
}

View File

@@ -6,8 +6,10 @@ package services
// 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
// - orgSheetSpecs registry shape: no duplicates, no excluded
// paliadin sheets, predictable prefix split between entity and ref.
// - composeOrgSheetSQL drift-resistance: missing ORDER BY cols drop,
// SQL override path bypasses the builder, all-missing → no clause.
// - LocalDiskStore Put / Get / Delete round-trip, key validation,
// URI traversal rejection.
@@ -22,60 +24,216 @@ import (
)
// ---------------------------------------------------------------------------
// orgSheetQueries registry
// orgSheetSpecs registry
// ---------------------------------------------------------------------------
func TestOrgSheetQueries_NoDuplicates(t *testing.T) {
func TestOrgSheetSpecs_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)
for _, sp := range orgSheetSpecs() {
if seen[sp.SheetName] {
t.Fatalf("duplicate sheet name in orgSheetSpecs: %q", sp.SheetName)
}
seen[sq.SheetName] = true
seen[sp.SheetName] = true
}
}
func TestOrgSheetQueries_ExcludesPaliadinTables(t *testing.T) {
func TestOrgSheetSpecs_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
for _, sp := range orgSheetSpecs() {
name := sp.SheetName
if strings.Contains(name, "paliadin") {
t.Fatalf("orgSheetQueries leaked paliadin sheet: %q (m's Q3 mandates structural exclusion)", name)
t.Fatalf("orgSheetSpecs 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)
if strings.Contains(sp.Table, "paliadin") {
t.Fatalf("orgSheetSpecs[%q].Table references a paliadin table: %s", name, sp.Table)
}
// Belt-and-braces: SQL override bodies (the few sheets that
// bypass the Table+OrderBy builder) also can't pull paliadin
// tables in through UNION/subquery.
if strings.Contains(sp.SQL, "paliadin_turns") || strings.Contains(sp.SQL, "paliadin_aichat_conversation") {
t.Fatalf("orgSheetSpecs[%q] SQL references a paliadin table: %s", name, sp.SQL)
}
}
}
func TestOrgSheetQueries_RefSheetsPrefixed(t *testing.T) {
func TestOrgSheetSpecs_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__") {
for _, sp := range orgSheetSpecs() {
if !strings.HasPrefix(sp.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)
// dump the whole reference table for portability). Only
// applies to the SQL-override path; the Table+OrderBy builder
// never emits a WHERE.
if sp.SQL != "" && strings.Contains(strings.ToUpper(sp.SQL), "WHERE") {
t.Fatalf("orgSheetSpecs[%q] is ref__ but has a WHERE clause; reference sheets dump the whole table", sp.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)
func TestOrgSheetSpecs_OrderByForDeterminism(t *testing.T) {
// Every sheet must declare a stable sort: either OrderBy on the
// Table+OrderBy path, or ORDER BY in the SQL override. Keeps the
// byte-deterministic contract from t-paliad-214 §3 across runs.
//
// (Drift removes ORDER BY columns at runtime, but only ones that
// no longer exist in the schema — the spec-level declaration is
// still required so we know what *should* be ordered.)
for _, sp := range orgSheetSpecs() {
if sp.SQL != "" {
if !strings.Contains(strings.ToUpper(sp.SQL), "ORDER BY") {
t.Fatalf("orgSheetSpecs[%q] SQL override missing ORDER BY (determinism contract): %s", sp.SheetName, sp.SQL)
}
continue
}
if len(sp.OrderBy) == 0 {
t.Fatalf("orgSheetSpecs[%q] has no OrderBy and no SQL override (determinism contract)", sp.SheetName)
}
}
}
// ---------------------------------------------------------------------------
// composeOrgSheetSQL — drift-resistant SQL builder
// ---------------------------------------------------------------------------
func TestComposeOrgSheetSQL_AllColumnsPresent(t *testing.T) {
spec := orgSheetSpec{
SheetName: "appointments",
Table: "paliad.appointments",
OrderBy: []string{"id"},
}
cols := map[string]map[string]struct{}{
"appointments": {"id": {}, "project_id": {}},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.appointments ORDER BY id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 0 {
t.Fatalf("expected no dropped columns, got %v", dropped)
}
}
func TestComposeOrgSheetSQL_DropsMissingOrderByColumn(t *testing.T) {
// The original bug from m/paliad#138 reproduced in unit form:
// orderBy references a column the table doesn't have.
spec := orgSheetSpec{
SheetName: "appointment_caldav_targets",
Table: "paliad.appointment_caldav_targets",
OrderBy: []string{"appointment_id", "calendar_binding_id"}, // wrong: real col is binding_id
}
cols := map[string]map[string]struct{}{
"appointment_caldav_targets": {
"appointment_id": {},
"binding_id": {},
},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.appointment_caldav_targets ORDER BY appointment_id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "calendar_binding_id" {
t.Fatalf("expected dropped=[calendar_binding_id], got %v", dropped)
}
}
func TestComposeOrgSheetSQL_AllOrderByMissing_NoClause(t *testing.T) {
// If every declared ORDER BY column is gone, the builder still
// produces a runnable SELECT — without ORDER BY. The export
// succeeds; the order across runs is no longer deterministic for
// this sheet until the spec is updated. WARN log alerts the
// operator (verified in TestResolveOrgSheets_LogsWarnings).
spec := orgSheetSpec{
SheetName: "ghost",
Table: "paliad.ghost",
OrderBy: []string{"missing_a", "missing_b"},
}
cols := map[string]map[string]struct{}{
"ghost": {"unrelated": {}},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.ghost"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 2 {
t.Fatalf("expected 2 dropped columns, got %v", dropped)
}
}
func TestComposeOrgSheetSQL_SQLOverride_BypassesBuilder(t *testing.T) {
// When a sheet declares SQL, the builder MUST NOT touch it — even
// if the column knowledge would suggest a change. Custom
// projections (documents drops ai_extracted) and special-case
// joins both rely on this.
spec := orgSheetSpec{
SheetName: "documents",
Table: "paliad.documents", // should be ignored
OrderBy: []string{"id"}, // should be ignored
SQL: "SELECT id, title FROM paliad.documents ORDER BY id",
}
cols := map[string]map[string]struct{}{
"documents": {}, // empty → would drop everything if builder ran
}
got, dropped := composeOrgSheetSQL(spec, cols)
if got != spec.SQL {
t.Fatalf("SQL override mutated: got %q, want %q", got, spec.SQL)
}
if len(dropped) != 0 {
t.Fatalf("override path should never report drops; got %v", dropped)
}
}
func TestComposeOrgSheetSQL_UnknownTable_DropsAllOrderBy(t *testing.T) {
// A table missing entirely from the schema snapshot is treated as
// "no columns known" — every ORDER BY column gets dropped, but
// the SELECT still emits (so a stale registry doesn't crash the
// backup; the operator gets WARNs to fix it).
spec := orgSheetSpec{
SheetName: "renamed_table",
Table: "paliad.renamed_table",
OrderBy: []string{"id"},
}
got, dropped := composeOrgSheetSQL(spec, map[string]map[string]struct{}{})
want := "SELECT * FROM paliad.renamed_table"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "id" {
t.Fatalf("expected dropped=[id], got %v", dropped)
}
}
func TestComposeOrgSheetSQL_PreservesOrderByOrder(t *testing.T) {
// Multi-column OrderBy must keep its declared order, with kept
// columns concatenated in the same sequence. Determinism contract
// from t-paliad-214 §3 depends on this.
spec := orgSheetSpec{
SheetName: "partner_unit_members",
Table: "paliad.partner_unit_members",
OrderBy: []string{"partner_unit_id", "missing_middle", "user_id"},
}
cols := map[string]map[string]struct{}{
"partner_unit_members": {
"partner_unit_id": {},
"user_id": {},
},
}
got, dropped := composeOrgSheetSQL(spec, cols)
want := "SELECT * FROM paliad.partner_unit_members ORDER BY partner_unit_id, user_id"
if got != want {
t.Fatalf("got SQL %q, want %q", got, want)
}
if len(dropped) != 1 || dropped[0] != "missing_middle" {
t.Fatalf("expected dropped=[missing_middle], got %v", dropped)
}
}

View File

@@ -46,6 +46,7 @@ import (
"encoding/csv"
"fmt"
"io"
"log/slog"
"regexp"
"sort"
"strings"
@@ -297,7 +298,10 @@ func (s *ExportService) WriteOrg(ctx context.Context, w io.Writer, spec ExportSp
// is just bookkeeping that releases the snapshot.
defer func() { _ = tx.Rollback() }()
sheets := orgSheetQueries()
sheets, err := resolveOrgSheets(ctx, tx, orgSheetSpecs())
if err != nil {
return meta, err
}
if err := s.writeBundle(ctx, tx, w, sheets, &meta); err != nil {
return meta, err
}
@@ -1560,73 +1564,249 @@ SELECT 'partner_unit_default'::text AS source,
// 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{
//
// Drift-resistance (m/paliad#140): each spec declares its desired
// ORDER BY columns as a list. At backup time the exporter probes
// information_schema.columns for the live schema; any ORDER BY column
// that no longer exists is dropped (logged WARN). This way a column
// rename or removal never breaks a backup — the worst case is a sheet
// that loses sort stability until the spec is updated. A sheet whose
// ORDER BY columns are all gone still exports, just in pg's natural
// (unspecified) order.
//
// Custom column projections (e.g. documents drops ai_extracted) live
// in the SQL override field; if set, it bypasses the Table+OrderBy
// builder entirely. Use it sparingly — every override re-introduces
// drift risk for that sheet.
// orgSheetSpec declares one org-scope sheet for the drift-resistant
// builder. Either set SQL (free-form override) or set Table+OrderBy
// (let the builder compose `SELECT * FROM <Table> ORDER BY <existing>`).
type orgSheetSpec struct {
// SheetName lands in the workbook sheet and the JSON top-level key.
SheetName string
// Table is schema-qualified (e.g. "paliad.appointments"). Used only
// when SQL is empty. The schema/table form must be valid SQL
// identifiers — the builder splits on the dot, no quoting.
Table string
// OrderBy is the *desired* sort columns. Missing columns are
// dropped silently-with-a-WARN at build time; remaining columns
// keep their declared order. Empty/all-missing → no ORDER BY (still
// deterministic-within-a-snapshot under the REPEATABLE READ tx, but
// the order across runs may differ).
OrderBy []string
// SQL is an explicit override; if non-empty, Table+OrderBy are
// ignored entirely. Use only when the projection cannot be
// expressed as SELECT * (e.g. documents drops the ai_extracted
// jsonb column).
SQL string
// Args are positional arguments. Only meaningful with SQL override;
// the Table+OrderBy path takes no args.
Args []any
// DropColumns is an explicit list of column names to drop from the
// result regardless of the PII deny-regex.
DropColumns []string
}
func orgSheetSpecs() []orgSheetSpec {
return []orgSheetSpec{
// --- 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`},
{SheetName: "appointment_caldav_targets", Table: "paliad.appointment_caldav_targets", OrderBy: []string{"appointment_id", "binding_id"}},
{SheetName: "appointments", Table: "paliad.appointments", OrderBy: []string{"id"}},
{SheetName: "approval_policies", Table: "paliad.approval_policies", OrderBy: []string{"id"}},
{SheetName: "approval_requests", Table: "paliad.approval_requests", OrderBy: []string{"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`},
{SheetName: "backups", Table: "paliad.backups", OrderBy: []string{"started_at", "id"}},
{SheetName: "caldav_sync_log", Table: "paliad.caldav_sync_log", OrderBy: []string{"occurred_at", "id"}},
{SheetName: "checklist_instances", Table: "paliad.checklist_instances", OrderBy: []string{"id"}},
{SheetName: "checklist_shares", Table: "paliad.checklist_shares", OrderBy: []string{"id"}},
{SheetName: "checklists", Table: "paliad.checklists", OrderBy: []string{"id"}},
{SheetName: "deadline_rule_audit", Table: "paliad.deadline_rule_audit", OrderBy: []string{"changed_at", "id"}},
{SheetName: "deadlines", Table: "paliad.deadlines", OrderBy: []string{"id"}},
// documents: ai_extracted jsonb dropped (verbose AI prompts;
// matches the personal/project precedent). Binaries are not in
// the export — only metadata.
// the export — only metadata. Uses SQL override because the
// projection isn't SELECT *.
{
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: "email_broadcasts", Table: "paliad.email_broadcasts", OrderBy: []string{"id"}},
{SheetName: "email_template_versions", Table: "paliad.email_template_versions", OrderBy: []string{"id"}},
{SheetName: "email_templates", Table: "paliad.email_templates", OrderBy: []string{"key", "lang"}},
{SheetName: "firm_dashboard_default", Table: "paliad.firm_dashboard_default", OrderBy: []string{"id"}},
{SheetName: "invitations", Table: "paliad.invitations", OrderBy: []string{"sent_at", "id"}},
{SheetName: "notes", Table: "paliad.notes", OrderBy: []string{"id"}},
{SheetName: "parties", Table: "paliad.parties", OrderBy: []string{"id"}},
{SheetName: "partner_unit_events", Table: "paliad.partner_unit_events", OrderBy: []string{"id"}},
{SheetName: "partner_unit_members", Table: "paliad.partner_unit_members", OrderBy: []string{"partner_unit_id", "user_id"}},
{SheetName: "partner_units", Table: "paliad.partner_units", OrderBy: []string{"id"}},
{SheetName: "policy_audit_log", Table: "paliad.policy_audit_log", OrderBy: []string{"created_at", "id"}},
{SheetName: "project_events", Table: "paliad.project_events", OrderBy: []string{"id"}},
{SheetName: "project_partner_units", Table: "paliad.project_partner_units", OrderBy: []string{"project_id", "partner_unit_id"}},
{SheetName: "project_teams", Table: "paliad.project_teams", OrderBy: []string{"project_id", "user_id"}},
{SheetName: "projects", Table: "paliad.projects", OrderBy: []string{"id"}},
{SheetName: "reminder_log", Table: "paliad.reminder_log", OrderBy: []string{"sent_at", "id"}},
{SheetName: "submission_drafts", Table: "paliad.submission_drafts", OrderBy: []string{"id"}},
{SheetName: "system_audit_log", Table: "paliad.system_audit_log", OrderBy: []string{"created_at", "id"}},
{
SheetName: "user_caldav_config",
SQL: `SELECT * FROM paliad.user_caldav_config ORDER BY user_id`,
Table: "paliad.user_caldav_config",
OrderBy: []string{"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`},
{SheetName: "user_calendar_bindings", Table: "paliad.user_calendar_bindings", OrderBy: []string{"user_id", "calendar_path"}},
{SheetName: "user_card_layouts", Table: "paliad.user_card_layouts", OrderBy: []string{"id"}},
{SheetName: "user_dashboard_layouts", Table: "paliad.user_dashboard_layouts", OrderBy: []string{"user_id"}},
{SheetName: "user_pinned_projects", Table: "paliad.user_pinned_projects", OrderBy: []string{"user_id", "project_id"}},
{SheetName: "user_views", Table: "paliad.user_views", OrderBy: []string{"id"}},
{SheetName: "users", Table: "paliad.users", OrderBy: []string{"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_unified 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`},
{SheetName: "ref__countries", Table: "paliad.countries", OrderBy: []string{"code"}},
{SheetName: "ref__courts", Table: "paliad.courts", OrderBy: []string{"id"}},
{SheetName: "ref__deadline_concept_event_types", Table: "paliad.deadline_concept_event_types", OrderBy: []string{"concept_id", "event_type_id"}},
{SheetName: "ref__deadline_concepts", Table: "paliad.deadline_concepts", OrderBy: []string{"id"}},
{SheetName: "ref__deadline_event_types", Table: "paliad.deadline_event_types", OrderBy: []string{"deadline_id", "event_type_id"}},
{SheetName: "ref__deadline_rules", Table: "paliad.deadline_rules_unified", OrderBy: []string{"id"}},
{SheetName: "ref__event_categories", Table: "paliad.event_categories", OrderBy: []string{"id"}},
{SheetName: "ref__event_category_concepts", Table: "paliad.event_category_concepts", OrderBy: []string{"event_category_id", "concept_id"}},
{SheetName: "ref__event_types", Table: "paliad.event_types", OrderBy: []string{"id"}},
{SheetName: "ref__holidays", Table: "paliad.holidays", OrderBy: []string{"date", "country"}},
{SheetName: "ref__proceeding_types", Table: "paliad.proceeding_types", OrderBy: []string{"id"}},
{SheetName: "ref__trigger_events", Table: "paliad.trigger_events", OrderBy: []string{"id"}},
}
}
// composeOrgSheetSQL turns one orgSheetSpec into the final SQL string,
// using a per-table column set (typically loaded once per backup run
// from information_schema.columns). Returns the SQL and the list of
// ORDER BY columns that were dropped because they don't exist in the
// live schema.
//
// Pure function — no DB access — so the missing-column behaviour is
// unit-testable without a fixture database.
//
// Rules:
// - If spec.SQL is non-empty, return it unchanged (override path).
// - Otherwise build `SELECT * FROM <Table> [ORDER BY <kept-cols>]`.
// - Columns are kept in their declared order; missing ones recorded
// in `dropped` and omitted from ORDER BY.
// - If no ORDER BY columns survive, the ORDER BY clause is omitted.
//
// knownCols maps unqualified table names (e.g. "appointments") to the
// set of columns they have. A table missing from knownCols is treated
// as "no columns known" — every declared ORDER BY column gets dropped.
func composeOrgSheetSQL(spec orgSheetSpec, knownCols map[string]map[string]struct{}) (sqlText string, dropped []string) {
if spec.SQL != "" {
return spec.SQL, nil
}
unqualified := spec.Table
if i := strings.IndexByte(unqualified, '.'); i >= 0 {
unqualified = unqualified[i+1:]
}
cols := knownCols[unqualified]
kept := make([]string, 0, len(spec.OrderBy))
for _, c := range spec.OrderBy {
if _, ok := cols[c]; ok {
kept = append(kept, c)
} else {
dropped = append(dropped, c)
}
}
var b strings.Builder
b.WriteString("SELECT * FROM ")
b.WriteString(spec.Table)
if len(kept) > 0 {
b.WriteString(" ORDER BY ")
b.WriteString(strings.Join(kept, ", "))
}
return b.String(), dropped
}
// loadOrgSheetColumns probes information_schema.columns once for every
// table referenced by Table+OrderBy specs. Returns a lookup
// {table_name → {column_name → {}}} restricted to the paliad schema.
//
// The queryer is whatever runs the backup's read snapshot — typically
// the REPEATABLE READ tx opened in WriteOrg, so the schema snapshot
// matches the row snapshot.
func loadOrgSheetColumns(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) (map[string]map[string]struct{}, error) {
tableSet := map[string]struct{}{}
for _, sp := range specs {
if sp.Table == "" {
continue // SQL-override sheets carry their own column refs
}
t := sp.Table
if i := strings.IndexByte(t, '.'); i >= 0 {
t = t[i+1:]
}
tableSet[t] = struct{}{}
}
if len(tableSet) == 0 {
return map[string]map[string]struct{}{}, nil
}
tables := make([]string, 0, len(tableSet))
for t := range tableSet {
tables = append(tables, t)
}
rows, err := queryer.QueryxContext(ctx, `
SELECT table_name, column_name
FROM information_schema.columns
WHERE table_schema = 'paliad'
AND table_name = ANY($1)
`, tables)
if err != nil {
return nil, fmt.Errorf("probe paliad columns: %w", err)
}
defer rows.Close()
out := make(map[string]map[string]struct{}, len(tableSet))
for rows.Next() {
var table, column string
if err := rows.Scan(&table, &column); err != nil {
return nil, fmt.Errorf("scan paliad columns: %w", err)
}
set, ok := out[table]
if !ok {
set = map[string]struct{}{}
out[table] = set
}
set[column] = struct{}{}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate paliad columns: %w", err)
}
return out, nil
}
// resolveOrgSheets materialises an org-scope spec list into the
// concrete []sheetQuery that writeBundle expects. Composes each
// spec's SQL via composeOrgSheetSQL using a schema snapshot loaded
// from the same queryer. Logs WARN per dropped ORDER BY column.
func resolveOrgSheets(ctx context.Context, queryer sqlx.QueryerContext, specs []orgSheetSpec) ([]sheetQuery, error) {
knownCols, err := loadOrgSheetColumns(ctx, queryer, specs)
if err != nil {
return nil, err
}
out := make([]sheetQuery, 0, len(specs))
for _, sp := range specs {
sqlText, dropped := composeOrgSheetSQL(sp, knownCols)
for _, c := range dropped {
slog.Warn("backup: ORDER BY column dropped (not in schema)",
"sheet", sp.SheetName,
"table", sp.Table,
"column", c,
)
}
out = append(out, sheetQuery{
SheetName: sp.SheetName,
SQL: sqlText,
Args: sp.Args,
DropColumns: sp.DropColumns,
})
}
return out, nil
}

View File

@@ -0,0 +1,58 @@
package litigationplanner
// AppealRole* are the canonical filer-role slugs used by the unified
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
//
// Every appeal filing rule carries primary_party='both' in the catalog
// (either party could be the appellant, depending on which side lost
// downstream), so the static primary_party column can't drive
// column-bucketing under a user-perspective `?side=` pick. The
// per-rule appeal role fills that gap: "appellant" rules are filed by
// the Berufungskläger (the party who lost in the lower instance and
// is now appealing); "appellee" rules are filed by the
// Berufungsbeklagter (the party defending the lower-instance
// decision). The mapping is rule-semantic, not data-driven — we know
// from R.224/235 which submission belongs to which side.
const (
AppealRoleAppellant = "appellant"
AppealRoleAppellee = "appellee"
)
// AppealFilerRole returns the appeal-filer role for a submission code
// in the unified upc.apl proceeding. Empty string for codes whose role
// is not statically known (court-issued events, unmapped codes, or
// non-appeal proceedings).
//
// The engine stamps TimelineEntry.AppealRole with this value when
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
// route each "both"-party rule into the correct user-perspective
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
// a side.
//
// Adding a new appeal rule? Add its submission_code to the matching
// branch below. Court-issued events (cost.decision, order.order,
// merits.oral, merits.decision) deliberately stay empty — they route
// to the court column on primary_party='court'.
func AppealFilerRole(submissionCode string) string {
switch submissionCode {
// Appellant filings — Berufungskläger initiates the appeal +
// replies to the cross-appeal.
case "upc.apl.merits.notice",
"upc.apl.merits.grounds",
"upc.apl.merits.cross_a_reply",
"upc.apl.cost.leave_app",
"upc.apl.order.with_leave",
"upc.apl.order.grounds_orders",
"upc.apl.order.discretion",
"upc.apl.order.cross_reply":
return AppealRoleAppellant
// Appellee filings — Berufungsbeklagter responds to the appeal +
// files the cross-appeal.
case "upc.apl.merits.response",
"upc.apl.merits.cross_a",
"upc.apl.order.response_orders",
"upc.apl.order.cross":
return AppealRoleAppellee
}
return ""
}

View File

@@ -0,0 +1,192 @@
package litigationplanner
import (
"context"
"testing"
"github.com/google/uuid"
)
// TestAppealFilerRole pins the rule-semantic mapping that drives
// column-bucketing on the unified upc.apl Berufung timeline
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
// primary_party='both' in the catalog so the bucketer can't decide
// between Berufungskläger and Berufungsbeklagter columns from
// primary_party alone — the appeal role fills that gap.
func TestAppealFilerRole(t *testing.T) {
cases := []struct {
code string
want string
}{
// Appellant filings (Berufungskläger initiates / replies to cross).
{"upc.apl.merits.notice", AppealRoleAppellant},
{"upc.apl.merits.grounds", AppealRoleAppellant},
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
{"upc.apl.cost.leave_app", AppealRoleAppellant},
{"upc.apl.order.with_leave", AppealRoleAppellant},
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
{"upc.apl.order.discretion", AppealRoleAppellant},
{"upc.apl.order.cross_reply", AppealRoleAppellant},
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
{"upc.apl.merits.response", AppealRoleAppellee},
{"upc.apl.merits.cross_a", AppealRoleAppellee},
{"upc.apl.order.response_orders", AppealRoleAppellee},
{"upc.apl.order.cross", AppealRoleAppellee},
// Court-issued events stay empty — they route on party='court'.
{"upc.apl.merits.decision", ""},
{"upc.apl.merits.oral", ""},
{"upc.apl.cost.decision", ""},
{"upc.apl.order.order", ""},
// Unmapped codes are empty (defensive — never silently picks a
// side for a new appeal rule we forgot to map).
{"upc.inf.cfi.soc", ""},
{"", ""},
{"foo.bar", ""},
}
for _, c := range cases {
if got := AppealFilerRole(c.code); got != c.want {
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
}
}
}
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
// row the engine prepends when CalcOptions.AppealTarget is set
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
// IsTriggerEvent=true, and party=court. Without the appeal_target
// filter, no synthetic row is emitted (regression guard).
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
ctx := context.Background()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.apl.unified",
Name: "Berufung",
NameEN: "Appeal",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
noticeCode := "upc.apl.merits.notice"
groundsCode := "upc.apl.merits.grounds"
rules := []Rule{
{
ID: mkID(),
ProceedingTypeID: procIDPtr,
SubmissionCode: &noticeCode,
Name: "Berufungseinlegung",
NameEN: "Notice of Appeal",
PrimaryParty: str(PrimaryPartyBoth),
DurationValue: 2,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
},
{
ID: mkID(),
ProceedingTypeID: procIDPtr,
SubmissionCode: &groundsCode,
Name: "Berufungsbegründung",
NameEN: "Statement of Grounds",
PrimaryParty: str(PrimaryPartyBoth),
DurationValue: 4,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 1,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
},
}
cat := &stubCatalog{pt: pt, rules: rules}
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
if len(timeline.Deadlines) < 3 {
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
}
// Synthetic row first.
first := timeline.Deadlines[0]
if !first.IsTriggerEvent {
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
}
if !first.IsRootEvent {
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
}
if first.Name != "Endentscheidung (R.118)" {
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
}
if first.NameEN != "Final decision (R.118)" {
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
}
if first.DueDate != "2026-05-26" {
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
}
if first.Party != PrimaryPartyCourt {
t.Errorf("first row Party=%q, want court", first.Party)
}
// Real rules should carry AppealRole.
byCode := map[string]TimelineEntry{}
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
t.Errorf("notice AppealRole=%q, want appellant", got)
}
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
t.Errorf("grounds AppealRole=%q, want appellant", got)
}
})
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
opts := CalcOptions{}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
for _, d := range timeline.Deadlines {
if d.IsTriggerEvent {
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
}
if d.AppealRole != "" {
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
}
}
})
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
opts := CalcOptions{AppealTarget: "bogus"}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
// IsValidAppealTarget("bogus") = false, so the engine skips
// both the rule filter AND the synthetic trigger emission.
for _, d := range timeline.Deadlines {
if d.IsTriggerEvent {
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
}
}
})
}

View File

@@ -572,6 +572,21 @@ func Calculate(
deadlines = append(deadlines, d)
}
// Stamp AppealRole on every entry when an appeal-target filter is
// active so the frontend column-bucketer can route primary_party=
// 'both' rules into the user-perspective columns
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
// — they route on Party='court' regardless. (t-paliad-307 /
// m/paliad#136 Bug 1)
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
for i := range deadlines {
if deadlines[i].Code == "" {
continue
}
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
}
}
// Restore sequence_order on the output slice. The compute walk
// re-ordered rules topologically (parent-first) so the parent-state
// checks resolved correctly; the wire shape and the linear timeline
@@ -594,6 +609,31 @@ func Calculate(
// same-group rows. Court-set / conditional rows sort LAST.
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
// it to surface as the first row of the timeline so the chain reads
// decision → appeal filings → next decision. Emitted only when an
// appeal_target is in play and the helper returns a non-empty label.
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
if nameDE != "" || nameEN != "" {
trig := TimelineEntry{
Name: nameDE,
NameEN: nameEN,
Party: PrimaryPartyCourt,
Priority: "informational",
DueDate: triggerDateStr,
OriginalDate: triggerDateStr,
IsRootEvent: true,
IsTriggerEvent: true,
}
deadlines = append([]TimelineEntry{trig}, deadlines...)
}
}
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,

View File

@@ -441,6 +441,22 @@ type TimelineEntry struct {
DurationValue int `json:"durationValue,omitempty"`
DurationUnit string `json:"durationUnit,omitempty"`
Timing string `json:"timing,omitempty"`
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
// m/paliad#136 Bug 1) when the timeline was computed under an
// appeal_target filter. One of AppealRoleAppellant /
// AppealRoleAppellee, or empty for court events / non-appeal
// timelines. The frontend column-bucketer reads this to route
// primary_party='both' rules to Berufungskläger vs
// Berufungsbeklagter columns once the user picks a side.
AppealRole string `json:"appealRole,omitempty"`
// IsTriggerEvent marks the synthetic root row that represents the
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
// Distinct from IsRootEvent in that the row carries no real rule
// id — it's a UI marker dated to the trigger date with the
// per-appeal-target label from TriggerEventLabelForAppealTarget.
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
}
// RuleCalculation is the single-rule calc response that backs the