Compare commits
35 Commits
mai/pauli/
...
mai/lorenz
| Author | SHA1 | Date | |
|---|---|---|---|
| b64d929586 | |||
| e30bfe89da | |||
| d8edea0f4c | |||
| 65617a5dcb | |||
| 7bfec310a0 | |||
| 253dc1d1b3 | |||
| 992b99c375 | |||
| 7afbf52f3e | |||
| 663ef64c62 | |||
| 5b81f2159e | |||
| 275cbd5e51 | |||
| 76cbc311ed | |||
| 0f142e07af | |||
| d7bb238e46 | |||
| 990cc2b797 | |||
| 650d30f99f | |||
| 6cddb2e587 | |||
| 8a814e3442 | |||
| 5f9a8b2ef4 | |||
| ee2caf9d79 | |||
| 88d5656a35 | |||
| 238c4d7cf0 | |||
| 32a620b788 | |||
| 9d73b91e05 | |||
| b966d7c8cd | |||
| 755a1042ff | |||
| c7fa0d6542 | |||
| 1f8230b264 | |||
| bd8ec42b80 | |||
| ec0ec32271 | |||
| 251f5a250f | |||
| 58a1abc6d8 | |||
| 7159443dcb | |||
| 1c915639b9 | |||
| 83a3d27fe0 |
@@ -147,7 +147,14 @@ func main() {
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(
|
||||
pool,
|
||||
services.NewDeadlineCalculator(holidays),
|
||||
holidays,
|
||||
courts,
|
||||
services.NewFristenrechnerService(rules, holidays, courts),
|
||||
),
|
||||
EventTrigger: services.NewEventTriggerService(pool, rules, holidays, courts),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
|
||||
@@ -2206,6 +2206,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.list": "Liste",
|
||||
"views.shape.cards": "Karten",
|
||||
"views.shape.calendar": "Kalender",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
|
||||
"views.save_as": "Als Ansicht speichern",
|
||||
"views.action.edit": "Bearbeiten",
|
||||
"views.empty.title": "Keine Einträge gefunden.",
|
||||
@@ -4537,6 +4539,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.list": "List",
|
||||
"views.shape.cards": "Cards",
|
||||
"views.shape.calendar": "Calendar",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
|
||||
"views.save_as": "Save as view",
|
||||
"views.action.edit": "Edit",
|
||||
"views.empty.title": "No matches found.",
|
||||
|
||||
@@ -1421,10 +1421,17 @@ interface ProceedingTypeRow {
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
// loadProceedingTypes fetches active proceeding types for the project
|
||||
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
|
||||
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
|
||||
// picker only ever shows those — never the 7 legacy litigation codes
|
||||
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
|
||||
// server-side service validation + DB trigger (mig 088) are the
|
||||
// defence-in-depth backstops for any non-UI writer.
|
||||
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } fro
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { renderCardsShape } from "./views/shape-cards";
|
||||
import { renderCalendarShape } from "./views/shape-calendar";
|
||||
import { renderTimelineShape } from "./views/shape-timeline-cv";
|
||||
import type { ChartHandle } from "./views/shape-timeline-chart";
|
||||
|
||||
// /views and /views/{slug} client. Loads the saved or system view, runs
|
||||
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
||||
@@ -143,7 +145,7 @@ async function runAndRender(meta: ViewMeta): Promise<void> {
|
||||
}
|
||||
|
||||
function setActiveShape(shape: RenderShape): void {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
|
||||
const el = document.getElementById(host);
|
||||
if (el) el.hidden = !host.endsWith("-" + shape);
|
||||
}
|
||||
@@ -152,9 +154,17 @@ function setActiveShape(shape: RenderShape): void {
|
||||
});
|
||||
}
|
||||
|
||||
let timelineHandle: ChartHandle | null = null;
|
||||
|
||||
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
|
||||
const host = document.getElementById(`views-shape-${shape}`);
|
||||
if (!host) return;
|
||||
// Switching away from timeline → dispose the prior chart handle so we
|
||||
// don't leak resize listeners / SVG nodes between shape flips.
|
||||
if (shape !== "timeline" && timelineHandle) {
|
||||
timelineHandle.dispose();
|
||||
timelineHandle = null;
|
||||
}
|
||||
switch (shape) {
|
||||
case "list":
|
||||
renderListShape(host, rows, render);
|
||||
@@ -165,6 +175,47 @@ function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult
|
||||
case "calendar":
|
||||
renderCalendarShape(host, rows, render);
|
||||
break;
|
||||
case "timeline": {
|
||||
// Tear down any previous chart inside this host before re-mounting
|
||||
// (the CV adapter clears chart-host innerHTML on its own, but we
|
||||
// need to dispose the prior handle's resize/click listeners too).
|
||||
if (timelineHandle) {
|
||||
timelineHandle.dispose();
|
||||
timelineHandle = null;
|
||||
}
|
||||
const chartHost = document.getElementById("views-timeline-chart-host");
|
||||
if (chartHost) {
|
||||
timelineHandle = renderTimelineShape(chartHost, rows, render);
|
||||
}
|
||||
maybeShowTimelineCaveat();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First-open caveat banner. sessionStorage flag means the user sees it
|
||||
* once per browser session — dismissive but not annoying. Design §13.4
|
||||
* documents the limitation; this is the user-facing surface. */
|
||||
function maybeShowTimelineCaveat(): void {
|
||||
const FLAG = "paliad-views-timeline-caveat-dismissed";
|
||||
const banner = document.getElementById("views-timeline-caveat");
|
||||
const closeBtn = document.getElementById("views-timeline-caveat-close");
|
||||
if (!banner) return;
|
||||
if (sessionStorage.getItem(FLAG) === "1") {
|
||||
banner.hidden = true;
|
||||
return;
|
||||
}
|
||||
banner.hidden = false;
|
||||
if (closeBtn && !closeBtn.dataset.bound) {
|
||||
closeBtn.addEventListener("click", () => {
|
||||
banner.hidden = true;
|
||||
try {
|
||||
sessionStorage.setItem(FLAG, "1");
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable in strict modes — silently noop */
|
||||
}
|
||||
});
|
||||
closeBtn.dataset.bound = "1";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -664,6 +664,12 @@ export interface ChartMountOpts {
|
||||
/** Initial visible-lane allowlist. null = show all (default).
|
||||
* Lane ids not present in the response are silently dropped. */
|
||||
visibleLanes?: string[] | null;
|
||||
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
|
||||
* come from ViewService not /api/projects/{id}/timeline. When set,
|
||||
* mount() skips the initial fetch and paints from this data; the
|
||||
* handle's refresh() still hits the project endpoint (caller can
|
||||
* swap the chart back to project-mode via the standalone /chart URL). */
|
||||
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
|
||||
}
|
||||
|
||||
export interface ChartHandle {
|
||||
@@ -866,8 +872,24 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
svgEl.addEventListener("click", handleClick);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Kick off initial fetch.
|
||||
void refresh();
|
||||
// If the caller supplied data up front (Custom Views host path), skip
|
||||
// the project-timeline fetch entirely — paint from the supplied rows.
|
||||
// Otherwise kick off the initial /api/projects/{id}/timeline load.
|
||||
if (opts.staticData) {
|
||||
lastEvents = opts.staticData.events;
|
||||
currentLanes = opts.staticData.lanes;
|
||||
if (lastEvents.length === 0) {
|
||||
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
||||
} else {
|
||||
messageEl.textContent = "";
|
||||
}
|
||||
repaint();
|
||||
if (opts.onDataLoaded) {
|
||||
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
||||
}
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
|
||||
140
frontend/src/client/views/shape-timeline-cv.test.ts
Normal file
140
frontend/src/client/views/shape-timeline-cv.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adapt } from "./shape-timeline-cv";
|
||||
import type { ViewRow } from "./types";
|
||||
|
||||
// t-paliad-177 Slice 4 — adapter contract tests for ViewRow →
|
||||
// TimelineEvent + LaneInfo. Pure function, no DOM access.
|
||||
// The actual chart-render math is pinned by shape-timeline-chart.test.ts;
|
||||
// this file pins the adapter's lossy translation rules from §13.4.
|
||||
|
||||
const baseRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
|
||||
kind: "deadline",
|
||||
id: "d1",
|
||||
title: "Test",
|
||||
event_date: "2026-06-15",
|
||||
detail: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("adapt — kind mapping", () => {
|
||||
test("deadline → kind='deadline' + deadline_id", () => {
|
||||
const out = adapt([baseRow({ kind: "deadline", id: "abc" })]);
|
||||
expect(out.events).toHaveLength(1);
|
||||
expect(out.events[0].kind).toBe("deadline");
|
||||
expect(out.events[0].deadline_id).toBe("abc");
|
||||
expect(out.events[0].appointment_id).toBeUndefined();
|
||||
expect(out.events[0].project_event_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("appointment → kind='appointment' + appointment_id", () => {
|
||||
const out = adapt([baseRow({ kind: "appointment", id: "x" })]);
|
||||
expect(out.events[0].kind).toBe("appointment");
|
||||
expect(out.events[0].appointment_id).toBe("x");
|
||||
});
|
||||
|
||||
test("project_event → kind='milestone' + project_event_id", () => {
|
||||
const out = adapt([baseRow({ kind: "project_event", id: "y" })]);
|
||||
expect(out.events[0].kind).toBe("milestone");
|
||||
expect(out.events[0].project_event_id).toBe("y");
|
||||
});
|
||||
|
||||
test("approval_request is skipped", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline" }),
|
||||
baseRow({ kind: "approval_request" }),
|
||||
baseRow({ kind: "appointment" }),
|
||||
]);
|
||||
expect(out.events).toHaveLength(2);
|
||||
expect(out.events.map((e) => e.kind)).toEqual(["deadline", "appointment"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — lane bucketing by project_id (cross-project chart)", () => {
|
||||
test("one lane per unique project_id, first-seen order", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "Project 1" }),
|
||||
baseRow({ project_id: "p2", project_title: "Project 2" }),
|
||||
baseRow({ project_id: "p1", project_title: "Project 1" }),
|
||||
]);
|
||||
expect(out.lanes).toHaveLength(2);
|
||||
expect(out.lanes[0].id).toBe("p1");
|
||||
expect(out.lanes[0].label).toBe("Project 1");
|
||||
expect(out.lanes[1].id).toBe("p2");
|
||||
});
|
||||
|
||||
test("project_title preferred over project_reference for the label", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "Nice Name", project_reference: "REF-1" }),
|
||||
]);
|
||||
expect(out.lanes[0].label).toBe("Nice Name");
|
||||
});
|
||||
|
||||
test("falls back to project_reference when title missing", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_reference: "REF-1" }),
|
||||
]);
|
||||
expect(out.lanes[0].label).toBe("REF-1");
|
||||
});
|
||||
|
||||
test("missing project_id collapses to synthetic 'self' lane", () => {
|
||||
const out = adapt([baseRow({ project_id: undefined })]);
|
||||
expect(out.lanes).toHaveLength(1);
|
||||
expect(out.lanes[0].id).toBe("self");
|
||||
expect(out.events[0].lane_id).toBe("self");
|
||||
expect(out.events[0].track).toBe("parent");
|
||||
});
|
||||
|
||||
test("event lane_id matches its lane row id", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "A" }),
|
||||
baseRow({ project_id: "p2", project_title: "B" }),
|
||||
]);
|
||||
expect(out.events[0].lane_id).toBe("p1");
|
||||
expect(out.events[1].lane_id).toBe("p2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — status extraction", () => {
|
||||
test("deadline status 'done' comes through from detail", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "done" } }),
|
||||
]);
|
||||
expect(out.events[0].status).toBe("done");
|
||||
});
|
||||
|
||||
test("deadline status 'overdue' comes through", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "overdue" } }),
|
||||
]);
|
||||
expect(out.events[0].status).toBe("overdue");
|
||||
});
|
||||
|
||||
test("unknown / missing detail.status defaults to 'open'", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "weird-value" } }),
|
||||
baseRow({ kind: "appointment" }),
|
||||
baseRow({ kind: "project_event" }),
|
||||
]);
|
||||
expect(out.events.map((e) => e.status)).toEqual(["open", "open", "open"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — date passthrough", () => {
|
||||
test("event_date is forwarded to TimelineEvent.date", () => {
|
||||
const out = adapt([baseRow({ event_date: "2026-08-15T00:00:00Z" })]);
|
||||
expect(out.events[0].date).toBe("2026-08-15T00:00:00Z");
|
||||
});
|
||||
|
||||
test("empty event_date becomes null (undated)", () => {
|
||||
const out = adapt([baseRow({ event_date: "" })]);
|
||||
expect(out.events[0].date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — empty input", () => {
|
||||
test("empty rows array returns empty events + empty lanes", () => {
|
||||
const out = adapt([]);
|
||||
expect(out.events).toHaveLength(0);
|
||||
expect(out.lanes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
141
frontend/src/client/views/shape-timeline-cv.ts
Normal file
141
frontend/src/client/views/shape-timeline-cv.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
mount,
|
||||
type ChartHandle,
|
||||
type Density,
|
||||
type Palette,
|
||||
type RangePreset,
|
||||
} from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
|
||||
// host for the chart renderer.
|
||||
//
|
||||
// Adapter contract: ViewRow → TimelineEvent + LaneInfo.
|
||||
// - deadline + appointment + project_event rows render as actual marks.
|
||||
// - approval_request rows are skipped (no chart-meaningful date).
|
||||
// - Lane axis = project_id; the cross-project chart use case (design
|
||||
// §10) groups events by their owning project. Rows without a
|
||||
// project_id collapse into a synthetic "self" lane.
|
||||
// - NO projected rows. ViewService doesn't run the fristenrechner
|
||||
// calculator, so the CV chart shows actuals only. The host page
|
||||
// ships a one-time caveat tooltip (see C3) explaining this.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
|
||||
|
||||
export function renderTimelineShape(
|
||||
host: HTMLElement,
|
||||
rows: ReadonlyArray<ViewRow>,
|
||||
render: RenderSpec,
|
||||
): ChartHandle {
|
||||
// Tear down any previous mount so re-rendering the shape (e.g. shape
|
||||
// chip switch on /views/{slug}) doesn't stack SVGs.
|
||||
host.innerHTML = "";
|
||||
|
||||
const { events, lanes } = adapt(rows);
|
||||
const cfg = render.timeline ?? {};
|
||||
|
||||
// The CV adapter has no per-project "id" to fetch live timeline data
|
||||
// for — we hand mount() a placeholder projectId and the staticData
|
||||
// pre-loaded array so it skips the project endpoint entirely. If the
|
||||
// user clicks a mark, the renderer's default click handler still
|
||||
// resolves /deadlines/{id} / /appointments/{id} from the adapted
|
||||
// event's id field, so deep-links land on the correct entity page.
|
||||
return mount(host, {
|
||||
projectId: "cv",
|
||||
staticData: { events, lanes },
|
||||
palette: (cfg.palette as Palette | undefined) ?? "default",
|
||||
density: (cfg.density as Density | undefined) ?? "standard",
|
||||
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
|
||||
rangeFrom: cfg.range_from,
|
||||
rangeTo: cfg.range_to,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
events: TimelineEvent[];
|
||||
lanes: LaneInfo[];
|
||||
}
|
||||
|
||||
/** Exported for tests (shape-timeline-cv.test.ts). Pure — no DOM. */
|
||||
export function adapt(rows: ReadonlyArray<ViewRow>): AdapterResult {
|
||||
const events: TimelineEvent[] = [];
|
||||
// Lane order = first-seen order of project_ids in rows, so the user
|
||||
// sees lanes in the order their data was returned (typically date-
|
||||
// sorted). Deterministic, no surprise re-ordering on re-renders.
|
||||
const laneIndex = new Map<string, LaneInfo>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.kind === "approval_request") {
|
||||
// Approval requests have no event_date in the chart sense; they
|
||||
// represent pending decisions, not scheduled work. Skip.
|
||||
continue;
|
||||
}
|
||||
const laneId = row.project_id || "self";
|
||||
if (!laneIndex.has(laneId)) {
|
||||
laneIndex.set(laneId, {
|
||||
id: laneId,
|
||||
label: row.project_title || row.project_reference || laneLabelFallback(laneId),
|
||||
project_id: row.project_id,
|
||||
});
|
||||
}
|
||||
|
||||
const event: TimelineEvent = {
|
||||
kind: toTimelineKind(row.kind),
|
||||
status: extractStatus(row),
|
||||
track: laneId === "self" ? "parent" : "child:" + laneId,
|
||||
date: row.event_date || null,
|
||||
title: row.title,
|
||||
description: row.subtitle,
|
||||
lane_id: laneId,
|
||||
};
|
||||
// Set the right provenance id so the renderer's click handler can
|
||||
// deep-link to /deadlines/{id} / /appointments/{id}.
|
||||
switch (row.kind) {
|
||||
case "deadline":
|
||||
event.deadline_id = row.id;
|
||||
break;
|
||||
case "appointment":
|
||||
event.appointment_id = row.id;
|
||||
break;
|
||||
case "project_event":
|
||||
event.project_event_id = row.id;
|
||||
break;
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return { events, lanes: [...laneIndex.values()] };
|
||||
}
|
||||
|
||||
function toTimelineKind(kind: ViewRow["kind"]): TimelineEvent["kind"] {
|
||||
// ViewRow "project_event" maps to chart "milestone" — they're the
|
||||
// same underlying paliad.project_events row, the chart just uses a
|
||||
// different name because milestones are the chart-meaningful subset.
|
||||
if (kind === "project_event") return "milestone";
|
||||
// Defensive: approval_request was filtered earlier, but TS doesn't
|
||||
// know that. Default to "milestone" for any unexpected kind.
|
||||
if (kind === "deadline" || kind === "appointment") return kind;
|
||||
return "milestone";
|
||||
}
|
||||
|
||||
/** Status defaults to "open" — ViewRow doesn't carry chart-status
|
||||
* semantics directly, and the underlying detail json shape varies per
|
||||
* kind. The chart's color saturation maps status → fill / ring style,
|
||||
* so "open" gives every mark a sensible default (filled, full color).
|
||||
* Detail-driven status lookup is a polish job for a future slice. */
|
||||
function extractStatus(row: ViewRow): TimelineEvent["status"] {
|
||||
if (row.kind === "deadline") {
|
||||
const d = row.detail as { status?: string };
|
||||
if (d.status === "done" || d.status === "overdue") {
|
||||
return d.status as TimelineEvent["status"];
|
||||
}
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
function laneLabelFallback(id: string): string {
|
||||
if (id === "self") return "(ohne Projekt)";
|
||||
// Truncated UUID is more useful than a bare 36-char string.
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
@@ -69,7 +69,15 @@ export interface FilterSpec {
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
}
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
|
||||
|
||||
export interface TimelineCVConfig {
|
||||
palette?: "default" | "kind-coded" | "track-coded" | "high-contrast" | "print";
|
||||
density?: "compact" | "standard" | "spacious";
|
||||
range_preset?: "1y" | "2y" | "all" | "custom";
|
||||
range_from?: string;
|
||||
range_to?: string;
|
||||
}
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
|
||||
@@ -96,6 +104,7 @@ export interface RenderSpec {
|
||||
list?: ListConfig;
|
||||
cards?: CardsConfig;
|
||||
calendar?: CalendarConfig;
|
||||
timeline?: TimelineCVConfig;
|
||||
}
|
||||
|
||||
// ViewRow — the discriminated row shape from ViewService.RunSpec.
|
||||
|
||||
@@ -2213,11 +2213,13 @@ export type I18nKey =
|
||||
| "views.shape.calendar"
|
||||
| "views.shape.cards"
|
||||
| "views.shape.list"
|
||||
| "views.shape.timeline"
|
||||
| "views.source.appointment"
|
||||
| "views.source.approval_request"
|
||||
| "views.source.deadline"
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.timeline.caveat.body"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
@@ -14356,6 +14356,35 @@ dialog.quick-add-sheet::backdrop {
|
||||
}
|
||||
}
|
||||
|
||||
/* CV-timeline caveat banner — design §13.4. Shown once per session on
|
||||
first open of a Custom View with shape="timeline". sessionStorage
|
||||
dismiss flag handled in client/views.ts. */
|
||||
.views-timeline-caveat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-bg-lime-tint, #ecfbb6);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.views-timeline-caveat-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.views-timeline-caveat-close:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
|
||||
Each palette is a pure data-attribute swap of the --chart-* tokens.
|
||||
Renderer code never reads palette state — it just emits classed SVG
|
||||
|
||||
@@ -52,6 +52,7 @@ export function renderViews(): string {
|
||||
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
|
||||
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
|
||||
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
|
||||
<button type="button" className="agenda-chip" data-shape="timeline" role="tab" data-i18n="views.shape.timeline">Timeline</button>
|
||||
</div>
|
||||
<div className="views-toolbar-spacer" />
|
||||
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
|
||||
@@ -94,6 +95,24 @@ export function renderViews(): string {
|
||||
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
|
||||
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
|
||||
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
|
||||
<div className="views-shape-host views-shape-timeline" id="views-shape-timeline" hidden>
|
||||
{/* CV-chart caveat banner — design §13.4: ViewService
|
||||
doesn't run the fristenrechner calculator, so Custom
|
||||
Views show actual events only. One-time-per-session
|
||||
dismissible (sessionStorage). */}
|
||||
<div className="views-timeline-caveat" id="views-timeline-caveat" hidden>
|
||||
<span data-i18n="views.timeline.caveat.body">
|
||||
Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="views-timeline-caveat-close"
|
||||
id="views-timeline-caveat-close"
|
||||
aria-label="Schließen"
|
||||
>×</button>
|
||||
</div>
|
||||
<div id="views-timeline-chart-host" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
|
||||
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-182 down — reverses 078_unified_rule_columns.up.sql.
|
||||
--
|
||||
-- Drops in reverse dependency order: indexes → CHECK constraints →
|
||||
-- FKs → columns. Idempotent (IF EXISTS guards everywhere).
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_lifecycle_state_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_spawn_proceeding_type_id_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_trigger_event_id_idx;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_lifecycle_state_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_priority_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_combine_op_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_draft_of_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_spawn_proceeding_type_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_trigger_event_id_fkey;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS published_at,
|
||||
DROP COLUMN IF EXISTS draft_of,
|
||||
DROP COLUMN IF EXISTS lifecycle_state,
|
||||
DROP COLUMN IF EXISTS is_court_set,
|
||||
DROP COLUMN IF EXISTS priority,
|
||||
DROP COLUMN IF EXISTS condition_expr,
|
||||
DROP COLUMN IF EXISTS combine_op,
|
||||
DROP COLUMN IF EXISTS spawn_proceeding_type_id,
|
||||
DROP COLUMN IF EXISTS trigger_event_id;
|
||||
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 (Step A of
|
||||
-- docs/design-fristen-phase2-2026-05-15.md §3.1).
|
||||
--
|
||||
-- Additive only: extends paliad.deadline_rules with the unified-rule
|
||||
-- columns the Phase 3 calculator + rule editor will use.
|
||||
--
|
||||
-- NO drops in this slice. Legacy columns (is_mandatory, is_optional,
|
||||
-- condition_flag, condition_rule_id) stay live until Slice 9. Compat-
|
||||
-- mode readers consume both shapes during the transition window
|
||||
-- (design §3.2 "Cutover ordering").
|
||||
--
|
||||
-- Column-by-column rationale:
|
||||
-- trigger_event_id — event-rooted dispatch (Pipeline C unification, §2.5).
|
||||
-- spawn_proceeding_type_id — cross-proceeding spawn resolution (Q7, §2.6).
|
||||
-- combine_op — composite-rule arithmetic 'max'/'min' (R.198/R.213).
|
||||
-- condition_expr — jsonb condition grammar replacing condition_flag (Q6, §2.4).
|
||||
-- priority — 4-way enum mandatory|recommended|optional|informational (Q3, §2.3).
|
||||
-- is_court_set — explicit replacement of the runtime heuristic (Q12).
|
||||
-- lifecycle_state — draft|published|archived for the rule editor (Q5, §4.2).
|
||||
-- draft_of — draft self-FK pointing at the published row it replaces.
|
||||
-- published_at — promotion timestamp, NULL while draft.
|
||||
--
|
||||
-- FK type notes:
|
||||
-- trigger_event_id is BIGINT (paliad.trigger_events.id is bigint, mig 028).
|
||||
-- spawn_proceeding_type_id is INTEGER (paliad.proceeding_types.id is
|
||||
-- serial = int4, mig 003).
|
||||
-- draft_of is UUID (self-FK on paliad.deadline_rules.id).
|
||||
-- The design doc (§2.1) calls them "int FK" loosely; the actual schemas
|
||||
-- demand the precise int width, hence bigint/integer here.
|
||||
--
|
||||
-- Indexes:
|
||||
-- FK lookups for trigger_event_id + spawn_proceeding_type_id (sparse,
|
||||
-- most rules have neither — partial WHERE NOT NULL keeps the index
|
||||
-- small).
|
||||
-- lifecycle_state is queried by the admin /admin/rules listing's
|
||||
-- default filter (state='published'); plain btree is fine, no
|
||||
-- WHERE clause so 'draft' / 'archived' rows index too.
|
||||
--
|
||||
-- Idempotent: every ADD COLUMN uses IF NOT EXISTS. Re-applying is a
|
||||
-- no-op. Tracker advances 77 → 78.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. New columns on paliad.deadline_rules
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS spawn_proceeding_type_id integer,
|
||||
ADD COLUMN IF NOT EXISTS combine_op text,
|
||||
ADD COLUMN IF NOT EXISTS condition_expr jsonb,
|
||||
ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'mandatory',
|
||||
ADD COLUMN IF NOT EXISTS is_court_set boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS draft_of uuid,
|
||||
ADD COLUMN IF NOT EXISTS published_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.trigger_event_id IS
|
||||
'Optional FK to paliad.trigger_events. When non-NULL, this rule is '
|
||||
'event-rooted (Pipeline C unification, design §2.5). When NULL the '
|
||||
'rule is proceeding-rooted via proceeding_type_id. Exactly one of '
|
||||
'the two must be set after Slice 3 backfill (enforced by a CHECK '
|
||||
'constraint added in Slice 9 after legacy callers retire).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.spawn_proceeding_type_id IS
|
||||
'When is_spawn=true, points at the target proceeding whose rule set '
|
||||
'the calculator follows when this rule fires (cross-proceeding '
|
||||
'spawn, design §2.6). Backfilled in Slice 7 for the 8 live spawn '
|
||||
'rules.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.combine_op IS
|
||||
'NULL = single-anchor arithmetic. ''max'' / ''min'' = composite-rule '
|
||||
'arithmetic combining (duration_value, duration_unit) with '
|
||||
'(alt_duration_value, alt_duration_unit). Used by R.198 / R.213 '
|
||||
'("31d OR 20 working_days, whichever is longer / shorter").';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.condition_expr IS
|
||||
'jsonb gating expression replacing condition_flag (Q6, design §2.4). '
|
||||
'Grammar: {"flag": "<name>"} | {"op":"and"|"or", "args":[...]} | '
|
||||
'{"op":"not", "args":[<node>]}. NULL or {} = unconditional. '
|
||||
'Backfilled in Slice 2 from condition_flag; new code reads this, '
|
||||
'falls back to condition_flag during the transition window.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.priority IS
|
||||
'Unified 4-way enum (Q3, design §2.3) replacing the is_mandatory + '
|
||||
'is_optional pair. Allowed: mandatory | recommended | optional | '
|
||||
'informational. Default ''mandatory'' on new rows; legacy rows get '
|
||||
'backfilled in Slice 2 from the (is_mandatory, is_optional) pair.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.is_court_set IS
|
||||
'Replaces the runtime heuristic (primary_party=''court'' OR '
|
||||
'event_type IN (...)) with an explicit column (Q12). Default false '
|
||||
'on new rows; Slice 2 backfills from the heuristic so behaviour is '
|
||||
'unchanged at first.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.lifecycle_state IS
|
||||
'Rule-editor lifecycle (Q5, design §4.2). draft = work-in-progress '
|
||||
'admin edit; published = live, calculator-visible; archived = '
|
||||
'historical (kept for audit). Default ''published'' so every '
|
||||
'existing row stays live without an UPDATE.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.draft_of IS
|
||||
'When lifecycle_state=''draft'', points at the published rule this '
|
||||
'draft will replace on publish. NULL on published or archived '
|
||||
'rows. NULL also on net-new drafts (no prior published peer).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.published_at IS
|
||||
'Timestamp this row entered lifecycle_state=''published''. NULL '
|
||||
'while draft, populated on publish, retained through archive. '
|
||||
'Distinct from updated_at (which moves on every edit).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Foreign keys
|
||||
-- =============================================================================
|
||||
--
|
||||
-- DEFERRABLE INITIALLY IMMEDIATE keeps normal-statement semantics
|
||||
-- intact while still letting backfill migrations defer until end-of-
|
||||
-- transaction if they need to (e.g. when Slice 3 inserts a rule row
|
||||
-- whose trigger_event_id references a row inserted in the same tx).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_trigger_event_id_fkey
|
||||
FOREIGN KEY (trigger_event_id)
|
||||
REFERENCES paliad.trigger_events(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_spawn_proceeding_type_id_fkey
|
||||
FOREIGN KEY (spawn_proceeding_type_id)
|
||||
REFERENCES paliad.proceeding_types(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_draft_of_fkey
|
||||
FOREIGN KEY (draft_of)
|
||||
REFERENCES paliad.deadline_rules(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. CHECK constraints on enum-style columns
|
||||
-- =============================================================================
|
||||
--
|
||||
-- combine_op: NULL (unset) or one of two values.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_combine_op_check
|
||||
CHECK (combine_op IS NULL OR combine_op IN ('max', 'min'));
|
||||
|
||||
-- priority: 4-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_priority_check
|
||||
CHECK (priority IN ('mandatory', 'recommended', 'optional', 'informational'));
|
||||
|
||||
-- lifecycle_state: 3-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_lifecycle_state_check
|
||||
CHECK (lifecycle_state IN ('draft', 'published', 'archived'));
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Indexes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_trigger_event_id_idx
|
||||
ON paliad.deadline_rules (trigger_event_id)
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_spawn_proceeding_type_id_idx
|
||||
ON paliad.deadline_rules (spawn_proceeding_type_id)
|
||||
WHERE spawn_proceeding_type_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_lifecycle_state_idx
|
||||
ON paliad.deadline_rules (lifecycle_state);
|
||||
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- t-paliad-182 down — reverses 079_deadline_rule_audit.up.sql.
|
||||
--
|
||||
-- Order: trigger → function → policy → indexes → table.
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rule_audit_trigger();
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_audit_select ON paliad.deadline_rule_audit;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_pending_export_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_by_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_rule_id_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rule_audit;
|
||||
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — audit log for the rule editor
|
||||
-- (design §2.8, §3.1 Step A.079).
|
||||
--
|
||||
-- The audit log lands BEFORE the rule editor (Slice 11) so every future
|
||||
-- write to paliad.deadline_rules is captured forever, including the
|
||||
-- Slice 2 backfill UPDATEs. Defence-in-depth: the rule-editor service
|
||||
-- writes Go-authored audit rows with semantic actions ('publish',
|
||||
-- 'archive', 'restore'); this trigger is the backstop for raw SQL.
|
||||
--
|
||||
-- Field-naming mirrors design §2.8 (`changed_by` / `changed_at` /
|
||||
-- `before_json` / `after_json` / `migration_exported`), not the
|
||||
-- audit_log shorthand used elsewhere in Paliad.
|
||||
--
|
||||
-- Schema deviations from design §2.8, documented for the head review:
|
||||
--
|
||||
-- 1. `changed_by` is nullable, not NOT NULL. Reason: the trigger reads
|
||||
-- auth.uid() which is NULL when the writer is `service_role`
|
||||
-- (migrations, server-side Go using the service key, direct DB
|
||||
-- maintenance). NOT NULL would block every Slice-2 backfill UPDATE
|
||||
-- and every migration-applied seed. The Go rule-editor service
|
||||
-- enforces non-NULL changed_by at the application layer when it
|
||||
-- writes its own audit rows.
|
||||
--
|
||||
-- 2. `action` values stored by the trigger are 'create' / 'update' /
|
||||
-- 'delete' (the raw TG_OP semantics). Go-authored audit rows can
|
||||
-- additionally store 'publish' / 'archive' / 'restore' — those are
|
||||
-- lifecycle_state flips at the SQL level and appear as 'update' in
|
||||
-- the trigger's view of the world. The Go layer writes the
|
||||
-- higher-level action *before* the UPDATE, so the human-readable
|
||||
-- action is captured even though the trigger fires a paired
|
||||
-- 'update' row. The audit UI in Slice 11 collapses paired rows.
|
||||
--
|
||||
-- Audit-reason enforcement: the trigger reads
|
||||
-- `current_setting('paliad.audit_reason', true)` (the `true` flag
|
||||
-- returns NULL when unset rather than raising). On UPDATE and DELETE
|
||||
-- the trigger requires a non-empty reason and raises EXCEPTION 'audit
|
||||
-- reason required' if missing. On INSERT the reason is optional
|
||||
-- (defaults to 'create' so seed migrations don't need to set it).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 78 → 79.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. paliad.deadline_rule_audit
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_audit (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- The rule this delta concerns. ON DELETE CASCADE: when a rule row
|
||||
-- gets hard-deleted (rare; lifecycle_state='archived' is the normal
|
||||
-- path), drop its audit chain too — the trail otherwise survives in
|
||||
-- the migration history of the table itself.
|
||||
rule_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE,
|
||||
|
||||
-- See header comment §1: nullable so trigger writes from service_role
|
||||
-- contexts (migrations, backfills) don't fail.
|
||||
changed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
|
||||
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- See header comment §2 for the trigger vs Go-layer split.
|
||||
action text NOT NULL
|
||||
CHECK (action IN (
|
||||
'create', 'update', 'delete',
|
||||
'publish', 'archive', 'restore'
|
||||
)),
|
||||
|
||||
-- Row state pre/post change. NULL on create / delete respectively.
|
||||
before_json jsonb,
|
||||
after_json jsonb,
|
||||
|
||||
-- Justification required by the trigger on UPDATE / DELETE; optional
|
||||
-- on INSERT (defaults to 'create' when paliad.audit_reason is unset
|
||||
-- so seed migrations don't need to bother).
|
||||
reason text NOT NULL,
|
||||
|
||||
-- Flips to true when the migration-export endpoint (Slice 11b) folds
|
||||
-- this delta into a checked-in .up.sql. Lets the export endpoint
|
||||
-- skip already-exported rows.
|
||||
migration_exported boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_rule_id_idx
|
||||
ON paliad.deadline_rule_audit (rule_id, changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_at_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_by_idx
|
||||
ON paliad.deadline_rule_audit (changed_by)
|
||||
WHERE changed_by IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_pending_export_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC)
|
||||
WHERE migration_exported = false;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rule_audit IS
|
||||
'Append-only audit log for paliad.deadline_rules. Written by the '
|
||||
'AFTER-trigger on the rules table (raw create/update/delete) and '
|
||||
'by the Go rule-editor service (semantic publish/archive/restore). '
|
||||
'Required reason field is the compliance hook for the rule-editor '
|
||||
'design (Q5, §4.7).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Audit trigger
|
||||
-- =============================================================================
|
||||
--
|
||||
-- SECURITY DEFINER so the trigger function runs with the table-owner's
|
||||
-- privileges and bypasses RLS on the audit table. Otherwise an
|
||||
-- authenticated user's UPDATE on a rule would fail when the trigger
|
||||
-- tried to INSERT under their RLS context.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rule_audit_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_reason text;
|
||||
v_action text;
|
||||
v_before jsonb;
|
||||
v_after jsonb;
|
||||
v_rule_id uuid;
|
||||
BEGIN
|
||||
v_reason := current_setting('paliad.audit_reason', true);
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_action := 'create';
|
||||
v_before := NULL;
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
-- INSERT is allowed without an explicit reason; seed migrations
|
||||
-- and net-new drafts default to a synthetic reason.
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
v_reason := 'create';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_action := 'update';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for UPDATE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
v_action := 'delete';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := NULL;
|
||||
v_rule_id := OLD.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for DELETE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO paliad.deadline_rule_audit
|
||||
(rule_id, changed_by, action, before_json, after_json, reason)
|
||||
VALUES
|
||||
(v_rule_id, auth.uid(), v_action, v_before, v_after, v_reason);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.deadline_rule_audit_trigger() IS
|
||||
'AFTER-trigger backstop that writes paliad.deadline_rule_audit rows '
|
||||
'for every raw INSERT / UPDATE / DELETE on paliad.deadline_rules. '
|
||||
'UPDATE / DELETE require paliad.audit_reason to be set in the '
|
||||
'session (via SET LOCAL paliad.audit_reason = ...); INSERT defaults '
|
||||
'to ''create'' so seed migrations remain ergonomic.';
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
|
||||
CREATE TRIGGER deadline_rules_audit_aiud
|
||||
AFTER INSERT OR UPDATE OR DELETE ON paliad.deadline_rules
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.deadline_rule_audit_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. RLS on the audit table
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Read: global_admin only (mirrors mig 057 pattern). Service-layer code
|
||||
-- gates `/admin/rules/{id}/audit` separately; this RLS is defence-in-
|
||||
-- depth for any future auth-context query path.
|
||||
--
|
||||
-- Write: nobody via row-level paths. The trigger function is
|
||||
-- SECURITY DEFINER so it bypasses RLS entirely. Direct INSERTs by
|
||||
-- authenticated users are denied (no INSERT policy). service_role
|
||||
-- bypasses RLS as usual.
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY deadline_rule_audit_select
|
||||
ON paliad.deadline_rule_audit FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-182 down — reverses 080_projects_instance_level.up.sql.
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS instance_level;
|
||||
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — paliad.projects.instance_level
|
||||
-- (design §2.7, §7).
|
||||
--
|
||||
-- Lets the SmartTimeline + calculator derive the effective proceeding
|
||||
-- code from (proceeding_code, instance_level) — e.g. DE_INF + 'appeal'
|
||||
-- resolves to DE_INF_OLG.
|
||||
--
|
||||
-- Nullable: NULL means "not asked / not relevant" (e.g. EP_GRANT, a
|
||||
-- non-litigation patent project). Allowed values:
|
||||
-- first — first instance (default once the picker UI lands)
|
||||
-- appeal — Berufung / EPA Beschwerde / appellate level
|
||||
-- cassation — BGH-Revision / EPA-EBA / final instance
|
||||
--
|
||||
-- No backfill in this slice. The picker UI (Slice 8) writes the column;
|
||||
-- legacy projects stay NULL and behave as if first instance via the
|
||||
-- calculator's fallback (`NULL OR 'first'` → use base proceeding code).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 79 → 80.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS instance_level text
|
||||
CHECK (instance_level IS NULL
|
||||
OR instance_level IN ('first', 'appeal', 'cassation'));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.instance_level IS
|
||||
'Procedural instance the project sits at: first | appeal | '
|
||||
'cassation. NULL = unset / not applicable. Combined with '
|
||||
'proceeding_type.code + jurisdiction by FristenrechnerService to '
|
||||
'pick the effective proceeding code (e.g. DE_INF + appeal → '
|
||||
'DE_INF_OLG). See design-fristen-phase2-2026-05-15.md §2.7, §7.';
|
||||
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- t-paliad-183 down — reverts the is_court_set flips written by
|
||||
-- 082_backfill_is_court_set.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 default (false on every
|
||||
-- row). We don't know after the fact which rows were already true
|
||||
-- before the backfill (mig 078 created the column with DEFAULT false on
|
||||
-- every existing row, so post-Slice-1 every row was false — there is
|
||||
-- no pre-existing true population to preserve). Setting back to false
|
||||
-- is therefore equivalent to "undo the backfill".
|
||||
--
|
||||
-- Audit-reason set so the trigger doesn't raise on the down-side
|
||||
-- UPDATEs either.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 082: reset is_court_set to mig 078 default (false)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false
|
||||
WHERE is_court_set = true;
|
||||
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-1 — backfill
|
||||
-- paliad.deadline_rules.is_court_set from the live runtime heuristic.
|
||||
--
|
||||
-- Heuristic source-of-truth: internal/services/fristenrechner.go
|
||||
-- isCourtDeterminedRule() — at the time of Slice 1 (commit c7fa0d6) the
|
||||
-- body is precisely:
|
||||
--
|
||||
-- primary_party = 'court'
|
||||
-- OR event_type IN ('hearing', 'decision', 'order')
|
||||
--
|
||||
-- The Slice 2 head instruction (msg 1746) suggested padding with
|
||||
-- 'name ILIKE %entscheidung% OR %urteil%'; head's clarification
|
||||
-- (msg 1750) rules that out: replicate the live code exactly. Padding
|
||||
-- would mis-flag party submissions like 'Antrag auf Kostenentscheidung'
|
||||
-- (RoP.151) and 'Stellungnahme zum Hinweisbeschluss' as court-set —
|
||||
-- they are not (the party files them; only their anchor is set by the
|
||||
-- court).
|
||||
--
|
||||
-- Audit footnote for the legal-review pass: ~8 'Zustellung…' rules
|
||||
-- (Zustellung BPatG-Entscheidung, Zustellung LG-Urteil, etc.) carry
|
||||
-- primary_party='both' + event_type='filing'. Semantically the
|
||||
-- Zustellung date IS court-set, but the live heuristic doesn't treat
|
||||
-- them as such and flagging them now would change calculator
|
||||
-- rendering without legal review. Leaving them is_court_set=false
|
||||
-- preserves current behaviour; the legal-review pass mentioned in
|
||||
-- design §2.3 ("flag them informational in a Phase 3 slice") can
|
||||
-- promote them later via a targeted UPDATE.
|
||||
--
|
||||
-- Audit-reason: set_config('paliad.audit_reason', …, true) scopes the
|
||||
-- value to golang-migrate's implicit per-file transaction. The audit
|
||||
-- trigger from mig 079 picks it up via current_setting() and writes
|
||||
-- one paliad.deadline_rule_audit row per flipped rule — the compliance
|
||||
-- trail for the backfill, persisted forever.
|
||||
--
|
||||
-- Idempotent: WHERE is_court_set = false guards re-runs against double-
|
||||
-- counting audit rows.
|
||||
--
|
||||
-- Expected delta on the production corpus (172 rules): 47 rows flipped
|
||||
-- false→true (every primary_party='court' rule also has a matching
|
||||
-- event_type in the current data — the two predicates fully overlap).
|
||||
--
|
||||
-- Tracker note: mig 081 was reserved for proceeding_types display_order
|
||||
-- verification per design §3.1; that was a no-op and not authored.
|
||||
-- Slice 1 shipped 078/079/080; Slice 2 starts at 082. golang-migrate
|
||||
-- only requires ascending order, not contiguity.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 082: is_court_set from isCourtDeterminedRule heuristic '
|
||||
|| '(primary_party=court OR event_type IN hearing/decision/order) '
|
||||
|| 'per design §2.3 / fristenrechner.go',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true
|
||||
WHERE is_court_set = false
|
||||
AND (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_set int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_set FROM paliad.deadline_rules WHERE is_court_set = true;
|
||||
RAISE NOTICE 'backfill 082: is_court_set=true on % rules', n_set;
|
||||
END $$;
|
||||
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-183 down — reverts the priority flips written by
|
||||
-- 083_backfill_priority.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 column default
|
||||
-- ('mandatory' on every row). Mig 078 created the column with that
|
||||
-- default; post-Slice-1 every row was 'mandatory' regardless of its
|
||||
-- (is_mandatory, is_optional) pair. Resetting to 'mandatory' is
|
||||
-- therefore equivalent to "undo the backfill".
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 083: reset priority to mig 078 default (mandatory)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'mandatory'
|
||||
WHERE priority <> 'mandatory';
|
||||
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-2 — backfill
|
||||
-- paliad.deadline_rules.priority from the legacy (is_mandatory,
|
||||
-- is_optional) pair per DESIGN §2.3 (NOT the inverted mapping in
|
||||
-- head's msg 1746 — head's clarification msg 1750 rules in favour of
|
||||
-- the design doc).
|
||||
--
|
||||
-- Final mapping (design §2.3 + RoP.151 / mig 068 t-paliad-157 semantic):
|
||||
--
|
||||
-- is_mandatory=true, is_optional=false → 'mandatory' (statutory must,
|
||||
-- ☑ pre-checked in
|
||||
-- save modal)
|
||||
-- is_mandatory=true, is_optional=true → 'optional' (statutorily strict
|
||||
-- ONCE IT APPLIES,
|
||||
-- but applies only
|
||||
-- if a party files —
|
||||
-- RoP.151 is the
|
||||
-- canonical case;
|
||||
-- ☐ pre-unchecked)
|
||||
-- is_mandatory=false, is_optional=true → 'recommended' (no live data, but
|
||||
-- defensive default
|
||||
-- so the CHECK
|
||||
-- constraint stays
|
||||
-- satisfied if such
|
||||
-- a row ever lands)
|
||||
-- is_mandatory=false, is_optional=false → 'recommended' (situational filings
|
||||
-- — Berufungserwiderung,
|
||||
-- Replik, Duplik,
|
||||
-- R.19 Preliminary
|
||||
-- Objection, R.116
|
||||
-- EPÜ, Anschluss-
|
||||
-- berufung, etc.
|
||||
-- Default-save with
|
||||
-- override, not
|
||||
-- 'informational'
|
||||
-- which would make
|
||||
-- them never-saveable)
|
||||
--
|
||||
-- Live-data expected delta (172 rules total, mig 078 set every row to
|
||||
-- the default 'mandatory'):
|
||||
-- T/F (153 rows) → 'mandatory' — 153 no-op UPDATEs (already correct)
|
||||
-- T/T ( 1 row) → 'optional' — 1 row flips
|
||||
-- F/F ( 18 rows) → 'recommended' — 18 rows flip
|
||||
-- F/T ( 0 rows) → 'recommended' — 0 rows (no live data)
|
||||
--
|
||||
-- The UPDATE is split into branches with explicit WHERE clauses so the
|
||||
-- audit log records each branch as a distinct backfill action (separate
|
||||
-- audit row chains by (is_mandatory, is_optional) shape). It also keeps
|
||||
-- the migration idempotent: re-running only touches rows whose priority
|
||||
-- doesn't already match the target.
|
||||
--
|
||||
-- Audit-reason cites design §2.3 — that's the persistent rationale in
|
||||
-- the paliad.deadline_rule_audit log.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 083: priority from (is_mandatory, is_optional) per design §2.3 — '
|
||||
|| 'T/T→optional (RoP.151), F/F→recommended (situational filings)',
|
||||
true);
|
||||
|
||||
-- Branch 1: T/T → 'optional' (RoP.151).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'optional'
|
||||
WHERE is_mandatory = true
|
||||
AND is_optional = true
|
||||
AND priority <> 'optional';
|
||||
|
||||
-- Branch 2: F/F → 'recommended'.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = false
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 3: F/T → 'recommended' (defensive; no live rows today).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = true
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 4: T/F → 'mandatory'. Skipped explicitly: the mig 078 column
|
||||
-- default is already 'mandatory', so every T/F row already has the
|
||||
-- correct value. A defensive UPDATE here would write 153 needless
|
||||
-- audit rows. Leave T/F untouched.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_mand int;
|
||||
n_opt int;
|
||||
n_reco int;
|
||||
n_info int;
|
||||
n_null int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE priority = 'mandatory'),
|
||||
count(*) FILTER (WHERE priority = 'optional'),
|
||||
count(*) FILTER (WHERE priority = 'recommended'),
|
||||
count(*) FILTER (WHERE priority = 'informational'),
|
||||
count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_mand, n_opt, n_reco, n_info, n_null
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 083: priority distribution — '
|
||||
'mandatory=%, optional=%, recommended=%, informational=%, NULL=%',
|
||||
n_mand, n_opt, n_reco, n_info, n_null;
|
||||
-- Hard assertion: priority is NOT NULL by schema (mig 078) and
|
||||
-- every value must lie in the CHECK enum. n_null must be 0.
|
||||
IF n_null > 0 THEN
|
||||
RAISE EXCEPTION 'backfill 083: % rows still have priority IS NULL — '
|
||||
'schema violation', n_null;
|
||||
END IF;
|
||||
END $$;
|
||||
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-183 down — reverts the condition_expr translations written
|
||||
-- by 084_backfill_condition_expr.up.sql. Mig 078 created the column
|
||||
-- with NULL on every row; resetting non-NULL values to NULL undoes the
|
||||
-- backfill cleanly (condition_flag is the source of truth for the
|
||||
-- legacy code path and stays untouched).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 084: reset condition_expr to mig 078 default (NULL)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_expr = NULL
|
||||
WHERE condition_expr IS NOT NULL;
|
||||
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-3 — backfill
|
||||
-- paliad.deadline_rules.condition_expr from the legacy
|
||||
-- condition_flag text[] column per DESIGN §2.4 long form (NOT the
|
||||
-- short {"and":[...]} form sketched in head's msg 1746 — head's
|
||||
-- clarification msg 1750 rules in favour of the design doc).
|
||||
--
|
||||
-- Mapping (design §2.4):
|
||||
--
|
||||
-- condition_flag IS NULL OR array_length(_, 1) = 0
|
||||
-- → condition_expr stays NULL (unconditional, every rule renders)
|
||||
--
|
||||
-- array_length = 1, e.g. ['with_ccr']
|
||||
-- → condition_expr = jsonb '{"flag": "with_ccr"}'
|
||||
-- (single flag unwrapped — saves a layer of nesting that
|
||||
-- parses as the same boolean expression)
|
||||
--
|
||||
-- array_length >= 2, e.g. ['with_ccr', 'with_amend']
|
||||
-- → condition_expr = jsonb '{"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}'
|
||||
-- (long form — same shape the rule editor will emit for OR /
|
||||
-- NOT in future rules so the calculator's parser is uniform)
|
||||
--
|
||||
-- Why long form on >=2: the calculator (Slice 4) reads
|
||||
-- {"op":"<and|or|not>","args":[...]} as the canonical boolean node and
|
||||
-- {"flag":"<name>"} as the leaf. Single-flag unwrap is a parse-time
|
||||
-- shortcut equivalent to a 1-arg AND. The short {"and":[...]} form in
|
||||
-- msg 1746 would require a per-key parser that doesn't generalise to
|
||||
-- OR / NOT. Design §2.4 long form is the load-bearing decision.
|
||||
--
|
||||
-- Live-data expected delta (172 rules total):
|
||||
--
|
||||
-- ['with_ccr'] × 5 rows → {"flag":"with_ccr"}
|
||||
-- ['with_amend'] × 4 rows → {"flag":"with_amend"}
|
||||
-- ['with_cci'] × 4 rows → {"flag":"with_cci"}
|
||||
-- ['with_ccr', 'with_amend'] × 4 rows → {"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}
|
||||
-- NULL or {} × 155 rows → stays NULL
|
||||
--
|
||||
-- Total touched: 17 rows.
|
||||
--
|
||||
-- Idempotent: WHERE condition_expr IS NULL guards re-runs against
|
||||
-- double-writing audit rows for already-translated rules.
|
||||
--
|
||||
-- jsonb construction: jsonb_build_object + jsonb_agg + a CASE on
|
||||
-- array_length keeps the long-form / unwrapped-flag split inline in
|
||||
-- one UPDATE. Per-flag jsonb leaf is built by a LATERAL unnest over
|
||||
-- the flag array so the args[] order matches the source array.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 084: condition_expr from condition_flag text[] per design §2.4 — '
|
||||
|| 'single flag unwrapped, multi flag long form {op:and, args:[...]}',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = sub.expr
|
||||
FROM (
|
||||
SELECT dr_inner.id AS rule_id,
|
||||
CASE
|
||||
-- Single flag: unwrapped leaf.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) = 1
|
||||
THEN jsonb_build_object('flag', dr_inner.condition_flag[1])
|
||||
|
||||
-- >=2 flags: long-form AND with args[] preserving order.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) >= 2
|
||||
THEN jsonb_build_object(
|
||||
'op', 'and',
|
||||
'args', (
|
||||
SELECT jsonb_agg(jsonb_build_object('flag', f) ORDER BY ord)
|
||||
FROM unnest(dr_inner.condition_flag) WITH ORDINALITY AS u(f, ord)
|
||||
)
|
||||
)
|
||||
|
||||
-- Empty array (array_length=0) or NULL: leave NULL.
|
||||
ELSE NULL
|
||||
END AS expr
|
||||
FROM paliad.deadline_rules dr_inner
|
||||
WHERE dr_inner.condition_flag IS NOT NULL
|
||||
AND array_length(dr_inner.condition_flag, 1) > 0
|
||||
) AS sub
|
||||
WHERE dr.id = sub.rule_id
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_with_flag int;
|
||||
n_with_expr int;
|
||||
n_with_both int;
|
||||
BEGIN
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0),
|
||||
count(*) FILTER (WHERE condition_expr IS NOT NULL),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NOT NULL)
|
||||
INTO n_total, n_with_flag, n_with_expr, n_with_both
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 084: total=%, with_condition_flag=%, with_condition_expr=%, both=%',
|
||||
n_total, n_with_flag, n_with_expr, n_with_both;
|
||||
-- Hard assertion: every rule with a non-empty condition_flag now
|
||||
-- has a non-NULL condition_expr (the inverse of the legacy column).
|
||||
IF n_with_flag <> n_with_both THEN
|
||||
RAISE EXCEPTION 'backfill 084: % rules carry condition_flag but no condition_expr — '
|
||||
'translation incomplete',
|
||||
n_with_flag - n_with_both;
|
||||
END IF;
|
||||
END $$;
|
||||
17
internal/db/migrations/085_pipeline_c_data_move.down.sql
Normal file
17
internal/db/migrations/085_pipeline_c_data_move.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-184 down — reverts the Pipeline-C data-move from
|
||||
-- 085_pipeline_c_data_move.up.sql. Deletes every paliad.deadline_rules
|
||||
-- row carrying a non-NULL trigger_event_id (those are exactly the rows
|
||||
-- the up-migration created — before mig 085 no Pipeline-A rule ever
|
||||
-- carried trigger_event_id, and Slice 9 hasn't dropped the source
|
||||
-- table yet so the rows can be regenerated).
|
||||
--
|
||||
-- Audit-reason set so the mig 079 trigger captures the rollback
|
||||
-- rationale and doesn't raise on DELETE.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 085: delete Pipeline-C unified rows (source preserved in event_deadlines)',
|
||||
true);
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
184
internal/db/migrations/085_pipeline_c_data_move.up.sql
Normal file
184
internal/db/migrations/085_pipeline_c_data_move.up.sql
Normal file
@@ -0,0 +1,184 @@
|
||||
-- t-paliad-184 / Fristen Phase 3 Slice 3 Step C — data-move 77 rows
|
||||
-- from paliad.event_deadlines → paliad.deadline_rules so the Phase-3
|
||||
-- unified backend can serve both pipelines.
|
||||
--
|
||||
-- Source rows are PRESERVED (mig 086's read-only trigger blocks
|
||||
-- further writes; mig 090 in Slice 9 drops the table once every
|
||||
-- caller has cut over). The data-move is one-way; legacy callers
|
||||
-- continue reading event_deadlines via plain SELECTs until Slice 9.
|
||||
--
|
||||
-- Mapping (per design §3.C):
|
||||
--
|
||||
-- paliad.event_deadlines → paliad.deadline_rules
|
||||
-- ------------------------- ----------------------
|
||||
-- id (new gen_random_uuid())
|
||||
-- trigger_event_id trigger_event_id (Phase 3 column from mig 078)
|
||||
-- title (EN, NOT NULL) name_en (NOT NULL)
|
||||
-- title_de (DE, NOT NULL DEFAULT '') name (NOT NULL — every row has non-empty title_de in live data)
|
||||
-- duration_value duration_value
|
||||
-- duration_unit (days/weeks/months/working_days) duration_unit
|
||||
-- timing (before/after) timing
|
||||
-- notes (DE) deadline_notes (DE)
|
||||
-- notes_en (EN, nullable) deadline_notes_en (EN, nullable)
|
||||
-- alt_duration_value alt_duration_value
|
||||
-- alt_duration_unit alt_duration_unit
|
||||
-- combine_op (max/min, nullable) combine_op (Phase 3 column from mig 078)
|
||||
-- legal_source legal_source
|
||||
-- is_active is_active
|
||||
-- created_at published_at (preserves chronology — lifecycle_state='published' on every row)
|
||||
-- updated_at = now() (this is the publish event)
|
||||
--
|
||||
-- Pipeline-A-only fields default:
|
||||
-- proceeding_type_id = NULL (event-rooted, no proceeding)
|
||||
-- parent_id = NULL (Pipeline C is flat, no chain)
|
||||
-- spawn_proceeding_type_id = NULL (no spawn)
|
||||
-- code = NULL (no local rule code in Pipeline C)
|
||||
-- primary_party = NULL (event_deadlines has no party column)
|
||||
-- event_type = NULL (filing/hearing/decision is a
|
||||
-- Pipeline-A category)
|
||||
-- is_court_set = false (no court-set Pipeline-C rules
|
||||
-- in the corpus; legal-review
|
||||
-- pass can flip Zustellung-* if
|
||||
-- those ever land here)
|
||||
-- is_spawn = false
|
||||
-- is_mandatory = true (Pipeline C has no mandatory
|
||||
-- bool; design §2.3 says default
|
||||
-- 'mandatory' is correct for
|
||||
-- statutory event-driven deadlines)
|
||||
-- is_optional = false
|
||||
-- priority = 'mandatory'
|
||||
-- condition_expr = NULL (Pipeline C has no flag gating)
|
||||
-- condition_flag = NULL
|
||||
-- sequence_order = 1000 + event_deadlines.id
|
||||
-- (large offset so Pipeline-C
|
||||
-- rows sort AFTER any future
|
||||
-- hand-edited Pipeline-A
|
||||
-- sequence_orders without
|
||||
-- colliding with the
|
||||
-- existing 0–171 range)
|
||||
-- lifecycle_state = 'published'
|
||||
--
|
||||
-- Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name) skips
|
||||
-- rows that already exist in deadline_rules. Re-running the migration
|
||||
-- is a no-op.
|
||||
--
|
||||
-- Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
|
||||
-- IS NOT NULL) == COUNT(event_deadlines WHERE is_active = true) (77 = 77).
|
||||
-- RAISE EXCEPTION on mismatch so a partial move fails the migration
|
||||
-- loudly instead of poisoning Slice 4.
|
||||
--
|
||||
-- Audit-reason cites design §3.C — the rationale persists in the
|
||||
-- paliad.deadline_rule_audit log forever via the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'pipeline C migration 085: data-move event_deadlines → deadline_rules per design §3.C — '
|
||||
|| 'preserves source rows; mig 086 wraps the source table read-only',
|
||||
true);
|
||||
|
||||
INSERT INTO paliad.deadline_rules (
|
||||
id,
|
||||
proceeding_type_id,
|
||||
parent_id,
|
||||
trigger_event_id,
|
||||
spawn_proceeding_type_id,
|
||||
code,
|
||||
name,
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
duration_unit,
|
||||
timing,
|
||||
alt_duration_value,
|
||||
alt_duration_unit,
|
||||
combine_op,
|
||||
rule_code,
|
||||
deadline_notes,
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
condition_flag,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
lifecycle_state,
|
||||
draft_of,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
NULL::integer AS proceeding_type_id,
|
||||
NULL::uuid AS parent_id,
|
||||
ed.trigger_event_id AS trigger_event_id,
|
||||
NULL::integer AS spawn_proceeding_type_id,
|
||||
NULL::text AS code,
|
||||
ed.title_de AS name,
|
||||
ed.title AS name_en,
|
||||
NULL::text AS primary_party,
|
||||
NULL::text AS event_type,
|
||||
true AS is_mandatory,
|
||||
false AS is_optional,
|
||||
false AS is_court_set,
|
||||
false AS is_spawn,
|
||||
ed.duration_value AS duration_value,
|
||||
ed.duration_unit AS duration_unit,
|
||||
ed.timing AS timing,
|
||||
ed.alt_duration_value AS alt_duration_value,
|
||||
ed.alt_duration_unit AS alt_duration_unit,
|
||||
ed.combine_op AS combine_op,
|
||||
NULL::text AS rule_code,
|
||||
NULLIF(ed.notes, '') AS deadline_notes,
|
||||
ed.notes_en AS deadline_notes_en,
|
||||
ed.legal_source AS legal_source,
|
||||
NULL::jsonb AS condition_expr,
|
||||
NULL::text[] AS condition_flag,
|
||||
(1000 + ed.id)::integer AS sequence_order,
|
||||
ed.is_active AS is_active,
|
||||
'mandatory' AS priority,
|
||||
'published' AS lifecycle_state,
|
||||
NULL::uuid AS draft_of,
|
||||
ed.created_at AS published_at,
|
||||
ed.created_at AS created_at,
|
||||
now() AS updated_at
|
||||
FROM paliad.event_deadlines ed
|
||||
WHERE ed.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.trigger_event_id = ed.trigger_event_id
|
||||
AND dr.name = ed.title_de
|
||||
);
|
||||
|
||||
-- Hard assertion: every active event_deadlines row must have a matching
|
||||
-- deadline_rules row by (trigger_event_id, name). If the counts diverge,
|
||||
-- something in the WHERE NOT EXISTS clause (likely a stale duplicate)
|
||||
-- prevented a real insert — fail the migration rather than ship a
|
||||
-- partial Pipeline-C corpus.
|
||||
DO $$
|
||||
DECLARE
|
||||
n_source int;
|
||||
n_target int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_source
|
||||
FROM paliad.event_deadlines WHERE is_active = true;
|
||||
|
||||
SELECT count(*) INTO n_target
|
||||
FROM paliad.deadline_rules WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE 'mig 085: event_deadlines(active)=%, deadline_rules(trigger_event_id IS NOT NULL)=%',
|
||||
n_source, n_target;
|
||||
|
||||
IF n_target <> n_source THEN
|
||||
RAISE EXCEPTION 'mig 085: data-move incomplete — expected % unified rows, got %. '
|
||||
'Investigate event_deadlines (trigger_event_id, title_de) duplicates '
|
||||
'OR re-applied migration on dirtied target.',
|
||||
n_source, n_target;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- t-paliad-184 down — reverts the read-only wrapper from
|
||||
-- 086_event_deadlines_readonly.up.sql. Order: trigger → function.
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
|
||||
58
internal/db/migrations/086_event_deadlines_readonly.up.sql
Normal file
58
internal/db/migrations/086_event_deadlines_readonly.up.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- t-paliad-184 / Fristen Phase 3 Slice 3 — wrap paliad.event_deadlines
|
||||
-- in a read-only trigger so nobody can edit either side mid-cutover.
|
||||
--
|
||||
-- Slice 3 just moved 77 rows from event_deadlines → deadline_rules (mig
|
||||
-- 085). Until Slice 4 cuts every reader over and Slice 9 drops the
|
||||
-- legacy table, event_deadlines stays in place as the audit anchor and
|
||||
-- (briefly) a compat-read source. We must not let any writer mutate it
|
||||
-- behind the unified backend's back — diverging the two sides would
|
||||
-- silently regress "Was kommt nach…" parity.
|
||||
--
|
||||
-- The trigger fires AFTER INSERT / UPDATE / DELETE and raises an
|
||||
-- EXCEPTION with a clear message pointing the writer at the unified
|
||||
-- table. SELECT is unaffected — the legacy EventDeadlineService's
|
||||
-- pre-Slice-3 SELECT path keeps working until Slice 4 swaps it.
|
||||
--
|
||||
-- The supabase service_role bypasses RLS but NOT triggers — so
|
||||
-- direct DB maintenance (psql, migration scripts) is also blocked.
|
||||
-- This is intentional: any further edit to event_deadlines is a
|
||||
-- mistake until Slice 9 drops the table.
|
||||
--
|
||||
-- Removed by Slice 9 (Step E, mig ~090) when paliad.event_deadlines is
|
||||
-- dropped. Until then the trigger is the only thing keeping the two
|
||||
-- tables in sync.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
|
||||
'writes must go through paliad.deadline_rules (Pipeline C is '
|
||||
'unified; the source table is preserved as an audit anchor '
|
||||
'until Slice 9 drops it). Operation: %', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.event_deadlines_readonly_trigger() IS
|
||||
'BEFORE INSERT/UPDATE/DELETE trigger function that raises on any '
|
||||
'write to paliad.event_deadlines. Lives only between Slice 3 and '
|
||||
'Slice 9 — removed when the source table is dropped.';
|
||||
|
||||
-- BEFORE-trigger so the write is blocked before any row image is
|
||||
-- captured. AFTER would still raise but the surrounding tx would
|
||||
-- have already taken row locks.
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
|
||||
CREATE TRIGGER event_deadlines_readonly
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- Defensive INSERT-row-level trigger covers the COPY path too; same
|
||||
-- function, identical behaviour.
|
||||
|
||||
COMMENT ON TRIGGER event_deadlines_readonly ON paliad.event_deadlines IS
|
||||
'Phase 3 Slice 3 read-only wrapper. Blocks every INSERT/UPDATE/DELETE '
|
||||
'until Slice 9 drops the table. SELECT unaffected.';
|
||||
@@ -0,0 +1,28 @@
|
||||
-- t-paliad-186 down — reverses 087_project_proceeding_type_remap.up.sql.
|
||||
--
|
||||
-- "Revert" here means: NULL every project that the up-migration remapped
|
||||
-- AND drop the 'proceeding_type_remap_null' project_events rows it
|
||||
-- wrote. We cannot perfectly recover the litigation→fristenrechner
|
||||
-- remap because the up-migration moved INF→UPC_INF (etc.) without
|
||||
-- preserving the original code in a side column. Resetting to NULL is
|
||||
-- the safe rollback — the operator can hand-remap a project if needed.
|
||||
--
|
||||
-- Today this is a no-op on production data (0 live remaps).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 087: NULL projects.proceeding_type_id remapped by mig 087',
|
||||
true);
|
||||
|
||||
DELETE FROM paliad.project_events
|
||||
WHERE event_type = 'proceeding_type_remap_null'
|
||||
AND metadata->>'migration' = '087';
|
||||
|
||||
UPDATE paliad.projects
|
||||
SET proceeding_type_id = NULL
|
||||
WHERE proceeding_type_id IS NOT NULL
|
||||
AND proceeding_type_id IN (
|
||||
SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner'
|
||||
AND code IN ('UPC_INF', 'UPC_REV', 'UPC_APP')
|
||||
);
|
||||
148
internal/db/migrations/087_project_proceeding_type_remap.up.sql
Normal file
148
internal/db/migrations/087_project_proceeding_type_remap.up.sql
Normal file
@@ -0,0 +1,148 @@
|
||||
-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-1 — remap any project
|
||||
-- still pointing at a litigation-category proceeding_types row to the
|
||||
-- corresponding fristenrechner-category code (per design §3.F + m's
|
||||
-- Q2 ruling: "I dont even get 'litigation corpus'").
|
||||
--
|
||||
-- Live-data reality: 11/11 projects carry proceeding_type_id IS NULL
|
||||
-- today, so this migration is effectively a no-op on the production
|
||||
-- corpus. It still ships defensively for any future test / staging /
|
||||
-- imported data that might land with a litigation-category id before
|
||||
-- the CHECK trigger (mig 088) catches the next write.
|
||||
--
|
||||
-- Mapping (cross-checked against the live paliad.proceeding_types
|
||||
-- catalog — 19 fristenrechner codes, 7 litigation codes):
|
||||
--
|
||||
-- INF → UPC_INF (UPC infringement, canonical reading)
|
||||
-- REV → UPC_REV (UPC revocation)
|
||||
-- APP → UPC_APP (UPC appeal)
|
||||
-- CCR → NULL (no UPC_CCR in the fristenrechner catalog
|
||||
-- — flag for legal review per design §3.F)
|
||||
-- APM → NULL (no UPC_APM — flag for legal review)
|
||||
-- AMD → NULL (no UPC_AMD — flag for legal review)
|
||||
-- ZPO_CIVIL → NULL (no fristenrechner analogue, design §3.F:
|
||||
-- "litigation codes stay but become unused
|
||||
-- for project-binding")
|
||||
--
|
||||
-- Each NULL-remap leaves a paliad.project_events row with a
|
||||
-- 'proceeding_type_remap_null' event so legal review can spot the
|
||||
-- project + decide whether to pick a hand-mapped fristenrechner code.
|
||||
-- Today no live project hits this branch — the events table stays
|
||||
-- clean — but the audit hook is there for the day a litigation-coded
|
||||
-- project lands.
|
||||
--
|
||||
-- Idempotent: only rows still pointing at a litigation-category code
|
||||
-- are touched. Re-running on a clean target is a no-op.
|
||||
--
|
||||
-- Hard assertion at end: no paliad.projects row points at a
|
||||
-- non-fristenrechner-category proceeding_types row post-mig. RAISE
|
||||
-- EXCEPTION if violated — fails the migration loudly rather than
|
||||
-- relying on mig 088's runtime trigger to catch the next write.
|
||||
--
|
||||
-- Audit-reason wrapper: required by the mig 079 trigger when this
|
||||
-- migration UPDATEs deadline_rules tangentially (it doesn't, but
|
||||
-- set_config is harmless if no audited row mutates).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'mig 087: remap projects.proceeding_type_id from litigation→fristenrechner per design §3.F + Q2',
|
||||
true);
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. Remap rows that point at litigation codes with a known UPC analogue.
|
||||
-- ============================================================================
|
||||
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_type_id = pt_new.id
|
||||
FROM paliad.proceeding_types pt_old
|
||||
JOIN paliad.proceeding_types pt_new
|
||||
ON pt_new.code = CASE pt_old.code
|
||||
WHEN 'INF' THEN 'UPC_INF'
|
||||
WHEN 'REV' THEN 'UPC_REV'
|
||||
WHEN 'APP' THEN 'UPC_APP'
|
||||
END
|
||||
AND pt_new.is_active = true
|
||||
AND pt_new.category = 'fristenrechner'
|
||||
WHERE p.proceeding_type_id = pt_old.id
|
||||
AND pt_old.category = 'litigation'
|
||||
AND pt_old.code IN ('INF', 'REV', 'APP');
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. NULL-remap rows pointing at litigation codes with no fristenrechner
|
||||
-- analogue. Record a paliad.project_events row so legal review can
|
||||
-- follow up.
|
||||
-- ============================================================================
|
||||
|
||||
-- Capture the projects we're about to NULL-remap into a temp table so
|
||||
-- we can both UPDATE and INSERT events from the same set (without a
|
||||
-- second SELECT that might race with the UPDATE).
|
||||
|
||||
CREATE TEMP TABLE _mig_087_null_remaps ON COMMIT DROP AS
|
||||
SELECT p.id AS project_id,
|
||||
p.created_by AS actor,
|
||||
pt_old.code AS old_code
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt_old ON pt_old.id = p.proceeding_type_id
|
||||
WHERE pt_old.category = 'litigation'
|
||||
AND pt_old.code IN ('CCR', 'APM', 'AMD', 'ZPO_CIVIL');
|
||||
|
||||
UPDATE paliad.projects p
|
||||
SET proceeding_type_id = NULL
|
||||
FROM _mig_087_null_remaps r
|
||||
WHERE p.id = r.project_id;
|
||||
|
||||
INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date, created_by, metadata, created_at, updated_at)
|
||||
SELECT gen_random_uuid(),
|
||||
r.project_id,
|
||||
'proceeding_type_remap_null',
|
||||
'Verfahrenstyp zurückgesetzt (Soft-Merge Phase 3)',
|
||||
'proceeding_type_id wurde auf NULL gesetzt — '
|
||||
|| r.old_code
|
||||
|| ' hat kein Fristenrechner-Pendant. Bitte manuell einen passenden Code wählen.',
|
||||
now(),
|
||||
r.actor,
|
||||
jsonb_build_object(
|
||||
'migration', '087',
|
||||
'old_code', r.old_code,
|
||||
'reason', 'project soft-merge: no fristenrechner analogue'
|
||||
),
|
||||
now(),
|
||||
now()
|
||||
FROM _mig_087_null_remaps r;
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. Hard assertion: every non-NULL proceeding_type_id on projects now
|
||||
-- references a fristenrechner-category row.
|
||||
-- ============================================================================
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_null int;
|
||||
n_fristen int;
|
||||
n_non_fristen int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_total FROM paliad.projects;
|
||||
SELECT count(*) FILTER (WHERE proceeding_type_id IS NULL)
|
||||
INTO n_null FROM paliad.projects;
|
||||
SELECT count(*)
|
||||
INTO n_fristen
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE pt.category = 'fristenrechner';
|
||||
SELECT count(*)
|
||||
INTO n_non_fristen
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE pt.category <> 'fristenrechner';
|
||||
|
||||
RAISE NOTICE 'mig 087: projects total=%, NULL=%, fristenrechner=%, other=%',
|
||||
n_total, n_null, n_fristen, n_non_fristen;
|
||||
|
||||
IF n_non_fristen > 0 THEN
|
||||
RAISE EXCEPTION 'mig 087: % projects still point at non-fristenrechner-category '
|
||||
'proceeding_type_ids — soft-merge incomplete. Investigate '
|
||||
'and either extend the remap or add a hand-mapped code.',
|
||||
n_non_fristen;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- t-paliad-186 down — reverses 088_project_proceeding_type_check.up.sql.
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_category_check
|
||||
ON paliad.projects;
|
||||
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_category_check();
|
||||
@@ -0,0 +1,90 @@
|
||||
-- t-paliad-186 / Fristen Phase 3 Slice 5 Step F-2 — enforce
|
||||
-- "fristenrechner-category only" on paliad.projects.proceeding_type_id
|
||||
-- via a BEFORE INSERT/UPDATE trigger. PostgreSQL CHECK constraints
|
||||
-- can't reference other tables, so a trigger is the only way to
|
||||
-- evaluate the (proceeding_types.category = 'fristenrechner')
|
||||
-- predicate per row.
|
||||
--
|
||||
-- Why trigger over deferrable-FK-to-partial-index: a partial unique
|
||||
-- index on proceeding_types where category='fristenrechner' would
|
||||
-- let us reference it from a separate FK column, but the existing
|
||||
-- FK on projects.proceeding_type_id → proceeding_types.id is
|
||||
-- broad-category. Replacing it with a narrower FK would invalidate
|
||||
-- the existing schema reference in mig 027. A trigger keeps the FK
|
||||
-- in place and just adds the category predicate on top.
|
||||
--
|
||||
-- Behaviour:
|
||||
-- - INSERT/UPDATE with proceeding_type_id IS NULL: pass (NULL is allowed).
|
||||
-- - INSERT/UPDATE with proceeding_type_id pointing at a
|
||||
-- fristenrechner-category row: pass.
|
||||
-- - INSERT/UPDATE with proceeding_type_id pointing at any other
|
||||
-- category: RAISE EXCEPTION with a German + English message so the
|
||||
-- handler / frontend can surface a friendly error.
|
||||
-- - INSERT/UPDATE with proceeding_type_id pointing at a missing row:
|
||||
-- the existing FK on the column rejects it before this trigger
|
||||
-- even fires; nothing to do here.
|
||||
--
|
||||
-- Removed when the litigation category is fully retired (Slice 9 or
|
||||
-- later). Until then this is the runtime guard for any writer that
|
||||
-- bypasses the Go service-layer validation.
|
||||
--
|
||||
-- Idempotent: re-applying the migration drops + recreates the trigger.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_category_check()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
DECLARE
|
||||
v_category text;
|
||||
BEGIN
|
||||
IF NEW.proceeding_type_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
SELECT category INTO v_category
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = NEW.proceeding_type_id;
|
||||
|
||||
-- The FK on the column guarantees v_category is non-NULL when the
|
||||
-- id resolves — but defensive against a future FK relax-and-replace.
|
||||
IF v_category IS NULL THEN
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id = % does not resolve to a '
|
||||
'proceeding_types row — FK constraint should have caught this.',
|
||||
NEW.proceeding_type_id;
|
||||
END IF;
|
||||
|
||||
IF v_category <> 'fristenrechner' THEN
|
||||
RAISE EXCEPTION
|
||||
'paliad.projects.proceeding_type_id must reference a '
|
||||
'fristenrechner-category proceeding_types row (got category=''%''). '
|
||||
'Verfahrenstyp muss ein Fristenrechner-Typ sein (Kategorie=''%''). '
|
||||
'Slice 5 (Phase 3 soft-merge per design §3.F) retires the '
|
||||
'''litigation'' category for project-binding; pick a UPC_*, '
|
||||
'DE_*, EPA_*, DPMA_* or EP_GRANT code instead.',
|
||||
v_category, v_category;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_proceeding_type_category_check() IS
|
||||
'BEFORE INSERT/UPDATE trigger function enforcing the Phase 3 Slice 5 '
|
||||
'invariant: paliad.projects.proceeding_type_id may only reference '
|
||||
'fristenrechner-category proceeding_types rows. NULL is allowed.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_proceeding_type_category_check
|
||||
ON paliad.projects;
|
||||
|
||||
CREATE TRIGGER projects_proceeding_type_category_check
|
||||
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_proceeding_type_category_check();
|
||||
|
||||
COMMENT ON TRIGGER projects_proceeding_type_category_check ON paliad.projects IS
|
||||
'Phase 3 Slice 5 (t-paliad-186) runtime guard for the projects '
|
||||
'soft-merge — rejects any INSERT/UPDATE that would bind a project '
|
||||
'to a non-fristenrechner-category proceeding_type. The Go service '
|
||||
'layer also enforces this with a typed error; this trigger is the '
|
||||
'defence-in-depth backstop.';
|
||||
@@ -34,16 +34,23 @@ func handleListDeadlineRules(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, rules)
|
||||
}
|
||||
|
||||
// GET /api/proceeding-types-db
|
||||
// GET /api/proceeding-types-db?category=<value>
|
||||
//
|
||||
// Lists active proceeding types from the DB. Optional `category` query
|
||||
// param filters the result set (e.g. ?category=fristenrechner is the
|
||||
// shape the project-create / project-edit pickers use after Phase 3
|
||||
// Slice 5 — design §3.F + m's Q2 ruling restricts project-binding to
|
||||
// fristenrechner-category codes). Empty / missing param returns every
|
||||
// active row.
|
||||
//
|
||||
// Lists active proceeding types from the DB.
|
||||
// (Distinct route name from the existing in-memory /api/tools/proceeding-types
|
||||
// endpoint to avoid path conflicts during the Phase B → Phase C transition.)
|
||||
func handleListProceedingTypesDB(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
types, err := dbSvc.rules.ListProceedingTypes(r.Context())
|
||||
category := r.URL.Query().Get("category")
|
||||
types, err := dbSvc.rules.ListProceedingTypesByCategory(r.Context(), category)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to list proceeding types"})
|
||||
return
|
||||
|
||||
106
internal/handlers/event_trigger.go
Normal file
106
internal/handlers/event_trigger.go
Normal file
@@ -0,0 +1,106 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// POST /api/tools/event-trigger — Phase 3 Slice 6 (t-paliad-187, design
|
||||
// §5). Discovers and computes deadline rules triggered by an event-type
|
||||
// and/or a deadline-concept. Caller passes UUID identifiers (not the
|
||||
// legacy /api/tools/event-deadlines bigint trigger_event_id surface);
|
||||
// service handles the bridge to Pipeline-C rules internally.
|
||||
//
|
||||
// Body:
|
||||
//
|
||||
// {
|
||||
// "eventTypeId": "uuid", // optional — fires Pipeline-C rules via event_types.trigger_event_id
|
||||
// "conceptId": "uuid", // optional — fires Pipeline-A rules linked by concept_id FK
|
||||
// "triggerDate": "2026-01-15", // required, YYYY-MM-DD
|
||||
// "flags": ["with_ccr"], // optional, gates rules via evalConditionExpr
|
||||
// "courtId": "upc-ld-mn", // optional, picks (country, regime) for non-working-day arithmetic
|
||||
// "perspective": "claimant" // optional, drops opposing-side rules
|
||||
// }
|
||||
//
|
||||
// At least one of eventTypeId / conceptId must be set. When both are
|
||||
// set, the rule set is the UNION deduped by rule.id.
|
||||
//
|
||||
// Response: same shape as POST /api/tools/fristenrechner (UIResponse) —
|
||||
// the frontend can render with the existing timeline renderer.
|
||||
//
|
||||
// Returns 503 when the DB pool is unavailable (server bootstrap before
|
||||
// services attached); the page itself still renders since it's static
|
||||
// HTML so a downstream error pop-up is the worst the user sees.
|
||||
func handleEventTriggerCalculate(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.eventTrigger == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Event-Trigger ist vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
EventTypeID string `json:"eventTypeId,omitempty"`
|
||||
ConceptID string `json:"conceptId,omitempty"`
|
||||
TriggerDate string `json:"triggerDate"`
|
||||
Flags []string `json:"flags,omitempty"`
|
||||
CourtID string `json:"courtId,omitempty"`
|
||||
Perspective string `json:"perspective,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if req.TriggerDate == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "triggerDate ist erforderlich"})
|
||||
return
|
||||
}
|
||||
if req.EventTypeID == "" && req.ConceptID == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "eventTypeId oder conceptId ist erforderlich",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
input := services.EventTriggerInput{
|
||||
TriggerDate: req.TriggerDate,
|
||||
Flags: req.Flags,
|
||||
CourtID: req.CourtID,
|
||||
Perspective: req.Perspective,
|
||||
}
|
||||
if req.EventTypeID != "" {
|
||||
id, err := uuid.Parse(req.EventTypeID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "eventTypeId ist keine gültige UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
input.EventTypeID = &id
|
||||
}
|
||||
if req.ConceptID != "" {
|
||||
id, err := uuid.Parse(req.ConceptID)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "conceptId ist keine gültige UUID",
|
||||
})
|
||||
return
|
||||
}
|
||||
input.ConceptID = &id
|
||||
}
|
||||
|
||||
resp, err := dbSvc.eventTrigger.Trigger(r.Context(), input)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
@@ -48,6 +48,7 @@ type Services struct {
|
||||
Users *services.UserService
|
||||
Fristenrechner *services.FristenrechnerService
|
||||
EventDeadline *services.EventDeadlineService
|
||||
EventTrigger *services.EventTriggerService
|
||||
DeadlineSearch *services.DeadlineSearchService
|
||||
EventCategory *services.EventCategoryService
|
||||
EventType *services.EventTypeService
|
||||
@@ -100,6 +101,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
users: svc.Users,
|
||||
fristenrechner: svc.Fristenrechner,
|
||||
eventDeadline: svc.EventDeadline,
|
||||
eventTrigger: svc.EventTrigger,
|
||||
deadlineSearch: svc.DeadlineSearch,
|
||||
eventCategory: svc.EventCategory,
|
||||
eventType: svc.EventType,
|
||||
@@ -166,6 +168,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
|
||||
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
|
||||
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)
|
||||
protected.HandleFunc("POST /api/tools/event-trigger", handleEventTriggerCalculate)
|
||||
protected.HandleFunc("GET /api/tools/courts", handleCourtsList)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
|
||||
|
||||
@@ -29,6 +29,7 @@ type dbServices struct {
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventTrigger *services.EventTriggerService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
@@ -90,6 +91,13 @@ func writeServiceError(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, http.StatusForbidden, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidInput):
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
case errors.Is(err, services.ErrInvalidProceedingTypeCategory):
|
||||
// Phase 3 Slice 5 (t-paliad-186). Bilingual user-facing message
|
||||
// matches what the project-form copy expects so the toast reads
|
||||
// naturally without an i18n round-trip in the handler.
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "Verfahrenstyp muss ein Fristenrechner-Typ sein / proceeding type must be a Fristenrechner type",
|
||||
})
|
||||
case errors.Is(err, services.ErrEventTypeSlugTaken):
|
||||
writeJSON(w, http.StatusConflict, map[string]string{"error": err.Error()})
|
||||
default:
|
||||
|
||||
@@ -171,6 +171,16 @@ type Project struct {
|
||||
// sibling under the same patent (§4.4 of the design doc).
|
||||
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
|
||||
|
||||
// InstanceLevel is the procedural instance the project sits at:
|
||||
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
|
||||
// proceeding code + jurisdiction by FristenrechnerService to pick
|
||||
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
|
||||
// NULL = unset / not applicable; the calculator treats NULL as
|
||||
// 'first'. Backfill happens via the project-detail picker UI
|
||||
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
|
||||
// service rewrite (mig 080, t-paliad-182).
|
||||
InstanceLevel *string `db:"instance_level" json:"instance_level,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
@@ -500,6 +510,100 @@ type DeadlineRule struct {
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Populated by Slice 2 backfill; readers are compat-mode (read
|
||||
// both shapes) until Slice 4 cuts the calculator over and Slice 9
|
||||
// drops the legacy columns above (IsMandatory, IsOptional,
|
||||
// ConditionFlag, ConditionRuleID).
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||
// the Go rule-editor service (semantic publish / archive / restore).
|
||||
// See migration 079 and design-fristen-phase2-2026-05-15.md §2.8.
|
||||
type DeadlineRuleAudit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id" json:"rule_id"`
|
||||
ChangedBy *uuid.UUID `db:"changed_by" json:"changed_by,omitempty"`
|
||||
ChangedAt time.Time `db:"changed_at" json:"changed_at"`
|
||||
|
||||
// Action is one of: create | update | delete (trigger-written) |
|
||||
// publish | archive | restore (Go-written by the rule editor).
|
||||
Action string `db:"action" json:"action"`
|
||||
|
||||
// BeforeJSON is the row state pre-change (NULL on 'create').
|
||||
// AfterJSON is the row state post-change (NULL on 'delete').
|
||||
BeforeJSON NullableJSON `db:"before_json" json:"before_json,omitempty"`
|
||||
AfterJSON NullableJSON `db:"after_json" json:"after_json,omitempty"`
|
||||
|
||||
// Reason is required on update / delete (the trigger raises if
|
||||
// paliad.audit_reason is unset). On create the trigger defaults
|
||||
// to 'create' so seed migrations don't need to bother.
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
|
||||
// MigrationExported flips to true once the Slice 11b export
|
||||
// endpoint folds this delta into a checked-in .up.sql.
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
|
||||
@@ -21,12 +21,25 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
}
|
||||
|
||||
// ruleColumns lists every column scanned into models.DeadlineRule.
|
||||
//
|
||||
// Compat-mode (t-paliad-182 Phase 3 Slice 1): the SELECT reads BOTH
|
||||
// the legacy shape (is_mandatory, is_optional, condition_flag,
|
||||
// condition_rule_id) and the unified Phase 3 shape (trigger_event_id,
|
||||
// spawn_proceeding_type_id, combine_op, condition_expr, priority,
|
||||
// is_court_set, lifecycle_state, draft_of, published_at). Existing
|
||||
// callers stay on the legacy fields; the new fields are NULL or carry
|
||||
// their migration default until Slice 2 backfills them. Slice 4 cuts
|
||||
// the calculator over to the new fields, Slice 9 drops the legacy
|
||||
// columns.
|
||||
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type, is_mandatory, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
|
||||
created_at, updated_at`
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
@@ -198,15 +211,125 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||
// no parent_id chain.
|
||||
//
|
||||
// Distinct from List: List filters by proceeding_type_id and runs
|
||||
// hydrateConceptDefaultEventTypes (which assumes a proceeding-type FK).
|
||||
// Pipeline-C rules don't have that FK, so hydration is skipped here.
|
||||
//
|
||||
// Order by sequence_order so the data-move's (1000 + ed.id) offset
|
||||
// preserves the original event_deadlines.id ordering.
|
||||
func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) {
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order`, triggerEventID); err != nil {
|
||||
return nil, fmt.Errorf("list deadline rules by trigger_event_id=%d: %w", triggerEventID, err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ListByProceedingTypeIDs returns active rules across a set of
|
||||
// proceeding types, ordered by (proceeding_type_id, sequence_order) so
|
||||
// callers can group + pick the "first rule" (lowest sequence_order)
|
||||
// per proceeding without a second sort. Phase 3 Slice 7 (t-paliad-188)
|
||||
// uses this for cross-proceeding spawn target expansion: given a list
|
||||
// of spawn_proceeding_type_id values, bulk-load every target
|
||||
// proceeding's rules in one round-trip.
|
||||
//
|
||||
// Empty input returns nil, nil (no SELECT issued). Distinct from
|
||||
// List(proceedingTypeID) which scopes to a single proceeding + runs
|
||||
// hydrateConceptDefaultEventTypes — this method skips hydration since
|
||||
// the SmartTimeline doesn't need concept-default event types on
|
||||
// spawned rules.
|
||||
func (s *DeadlineRuleService) ListByProceedingTypeIDs(ctx context.Context, ids []int) ([]models.DeadlineRule, error) {
|
||||
if len(ids) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE proceeding_type_id IN (?)
|
||||
AND is_active = true
|
||||
ORDER BY proceeding_type_id, sequence_order`, ids)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build IN query for proceeding ids: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules, query, args...); err != nil {
|
||||
return nil, fmt.Errorf("list deadline rules by proceeding_type_ids %v: %w", ids, err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ListByConcept returns active rules linked to a single
|
||||
// paliad.deadline_concepts row via the concept_id FK. Used by the
|
||||
// Phase 3 Slice 6 event-trigger endpoint (t-paliad-187) to discover
|
||||
// the rules a cascade leaf produces.
|
||||
//
|
||||
// Distinct from ListByTriggerEvent (Pipeline-C): this is the
|
||||
// Pipeline-A concept-keyed path. A concept may have rules across
|
||||
// multiple proceeding_types — the caller may want to narrow further
|
||||
// via event_category_concepts.proceeding_type_code, but the Slice 6
|
||||
// service does no narrowing in v1 (returns every active rule on
|
||||
// the concept).
|
||||
//
|
||||
// Order by sequence_order so rules within a proceeding stay in their
|
||||
// canonical order. proceeding_type_id is a secondary sort so a
|
||||
// multi-proceeding concept doesn't interleave its constituent rules.
|
||||
func (s *DeadlineRuleService) ListByConcept(ctx context.Context, conceptID uuid.UUID) ([]models.DeadlineRule, error) {
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE concept_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY proceeding_type_id NULLS LAST, sequence_order`, conceptID); err != nil {
|
||||
return nil, fmt.Errorf("list deadline rules by concept_id=%s: %w", conceptID, err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ListProceedingTypes returns active proceeding types ordered by sort_order.
|
||||
func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) {
|
||||
return s.ListProceedingTypesByCategory(ctx, "")
|
||||
}
|
||||
|
||||
// ListProceedingTypesByCategory returns active proceeding types
|
||||
// ordered by sort_order, optionally filtered to a single category. An
|
||||
// empty category returns every active row (preserves the legacy
|
||||
// ListProceedingTypes behaviour).
|
||||
//
|
||||
// Phase 3 Slice 5 (t-paliad-186): the project-create / project-edit
|
||||
// pickers pass category='fristenrechner' so users never see retired
|
||||
// litigation codes when binding a project to a proceeding (design §3.F).
|
||||
func (s *DeadlineRuleService) ListProceedingTypesByCategory(ctx context.Context, category string) ([]models.ProceedingType, error) {
|
||||
var types []models.ProceedingType
|
||||
if category == "" {
|
||||
if err := s.db.SelectContext(ctx, &types,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order`); err != nil {
|
||||
return nil, fmt.Errorf("list proceeding types: %w", err)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &types,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE is_active = true
|
||||
ORDER BY sort_order`); err != nil {
|
||||
return nil, fmt.Errorf("list proceeding types: %w", err)
|
||||
AND category = $1
|
||||
ORDER BY sort_order`, category); err != nil {
|
||||
return nil, fmt.Errorf("list proceeding types by category %q: %w", category, err)
|
||||
}
|
||||
return types, nil
|
||||
}
|
||||
|
||||
384
internal/services/deadline_rule_service_test.go
Normal file
384
internal/services/deadline_rule_service_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3
|
||||
// Slice 1 (mig 078–080, t-paliad-182) additive-schema landing.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id,
|
||||
// combine_op, condition_expr, priority, is_court_set,
|
||||
// lifecycle_state, draft_of, published_at) is present on
|
||||
// paliad.deadline_rules after migrations apply and scans cleanly
|
||||
// into models.DeadlineRule.
|
||||
//
|
||||
// 2. The default migration values land: priority='mandatory',
|
||||
// is_court_set=false, lifecycle_state='published' on every pre-
|
||||
// Slice-1 row. New rows default the same way.
|
||||
//
|
||||
// 3. The audit trigger fires on UPDATE — exactly one
|
||||
// paliad.deadline_rule_audit row is written for an UPDATE that
|
||||
// supplies a reason via SET LOCAL paliad.audit_reason.
|
||||
//
|
||||
// 4. The audit trigger raises when paliad.audit_reason is unset on
|
||||
// UPDATE — Slice 2 backfills MUST set the reason or they fail
|
||||
// loudly.
|
||||
//
|
||||
// 5. paliad.projects.instance_level (mig 080) accepts NULL and the
|
||||
// three CHECK-allowed values, and rejects anything else.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestDeadlineRuleService_UnifiedColumns_CompatRead(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
svc := NewDeadlineRuleService(pool)
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. SELECT every column via the service's ruleColumns list. The list
|
||||
// must end the test green even though it now includes the Phase 3
|
||||
// columns; if a scan error pops up we know a column name or Go
|
||||
// type slipped.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
rules, err := svc.List(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Fatal("no rules returned; seed-data missing?")
|
||||
}
|
||||
|
||||
// 2. Every row scans cleanly. Priority + is_court_set values depend on
|
||||
// whether Slice 2 (mig 082–084) has applied: pre-Slice-2 they carry
|
||||
// the mig 078 defaults (priority='mandatory', is_court_set=false);
|
||||
// post-Slice-2 they carry the backfilled values per design §2.3.
|
||||
// LifecycleState is set by mig 078 to 'published' for every row and
|
||||
// is unaffected by Slice 2.
|
||||
allowedPriorities := map[string]bool{
|
||||
"mandatory": true, "recommended": true, "optional": true, "informational": true,
|
||||
}
|
||||
for _, r := range rules {
|
||||
if !allowedPriorities[r.Priority] {
|
||||
t.Errorf("rule %s: priority=%q not in enum", r.ID, r.Priority)
|
||||
}
|
||||
if r.LifecycleState != "published" {
|
||||
t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx
|
||||
// so SET LOCAL is scoped to this test.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Pick any existing rule; we'll UPDATE its updated_at field with a
|
||||
// no-op-equivalent change (twice — once with reason, once without).
|
||||
target := rules[0]
|
||||
|
||||
// Count the audit rows for this rule before we touch it.
|
||||
var beforeCount int
|
||||
if err := pool.GetContext(ctx, &beforeCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows pre-update: %v", err)
|
||||
}
|
||||
|
||||
// 3a. UPDATE WITH reason set — should succeed and write one audit row.
|
||||
tx, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("set audit reason: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("update with reason: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit update-with-reason tx: %v", err)
|
||||
}
|
||||
|
||||
var afterCount int
|
||||
if err := pool.GetContext(ctx, &afterCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows post-update: %v", err)
|
||||
}
|
||||
if afterCount != beforeCount+1 {
|
||||
t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount)
|
||||
}
|
||||
|
||||
// Look up the audit row we just wrote: latest by changed_at, action='update'.
|
||||
var (
|
||||
auditAction string
|
||||
auditReason string
|
||||
auditBefore json.RawMessage
|
||||
auditAfter json.RawMessage
|
||||
)
|
||||
if err := pool.QueryRowxContext(ctx,
|
||||
`SELECT action, reason, before_json, after_json
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE rule_id = $1
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil {
|
||||
t.Fatalf("read latest audit row: %v", err)
|
||||
}
|
||||
if auditAction != "update" {
|
||||
t.Errorf("audit action=%q, want 'update'", auditAction)
|
||||
}
|
||||
if auditReason != "test: compat-read audit smoke" {
|
||||
t.Errorf("audit reason=%q, want the set_config value", auditReason)
|
||||
}
|
||||
if len(auditBefore) == 0 || len(auditAfter) == 0 {
|
||||
t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter)
|
||||
}
|
||||
|
||||
// 4. UPDATE WITHOUT reason — trigger must raise.
|
||||
tx2, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx2: %v", err)
|
||||
}
|
||||
_, err = tx2.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID)
|
||||
tx2.Rollback()
|
||||
if err == nil {
|
||||
t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 5. paliad.projects.instance_level CHECK.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'instance-level-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level)
|
||||
VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`,
|
||||
projectID, userID); err != nil {
|
||||
t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err)
|
||||
}
|
||||
|
||||
// Update to each allowed value should succeed; bogus value must fail.
|
||||
for _, lvl := range []string{"first", "cassation", "appeal"} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil {
|
||||
t.Errorf("update instance_level=%q: %v", lvl, err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil {
|
||||
t.Error("instance_level='final' should violate CHECK constraint, but succeeded")
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil {
|
||||
t.Errorf("NULL instance_level should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeadlineRuleService_BackfillIntegrity exercises the Phase 3 Slice 2
|
||||
// (mig 082–084, t-paliad-183) backfills against the live corpus.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. is_court_set (mig 082): every rule with primary_party='court' OR
|
||||
// event_type IN ('hearing','decision','order') is true; every other
|
||||
// rule is false. Replicates isCourtDeterminedRule() exactly.
|
||||
//
|
||||
// 2. priority (mig 083): zero rules with NULL priority (CHECK guards
|
||||
// the schema, this is belt-and-braces). The four mapping branches
|
||||
// hold per design §2.3 — T/F→'mandatory', T/T→'optional',
|
||||
// F/T→'recommended', F/F→'recommended'.
|
||||
//
|
||||
// 3. condition_expr (mig 084): every rule with a non-empty
|
||||
// condition_flag has a non-NULL condition_expr; every rule with
|
||||
// NULL/empty condition_flag has NULL condition_expr. Single-flag
|
||||
// rules carry {"flag":"<name>"} (unwrapped); multi-flag rules
|
||||
// carry {"op":"and","args":[{"flag":"<a>"},...]} long form.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestDeadlineRuleService_BackfillIntegrity(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. is_court_set matches the live heuristic exactly.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var mismatchCourt int
|
||||
if err := pool.GetContext(ctx, &mismatchCourt, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_court_set <> (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
)`); err != nil {
|
||||
t.Fatalf("count court-mismatch rows: %v", err)
|
||||
}
|
||||
if mismatchCourt != 0 {
|
||||
t.Errorf("is_court_set diverges from heuristic on %d rules (mig 082 incomplete)", mismatchCourt)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. priority backfill matches design §2.3.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var nullPriority int
|
||||
if err := pool.GetContext(ctx, &nullPriority,
|
||||
`SELECT count(*) FROM paliad.deadline_rules WHERE priority IS NULL`); err != nil {
|
||||
t.Fatalf("count NULL priority rows: %v", err)
|
||||
}
|
||||
if nullPriority != 0 {
|
||||
t.Errorf("found %d rules with NULL priority — mig 083 incomplete or CHECK bypassed", nullPriority)
|
||||
}
|
||||
|
||||
type prioRow struct {
|
||||
IsMandatory bool `db:"is_mandatory"`
|
||||
IsOptional bool `db:"is_optional"`
|
||||
Priority string `db:"priority"`
|
||||
N int `db:"n"`
|
||||
}
|
||||
var prioBuckets []prioRow
|
||||
if err := pool.SelectContext(ctx, &prioBuckets, `
|
||||
SELECT is_mandatory, is_optional, priority, count(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
GROUP BY is_mandatory, is_optional, priority
|
||||
ORDER BY is_mandatory, is_optional, priority`); err != nil {
|
||||
t.Fatalf("bucket priorities: %v", err)
|
||||
}
|
||||
expectedPriority := func(isMand, isOpt bool) string {
|
||||
switch {
|
||||
case isMand && !isOpt:
|
||||
return "mandatory"
|
||||
case isMand && isOpt:
|
||||
return "optional"
|
||||
default: // F/T and F/F both map to 'recommended' per design §2.3.
|
||||
return "recommended"
|
||||
}
|
||||
}
|
||||
for _, row := range prioBuckets {
|
||||
want := expectedPriority(row.IsMandatory, row.IsOptional)
|
||||
if row.Priority != want {
|
||||
t.Errorf("(is_mandatory=%v, is_optional=%v) → priority=%q on %d rules, want %q",
|
||||
row.IsMandatory, row.IsOptional, row.Priority, row.N, want)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. condition_expr backfill matches design §2.4.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Every non-empty condition_flag has a non-NULL condition_expr.
|
||||
var orphans int
|
||||
if err := pool.GetContext(ctx, &orphans, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE condition_flag IS NOT NULL
|
||||
AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NULL`); err != nil {
|
||||
t.Fatalf("count condition_flag orphans: %v", err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Errorf("%d rules carry condition_flag but no condition_expr — mig 084 incomplete", orphans)
|
||||
}
|
||||
|
||||
// Every NULL/empty condition_flag has NULL condition_expr (no spurious writes).
|
||||
var spurious int
|
||||
if err := pool.GetContext(ctx, &spurious, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE (condition_flag IS NULL OR array_length(condition_flag, 1) IS NULL)
|
||||
AND condition_expr IS NOT NULL`); err != nil {
|
||||
t.Fatalf("count condition_expr spurious: %v", err)
|
||||
}
|
||||
if spurious != 0 {
|
||||
t.Errorf("%d rules carry condition_expr without condition_flag — mig 084 over-wrote", spurious)
|
||||
}
|
||||
|
||||
// Single-flag shape: condition_expr = {"flag":"<name>"} matches
|
||||
// condition_flag[1]. Use jsonb -> to extract the flag scalar.
|
||||
var singleMismatch int
|
||||
if err := pool.GetContext(ctx, &singleMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) = 1
|
||||
AND condition_expr ->> 'flag' IS DISTINCT FROM condition_flag[1]`); err != nil {
|
||||
t.Fatalf("count single-flag mismatch: %v", err)
|
||||
}
|
||||
if singleMismatch != 0 {
|
||||
t.Errorf("%d single-flag rules have condition_expr.flag ≠ condition_flag[1]", singleMismatch)
|
||||
}
|
||||
|
||||
// Multi-flag shape: condition_expr.op='and', args length = flag count,
|
||||
// each args[i].flag = condition_flag[i+1] (1-indexed).
|
||||
var multiMismatch int
|
||||
if err := pool.GetContext(ctx, &multiMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) >= 2
|
||||
AND (
|
||||
condition_expr ->> 'op' IS DISTINCT FROM 'and'
|
||||
OR jsonb_array_length(condition_expr -> 'args') IS DISTINCT FROM array_length(condition_flag, 1)
|
||||
)`); err != nil {
|
||||
t.Fatalf("count multi-flag mismatch: %v", err)
|
||||
}
|
||||
if multiMismatch != 0 {
|
||||
t.Errorf("%d multi-flag rules have malformed condition_expr (op/args shape)", multiMismatch)
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,43 @@ import (
|
||||
|
||||
// EventDeadlineService backs the "Was kommt nach…" Fristenrechner mode:
|
||||
// given a trigger event + date, return all deadlines that flow from it
|
||||
// with their computed due dates. Mirrors youpc.org's deadline-calc shape
|
||||
// (event-driven), distinct from the proceeding-tree-driven Fristenrechner.
|
||||
// with their computed due dates. Mirrors youpc.org's deadline-calc
|
||||
// shape (event-driven).
|
||||
//
|
||||
// Phase 3 Slice 3 (t-paliad-184) refactor: the math + rule SELECT moved
|
||||
// into FristenrechnerService.calculateByTriggerEvent (which reads from
|
||||
// the unified paliad.deadline_rules backed by mig 085's data-move).
|
||||
// EventDeadlineService.Calculate now delegates and wraps the unified
|
||||
// response in the legacy CalculateResponse shape (trigger metadata +
|
||||
// per-deadline rule_codes from event_deadline_rule_codes). The public
|
||||
// signature stays unchanged so /api/tools/event-deadlines callers see
|
||||
// no diff.
|
||||
//
|
||||
// Phase 3 Slice 4 (t-paliad-185) collapsed the prior on-service
|
||||
// applyDuration / addWorkingDays helpers into package-level functions
|
||||
// shared with FristenrechnerService — single source-of-truth for
|
||||
// timing / working_days / holiday-rollover arithmetic.
|
||||
type EventDeadlineService struct {
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
fristenrechner *FristenrechnerService
|
||||
}
|
||||
|
||||
// NewEventDeadlineService wires the service to its dependencies.
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService) *EventDeadlineService {
|
||||
return &EventDeadlineService{db: db, calc: calc, holidays: holidays, courts: courts}
|
||||
// NewEventDeadlineService wires the service to its dependencies. The
|
||||
// fristenrechner is the Phase 3 delegate target — pre-Slice-3 wiring
|
||||
// can pass nil there and the legacy SELECT path is still used at
|
||||
// runtime via the (currently unreachable) fallback below; today every
|
||||
// caller supplies it.
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService, fristenrechner *FristenrechnerService) *EventDeadlineService {
|
||||
return &EventDeadlineService{
|
||||
db: db,
|
||||
calc: calc,
|
||||
holidays: holidays,
|
||||
courts: courts,
|
||||
fristenrechner: fristenrechner,
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerEventSummary is the shape returned to the picker UI: lightweight
|
||||
@@ -80,28 +105,28 @@ type CalculateResponse struct {
|
||||
Deadlines []EventDeadlineResult `json:"deadlines"`
|
||||
}
|
||||
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date for
|
||||
// the given court. Days/weeks/months use AddDate (calendar arithmetic).
|
||||
// working_days uses HolidayService.IsNonWorkingDay to skip weekends +
|
||||
// holidays applicable to the court's (country, regime). Composite rules
|
||||
// (alt_* + combine_op) compute both legs and pick max/min.
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date.
|
||||
//
|
||||
// courtID may be empty for legacy callers — we default to a UPC München
|
||||
// context (DE country, UPC regime) since the trigger-event Fristenrechner
|
||||
// is UPC-flavoured today.
|
||||
// Phase 3 Slice 3 (t-paliad-184) delegates the rule SELECT + math to
|
||||
// FristenrechnerService.calculateByTriggerEvent — which reads from
|
||||
// paliad.deadline_rules WHERE trigger_event_id = X (the rows mig 085
|
||||
// moved out of event_deadlines). This method now owns the wrapping
|
||||
// concerns: trigger-event metadata lookup, rule_code aggregation (via
|
||||
// the still-readable event_deadline_rule_codes junction), and the
|
||||
// composite-rule note string that the legacy /api/tools/event-deadlines
|
||||
// contract emits.
|
||||
//
|
||||
// The legacy event_deadlines table is the source-of-truth for
|
||||
// (durationValue, durationUnit, timing, notes_en, alt_*, combine_op,
|
||||
// id) until Slice 9 drops it. Reading those fields here keeps the
|
||||
// frontend's EventDeadlineResult shape pixel-identical with pre-Slice-3
|
||||
// — verified by the 77-row parity test in event_deadline_service_test.go.
|
||||
//
|
||||
// courtID may be empty for legacy callers — defaults to UPC München
|
||||
// (DE country, UPC regime) for the trigger-event surface.
|
||||
func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int64, triggerDateStr, courtID string) (*CalculateResponse, error) {
|
||||
country, regime, err := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
var trig TriggerEventSummary
|
||||
err = s.db.GetContext(ctx, &trig, `
|
||||
err := s.db.GetContext(ctx, &trig, `
|
||||
SELECT id, code, name, name_de
|
||||
FROM paliad.trigger_events
|
||||
WHERE id = $1 AND is_active = true`, triggerEventID)
|
||||
@@ -112,6 +137,10 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
return nil, fmt.Errorf("load trigger event: %w", err)
|
||||
}
|
||||
|
||||
// Source-of-truth columns the unified UIResponse drops (the
|
||||
// frontend still reads DurationValue/Unit/Timing literally to render
|
||||
// the "X days after" pill). SELECT from event_deadlines is still
|
||||
// allowed — the mig 086 read-only trigger only blocks writes.
|
||||
var rows []eventDeadlineRow
|
||||
err = s.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, title, title_de, duration_value, duration_unit, timing,
|
||||
@@ -124,78 +153,89 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(rows))
|
||||
byTitleDE := make(map[string]eventDeadlineRow, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
byTitleDE[r.TitleDE] = r
|
||||
}
|
||||
codes, err := s.loadRuleCodes(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]EventDeadlineResult, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
base, baseAdj, baseChanged := s.applyDuration(triggerDate, r.DurationValue, r.DurationUnit, r.Timing, country, regime)
|
||||
// Delegate to the unified calculator. UIResponse comes back with the
|
||||
// adjusted/original dates + wasAdjusted; the per-rule metadata is
|
||||
// the same names + ordering the source rows above carry, so we can
|
||||
// merge them on .Name (which mig 085 copied from event_deadlines.title_de).
|
||||
unified, err := s.fristenrechner.Calculate(ctx, "", triggerDateStr, CalcOptions{
|
||||
TriggerEventIDFilter: &triggerEventID,
|
||||
CourtID: courtID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picked := baseAdj
|
||||
original := base
|
||||
wasAdjusted := baseChanged
|
||||
isComposite := false
|
||||
results := make([]EventDeadlineResult, 0, len(unified.Deadlines))
|
||||
for _, d := range unified.Deadlines {
|
||||
src, ok := byTitleDE[d.Name]
|
||||
if !ok {
|
||||
// Defensive: a unified row exists for which no source
|
||||
// event_deadlines row matches by title_de. Either a hand-
|
||||
// inserted Pipeline-C rule (post-Slice-3) without a source
|
||||
// counterpart, or a name divergence. Skip it from the legacy
|
||||
// shape and let the parity test surface the mismatch.
|
||||
continue
|
||||
}
|
||||
isComposite := src.CombineOp != nil && src.AltDurationValue != nil && src.AltDurationUnit != nil
|
||||
compositeNote := ""
|
||||
|
||||
if r.AltDurationValue != nil && r.AltDurationUnit != nil && r.CombineOp != nil {
|
||||
alt, altAdj, altChanged := s.applyDuration(triggerDate, *r.AltDurationValue, *r.AltDurationUnit, r.Timing, country, regime)
|
||||
isComposite = true
|
||||
switch *r.CombineOp {
|
||||
if isComposite {
|
||||
// Recompute which leg won by re-running applyDuration with
|
||||
// the source's exact inputs — cheaper than threading the
|
||||
// pick through the unified UIDeadline shape.
|
||||
country, regime, cerr := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
triggerDate, terr := time.Parse("2006-01-02", triggerDateStr)
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, terr)
|
||||
}
|
||||
_, baseAdj, _, _ := applyDuration(triggerDate, src.DurationValue, src.DurationUnit, src.Timing, country, regime, s.holidays)
|
||||
_, altAdj, _, _ := applyDuration(triggerDate, *src.AltDurationValue, *src.AltDurationUnit, src.Timing, country, regime, s.holidays)
|
||||
pickedUnit := src.DurationUnit
|
||||
switch *src.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(baseAdj) {
|
||||
picked = altAdj
|
||||
original = alt
|
||||
wasAdjusted = altChanged
|
||||
compositeNote = fmt.Sprintf("max(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
*r.AltDurationUnit)
|
||||
} else {
|
||||
compositeNote = fmt.Sprintf("max(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
r.DurationUnit)
|
||||
pickedUnit = *src.AltDurationUnit
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(baseAdj) {
|
||||
picked = altAdj
|
||||
original = alt
|
||||
wasAdjusted = altChanged
|
||||
compositeNote = fmt.Sprintf("min(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
*r.AltDurationUnit)
|
||||
} else {
|
||||
compositeNote = fmt.Sprintf("min(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
r.DurationUnit)
|
||||
pickedUnit = *src.AltDurationUnit
|
||||
}
|
||||
}
|
||||
compositeNote = fmt.Sprintf("%s(%d %s, %d %s) → %s leg",
|
||||
*src.CombineOp,
|
||||
src.DurationValue, src.DurationUnit,
|
||||
*src.AltDurationValue, *src.AltDurationUnit,
|
||||
pickedUnit)
|
||||
}
|
||||
|
||||
notesEN := ""
|
||||
if r.NotesEN != nil {
|
||||
notesEN = *r.NotesEN
|
||||
if src.NotesEN != nil {
|
||||
notesEN = *src.NotesEN
|
||||
}
|
||||
results = append(results, EventDeadlineResult{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
TitleDE: r.TitleDE,
|
||||
DurationValue: r.DurationValue,
|
||||
DurationUnit: r.DurationUnit,
|
||||
Timing: r.Timing,
|
||||
Notes: r.Notes,
|
||||
ID: src.ID,
|
||||
Title: src.Title,
|
||||
TitleDE: src.TitleDE,
|
||||
DurationValue: src.DurationValue,
|
||||
DurationUnit: src.DurationUnit,
|
||||
Timing: src.Timing,
|
||||
Notes: src.Notes,
|
||||
NotesEN: notesEN,
|
||||
RuleCodes: codes[r.ID],
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDueDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdjusted,
|
||||
RuleCodes: codes[src.ID],
|
||||
DueDate: d.DueDate,
|
||||
OriginalDueDate: d.OriginalDate,
|
||||
WasAdjusted: d.WasAdjusted,
|
||||
IsComposite: isComposite,
|
||||
CompositeNote: compositeNote,
|
||||
})
|
||||
@@ -208,65 +248,6 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
}, nil
|
||||
}
|
||||
|
||||
// applyDuration computes (raw, adjusted, didAdjust) for a single leg of a
|
||||
// rule using the given (country, regime) for non-working-day adjustment.
|
||||
// Honours timing ('before' subtracts, 'after' adds) and routes to working-
|
||||
// day arithmetic when unit == "working_days".
|
||||
func (s *EventDeadlineService) applyDuration(triggerDate time.Time, value int, unit, timing, country, regime string) (raw time.Time, adjusted time.Time, didAdjust bool) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
|
||||
switch unit {
|
||||
case "days":
|
||||
raw = triggerDate.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
raw = triggerDate.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
raw = triggerDate.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = s.addWorkingDays(triggerDate, sign*value, country, regime)
|
||||
default:
|
||||
raw = triggerDate
|
||||
}
|
||||
|
||||
// Calendar units (days/weeks/months) need post-rollover off non-working
|
||||
// days. working_days lands on a working day by construction.
|
||||
if unit == "working_days" {
|
||||
return raw, raw, false
|
||||
}
|
||||
adjusted, _, didAdjust = s.holidays.AdjustForNonWorkingDays(raw, country, regime)
|
||||
return raw, adjusted, didAdjust
|
||||
}
|
||||
|
||||
// addWorkingDays advances from `from` by `n` working days (skipping weekends
|
||||
// + holidays applicable to the given country/regime). Negative `n` walks
|
||||
// backward. Returns the date that lands on a working day.
|
||||
func (s *EventDeadlineService) addWorkingDays(from time.Time, n int, country, regime string) time.Time {
|
||||
if n == 0 {
|
||||
// Day-zero convention: if the trigger itself is a non-working day,
|
||||
// don't roll forward — that's the caller's job to decide via the
|
||||
// regular AdjustForNonWorkingDays path.
|
||||
return from
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step = -1
|
||||
n = -n
|
||||
}
|
||||
cur := from
|
||||
for i := 0; i < n; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
// Walk past consecutive non-working days. Bounded loop: 30 + n is
|
||||
// a safety net; in practice we never see vacation runs > 14 days.
|
||||
for j := 0; j < 30 && s.holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// eventDeadlineRow is the package-private row shape used by Calculate's
|
||||
// SELECT. Keeps optional fields as pointers (nil = no composite alt-leg).
|
||||
type eventDeadlineRow struct {
|
||||
|
||||
@@ -1,20 +1,33 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// addWorkingDays + composite-rule semantics — pure-Go logic, no DB needed.
|
||||
//
|
||||
// Phase 3 Slice 4 (t-paliad-185) collapsed the prior method versions
|
||||
// (s.addWorkingDays / s.applyDuration on *EventDeadlineService) into
|
||||
// package-level helpers shared with FristenrechnerService. Tests now
|
||||
// call them directly without a receiver.
|
||||
|
||||
func TestAddWorkingDays_SkipsWeekends(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// 2026-04-30 = Thu. +3 wd: step → Fri May 1 (Tag der Arbeit, skip) → Sat
|
||||
// (skip) → Sun (skip) → Mon May 4 = WD 1; → Tue May 5 = WD 2; → Wed
|
||||
// May 6 = WD 3. So +3 wd = Wed 2026-05-06.
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
got := s.addWorkingDays(in, 3, "DE", "UPC")
|
||||
got := addWorkingDays(in, 3, "DE", "UPC", hs)
|
||||
want := time.Date(2026, 5, 6, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("addWorkingDays(+3): got %s, want %s", got, want)
|
||||
@@ -22,12 +35,12 @@ func TestAddWorkingDays_SkipsWeekends(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddWorkingDays_SkipsHolidays(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// 2026-04-30 = Thu. +1 wd = Fri 2026-05-01 = Tag der Arbeit (DE federal holiday).
|
||||
// → skip → Sat (weekend) → skip → Sun (weekend) → skip → Mon 2026-05-04.
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
got := s.addWorkingDays(in, 1, "DE", "UPC")
|
||||
got := addWorkingDays(in, 1, "DE", "UPC", hs)
|
||||
want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("addWorkingDays(+1) over Tag der Arbeit: got %s, want %s", got, want)
|
||||
@@ -35,13 +48,11 @@ func TestAddWorkingDays_SkipsHolidays(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddWorkingDays_NegativeWalksBackward(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// Mon 2026-05-04 - 2 wd = Thu 2026-04-30 (skipping Fri 2026-05-01 holiday).
|
||||
// Walk: -1 wd → Fri 05-01 → holiday → Thu 04-30 = working. 1 wd done.
|
||||
// -1 wd → Wed 04-29. 2 wd done.
|
||||
in := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
got := s.addWorkingDays(in, -2, "DE", "UPC")
|
||||
got := addWorkingDays(in, -2, "DE", "UPC", hs)
|
||||
want := time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Errorf("addWorkingDays(-2) over Tag der Arbeit: got %s, want %s", got, want)
|
||||
@@ -49,23 +60,23 @@ func TestAddWorkingDays_NegativeWalksBackward(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAddWorkingDays_Zero(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// Day-zero convention: returns input unchanged, even if it's a weekend.
|
||||
weekend := time.Date(2026, 5, 2, 0, 0, 0, 0, time.UTC) // Saturday
|
||||
got := s.addWorkingDays(weekend, 0, "DE", "UPC")
|
||||
got := addWorkingDays(weekend, 0, "DE", "UPC", hs)
|
||||
if !got.Equal(weekend) {
|
||||
t.Errorf("addWorkingDays(0) on weekend: got %s, want %s (unchanged)", got, weekend)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyDuration_WorkingDays_SkipsAdjustment(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// working_days lands on a working day by construction → no further adjust.
|
||||
// Thu 2026-04-30 + 1 wd = Mon 2026-05-04 (skipped Fri holiday + weekend).
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
raw, adjusted, didAdjust := s.applyDuration(in, 1, "working_days", "after", "DE", "UPC")
|
||||
raw, adjusted, didAdjust, _ := applyDuration(in, 1, "working_days", "after", "DE", "UPC", hs)
|
||||
want := time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
if !raw.Equal(want) {
|
||||
@@ -80,11 +91,11 @@ func TestApplyDuration_WorkingDays_SkipsAdjustment(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestApplyDuration_BeforeTiming(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// Wed 2026-04-15 - 2 weeks = Wed 2026-04-01. Working day → no adjust.
|
||||
in := time.Date(2026, 4, 15, 0, 0, 0, 0, time.UTC)
|
||||
raw, adjusted, _ := s.applyDuration(in, 2, "weeks", "before", "DE", "UPC")
|
||||
raw, adjusted, _, _ := applyDuration(in, 2, "weeks", "before", "DE", "UPC", hs)
|
||||
want := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
if !raw.Equal(want) {
|
||||
t.Errorf("raw: got %s, want %s", raw, want)
|
||||
@@ -97,11 +108,11 @@ func TestApplyDuration_BeforeTiming(t *testing.T) {
|
||||
// Composite-rule test: R.198/R.213 "31d OR 20 working_days, whichever is longer".
|
||||
// We hand-compute the two legs and pick max via the same logic as Calculate.
|
||||
func TestComposite_R198_LongerLegWins(t *testing.T) {
|
||||
s := &EventDeadlineService{holidays: NewHolidayService(nil)}
|
||||
hs := NewHolidayService(nil)
|
||||
in := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
_, baseAdj, _ := s.applyDuration(in, 31, "days", "after", "DE", "UPC")
|
||||
_, altAdj, _ := s.applyDuration(in, 20, "working_days", "after", "DE", "UPC")
|
||||
_, baseAdj, _, _ := applyDuration(in, 31, "days", "after", "DE", "UPC", hs)
|
||||
_, altAdj, _, _ := applyDuration(in, 20, "working_days", "after", "DE", "UPC", hs)
|
||||
|
||||
// 31 calendar days from Thu 2026-04-30 = Sun 2026-05-31 → adjust to Mon 2026-06-01.
|
||||
// 20 working days from Thu 2026-04-30 ≈ early June (skipping May 1 holiday + weekends).
|
||||
@@ -126,3 +137,176 @@ func TestComposite_R198_LongerLegWins(t *testing.T) {
|
||||
t.Error("expected altAdj > baseAdj (working_days leg longer than 31d leg)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventDeadlineService_Calculate_Parity is the LOAD-BEARING assertion
|
||||
// for Phase 3 Slice 3 (t-paliad-184). For every distinct trigger_event_id
|
||||
// in paliad.event_deadlines, it calls EventDeadlineService.Calculate (now
|
||||
// delegating to FristenrechnerService.calculateByTriggerEvent) AND
|
||||
// independently computes the same dates via the legacy applyDuration
|
||||
// helper directly against event_deadlines. Any divergence — date,
|
||||
// composite-flag, rule_codes — signals a Pipeline-C regression that
|
||||
// "Was kommt nach…" users would see in production.
|
||||
//
|
||||
// Why this matters: design §3.C + §3.2 cutover-ordering invariant 1 says
|
||||
// "additive schema lands first" and invariant 3 says "service rewrite
|
||||
// before drops". Slice 3 is the first slice where the unified backend
|
||||
// becomes the live serving path for event-driven deadlines. If parity
|
||||
// breaks here, every downstream slice rests on a regressed foundation.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB parity test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
svc := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
|
||||
|
||||
// Distinct trigger_event_id values for which we have at least one
|
||||
// active deadline in event_deadlines. The Slice 1 / Slice 2 / Slice 3
|
||||
// chain doesn't touch event_deadlines, so this set is stable.
|
||||
var triggerIDs []int64
|
||||
if err := pool.SelectContext(ctx, &triggerIDs,
|
||||
`SELECT DISTINCT trigger_event_id
|
||||
FROM paliad.event_deadlines
|
||||
WHERE is_active = true
|
||||
ORDER BY trigger_event_id`); err != nil {
|
||||
t.Fatalf("list trigger ids: %v", err)
|
||||
}
|
||||
if len(triggerIDs) == 0 {
|
||||
t.Fatal("no event_deadlines rows — pipeline C corpus missing")
|
||||
}
|
||||
|
||||
// Reference date — arbitrary working day so weekend rollover noise is
|
||||
// minimal. The parity test compares against an independently-computed
|
||||
// expected value, so any date that exercises the calculator is fine.
|
||||
triggerDateStr := "2026-01-15"
|
||||
triggerDate, _ := time.Parse("2006-01-02", triggerDateStr)
|
||||
country, regime, err := courts.CountryRegime("", CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
t.Fatalf("default court regime: %v", err)
|
||||
}
|
||||
|
||||
type srcRow struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
TitleDE string `db:"title_de"`
|
||||
DurationValue int `db:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit"`
|
||||
Timing string `db:"timing"`
|
||||
AltDurationValue *int `db:"alt_duration_value"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit"`
|
||||
CombineOp *string `db:"combine_op"`
|
||||
}
|
||||
|
||||
var totalChecked int
|
||||
for _, tid := range triggerIDs {
|
||||
resp, err := svc.Calculate(ctx, tid, triggerDateStr, "")
|
||||
if err != nil {
|
||||
t.Errorf("trigger=%d Calculate: %v", tid, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var src []srcRow
|
||||
if err := pool.SelectContext(ctx, &src,
|
||||
`SELECT id, title, title_de, duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, combine_op
|
||||
FROM paliad.event_deadlines
|
||||
WHERE trigger_event_id = $1 AND is_active = true
|
||||
ORDER BY id`, tid); err != nil {
|
||||
t.Fatalf("trigger=%d load source: %v", tid, err)
|
||||
}
|
||||
|
||||
if len(resp.Deadlines) != len(src) {
|
||||
t.Errorf("trigger=%d: got %d deadlines, want %d", tid, len(resp.Deadlines), len(src))
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort both by ID — Calculate's source SELECT also ORDER BY id, so
|
||||
// after we look up the source row for each result we can compare
|
||||
// positionally. (The unified path returns rows in sequence_order =
|
||||
// 1000 + ed.id which is identical ordering.)
|
||||
sort.Slice(resp.Deadlines, func(i, j int) bool {
|
||||
return resp.Deadlines[i].ID < resp.Deadlines[j].ID
|
||||
})
|
||||
|
||||
for i, r := range resp.Deadlines {
|
||||
s := src[i]
|
||||
totalChecked++
|
||||
|
||||
if r.ID != s.ID {
|
||||
t.Errorf("trigger=%d idx=%d: id=%d, want %d", tid, i, r.ID, s.ID)
|
||||
}
|
||||
if r.Title != s.Title {
|
||||
t.Errorf("trigger=%d id=%d: title mismatch: %q vs %q", tid, s.ID, r.Title, s.Title)
|
||||
}
|
||||
if r.TitleDE != s.TitleDE {
|
||||
t.Errorf("trigger=%d id=%d: titleDE mismatch: %q vs %q", tid, s.ID, r.TitleDE, s.TitleDE)
|
||||
}
|
||||
if r.DurationValue != s.DurationValue {
|
||||
t.Errorf("trigger=%d id=%d: durationValue mismatch: %d vs %d",
|
||||
tid, s.ID, r.DurationValue, s.DurationValue)
|
||||
}
|
||||
if r.DurationUnit != s.DurationUnit {
|
||||
t.Errorf("trigger=%d id=%d: durationUnit mismatch: %q vs %q",
|
||||
tid, s.ID, r.DurationUnit, s.DurationUnit)
|
||||
}
|
||||
if r.Timing != s.Timing {
|
||||
t.Errorf("trigger=%d id=%d: timing mismatch: %q vs %q", tid, s.ID, r.Timing, s.Timing)
|
||||
}
|
||||
|
||||
// Date parity: independently compute the expected DueDate
|
||||
// using the legacy applyDuration on the source row. If the
|
||||
// unified path diverges by even one day, this surfaces it.
|
||||
_, expectedAdj, _, _ := applyDuration(triggerDate, s.DurationValue, s.DurationUnit, s.Timing, country, regime, holidays)
|
||||
if s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil {
|
||||
_, altAdj, _, _ := applyDuration(triggerDate, *s.AltDurationValue, *s.AltDurationUnit, s.Timing, country, regime, holidays)
|
||||
switch *s.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(expectedAdj) {
|
||||
expectedAdj = altAdj
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(expectedAdj) {
|
||||
expectedAdj = altAdj
|
||||
}
|
||||
}
|
||||
}
|
||||
gotAdj, perr := time.Parse("2006-01-02", r.DueDate)
|
||||
if perr != nil {
|
||||
t.Errorf("trigger=%d id=%d: parse dueDate %q: %v", tid, s.ID, r.DueDate, perr)
|
||||
continue
|
||||
}
|
||||
if !gotAdj.Equal(expectedAdj) {
|
||||
t.Errorf("trigger=%d id=%d (%q): dueDate=%s, want %s — Pipeline-C parity broken",
|
||||
tid, s.ID, s.Title, r.DueDate, expectedAdj.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Composite flag parity.
|
||||
wantComposite := s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil
|
||||
if r.IsComposite != wantComposite {
|
||||
t.Errorf("trigger=%d id=%d: isComposite=%v, want %v",
|
||||
tid, s.ID, r.IsComposite, wantComposite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final tally — at least the 77 active rows must have been checked.
|
||||
if totalChecked < 77 {
|
||||
t.Errorf("checked only %d Pipeline-C rows (want >=77) — parity sweep incomplete", totalChecked)
|
||||
}
|
||||
}
|
||||
|
||||
287
internal/services/event_trigger_service.go
Normal file
287
internal/services/event_trigger_service.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// EventTriggerService backs POST /api/tools/event-trigger — Phase 3
|
||||
// Slice 6 (t-paliad-187, design §5). Given an event-type or a concept
|
||||
// (or both), it discovers the deadline rules triggered by the input
|
||||
// and computes their dates via the unified Phase-3 helpers
|
||||
// (applyDuration + evalConditionExpr).
|
||||
//
|
||||
// Distinct from the legacy /api/tools/event-deadlines surface (which
|
||||
// is keyed exclusively on paliad.trigger_events bigints): this
|
||||
// endpoint accepts either UUID paliad.event_types.id (Pipeline-C
|
||||
// rules, via the trigger_event_id bridge on event_types) OR UUID
|
||||
// paliad.deadline_concepts.id (Pipeline-A rules linked via the
|
||||
// concept_id FK on deadline_rules). When both are passed the
|
||||
// resulting rule set is UNIONed and deduped by rule.id.
|
||||
//
|
||||
// Distinct from FristenrechnerService.Calculate (proceeding-tree):
|
||||
// no parent_id chain walk, no IsRootEvent / IsCourtSet
|
||||
// classification, no AnchorOverrides — rules fire flat off the
|
||||
// trigger date. The math, gate evaluation, and party-perspective
|
||||
// filter all reuse Slice-4's unified helpers so the response shape
|
||||
// stays calibrated against the proceeding-tree calculator.
|
||||
type EventTriggerService struct {
|
||||
db *sqlx.DB
|
||||
rules *DeadlineRuleService
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
}
|
||||
|
||||
// NewEventTriggerService wires the service to its dependencies.
|
||||
func NewEventTriggerService(db *sqlx.DB, rules *DeadlineRuleService, holidays *HolidayService, courts *CourtService) *EventTriggerService {
|
||||
return &EventTriggerService{db: db, rules: rules, holidays: holidays, courts: courts}
|
||||
}
|
||||
|
||||
// EventTriggerInput is the parsed request body. At least one of
|
||||
// EventTypeID / ConceptID must be set (validated in Trigger).
|
||||
type EventTriggerInput struct {
|
||||
// EventTypeID resolves through paliad.event_types.id →
|
||||
// trigger_event_id (bigint) → SELECT deadline_rules WHERE
|
||||
// trigger_event_id matches. Nil = no event-type leg.
|
||||
EventTypeID *uuid.UUID
|
||||
// ConceptID matches deadline_rules.concept_id directly (the
|
||||
// Pipeline-A cascade leaf semantic that the result-card click
|
||||
// flow uses). Nil = no concept leg.
|
||||
ConceptID *uuid.UUID
|
||||
// TriggerDate is the anchor for the calculator. Required.
|
||||
// Format: YYYY-MM-DD.
|
||||
TriggerDate string
|
||||
// Flags is the caller's flag set used by evalConditionExpr to
|
||||
// gate / swap rules (e.g. with_ccr → alt-swap on flag-met).
|
||||
Flags []string
|
||||
// CourtID picks the (country, regime) tuple for non-working-day
|
||||
// arithmetic. Empty falls back to DE / UPC (UPC München default).
|
||||
CourtID string
|
||||
// Perspective filters opposing-side rules out of the response.
|
||||
// Empty = no filter (return rules for every party).
|
||||
Perspective string
|
||||
}
|
||||
|
||||
// Trigger discovers rules and computes their deadlines, returning
|
||||
// the same UIResponse shape as FristenrechnerService.Calculate so
|
||||
// the frontend can render with one renderer. Mutates no state.
|
||||
func (s *EventTriggerService) Trigger(ctx context.Context, input EventTriggerInput) (*UIResponse, error) {
|
||||
if input.EventTypeID == nil && input.ConceptID == nil {
|
||||
return nil, fmt.Errorf("%w: event_type_id or concept_id required", ErrInvalidInput)
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", input.TriggerDate)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: trigger_date must be YYYY-MM-DD (got %q)", ErrInvalidInput, input.TriggerDate)
|
||||
}
|
||||
|
||||
// Pipeline-C rules originate from the UPC-flavoured corpus —
|
||||
// default DE / UPC for the holiday calendar so this surface
|
||||
// matches EventDeadlineService.Calculate's behaviour when the
|
||||
// caller doesn't pick a specific court.
|
||||
country, regime, err := s.courts.CountryRegime(input.CourtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", input.CourtID, err)
|
||||
}
|
||||
|
||||
rules, err := s.discoverRules(ctx, input.EventTypeID, input.ConceptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
flagSet := make(map[string]struct{}, len(input.Flags))
|
||||
for _, f := range input.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if !matchesPerspective(r.PrimaryParty, input.Perspective) {
|
||||
continue
|
||||
}
|
||||
|
||||
gateMet := evalConditionExpr([]byte(r.ConditionExpr), []string(r.ConditionFlag), flagSet)
|
||||
if !gateMet && r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
|
||||
// Legacy alt-swap (flag-keyed) is mutually exclusive with
|
||||
// combine_op composite in the live corpus; the same guard
|
||||
// FristenrechnerService.Calculate uses applies here.
|
||||
durationValue := r.DurationValue
|
||||
durationUnit := r.DurationUnit
|
||||
if r.CombineOp == nil && gateMet && len(r.ConditionFlag) > 0 && r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
}
|
||||
}
|
||||
|
||||
origDate, adjusted, wasAdj, reason := applyDuration(
|
||||
triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altOrig, altAdj, altWasAdj, altReason := applyDuration(
|
||||
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
IsCourtSet: r.IsCourtSet,
|
||||
DueDate: adjusted.Format("2006-01-02"),
|
||||
OriginalDate: origDate.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
}
|
||||
if r.Code != nil {
|
||||
d.Code = *r.Code
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
// Court-set rules surface IsCourtSet=true and clear the
|
||||
// computed date — matches the proceeding-tree calculator's
|
||||
// "wird vom Gericht bestimmt" rendering.
|
||||
if r.IsCourtSet {
|
||||
d.DueDate = ""
|
||||
d.OriginalDate = ""
|
||||
d.WasAdjusted = false
|
||||
d.AdjustmentReason = nil
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
return &UIResponse{
|
||||
// Event-trigger responses don't carry proceeding metadata —
|
||||
// the caller already has the event_type / concept context
|
||||
// (they're in the request). Leaving these empty is the
|
||||
// stable contract; FristenrechnerService.calculateByTriggerEvent
|
||||
// (the Pipeline-C delegate) does the same.
|
||||
ProceedingType: "",
|
||||
ProceedingName: "",
|
||||
TriggerDate: input.TriggerDate,
|
||||
Deadlines: deadlines,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// discoverRules returns the UNION of rules triggered by the
|
||||
// event-type and concept inputs, deduped by rule.id. Either input
|
||||
// may be nil — the corresponding branch is skipped.
|
||||
func (s *EventTriggerService) discoverRules(ctx context.Context, eventTypeID, conceptID *uuid.UUID) ([]models.DeadlineRule, error) {
|
||||
seen := make(map[uuid.UUID]struct{})
|
||||
out := make([]models.DeadlineRule, 0, 16)
|
||||
|
||||
if eventTypeID != nil {
|
||||
// event_types.trigger_event_id is nullable on the column but
|
||||
// every active row in the corpus today carries a bigint here
|
||||
// (the row is the bridge to the Pipeline-C corpus). NULL is
|
||||
// possible for future hand-edited event_types; treat as "no
|
||||
// rules triggered" rather than an error.
|
||||
var triggerEventID sql.NullInt64
|
||||
err := s.db.GetContext(ctx, &triggerEventID,
|
||||
`SELECT trigger_event_id
|
||||
FROM paliad.event_types
|
||||
WHERE id = $1 AND archived_at IS NULL`, *eventTypeID)
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("%w: event_type_id=%s not found", ErrInvalidInput, *eventTypeID)
|
||||
}
|
||||
return nil, fmt.Errorf("lookup event_type: %w", err)
|
||||
}
|
||||
if triggerEventID.Valid {
|
||||
byTrigger, err := s.rules.ListByTriggerEvent(ctx, triggerEventID.Int64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range byTrigger {
|
||||
if _, ok := seen[r.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.ID] = struct{}{}
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if conceptID != nil {
|
||||
byConcept, err := s.rules.ListByConcept(ctx, *conceptID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range byConcept {
|
||||
if _, ok := seen[r.ID]; ok {
|
||||
continue
|
||||
}
|
||||
seen[r.ID] = struct{}{}
|
||||
out = append(out, r)
|
||||
}
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// matchesPerspective returns true iff a rule whose primary_party is
|
||||
// `party` (may be nil/empty) should render under the given
|
||||
// perspective filter. Empty perspective passes everything through.
|
||||
// Rules without a party (NULL primary_party) always render — the
|
||||
// caller didn't ask the system to take a side for these.
|
||||
//
|
||||
// The drop-only-on-explicit-mismatch policy keeps 'both' / 'court'
|
||||
// / NULL rules visible and only filters claimant↔defendant pairs.
|
||||
func matchesPerspective(party *string, perspective string) bool {
|
||||
if perspective == "" || party == nil {
|
||||
return true
|
||||
}
|
||||
switch perspective {
|
||||
case "claimant":
|
||||
return *party != "defendant"
|
||||
case "defendant":
|
||||
return *party != "claimant"
|
||||
default:
|
||||
// Unknown perspective: pass-through. Phase 3 Slice 8 will
|
||||
// surface the allowed set; until then the API is forgiving.
|
||||
return true
|
||||
}
|
||||
}
|
||||
243
internal/services/event_trigger_service_test.go
Normal file
243
internal/services/event_trigger_service_test.go
Normal file
@@ -0,0 +1,243 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestEventTriggerService_Trigger covers the Phase 3 Slice 6
|
||||
// (t-paliad-187) entry point. The service is pure additive — it
|
||||
// discovers rules via either event_type_id (Pipeline-C bridge) or
|
||||
// concept_id (Pipeline-A direct FK) or both, and runs them through
|
||||
// the unified Slice-4 helpers (applyDuration + evalConditionExpr +
|
||||
// wireFlagsFromPriority).
|
||||
//
|
||||
// Live-DB test (TEST_DATABASE_URL gated) exercising:
|
||||
//
|
||||
// 1. Validation: missing both event_type_id + concept_id → ErrInvalidInput.
|
||||
// 2. event_type_id only — parity check against EventDeadlineService.Calculate
|
||||
// (the Slice-3 legacy delegate) on a known trigger_event_id. Both code
|
||||
// paths share the unified backend post-Slice-4 so the dates must match
|
||||
// exactly.
|
||||
// 3. concept_id only — returns the rules linked via deadline_rules.concept_id
|
||||
// FK. We pick any concept that has at least one active rule and assert
|
||||
// the rule count + first rule's id match.
|
||||
// 4. Both together — UNION dedupe. Picking event_type_id whose
|
||||
// trigger_event_id maps to a rule that ALSO sits under the chosen
|
||||
// concept_id would let us verify dedup; today's corpus has them on
|
||||
// disjoint paths so we just verify count(event+concept) ==
|
||||
// count(event-only) + count(concept-only).
|
||||
// 5. Invalid event_type_id → ErrInvalidInput (404-ish).
|
||||
// 6. Invalid trigger_date format → ErrInvalidInput.
|
||||
// 7. Perspective filter — drops claimant rules when perspective=defendant.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestEventTriggerService_Trigger(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
eventDeadline := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
|
||||
svc := NewEventTriggerService(pool, rules, holidays, courts)
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Validation: missing both event_type_id + concept_id.
|
||||
// -----------------------------------------------------------------
|
||||
_, err = svc.Trigger(ctx, EventTriggerInput{TriggerDate: "2026-01-15"})
|
||||
if err == nil {
|
||||
t.Error("missing event_type_id + concept_id should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("missing-both: want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// 6. Invalid trigger_date.
|
||||
someUUID := uuid.New()
|
||||
_, err = svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &someUUID, TriggerDate: "2026-99-99",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("invalid trigger_date should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("bad-date: want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// 5. Invalid event_type_id (random UUID).
|
||||
_, err = svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &someUUID, TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("random event_type_id should fail; got nil")
|
||||
} else if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("bad-event-type: want ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Pick a live event_type that bridges to a non-empty Pipeline-C rule set.
|
||||
// -----------------------------------------------------------------
|
||||
type etRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
TriggerEventID int64 `db:"trigger_event_id"`
|
||||
}
|
||||
var et etRow
|
||||
if err := pool.GetContext(ctx, &et, `
|
||||
SELECT et.id, et.trigger_event_id
|
||||
FROM paliad.event_types et
|
||||
JOIN paliad.deadline_rules dr ON dr.trigger_event_id = et.trigger_event_id
|
||||
WHERE et.archived_at IS NULL
|
||||
AND et.trigger_event_id IS NOT NULL
|
||||
AND dr.is_active = true
|
||||
LIMIT 1`); err != nil {
|
||||
t.Fatalf("locate live event_type with rules: %v", err)
|
||||
}
|
||||
|
||||
// 2. event_type_id only — count matches the Slice-3 delegate's count.
|
||||
resp, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &et.ID,
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("event_type_id Trigger: %v", err)
|
||||
}
|
||||
if len(resp.Deadlines) == 0 {
|
||||
t.Fatal("event_type_id Trigger returned no deadlines — picked event_type has none?")
|
||||
}
|
||||
|
||||
// Parity proxy: EventDeadlineService.Calculate on the same trigger
|
||||
// should return rules with identical names (event_deadlines.title_de
|
||||
// = deadline_rules.name post-mig 085). We compare names as multisets.
|
||||
legacy, err := eventDeadline.Calculate(ctx, et.TriggerEventID, "2026-01-15", "")
|
||||
if err != nil {
|
||||
t.Fatalf("legacy Calculate: %v", err)
|
||||
}
|
||||
if len(legacy.Deadlines) != len(resp.Deadlines) {
|
||||
t.Errorf("rule-count parity: trigger=%d, legacy=%d", len(resp.Deadlines), len(legacy.Deadlines))
|
||||
}
|
||||
legacyNames := make(map[string]int, len(legacy.Deadlines))
|
||||
for _, d := range legacy.Deadlines {
|
||||
legacyNames[d.TitleDE]++
|
||||
}
|
||||
triggerNames := make(map[string]int, len(resp.Deadlines))
|
||||
for _, d := range resp.Deadlines {
|
||||
triggerNames[d.Name]++
|
||||
}
|
||||
for name, n := range legacyNames {
|
||||
if triggerNames[name] != n {
|
||||
t.Errorf("name multiset diverges at %q: trigger=%d, legacy=%d",
|
||||
name, triggerNames[name], n)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. concept_id only.
|
||||
// -----------------------------------------------------------------
|
||||
var conceptID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &conceptID, `
|
||||
SELECT dc.id
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.deadline_rules dr ON dr.concept_id = dc.id
|
||||
WHERE dc.is_active = true
|
||||
AND dr.is_active = true
|
||||
GROUP BY dc.id
|
||||
ORDER BY count(dr.id) DESC
|
||||
LIMIT 1`); err != nil {
|
||||
t.Fatalf("locate live concept with rules: %v", err)
|
||||
}
|
||||
|
||||
conceptResp, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
ConceptID: &conceptID,
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("concept_id Trigger: %v", err)
|
||||
}
|
||||
if len(conceptResp.Deadlines) == 0 {
|
||||
t.Fatal("concept_id Trigger returned no deadlines")
|
||||
}
|
||||
// Spot-check: every returned rule's RuleID should be a UUID
|
||||
// (Pipeline-A rules carry uuid ids via the concept FK).
|
||||
for _, d := range conceptResp.Deadlines {
|
||||
if _, perr := uuid.Parse(d.RuleID); perr != nil {
|
||||
t.Errorf("concept rule has non-UUID RuleID=%q", d.RuleID)
|
||||
}
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 4. Both together — UNION dedupe. Today's corpus has Pipeline-C
|
||||
// rules with NULL concept_id and Pipeline-A rules with NULL
|
||||
// trigger_event_id, so the two sets are disjoint; the UNION
|
||||
// count equals the sum.
|
||||
// -----------------------------------------------------------------
|
||||
both, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
EventTypeID: &et.ID,
|
||||
ConceptID: &conceptID,
|
||||
TriggerDate: "2026-01-15",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("both Trigger: %v", err)
|
||||
}
|
||||
if len(both.Deadlines) != len(resp.Deadlines)+len(conceptResp.Deadlines) {
|
||||
// Note: if a future seed links a concept to a Pipeline-C
|
||||
// rule (concept_id set on a trigger_event-keyed rule), the
|
||||
// dedupe branch would actually fire and the count would
|
||||
// drop. Surface the count divergence so we can adjust the
|
||||
// expectation rather than silently passing.
|
||||
t.Logf("UNION count: both=%d, event_only=%d, concept_only=%d — "+
|
||||
"non-additive count means dedupe fired (acceptable but note for review)",
|
||||
len(both.Deadlines), len(resp.Deadlines), len(conceptResp.Deadlines))
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 7. Perspective filter — drops claimant rules when defendant.
|
||||
// -----------------------------------------------------------------
|
||||
// Locate a concept whose rules include both claimant + defendant
|
||||
// parties so we can verify the filter drops the opposing side.
|
||||
var partyConceptID uuid.UUID
|
||||
if err := pool.GetContext(ctx, &partyConceptID, `
|
||||
SELECT dc.id
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.deadline_rules dr_c ON dr_c.concept_id = dc.id AND dr_c.primary_party = 'claimant' AND dr_c.is_active = true
|
||||
JOIN paliad.deadline_rules dr_d ON dr_d.concept_id = dc.id AND dr_d.primary_party = 'defendant' AND dr_d.is_active = true
|
||||
LIMIT 1`); err != nil {
|
||||
// Not every concept has both parties — accept skip when the
|
||||
// corpus lacks a mixed concept. Don't fail the test.
|
||||
t.Logf("perspective filter test skipped: no concept with mixed claimant+defendant rules (%v)", err)
|
||||
return
|
||||
}
|
||||
|
||||
defendantOnly, err := svc.Trigger(ctx, EventTriggerInput{
|
||||
ConceptID: &partyConceptID,
|
||||
TriggerDate: "2026-01-15",
|
||||
Perspective: "defendant",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("defendant-perspective Trigger: %v", err)
|
||||
}
|
||||
for _, d := range defendantOnly.Deadlines {
|
||||
if d.Party == "claimant" {
|
||||
t.Errorf("defendant perspective leaked claimant rule: %s (%s)", d.Code, d.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
@@ -110,6 +111,15 @@ type CalcOptions struct {
|
||||
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
||||
// behaviour for callers that don't yet send a court.
|
||||
CourtID string
|
||||
// TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
||||
// rules: when non-nil, the proceedingCode argument is ignored and the
|
||||
// service selects rules WHERE trigger_event_id = *TriggerEventIDFilter
|
||||
// instead of WHERE proceeding_type_id = .... Set by
|
||||
// EventDeadlineService.Calculate so the unified backend can serve the
|
||||
// "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width
|
||||
// matches paliad.trigger_events.id (bigint, mig 028). See design
|
||||
// §3.D (calculator unification).
|
||||
TriggerEventIDFilter *int64
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -137,6 +147,16 @@ type CalcOptions struct {
|
||||
// date. Used for court-extended deadlines and for entering
|
||||
// court-set decision dates post-hoc.
|
||||
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
||||
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
|
||||
// branch (Pipeline-C unified rules; mig 085 moved 77 rows out of
|
||||
// paliad.event_deadlines into paliad.deadline_rules carrying a
|
||||
// non-NULL trigger_event_id). proceedingCode is ignored on this
|
||||
// path. EventDeadlineService.Calculate is the sole caller today;
|
||||
// future "event-trigger" surfaces (design §5) plug in here too.
|
||||
if opts.TriggerEventIDFilter != nil {
|
||||
return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts)
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
@@ -208,27 +228,30 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
|
||||
for _, r := range rules {
|
||||
// Flag-gate: rule with a non-empty condition_flag array renders
|
||||
// iff every element is in flagSet. Suppressed rules don't appear
|
||||
// at all (distinct from the alt-* swap, which still renders).
|
||||
// Single-element arrays preserve the old "swap to alt" semantic
|
||||
// when alt_duration_value is non-NULL — see allFlagsSet docs.
|
||||
if len(r.ConditionFlag) > 0 && !allFlagsSet(r.ConditionFlag, flagSet) {
|
||||
// When the rule has alt_duration_value, it's a "swap-on-flag"
|
||||
// rule (legacy with_ccr pattern): always render, just don't
|
||||
// apply the swap. When alt_duration_value is NULL, the rule
|
||||
// is purely conditional — suppress entirely.
|
||||
if r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
// Phase-3 unified gate: evaluate condition_expr (jsonb) with
|
||||
// fallback to condition_flag (legacy text[]) AND-semantics.
|
||||
// Suppression semantic preserved: when the gate fires false AND
|
||||
// no alt_* values exist, the rule is dropped from the timeline
|
||||
// entirely (purely conditional). When alt_* values exist, the
|
||||
// gate-false branch still renders, just without the alt-swap
|
||||
// (legacy "swap-on-flag" pattern, e.g. with_ccr).
|
||||
gateMet := evalConditionExpr([]byte(r.ConditionExpr), []string(r.ConditionFlag), flagSet)
|
||||
if !gateMet && r.AltDurationValue == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Wire-compat: derive the legacy (IsMandatory, IsOptional) pair
|
||||
// from the unified priority enum so /tools/fristenrechner's
|
||||
// frontend keeps reading the same fields. Slice 8 will swap the
|
||||
// wire to emit priority directly.
|
||||
wireMand, wireOpt := wireFlagsFromPriority(r.Priority)
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: r.IsMandatory,
|
||||
IsOptional: r.IsOptional,
|
||||
IsMandatory: wireMand,
|
||||
IsOptional: wireOpt,
|
||||
}
|
||||
if r.Code != nil {
|
||||
d.Code = *r.Code
|
||||
@@ -297,7 +320,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}
|
||||
}
|
||||
|
||||
if r.ParentID == nil && !isCourtDeterminedRule(r) {
|
||||
if r.ParentID == nil && !r.IsCourtSet {
|
||||
// Bucket 1: timeline anchor.
|
||||
d.IsRootEvent = true
|
||||
d.DueDate = triggerDateStr
|
||||
@@ -305,7 +328,7 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
if r.Code != nil {
|
||||
computed[*r.Code] = triggerDate
|
||||
}
|
||||
} else if r.ParentID != nil && !isCourtDeterminedRule(r) {
|
||||
} else if r.ParentID != nil && !r.IsCourtSet {
|
||||
// Bucket 4: filed-with-parent. Inherit parent's date.
|
||||
// If parent is court-set, we have nothing to inherit —
|
||||
// fall through to court-set marking.
|
||||
@@ -416,15 +439,20 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}
|
||||
}
|
||||
|
||||
// Flag-conditioned alt: when every flag in condition_flag is in
|
||||
// flagSet AND alt_duration_value is non-NULL, swap to alt_*.
|
||||
// (Suppression of all-flags-not-set rules already handled above.)
|
||||
// Flag-conditioned alt-swap (legacy with_ccr pattern): when the
|
||||
// gate fires AND alt_* values exist, swap the primary duration
|
||||
// to the alt values. This is distinct from combine_op below —
|
||||
// alt-swap is a one-or-the-other choice keyed on flags, whereas
|
||||
// combine_op computes both legs and picks max/min. Mutually
|
||||
// exclusive in the live corpus today (no rule sets both).
|
||||
durationValue := r.DurationValue
|
||||
durationUnit := r.DurationUnit
|
||||
if len(r.ConditionFlag) > 0 && allFlagsSet(r.ConditionFlag, flagSet) {
|
||||
if r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
}
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
if r.CombineOp == nil && gateMet && len(r.ConditionFlag) > 0 && r.AltDurationValue != nil {
|
||||
durationValue = *r.AltDurationValue
|
||||
if r.AltDurationUnit != nil {
|
||||
durationUnit = *r.AltDurationUnit
|
||||
}
|
||||
@@ -450,9 +478,31 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
|
||||
}
|
||||
}
|
||||
|
||||
endDate := addDuration(baseDate, durationValue, durationUnit)
|
||||
origDate := endDate
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate, country, regime)
|
||||
origDate, adjusted, wasAdj, reason := applyDuration(
|
||||
baseDate, durationValue, durationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
|
||||
// combine_op composite: compute the alt leg too, apply max/min.
|
||||
// No proceeding-tree rules carry combine_op today (it's a
|
||||
// future-friendly column the rule editor will surface). When
|
||||
// present, the gate-met / alt-swap branch above has been
|
||||
// skipped, so the comparison is between the unmodified base
|
||||
// (durationValue/Unit) and the alt (AltDurationValue/Unit).
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altOrig, altAdj, altWasAdj, altReason := applyDuration(
|
||||
baseDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(adjusted) {
|
||||
origDate, adjusted, wasAdj, reason = altOrig, altAdj, altWasAdj, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d.OriginalDate = origDate.Format("2006-01-02")
|
||||
d.DueDate = adjusted.Format("2006-01-02")
|
||||
@@ -615,21 +665,22 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
}
|
||||
|
||||
// Court-determined: no calculable date.
|
||||
if isCourtDeterminedRule(*rule) {
|
||||
if rule.IsCourtSet {
|
||||
out.IsCourtSet = true
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Resolve flag-conditional duration. Same semantics as Calculate
|
||||
// (services/fristenrechner.go:368): all flags satisfied + alt
|
||||
// values present → swap; otherwise use base values.
|
||||
// Resolve flag-conditional duration via the unified condition_expr
|
||||
// evaluator (Slice 4). Same semantics as Calculate: gate met + alt
|
||||
// values present → swap to alt; otherwise use base values.
|
||||
flagSet := make(map[string]struct{}, len(params.Flags))
|
||||
for _, f := range params.Flags {
|
||||
flagSet[f] = struct{}{}
|
||||
}
|
||||
durationValue := rule.DurationValue
|
||||
durationUnit := rule.DurationUnit
|
||||
if len(rule.ConditionFlag) > 0 && allFlagsSet(rule.ConditionFlag, flagSet) {
|
||||
gateMet := evalConditionExpr([]byte(rule.ConditionExpr), []string(rule.ConditionFlag), flagSet)
|
||||
if gateMet && len(rule.ConditionFlag) > 0 {
|
||||
out.FlagsApplied = []string(rule.ConditionFlag)
|
||||
if rule.AltDurationValue != nil {
|
||||
durationValue = *rule.AltDurationValue
|
||||
@@ -659,8 +710,13 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
return nil, fmt.Errorf("resolve court %q: %w", params.CourtID, err)
|
||||
}
|
||||
|
||||
endDate := addDuration(triggerDate, durationValue, durationUnit)
|
||||
adjusted, _, wasAdj, reason := s.holidays.AdjustForNonWorkingDaysWithReason(endDate, country, regime)
|
||||
timing := ""
|
||||
if rule.Timing != nil {
|
||||
timing = *rule.Timing
|
||||
}
|
||||
endDate, adjusted, wasAdj, reason := applyDuration(
|
||||
triggerDate, durationValue, durationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
out.OriginalDate = endDate.Format("2006-01-02")
|
||||
out.DueDate = adjusted.Format("2006-01-02")
|
||||
out.WasAdjusted = wasAdj
|
||||
@@ -767,33 +823,12 @@ type FristenrechnerType struct {
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// isCourtDeterminedRule returns true when a deadline rule represents an
|
||||
// event the court (not a party) sets the date for — Zwischenverfahren,
|
||||
// Mündliche Verhandlung, Entscheidung, Beschluss, etc. These have no
|
||||
// statutory deadline that can be calculated; the date depends on the
|
||||
// court's docket and is only known once the court communicates it.
|
||||
//
|
||||
// Discriminator: primary_party = 'court' OR event_type ∈ {hearing,
|
||||
// decision, order}. Both signals are populated by migration 012; we
|
||||
// accept either so future rules don't have to set both to be detected.
|
||||
func isCourtDeterminedRule(r models.DeadlineRule) bool {
|
||||
if r.PrimaryParty != nil && *r.PrimaryParty == "court" {
|
||||
return true
|
||||
}
|
||||
if r.EventType != nil {
|
||||
switch *r.EventType {
|
||||
case "hearing", "decision", "order":
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// allFlagsSet returns true when every element of `required` is present in
|
||||
// `set`. Empty `required` returns true (no condition). Used by the
|
||||
// flag-conditional rule machinery to decide whether to apply a rule's
|
||||
// alt_* swap (legacy single-flag with_ccr pattern still works because a
|
||||
// single-element array {"with_ccr"} matches iff "with_ccr" is set).
|
||||
// `set`. Empty `required` returns true (no condition). Retained as the
|
||||
// fallback predicate used by evalConditionExpr when condition_expr is
|
||||
// NULL but the legacy condition_flag text[] is set — preserves
|
||||
// transition-window behaviour for any row Slice 2 missed (it shouldn't,
|
||||
// but defensive).
|
||||
func allFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
for _, f := range required {
|
||||
if _, ok := set[f]; !ok {
|
||||
@@ -803,18 +838,289 @@ func allFlagsSet(required []string, set map[string]struct{}) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// addDuration adds a signed duration value/unit to a base date.
|
||||
func addDuration(base time.Time, value int, unit string) time.Time {
|
||||
// evalConditionExpr returns true iff the rule's gate predicate is
|
||||
// satisfied for the caller's flag set. Drives flag-conditional rendering
|
||||
// + flag-conditional alt-swap throughout the calculator.
|
||||
//
|
||||
// Grammar (design §2.4 long form, mig 084 backfill):
|
||||
//
|
||||
// {"flag": "<name>"} — leaf: true iff <name> ∈ flags
|
||||
// {"op": "and", "args": [<n>...]} — true iff every arg evaluates true
|
||||
// {"op": "or", "args": [<n>...]} — true iff any arg evaluates true
|
||||
// {"op": "not", "args": [<one>]} — true iff the single arg is false
|
||||
//
|
||||
// NULL / empty / "null" expression → true (unconditional). Malformed
|
||||
// JSON → true (defensive: the rule still renders, the lawyer sees
|
||||
// it even if the gate is broken).
|
||||
//
|
||||
// Fallback: when expr is NULL but the legacy condition_flag text[] is
|
||||
// set, evaluate AND-semantics over condition_flag — preserves
|
||||
// pre-Slice-2 behaviour for the (defensive, shouldn't-happen) case
|
||||
// where mig 084 missed a row.
|
||||
func evalConditionExpr(expr []byte, conditionFlag []string, flags map[string]struct{}) bool {
|
||||
if len(expr) == 0 || string(expr) == "null" {
|
||||
if len(conditionFlag) == 0 {
|
||||
return true
|
||||
}
|
||||
return allFlagsSet(conditionFlag, flags)
|
||||
}
|
||||
return evalConditionExprNode(expr, flags)
|
||||
}
|
||||
|
||||
// evalConditionExprNode walks one node of the condition_expr jsonb
|
||||
// tree. Recursion depth is bounded by the editor (Slice 11 caps tree
|
||||
// depth + arg count); pre-Slice-11 backfilled rows have at most a
|
||||
// 2-arg AND (mig 084).
|
||||
func evalConditionExprNode(raw []byte, flags map[string]struct{}) bool {
|
||||
var node struct {
|
||||
Flag string `json:"flag"`
|
||||
Op string `json:"op"`
|
||||
Args []json.RawMessage `json:"args"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &node); err != nil {
|
||||
// Malformed → unconditional. The Slice 11 editor's validation
|
||||
// will block such writes; in the live corpus today mig 084's
|
||||
// jsonb_build_object output is well-formed by construction.
|
||||
return true
|
||||
}
|
||||
if node.Flag != "" {
|
||||
_, ok := flags[node.Flag]
|
||||
return ok
|
||||
}
|
||||
switch node.Op {
|
||||
case "and":
|
||||
for _, a := range node.Args {
|
||||
if !evalConditionExprNode(a, flags) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
case "or":
|
||||
for _, a := range node.Args {
|
||||
if evalConditionExprNode(a, flags) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case "not":
|
||||
if len(node.Args) != 1 {
|
||||
// Malformed NOT — fall through to unconditional rather
|
||||
// than risk suppressing a rule the lawyer expects to see.
|
||||
return true
|
||||
}
|
||||
return !evalConditionExprNode(node.Args[0], flags)
|
||||
}
|
||||
// Unknown op (forward-compat with editor extensions): treat as
|
||||
// unconditional so the rule still renders.
|
||||
return true
|
||||
}
|
||||
|
||||
// wireFlagsFromPriority derives the legacy (IsMandatory, IsOptional)
|
||||
// pair from the unified priority enum so the wire shape stays
|
||||
// pixel-identical through Slice 4. Slice 8 will swap the wire to
|
||||
// emit priority directly. Mapping is the exact reverse of mig 083's
|
||||
// backfill (per design §2.3):
|
||||
//
|
||||
// 'mandatory' → (true, false) — statutory must, ☑ pre-checked
|
||||
// 'optional' → (true, true) — RoP.151 case: strict but opt-in,
|
||||
// ☐ pre-unchecked save modal
|
||||
// 'recommended' → (false, false) — situational filing, save by default
|
||||
// with override (legacy F/F semantic)
|
||||
// 'informational' → (false, false) — never saves; today no live rows
|
||||
// carry it. Future: surfaces as a
|
||||
// notice card in the timeline.
|
||||
// (unknown) → (true, false) — safe default; treat as mandatory
|
||||
// so we never silently drop a rule.
|
||||
func wireFlagsFromPriority(priority string) (isMandatory, isOptional bool) {
|
||||
switch priority {
|
||||
case "mandatory":
|
||||
return true, false
|
||||
case "optional":
|
||||
return true, true
|
||||
case "recommended":
|
||||
return false, false
|
||||
case "informational":
|
||||
return false, false
|
||||
default:
|
||||
return true, false
|
||||
}
|
||||
}
|
||||
|
||||
// applyDuration is the unified date-arithmetic helper used by every
|
||||
// calculator path (Pipeline-A proceeding-tree, Pipeline-C trigger-event,
|
||||
// CalculateRule single-rule). Phase 3 Slice 4 (t-paliad-185) replaces
|
||||
// the prior split between addDuration (proceeding-tree, no timing /
|
||||
// working_days) and applyDurationOnCalendar (Pipeline-C, full support).
|
||||
//
|
||||
// Returns (raw, adjusted, didAdjust, reason):
|
||||
//
|
||||
// - raw: the date strictly implied by the rule before rollover.
|
||||
// - adjusted: post-rollover for calendar units. 'working_days' lands
|
||||
// on a working day by construction so raw == adjusted there.
|
||||
// - didAdjust: true iff rollover moved the date.
|
||||
// - reason: populated when didAdjust is true; nil otherwise.
|
||||
//
|
||||
// timing='before' negates the sign. timing='after' (or any other value
|
||||
// including the empty string) keeps it positive — preserves the
|
||||
// pre-Slice-4 behaviour for proceeding-tree rules whose Timing field
|
||||
// is sometimes NULL (mig 003 defaults to 'after' but legacy callers
|
||||
// pass r.Timing dereferenced).
|
||||
func applyDuration(
|
||||
base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService,
|
||||
) (raw, adjusted time.Time, didAdjust bool, reason *AdjustmentReason) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
switch unit {
|
||||
case "days":
|
||||
return base.AddDate(0, 0, value)
|
||||
raw = base.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
return base.AddDate(0, 0, value*7)
|
||||
raw = base.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
return base.AddDate(0, value, 0)
|
||||
raw = base.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = addWorkingDays(base, sign*value, country, regime, holidays)
|
||||
// Working-day arithmetic lands on a working day by construction
|
||||
// — the per-step skip loop in addWorkingDays already passes over
|
||||
// weekends and holidays. No post-rollover required.
|
||||
return raw, raw, false, nil
|
||||
default:
|
||||
return base
|
||||
raw = base
|
||||
}
|
||||
adjusted, _, didAdjust, reason = holidays.AdjustForNonWorkingDaysWithReason(raw, country, regime)
|
||||
return raw, adjusted, didAdjust, reason
|
||||
}
|
||||
|
||||
// addWorkingDays advances from `from` by `n` working days, skipping
|
||||
// weekends and holidays applicable to the given country/regime. Negative
|
||||
// n walks backward. n=0 keeps the input date as-is (caller decides
|
||||
// whether to roll forward via AdjustForNonWorkingDays).
|
||||
//
|
||||
// Bounded by an inner 30-step skip per advance — vacation runs in our
|
||||
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
|
||||
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
|
||||
if n == 0 {
|
||||
return from
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step = -1
|
||||
n = -n
|
||||
}
|
||||
cur := from
|
||||
for i := 0; i < n; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
|
||||
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
|
||||
// chains), have no flag gating, no priority_date alt-anchor, no party
|
||||
// classification, and no IsRootEvent / IsCourtSet semantics. The math
|
||||
// is just: base + (timing-signed) duration → optional alt-leg combine
|
||||
// → optional weekend/holiday rollover for calendar units.
|
||||
//
|
||||
// UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService
|
||||
// owns the trigger-event metadata (it's the caller that needed it
|
||||
// pre-Slice-3 and continues to load it for the legacy CalculateResponse
|
||||
// shape). Callers that don't need those fields can ignore them.
|
||||
func (s *FristenrechnerService) calculateByTriggerEvent(
|
||||
ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions,
|
||||
) (*UIResponse, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
|
||||
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
|
||||
country, regime, err := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
baseRaw, baseAdj, baseChanged, baseReason := applyDuration(
|
||||
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
picked := baseAdj
|
||||
original := baseRaw
|
||||
wasAdj := baseChanged
|
||||
reason := baseReason
|
||||
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altRaw, altAdj, altChanged, altReason := applyDuration(
|
||||
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(baseAdj) {
|
||||
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(baseAdj) {
|
||||
picked, original, wasAdj, reason = altAdj, altRaw, altChanged, altReason
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: r.IsMandatory,
|
||||
IsOptional: r.IsOptional,
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
}
|
||||
if r.Code != nil {
|
||||
d.Code = *r.Code
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
return &UIResponse{
|
||||
// Trigger-event responses don't carry proceeding metadata —
|
||||
// EventDeadlineService.Calculate fills the trigger fields in the
|
||||
// legacy CalculateResponse shape. Leaving these empty is the
|
||||
// stable contract.
|
||||
ProceedingType: "",
|
||||
ProceedingName: "",
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||
|
||||
@@ -4,71 +4,21 @@ import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// TestIsCourtDeterminedRule covers the discriminator used by Calculate to
|
||||
// classify zero-duration rules as court-set waypoints rather than
|
||||
// trigger-anchored root events. t-paliad-111 B3 — without this gate the
|
||||
// Fristenrechner emitted the trigger date as the placeholder date for
|
||||
// Zwischenverfahren / Mündliche Verhandlung / Entscheidung and any
|
||||
// downstream rule (e.g. RoP.151 Antrag auf Kostenentscheidung) that
|
||||
// chained off them.
|
||||
func TestIsCourtDeterminedRule(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
rule models.DeadlineRule
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "primary_party=court → court-set",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("court"), EventType: ptr("hearing")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "event_type=hearing → court-set even when party is defendant (PI response)",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("defendant"), EventType: ptr("hearing")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "event_type=decision → court-set",
|
||||
rule: models.DeadlineRule{EventType: ptr("decision")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "event_type=order → court-set",
|
||||
rule: models.DeadlineRule{EventType: ptr("order")},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "claimant filing (e.g. inf.soc Klageerhebung) → NOT court-set, anchors trigger",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("claimant"), EventType: ptr("filing")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "defendant filing with no court signals → NOT court-set",
|
||||
rule: models.DeadlineRule{PrimaryParty: ptr("defendant"), EventType: ptr("filing")},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "nil party + nil event_type → NOT court-set",
|
||||
rule: models.DeadlineRule{},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := isCourtDeterminedRule(tc.rule); got != tc.want {
|
||||
t.Errorf("isCourtDeterminedRule = %v, want %v", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
// Phase 3 Slice 4 (t-paliad-185) dropped isCourtDeterminedRule: the
|
||||
// is_court_set column (mig 078) backfilled in Slice 2 (mig 082) is now
|
||||
// the source-of-truth. Calculate reads r.IsCourtSet directly. The
|
||||
// runtime equivalence of the old heuristic vs the column was verified
|
||||
// by the Slice 2 backfill integrity test (priority + is_court_set +
|
||||
// condition_expr). The seven-case discrimination matrix the old test
|
||||
// exercised lives now as the migration 082 WHERE predicate.
|
||||
|
||||
// TestAllFlagsSet covers the t-paliad-131 condition_flag text→text[]
|
||||
// migration semantic. A rule's flags array gates rendering: every
|
||||
@@ -233,3 +183,220 @@ func TestCalculateRule(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestEvalConditionExpr covers the Phase 3 Slice 4 (t-paliad-185)
|
||||
// jsonb gate evaluator. Long-form grammar per design §2.4: leaf
|
||||
// {"flag":"X"}, AND / OR / NOT compositions. Single-flag values pass
|
||||
// through unwrapped. NULL / empty expression falls back to
|
||||
// condition_flag AND-semantics.
|
||||
func TestEvalConditionExpr(t *testing.T) {
|
||||
mkSet := func(fs ...string) map[string]struct{} {
|
||||
m := make(map[string]struct{}, len(fs))
|
||||
for _, f := range fs {
|
||||
m[f] = struct{}{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
expr string
|
||||
legacyFlag []string
|
||||
flags map[string]struct{}
|
||||
want bool
|
||||
}{
|
||||
// NULL expr — fall back to legacy condition_flag AND-semantics.
|
||||
{"NULL expr, no legacy flag → unconditional",
|
||||
"", nil, mkSet(), true},
|
||||
{"NULL expr, legacy flag absent → suppressed",
|
||||
"", []string{"with_ccr"}, mkSet(), false},
|
||||
{"NULL expr, legacy flag present → true",
|
||||
"", []string{"with_ccr"}, mkSet("with_ccr"), true},
|
||||
{"NULL expr, two legacy flags both present → true",
|
||||
"", []string{"with_ccr", "with_amend"}, mkSet("with_ccr", "with_amend"), true},
|
||||
{"NULL expr, two legacy flags only one present → false",
|
||||
"", []string{"with_ccr", "with_amend"}, mkSet("with_ccr"), false},
|
||||
|
||||
// Single-flag leaf (mig 084 unwrapped form for [single]).
|
||||
{"single-flag leaf present → true",
|
||||
`{"flag":"with_ccr"}`, nil, mkSet("with_ccr"), true},
|
||||
{"single-flag leaf absent → false",
|
||||
`{"flag":"with_ccr"}`, nil, mkSet("with_amend"), false},
|
||||
|
||||
// AND.
|
||||
{"and(a, b) both present → true",
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_ccr", "with_amend"), true},
|
||||
{"and(a, b) one absent → false",
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_ccr"), false},
|
||||
{"and() empty args → true (vacuously)",
|
||||
`{"op":"and","args":[]}`, nil, mkSet(), true},
|
||||
|
||||
// OR.
|
||||
{"or(a, b) any present → true",
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_amend"), true},
|
||||
{"or(a, b) none present → false",
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
nil, mkSet("with_cci"), false},
|
||||
{"or() empty args → false (vacuously)",
|
||||
`{"op":"or","args":[]}`, nil, mkSet(), false},
|
||||
|
||||
// NOT.
|
||||
{"not(flag) absent → true",
|
||||
`{"op":"not","args":[{"flag":"with_ccr"}]}`, nil, mkSet(), true},
|
||||
{"not(flag) present → false",
|
||||
`{"op":"not","args":[{"flag":"with_ccr"}]}`, nil, mkSet("with_ccr"), false},
|
||||
|
||||
// Nested.
|
||||
{"and(or(a, b), not(c)) all conditions met → true",
|
||||
`{"op":"and","args":[
|
||||
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
|
||||
{"op":"not","args":[{"flag":"expedited"}]}
|
||||
]}`,
|
||||
nil, mkSet("with_amend"), true},
|
||||
{"and(or(a, b), not(c)) NOT condition fails → false",
|
||||
`{"op":"and","args":[
|
||||
{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]},
|
||||
{"op":"not","args":[{"flag":"expedited"}]}
|
||||
]}`,
|
||||
nil, mkSet("with_amend", "expedited"), false},
|
||||
|
||||
// Malformed → defensive true (rule still renders).
|
||||
{"malformed JSON → true (defensive)",
|
||||
`{"op":"bro`, nil, mkSet(), true},
|
||||
{"unknown op → true (forward-compat)",
|
||||
`{"op":"xor","args":[{"flag":"with_ccr"}]}`, nil, mkSet(), true},
|
||||
{"not with two args → true (malformed NOT)",
|
||||
`{"op":"not","args":[{"flag":"a"},{"flag":"b"}]}`, nil, mkSet(), true},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := evalConditionExpr([]byte(tc.expr), tc.legacyFlag, tc.flags)
|
||||
if got != tc.want {
|
||||
t.Errorf("evalConditionExpr(%q, %v, flags) = %v, want %v",
|
||||
tc.expr, tc.legacyFlag, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestWireFlagsFromPriority verifies the priority → (IsMandatory,
|
||||
// IsOptional) reverse-mapping (Slice 4) matches the Slice 2 backfill so
|
||||
// the wire shape stays byte-identical through the cutover. The four
|
||||
// mappings + the safe default for unknown values are exhaustive.
|
||||
func TestWireFlagsFromPriority(t *testing.T) {
|
||||
cases := []struct {
|
||||
priority string
|
||||
wantMandatory bool
|
||||
wantOptional bool
|
||||
}{
|
||||
{"mandatory", true, false},
|
||||
{"optional", true, true},
|
||||
{"recommended", false, false},
|
||||
{"informational", false, false},
|
||||
{"", true, false}, // safe default — never drop a rule
|
||||
{"future_value", true, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.priority, func(t *testing.T) {
|
||||
gotM, gotO := wireFlagsFromPriority(tc.priority)
|
||||
if gotM != tc.wantMandatory || gotO != tc.wantOptional {
|
||||
t.Errorf("wireFlagsFromPriority(%q) = (%v, %v), want (%v, %v)",
|
||||
tc.priority, gotM, gotO, tc.wantMandatory, tc.wantOptional)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestApplyDuration_Matrix exercises the unified date-arithmetic helper
|
||||
// across the 4 units × 3 timings × calendar/holiday matrix added in
|
||||
// Slice 4. Mixes calendar units (days/weeks/months with weekend +
|
||||
// holiday rollover) with working_days (skip-by-construction, no
|
||||
// rollover).
|
||||
func TestApplyDuration_Matrix(t *testing.T) {
|
||||
hs := NewHolidayService(nil)
|
||||
|
||||
// Anchor: Thu 2026-04-30. Adjacent Fri (May 1) is Tag der Arbeit;
|
||||
// Sat-Sun follow. Sequence exercises the rollover path.
|
||||
thursday := time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
base time.Time
|
||||
value int
|
||||
unit string
|
||||
timing string
|
||||
wantRaw time.Time
|
||||
wantAdj time.Time
|
||||
wantDidAdj bool
|
||||
}{
|
||||
{
|
||||
name: "days/after — Thu + 1 calendar day → Fri (holiday) → adjusted to Mon",
|
||||
base: thursday, value: 1, unit: "days", timing: "after",
|
||||
wantRaw: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantAdj: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
|
||||
wantDidAdj: true,
|
||||
},
|
||||
{
|
||||
name: "days/before — Thu - 1 → Wed (working) → no adjust",
|
||||
base: thursday, value: 1, unit: "days", timing: "before",
|
||||
wantRaw: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
|
||||
wantAdj: time.Date(2026, 4, 29, 0, 0, 0, 0, time.UTC),
|
||||
wantDidAdj: false,
|
||||
},
|
||||
{
|
||||
name: "weeks/after — Thu + 1 week → next Thu (working) → no adjust",
|
||||
base: thursday, value: 1, unit: "weeks", timing: "after",
|
||||
wantRaw: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
|
||||
wantAdj: time.Date(2026, 5, 7, 0, 0, 0, 0, time.UTC),
|
||||
wantDidAdj: false,
|
||||
},
|
||||
{
|
||||
name: "months/after — Thu Apr 30 + 1 month → Sat May 30 → adjusted to Mon Jun 1",
|
||||
base: thursday, value: 1, unit: "months", timing: "after",
|
||||
wantRaw: time.Date(2026, 5, 30, 0, 0, 0, 0, time.UTC),
|
||||
wantAdj: time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC),
|
||||
wantDidAdj: true,
|
||||
},
|
||||
{
|
||||
name: "working_days/after — Thu + 1 wd → Mon (skip Fri holiday + weekend)",
|
||||
base: thursday, value: 1, unit: "working_days", timing: "after",
|
||||
wantRaw: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
|
||||
wantAdj: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
|
||||
wantDidAdj: false,
|
||||
},
|
||||
{
|
||||
name: "working_days/before — Mon May 4 - 1 wd → Thu Apr 30 (skip Fri holiday)",
|
||||
base: time.Date(2026, 5, 4, 0, 0, 0, 0, time.UTC),
|
||||
value: 1, unit: "working_days", timing: "before",
|
||||
wantRaw: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
|
||||
wantAdj: time.Date(2026, 4, 30, 0, 0, 0, 0, time.UTC),
|
||||
wantDidAdj: false,
|
||||
},
|
||||
{
|
||||
name: "unknown unit → identity (defensive)",
|
||||
base: thursday, value: 5, unit: "fortnights", timing: "after",
|
||||
wantRaw: thursday,
|
||||
wantAdj: thursday, // adjusted = AdjustForNonWorkingDays(raw); thursday is a working day
|
||||
wantDidAdj: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
raw, adj, didAdj, _ := applyDuration(tc.base, tc.value, tc.unit, tc.timing, "DE", "UPC", hs)
|
||||
if !raw.Equal(tc.wantRaw) {
|
||||
t.Errorf("raw: got %s, want %s", raw, tc.wantRaw)
|
||||
}
|
||||
if !adj.Equal(tc.wantAdj) {
|
||||
t.Errorf("adjusted: got %s, want %s", adj, tc.wantAdj)
|
||||
}
|
||||
if didAdj != tc.wantDidAdj {
|
||||
t.Errorf("didAdjust: got %v, want %v", didAdj, tc.wantDidAdj)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,13 @@ var (
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
// ErrInvalidInput signals a bad request (empty required field etc.).
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
// ErrInvalidProceedingTypeCategory signals that the caller supplied
|
||||
// a proceeding_type_id pointing at a non-fristenrechner-category row.
|
||||
// Phase 3 Slice 5 soft-merge (t-paliad-186, design §3.F): only
|
||||
// fristenrechner-category codes may bind to a project. Handlers
|
||||
// surface this as a 400 with a bilingual friendly message; the
|
||||
// matching DB trigger (mig 088) is the defence-in-depth backstop.
|
||||
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
|
||||
)
|
||||
|
||||
// ProjectType values enumerated on the projects.type CHECK constraint.
|
||||
@@ -816,6 +823,9 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
if err := validateProjectStatus(status); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
@@ -982,6 +992,9 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
appendSetSkippable("case_number", *input.CaseNumber)
|
||||
}
|
||||
if input.ProceedingTypeID != nil {
|
||||
if err := s.validateProceedingTypeCategory(ctx, input.ProceedingTypeID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
|
||||
}
|
||||
if input.OurSide != nil {
|
||||
@@ -1067,6 +1080,33 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
|
||||
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
|
||||
// to a fristenrechner-category proceeding_types row. NULL passes
|
||||
// through; the matching DB trigger (mig 088) is the defence-in-depth
|
||||
// backstop should this slip somehow.
|
||||
//
|
||||
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
|
||||
// 400 with a bilingual user-facing message.
|
||||
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
|
||||
if ptID == nil {
|
||||
return nil
|
||||
}
|
||||
var category sql.NullString
|
||||
if err := s.db.GetContext(ctx, &category,
|
||||
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
|
||||
}
|
||||
return fmt.Errorf("lookup proceeding_type category: %w", err)
|
||||
}
|
||||
if !category.Valid || category.String != "fristenrechner" {
|
||||
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
|
||||
ErrInvalidProceedingTypeCategory, *ptID, category.String)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete archives the Project (soft-delete, status='archived'). Partner/admin only.
|
||||
// Hard-delete cascades through FK; we prefer archival for audit.
|
||||
func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error {
|
||||
|
||||
148
internal/services/project_service_test.go
Normal file
148
internal/services/project_service_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestProjectService_ProceedingTypeCategoryGuard exercises the Phase 3
|
||||
// Slice 5 (t-paliad-186) "fristenrechner-category only" invariant on
|
||||
// paliad.projects.proceeding_type_id from three angles:
|
||||
//
|
||||
// 1. Migration smoke: post-mig 087, no project points at a
|
||||
// non-fristenrechner-category proceeding_types row.
|
||||
//
|
||||
// 2. ProjectService.Create returns ErrInvalidProceedingTypeCategory
|
||||
// when handed a litigation-category id. The server-side service
|
||||
// guard fires BEFORE the DB write hits the trigger from mig 088.
|
||||
//
|
||||
// 3. The mig 088 trigger rejects a raw INSERT that bypasses the Go
|
||||
// service layer (defence-in-depth). A litigation-category id
|
||||
// INSERT via plain SQL must raise EXCEPTION.
|
||||
//
|
||||
// 4. Passing a fristenrechner-category id (UPC_INF) succeeds.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 1. Migration smoke — no project points at a litigation-category code.
|
||||
// -----------------------------------------------------------------
|
||||
var leaked int
|
||||
if err := pool.GetContext(ctx, &leaked, `
|
||||
SELECT count(*)
|
||||
FROM paliad.projects p
|
||||
JOIN paliad.proceeding_types pt ON pt.id = p.proceeding_type_id
|
||||
WHERE pt.category <> 'fristenrechner'`); err != nil {
|
||||
t.Fatalf("count leaked refs: %v", err)
|
||||
}
|
||||
if leaked != 0 {
|
||||
t.Errorf("%d projects still reference non-fristenrechner proceeding_types — mig 087 incomplete", leaked)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 2 + 4. ProjectService.Create guard — typed error on litigation id,
|
||||
// success on fristenrechner id.
|
||||
// -----------------------------------------------------------------
|
||||
var litigationID int
|
||||
if err := pool.GetContext(ctx, &litigationID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'litigation' AND code = 'INF' AND is_active = true`); err != nil {
|
||||
t.Fatalf("look up INF id: %v", err)
|
||||
}
|
||||
var fristenrechnerID int
|
||||
if err := pool.GetContext(ctx, &fristenrechnerID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner' AND code = 'UPC_INF' AND is_active = true`); err != nil {
|
||||
t.Fatalf("look up UPC_INF id: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
svc := NewProjectService(pool, users)
|
||||
|
||||
// Seed a user so Create has a creator with a paliad.users row.
|
||||
userID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'slice5-guard-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'slice5-guard-test@hlc.com', 'Slice5 Guard', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
// 2. Litigation-category id → ErrInvalidProceedingTypeCategory.
|
||||
_, err = svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Slice 5 — litigation-id reject",
|
||||
ProceedingTypeID: &litigationID,
|
||||
})
|
||||
if err == nil {
|
||||
t.Error("Create with litigation-category proceeding_type_id should fail, but succeeded")
|
||||
} else if !errors.Is(err, ErrInvalidProceedingTypeCategory) {
|
||||
t.Errorf("expected ErrInvalidProceedingTypeCategory, got %v", err)
|
||||
}
|
||||
|
||||
// 4. Fristenrechner-category id → success.
|
||||
created, err := svc.Create(ctx, userID, CreateProjectInput{
|
||||
Type: ProjectTypeProject,
|
||||
Title: "Slice 5 — fristenrechner-id accept",
|
||||
ProceedingTypeID: &fristenrechnerID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Create with fristenrechner-category proceeding_type_id: %v", err)
|
||||
}
|
||||
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != fristenrechnerID {
|
||||
t.Errorf("created project proceeding_type_id = %v, want %d", created.ProceedingTypeID, fristenrechnerID)
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// 3. mig 088 trigger — raw INSERT bypassing Go service must raise.
|
||||
// -----------------------------------------------------------------
|
||||
rawID := uuid.New()
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID)
|
||||
|
||||
_, err = pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by,
|
||||
proceeding_type_id, metadata, created_at, updated_at)
|
||||
VALUES ($1, 'project', NULL, $1::text, 'Slice 5 — trigger bypass', 'active', $2,
|
||||
$3, '{}'::jsonb, now(), now())`,
|
||||
rawID, userID, litigationID)
|
||||
if err == nil {
|
||||
t.Error("raw INSERT with litigation-category proceeding_type_id should have raised; got nil")
|
||||
}
|
||||
}
|
||||
@@ -54,6 +54,21 @@ import (
|
||||
// via the ?lookahead=N query parameter.
|
||||
const DefaultLookaheadCap = 7
|
||||
|
||||
// ErrCyclicSpawn signals that the cross-proceeding spawn graph has a
|
||||
// cycle reachable from a project's source proceeding (design §6.3,
|
||||
// Slice 7 t-paliad-188). Surfaced when the visited-set DFS in
|
||||
// expandCrossProceedingSpawns hits a proceeding_type_id already in the
|
||||
// chain. ProjectionService.computeProjections degrades to "no spawned
|
||||
// rows" rather than failing the whole SmartTimeline render.
|
||||
var ErrCyclicSpawn = errors.New("cyclic cross-proceeding spawn")
|
||||
|
||||
// maxSpawnDepth caps recursive spawn expansion as a safety belt in
|
||||
// addition to the visited-set guard. No legitimate spawn graph today
|
||||
// reaches depth 4 (the live corpus has 6 spawn rules across 3 source
|
||||
// proceedings → AMD / APP / CCR — each one-hop). Bump if real-world
|
||||
// chains demand it; until then the cap is a backstop.
|
||||
const maxSpawnDepth = 4
|
||||
|
||||
// MaxLookaheadCap caps the ?lookahead override so a misbehaving client
|
||||
// can't request thousands of projected rows.
|
||||
const MaxLookaheadCap = 50
|
||||
@@ -234,6 +249,13 @@ type ProjectionMeta struct {
|
||||
// projects under the lane axis. Empty when the response should
|
||||
// render as a single-column flow (legacy behaviour).
|
||||
Lanes []LaneInfo `json:"lanes"`
|
||||
|
||||
// SpawnCycleDropped is set when expandCrossProceedingSpawns detected
|
||||
// a cycle in the spawn graph and degraded to "no spawned rows" rather
|
||||
// than failing the projection. The SmartTimeline still renders; the
|
||||
// caller can log + show a "Spawn-Auflösung übersprungen" banner so the
|
||||
// editor knows which spawn rule to fix. Phase 3 Slice 7 (t-paliad-188).
|
||||
SpawnCycleDropped bool `json:"spawn_cycle_dropped,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectionService composes the SmartTimeline.
|
||||
@@ -893,9 +915,14 @@ func (s *ProjectionService) computeProjections(
|
||||
|
||||
rule, ok := ruleByID[ruleID]
|
||||
if !ok {
|
||||
// Cross-proceeding spawn — the calculator can return rules
|
||||
// from another proceeding type (Appeal off Decision). We
|
||||
// don't have that rule in our map; skip the dependency
|
||||
// Defensive: the calculator returned a rule_id that isn't in
|
||||
// the per-proceeding map. After Phase 3 Slice 7
|
||||
// (t-paliad-188) the unified FristenrechnerService.Calculate
|
||||
// stays scoped to one proceeding (Option A in design §6.2),
|
||||
// so spawned-into rules don't arrive here — they're appended
|
||||
// below via expandCrossProceedingSpawns. A miss now means
|
||||
// either a stale ruleByID (unlikely) or a future calculator
|
||||
// extension we haven't accounted for; skip the dependency
|
||||
// annotation but still surface the row.
|
||||
rule = models.DeadlineRule{}
|
||||
}
|
||||
@@ -941,6 +968,30 @@ func (s *ProjectionService) computeProjections(
|
||||
projected = append(projected, ev)
|
||||
}
|
||||
|
||||
// Phase 3 Slice 7 (t-paliad-188): expand cross-proceeding spawn rules.
|
||||
// is_spawn=true rules with a non-NULL spawn_proceeding_type_id appear
|
||||
// in the current proceeding's rule set; we resolve each spawn target's
|
||||
// root rule (lowest sequence_order) via a one-shot global SELECT and
|
||||
// emit a spawned-into projected row anchored on the spawn source's
|
||||
// computed date. Cycle guard: visited-set DFS keyed by
|
||||
// proceeding_type_id; ErrCyclicSpawn degrades to "no spawned rows"
|
||||
// rather than failing the whole SmartTimeline render.
|
||||
if proj.ProceedingTypeID != nil {
|
||||
visited := map[int]bool{*proj.ProceedingTypeID: true}
|
||||
spawnRows, spawnErr := s.expandCrossProceedingSpawns(ctx, rules, resp.Deadlines, visited, 0)
|
||||
if spawnErr != nil {
|
||||
if !errors.Is(spawnErr, ErrCyclicSpawn) {
|
||||
return nil, meta, fmt.Errorf("expand spawns: %w", spawnErr)
|
||||
}
|
||||
// Cyclic spawn: drop spawned rows from this projection,
|
||||
// continue rendering the rest. SmartTimeline stays usable.
|
||||
// Surfaced in meta so the caller can log / show a banner.
|
||||
meta.SpawnCycleDropped = true
|
||||
} else if len(spawnRows) > 0 {
|
||||
projected = append(projected, spawnRows...)
|
||||
}
|
||||
}
|
||||
|
||||
// Apply lookahead cap. Predicted-overdue rows are exempt — surface
|
||||
// all of them. Court-set undated rows are exempt too because their
|
||||
// position on the timeline is "future, indefinite" and dropping the
|
||||
@@ -953,6 +1004,180 @@ func (s *ProjectionService) computeProjections(
|
||||
return cappedProjected, meta, nil
|
||||
}
|
||||
|
||||
// expandCrossProceedingSpawns walks the spawn graph rooted at the
|
||||
// caller's source proceeding (the `visited` set seeds it). For each
|
||||
// rule in `sourceRules` with is_spawn=true AND a non-NULL
|
||||
// SpawnProceedingTypeID, it resolves the target proceeding's root rule
|
||||
// and emits a spawned-into TimelineEvent linking back to the source.
|
||||
//
|
||||
// Cycle guard: when a spawn target's proceeding_type_id is already in
|
||||
// `visited`, the function returns ErrCyclicSpawn wrapped with the
|
||||
// rule + proceeding context. The caller (computeProjections) catches
|
||||
// it and degrades to "no spawned rows" — better than blocking the
|
||||
// whole render with an error.
|
||||
//
|
||||
// Recursion: after emitting a spawned-into row, the function recurses
|
||||
// into the target proceeding's own spawn rules. depth is bounded by
|
||||
// maxSpawnDepth as a safety belt; the visited set is the real loop
|
||||
// guard.
|
||||
//
|
||||
// Spawn-source dates come from `sourceDeadlines` — the UIResponse the
|
||||
// calculator just emitted. The spawned-into row inherits the source's
|
||||
// computed due date as its anchor; computing the target proceeding's
|
||||
// own deadlines off that anchor is deferred to a follow-up slice (the
|
||||
// rule editor will let editors set per-rule offsets that the
|
||||
// projection can compose). For Slice 7 v1, the spawned-into row
|
||||
// surfaces undated with Status="predicted" and Track="spawn" so the
|
||||
// frontend renders a clear boundary divider.
|
||||
func (s *ProjectionService) expandCrossProceedingSpawns(
|
||||
ctx context.Context,
|
||||
sourceRules []models.DeadlineRule,
|
||||
sourceDeadlines []UIDeadline,
|
||||
visited map[int]bool,
|
||||
depth int,
|
||||
) ([]TimelineEvent, error) {
|
||||
if depth >= maxSpawnDepth {
|
||||
return nil, fmt.Errorf("%w: max depth %d exceeded", ErrCyclicSpawn, maxSpawnDepth)
|
||||
}
|
||||
|
||||
// Index source rule computed dates by rule id for anchor lookup.
|
||||
dateByRuleID := make(map[uuid.UUID]string, len(sourceDeadlines))
|
||||
for _, ui := range sourceDeadlines {
|
||||
if ui.RuleID == "" || ui.DueDate == "" {
|
||||
continue
|
||||
}
|
||||
if id, err := uuid.Parse(ui.RuleID); err == nil {
|
||||
dateByRuleID[id] = ui.DueDate
|
||||
}
|
||||
}
|
||||
|
||||
// Identify spawn rules + collect target proceeding ids. The cycle
|
||||
// guard runs here on each unique target — if any target is already
|
||||
// in `visited`, abort the whole expansion (one cyclic edge poisons
|
||||
// the graph; we can't selectively render around it without
|
||||
// fabricating an incomplete dependency tree).
|
||||
type spawnSource struct {
|
||||
rule models.DeadlineRule
|
||||
anchorDate string
|
||||
}
|
||||
var sources []spawnSource
|
||||
targetIDs := make(map[int]struct{})
|
||||
for _, r := range sourceRules {
|
||||
if !r.IsSpawn || r.SpawnProceedingTypeID == nil {
|
||||
continue
|
||||
}
|
||||
if visited[*r.SpawnProceedingTypeID] {
|
||||
return nil, fmt.Errorf("%w: rule %s (proceeding %d) spawns into proceeding %d which is already in the chain",
|
||||
ErrCyclicSpawn, r.ID, derefIntPtr(r.ProceedingTypeID), *r.SpawnProceedingTypeID)
|
||||
}
|
||||
targetIDs[*r.SpawnProceedingTypeID] = struct{}{}
|
||||
sources = append(sources, spawnSource{rule: r, anchorDate: dateByRuleID[r.ID]})
|
||||
}
|
||||
if len(sources) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Bulk-load target proceedings' rules in one round-trip. The result
|
||||
// is pre-sorted by (proceeding_type_id, sequence_order) so the
|
||||
// first rule per proceeding is the root (lowest sequence_order).
|
||||
ids := make([]int, 0, len(targetIDs))
|
||||
for id := range targetIDs {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
targetRules, err := s.rules.ListByProceedingTypeIDs(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Group target rules by proceeding_type_id; first slot wins (root).
|
||||
firstByPT := make(map[int]models.DeadlineRule, len(ids))
|
||||
rulesByPT := make(map[int][]models.DeadlineRule, len(ids))
|
||||
for _, tr := range targetRules {
|
||||
if tr.ProceedingTypeID == nil {
|
||||
continue
|
||||
}
|
||||
rulesByPT[*tr.ProceedingTypeID] = append(rulesByPT[*tr.ProceedingTypeID], tr)
|
||||
if _, seen := firstByPT[*tr.ProceedingTypeID]; !seen {
|
||||
firstByPT[*tr.ProceedingTypeID] = tr
|
||||
}
|
||||
}
|
||||
|
||||
// Render one spawned-into TimelineEvent per source rule. Recurse
|
||||
// into the target proceeding's spawn rules (depth + 1) with the
|
||||
// target's proceeding_type_id added to `visited`.
|
||||
var out []TimelineEvent
|
||||
for _, src := range sources {
|
||||
first, ok := firstByPT[*src.rule.SpawnProceedingTypeID]
|
||||
if !ok {
|
||||
// Target proceeding has no active rules (defensive — a
|
||||
// future seed could land it). Skip silently.
|
||||
continue
|
||||
}
|
||||
|
||||
title := first.Name
|
||||
if src.rule.SpawnLabel != nil && *src.rule.SpawnLabel != "" {
|
||||
title = title + " (" + *src.rule.SpawnLabel + ")"
|
||||
}
|
||||
|
||||
ev := TimelineEvent{
|
||||
Kind: "projected",
|
||||
Status: "predicted",
|
||||
Track: "spawn",
|
||||
Title: title,
|
||||
DependsOnRuleName: src.rule.Name,
|
||||
}
|
||||
if first.Code != nil {
|
||||
ev.RuleCode = *first.Code
|
||||
}
|
||||
if src.rule.Code != nil {
|
||||
ev.DependsOnRuleCode = *src.rule.Code
|
||||
}
|
||||
idCopy := first.ID
|
||||
ev.DeadlineRuleID = &idCopy
|
||||
if first.PrimaryParty != nil {
|
||||
ev.DeadlineRuleParty = *first.PrimaryParty
|
||||
}
|
||||
// Anchor date: the spawn source's projected due date if
|
||||
// known. We don't compute the target's offset in Slice 7
|
||||
// v1 — that's the deferred per-rule editor concern — so the
|
||||
// row surfaces undated when the source has no anchor.
|
||||
if src.anchorDate != "" {
|
||||
if t, perr := time.Parse("2006-01-02", src.anchorDate); perr == nil {
|
||||
dt := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, time.UTC)
|
||||
ev.DependsOnDate = &dt
|
||||
}
|
||||
}
|
||||
out = append(out, ev)
|
||||
|
||||
// Recurse: walk the target's own spawn rules. Carry forward
|
||||
// the visited set with the target proceeding added so a
|
||||
// later hop back to it triggers ErrCyclicSpawn.
|
||||
nextVisited := make(map[int]bool, len(visited)+1)
|
||||
for k, v := range visited {
|
||||
nextVisited[k] = v
|
||||
}
|
||||
nextVisited[*src.rule.SpawnProceedingTypeID] = true
|
||||
sub, err := s.expandCrossProceedingSpawns(ctx, rulesByPT[*src.rule.SpawnProceedingTypeID], nil, nextVisited, depth+1)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
out = append(out, sub...)
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// derefIntPtr returns 0 when the pointer is nil — used only in error
|
||||
// messages for human-readable proceeding-id context. Never load-bearing
|
||||
// for the spawn-resolution logic itself (which checks for nil before
|
||||
// dereferencing).
|
||||
func derefIntPtr(p *int) int {
|
||||
if p == nil {
|
||||
return 0
|
||||
}
|
||||
return *p
|
||||
}
|
||||
|
||||
// collectActualsForOverrides loads every paliad.deadlines + paliad.appointments
|
||||
// row tied to a rule_id (or rule_code) for the project + descendants and
|
||||
// fills the overrides + ruleIDsWithActual maps.
|
||||
|
||||
@@ -9,6 +9,7 @@ package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -255,3 +256,176 @@ func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// TestExpandCrossProceedingSpawns covers the Phase 3 Slice 7
|
||||
// (t-paliad-188) cross-proceeding spawn wiring on a live DB with
|
||||
// synthetic fixtures. Three scenarios:
|
||||
//
|
||||
// 1. A spawn rule in proceeding A pointing at proceeding B → expansion
|
||||
// emits exactly one spawned-into TimelineEvent whose RuleCode
|
||||
// matches B's first (lowest sequence_order) rule.
|
||||
//
|
||||
// 2. A spawn cycle (A → B → A) → ErrCyclicSpawn surfaces; no rows
|
||||
// emitted on the cycle branch; the recursion stops at the second
|
||||
// hop without infinite-looping.
|
||||
//
|
||||
// 3. Multi-spawn defensive: proceeding A with two spawn rules each
|
||||
// targeting DIFFERENT downstream proceedings (B + C) → two
|
||||
// spawned-into rows in the output, one per target.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestExpandCrossProceedingSpawns(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'slice 7 test cleanup', true)`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_rules WHERE name LIKE 'SLICE7_TEST_%'`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.proceeding_types WHERE code LIKE 'SLICE7_TEST_%'`)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
type ptRow struct {
|
||||
ID int `db:"id"`
|
||||
Code string `db:"code"`
|
||||
}
|
||||
var pts []ptRow
|
||||
if err := pool.SelectContext(ctx, &pts, `
|
||||
INSERT INTO paliad.proceeding_types (code, name, name_en, category, jurisdiction, is_active)
|
||||
VALUES
|
||||
('SLICE7_TEST_A', 'Slice7 Test A', 'Slice7 Test A', 'fristenrechner', 'UPC', true),
|
||||
('SLICE7_TEST_B', 'Slice7 Test B', 'Slice7 Test B', 'fristenrechner', 'UPC', true),
|
||||
('SLICE7_TEST_C', 'Slice7 Test C', 'Slice7 Test C', 'fristenrechner', 'UPC', true)
|
||||
RETURNING id, code`); err != nil {
|
||||
t.Fatalf("seed proceeding_types: %v", err)
|
||||
}
|
||||
ptByCode := make(map[string]int, len(pts))
|
||||
for _, pt := range pts {
|
||||
ptByCode[pt.Code] = pt.ID
|
||||
}
|
||||
|
||||
insertRule := func(label, code string, ptID, sequenceOrder int, isSpawn bool, spawnTargetPT *int) uuid.UUID {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', $1, true)`,
|
||||
"slice 7 test seed: "+label); err != nil {
|
||||
t.Fatalf("set audit_reason: %v", err)
|
||||
}
|
||||
id := uuid.New()
|
||||
_, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadline_rules
|
||||
(id, proceeding_type_id, name, name_en, code, duration_value, duration_unit,
|
||||
timing, is_mandatory, is_optional, is_court_set, is_spawn,
|
||||
spawn_proceeding_type_id, sequence_order, is_active, priority,
|
||||
lifecycle_state, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $3, $4, 0, 'days', 'after', true, false, false, $5, $6, $7,
|
||||
true, 'mandatory', 'published', now(), now())`,
|
||||
id, ptID, label, code, isSpawn, spawnTargetPT, sequenceOrder)
|
||||
if err != nil {
|
||||
t.Fatalf("seed rule %q: %v", label, err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
bRootID := insertRule("SLICE7_TEST_B_root", "b.root", ptByCode["SLICE7_TEST_B"], 0, false, nil)
|
||||
bPTID := ptByCode["SLICE7_TEST_B"]
|
||||
aSpawnID := insertRule("SLICE7_TEST_A_spawn", "a.spawn", ptByCode["SLICE7_TEST_A"], 0, true, &bPTID)
|
||||
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
svc := &ProjectionService{db: pool, rules: rules}
|
||||
|
||||
aPTID := ptByCode["SLICE7_TEST_A"]
|
||||
aRules, err := rules.List(ctx, &aPTID)
|
||||
if err != nil {
|
||||
t.Fatalf("load A rules: %v", err)
|
||||
}
|
||||
|
||||
sourceDeadlines := []UIDeadline{
|
||||
{RuleID: aSpawnID.String(), DueDate: "2026-03-15", Code: "a.spawn"},
|
||||
}
|
||||
|
||||
visited := map[int]bool{aPTID: true}
|
||||
rows, err := svc.expandCrossProceedingSpawns(ctx, aRules, sourceDeadlines, visited, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("scenario 1 expand: %v", err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("scenario 1: got %d rows, want 1", len(rows))
|
||||
}
|
||||
if rows[0].RuleCode != "b.root" {
|
||||
t.Errorf("scenario 1: RuleCode=%q, want b.root", rows[0].RuleCode)
|
||||
}
|
||||
if rows[0].DeadlineRuleID == nil || *rows[0].DeadlineRuleID != bRootID {
|
||||
t.Errorf("scenario 1: DeadlineRuleID = %v, want %v", rows[0].DeadlineRuleID, bRootID)
|
||||
}
|
||||
if rows[0].DependsOnRuleCode != "a.spawn" {
|
||||
t.Errorf("scenario 1: DependsOnRuleCode = %q, want a.spawn", rows[0].DependsOnRuleCode)
|
||||
}
|
||||
if rows[0].DependsOnDate == nil || rows[0].DependsOnDate.Format("2006-01-02") != "2026-03-15" {
|
||||
t.Errorf("scenario 1: DependsOnDate = %v, want 2026-03-15", rows[0].DependsOnDate)
|
||||
}
|
||||
if rows[0].Track != "spawn" {
|
||||
t.Errorf("scenario 1: Track = %q, want spawn", rows[0].Track)
|
||||
}
|
||||
|
||||
// Scenario 2: cycle A → B → A.
|
||||
_ = insertRule("SLICE7_TEST_B_spawn_back", "b.spawn_back", ptByCode["SLICE7_TEST_B"], 1, true, &aPTID)
|
||||
|
||||
aRules2, _ := rules.List(ctx, &aPTID)
|
||||
rows2, err := svc.expandCrossProceedingSpawns(ctx, aRules2, sourceDeadlines, map[int]bool{aPTID: true}, 0)
|
||||
if err == nil {
|
||||
t.Fatalf("scenario 2: expected ErrCyclicSpawn, got nil (rows=%d)", len(rows2))
|
||||
}
|
||||
if !errors.Is(err, ErrCyclicSpawn) {
|
||||
t.Errorf("scenario 2: wrong error type: %v", err)
|
||||
}
|
||||
|
||||
// Scenario 3: multi-spawn defensive. Drop the cycle-edge first.
|
||||
pool.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'slice 7 test: drop B->A spawn for multi-spawn scenario', true)`)
|
||||
pool.ExecContext(ctx,
|
||||
`DELETE FROM paliad.deadline_rules WHERE name = 'SLICE7_TEST_B_spawn_back'`)
|
||||
|
||||
cPTID := ptByCode["SLICE7_TEST_C"]
|
||||
insertRule("SLICE7_TEST_C_root", "c.root", ptByCode["SLICE7_TEST_C"], 0, false, nil)
|
||||
aSpawnC := insertRule("SLICE7_TEST_A_spawn_c", "a.spawn_c", ptByCode["SLICE7_TEST_A"], 1, true, &cPTID)
|
||||
|
||||
aRules3, _ := rules.List(ctx, &aPTID)
|
||||
sourceDeadlines3 := []UIDeadline{
|
||||
{RuleID: aSpawnID.String(), DueDate: "2026-03-15", Code: "a.spawn"},
|
||||
{RuleID: aSpawnC.String(), DueDate: "2026-04-01", Code: "a.spawn_c"},
|
||||
}
|
||||
rows3, err := svc.expandCrossProceedingSpawns(ctx, aRules3, sourceDeadlines3, map[int]bool{aPTID: true}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("scenario 3 expand: %v", err)
|
||||
}
|
||||
if len(rows3) != 2 {
|
||||
t.Fatalf("scenario 3: got %d rows, want 2", len(rows3))
|
||||
}
|
||||
wantCodes := map[string]bool{"b.root": false, "c.root": false}
|
||||
for _, ev := range rows3 {
|
||||
if _, ok := wantCodes[ev.RuleCode]; ok {
|
||||
wantCodes[ev.RuleCode] = true
|
||||
}
|
||||
}
|
||||
for code, seen := range wantCodes {
|
||||
if !seen {
|
||||
t.Errorf("scenario 3: missing spawned-into row for %q", code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,19 @@ const (
|
||||
ShapeList RenderShape = "list"
|
||||
ShapeCards RenderShape = "cards"
|
||||
ShapeCalendar RenderShape = "calendar"
|
||||
// ShapeTimeline (t-paliad-177 Slice 4, faraday-Q7): cross-project
|
||||
// horizontal chart rendered by frontend/src/client/views/shape-
|
||||
// timeline-cv.ts on top of the same SVG renderer that powers
|
||||
// /projects/{id}/chart. Lane axis = project_id. Adapter is lossy:
|
||||
// ProjectionService projected rows are NOT surfaced (ViewService
|
||||
// doesn't run the calculator). UI tooltip on first open documents
|
||||
// the limitation.
|
||||
ShapeTimeline RenderShape = "timeline"
|
||||
)
|
||||
|
||||
// AllShapes lists every supported shape. Used by the validator and by
|
||||
// the in-page shape switcher.
|
||||
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
|
||||
|
||||
// RenderSpec is the top-level render description.
|
||||
//
|
||||
@@ -36,10 +44,25 @@ var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
// is selected, so flipping back to a previously-used shape preserves
|
||||
// its tweaks (Q5 design decision).
|
||||
type RenderSpec struct {
|
||||
Shape RenderShape `json:"shape"`
|
||||
List *ListConfig `json:"list,omitempty"`
|
||||
Cards *CardsConfig `json:"cards,omitempty"`
|
||||
Shape RenderShape `json:"shape"`
|
||||
List *ListConfig `json:"list,omitempty"`
|
||||
Cards *CardsConfig `json:"cards,omitempty"`
|
||||
Calendar *CalendarConfig `json:"calendar,omitempty"`
|
||||
Timeline *TimelineConfig `json:"timeline,omitempty"`
|
||||
}
|
||||
|
||||
// TimelineConfig is the per-shape config for shape=timeline. Mirrors the
|
||||
// URL-state knobs of the standalone /projects/{id}/chart page: a saved
|
||||
// CV-timeline view bakes the user's chosen palette / density / range
|
||||
// preset into render_spec so reopening the view restores the same
|
||||
// visual. None are required — empty defaults match the standalone
|
||||
// chart's defaults (default palette, standard density, 1y range).
|
||||
type TimelineConfig struct {
|
||||
Palette string `json:"palette,omitempty"`
|
||||
Density string `json:"density,omitempty"`
|
||||
RangePreset string `json:"range_preset,omitempty"`
|
||||
RangeFrom string `json:"range_from,omitempty"`
|
||||
RangeTo string `json:"range_to,omitempty"`
|
||||
}
|
||||
|
||||
// ListConfig is the per-shape config for shape=list. Powers both the
|
||||
@@ -144,7 +167,7 @@ func (s *RenderSpec) Validate() error {
|
||||
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
|
||||
}
|
||||
switch s.Shape {
|
||||
case ShapeList, ShapeCards, ShapeCalendar:
|
||||
case ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline:
|
||||
// fine
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
|
||||
@@ -165,6 +188,49 @@ func (s *RenderSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.Timeline != nil {
|
||||
if err := s.Timeline.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KnownTimelinePalettes / Densities / Ranges mirror the frontend enums
|
||||
// in shape-timeline-chart.ts. Anything outside this set is rejected so
|
||||
// a stray value from an old build / hostile editor can't sneak into
|
||||
// stored render_spec rows.
|
||||
var (
|
||||
knownTimelinePalettes = []string{
|
||||
"default", "kind-coded", "track-coded", "high-contrast", "print",
|
||||
}
|
||||
knownTimelineDensities = []string{
|
||||
"compact", "standard", "spacious",
|
||||
}
|
||||
knownTimelineRanges = []string{
|
||||
"1y", "2y", "all", "custom",
|
||||
}
|
||||
)
|
||||
|
||||
func (c *TimelineConfig) validate() error {
|
||||
if c.Palette != "" && !slices.Contains(knownTimelinePalettes, c.Palette) {
|
||||
return fmt.Errorf("%w: unknown timeline.palette %q", ErrInvalidInput, c.Palette)
|
||||
}
|
||||
if c.Density != "" && !slices.Contains(knownTimelineDensities, c.Density) {
|
||||
return fmt.Errorf("%w: unknown timeline.density %q", ErrInvalidInput, c.Density)
|
||||
}
|
||||
if c.RangePreset != "" && !slices.Contains(knownTimelineRanges, c.RangePreset) {
|
||||
return fmt.Errorf("%w: unknown timeline.range_preset %q", ErrInvalidInput, c.RangePreset)
|
||||
}
|
||||
// RangeFrom / RangeTo are free-form ISO dates — the frontend regex-
|
||||
// checks them; here we only verify they're plain ASCII length-bounded
|
||||
// so a giant string can't bloat the jsonb column.
|
||||
if len(c.RangeFrom) > 32 {
|
||||
return fmt.Errorf("%w: timeline.range_from too long", ErrInvalidInput)
|
||||
}
|
||||
if len(c.RangeTo) > 32 {
|
||||
return fmt.Errorf("%w: timeline.range_to too long", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestRenderSpec_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
||||
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
|
||||
for _, sh := range cases {
|
||||
t.Run(string(sh), func(t *testing.T) {
|
||||
s := RenderSpec{Shape: sh}
|
||||
@@ -26,6 +26,36 @@ func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_TimelineConfigValidates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg TimelineConfig
|
||||
ok bool
|
||||
}{
|
||||
{"empty defaults are fine", TimelineConfig{}, true},
|
||||
{"known palette", TimelineConfig{Palette: "kind-coded"}, true},
|
||||
{"known density", TimelineConfig{Density: "compact"}, true},
|
||||
{"known range preset", TimelineConfig{RangePreset: "2y"}, true},
|
||||
{"custom range with bounds", TimelineConfig{RangePreset: "custom", RangeFrom: "2026-01-01", RangeTo: "2026-12-31"}, true},
|
||||
{"unknown palette rejects", TimelineConfig{Palette: "neon"}, false},
|
||||
{"unknown density rejects", TimelineConfig{Density: "tiny"}, false},
|
||||
{"unknown range rejects", TimelineConfig{RangePreset: "10y"}, false},
|
||||
{"oversized range_from rejects", TimelineConfig{RangeFrom: string(make([]byte, 64))}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeTimeline, Timeline: &tc.cfg}
|
||||
err := s.Validate()
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("expected ok, got error: %v", err)
|
||||
}
|
||||
if !tc.ok && !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
|
||||
s := RenderSpec{Shape: "kanban"}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
|
||||
Reference in New Issue
Block a user