Compare commits
11 Commits
mai/curie/
...
mai/ritchi
| Author | SHA1 | Date | |
|---|---|---|---|
| 8125caf49a | |||
| 935ea23038 | |||
| da464813b7 | |||
| 6d24fb8931 | |||
| 446c46e5c5 | |||
| d1aa0f72c0 | |||
| 94f2831f3f | |||
| 83be122b19 | |||
| b6c2df95cc | |||
| 367627af0d | |||
| 7d7b20651d |
@@ -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 });
|
||||
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
internal/services/backup_service_live_test.go
Normal file
99
internal/services/backup_service_live_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
58
pkg/litigationplanner/appeal_role.go
Normal file
58
pkg/litigationplanner/appeal_role.go
Normal 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 ""
|
||||
}
|
||||
192
pkg/litigationplanner/appeal_role_test.go
Normal file
192
pkg/litigationplanner/appeal_role_test.go
Normal 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: ¬iceCode,
|
||||
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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user