Compare commits

...

11 Commits

Author SHA1 Message Date
mAi
7cdccd55ae feat(submission-draft): grouped sections + per-side Add Party with DB picker (t-paliad-287)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Restructures the submission-draft sidebar per m's m/paliad#119 review.

Three changes on the variable form (Part B):
- VARIABLE_GROUPS collapses into four lawyer-facing sections: Mandant
  & Verfahren (firm.* + project.* + procedural_event.*), Parteien
  (manual {{parties.<role>.*}} overrides), Frist (the now-internal
  deadline.* block, COLLAPSED by default since the skeletons no
  longer render it), Sonstiges (today.* / user.* trim).
- Group sections are click-to-collapse via a sticky state map; the
  Frist + Parteien-override sections open closed so the visible form
  stays tight on first load.
- The legacy {{rule.*}} aliases drop off the sidebar — still resolved
  by SubmissionVarsService for old templates, no longer surfaced as
  override rows (they cluttered the form and the canonical
  procedural_event.* names cover the same ground).

Multi-party + Add Party (Part C):
- The party picker now renders all three role buckets (claimants /
  defendants / others) even when empty, so the lawyer can populate via
  Add Party. The block is hidden only when no project is attached.
- Each side gets a "+ Partei hinzufügen (Klägerseite / Beklagtenseite
  / Weitere Parteien)" button that opens an inline panel with two
  tabs:
  - Manual entry — name, role (pre-filled from side), representative.
    Submits to POST /api/projects/{id}/parties, creating a real
    paliad.parties row that immediately surfaces in available_parties.
  - Aus DB übernehmen — debounced (200ms) search against the new
    GET /api/parties/search endpoint. Returns hits across every
    visible project with project_title + reference for context.
    Already-on-this-project rows are filtered out client-side. Picking
    a hit clones name/role/representative into a fresh row on the
    current project — the simplest semantics that survives the
    paliad.parties.project_id NOT NULL contract while honouring m's
    "no manual re-typing" requirement.
- Newly-added parties land in selected_parties immediately so the new
  party is rendered in the next preview round-trip without an extra
  click. Implicit-"all" default is preserved (empty selected_parties
  still means "every party on the project, including this new one").
- Search-result repaints reach only into the <ul>, not the whole
  picker — keeps focus + selection on the search input across
  keystrokes.

CSS:
- Collapsible-section caret rotation, busy/disabled form states, tab
  highlights, DB-picker result rows with project chip + hover, all
  inherit the existing lime-tint accent so the new affordances look
  native to the editor.

TSX:
- Comment update on the parties block; no structural change. The
  bilingual hint copy in i18n.ts now nudges towards Add Party.
2026-05-26 09:41:36 +02:00
mAi
d4ed989b8f feat(parties): cross-project party search endpoint for submission picker (t-paliad-287)
Adds PartyService.Search returning paliad.parties rows from every
project the caller can see, matched by case-insensitive substring on
name or representative. Wired via GET /api/parties/search?q=... — used
by the submission-draft Add-Party panel's "Aus DB übernehmen" tab.

Visibility flows through the same visibilityPredicatePositional helper
every project-scoped read uses; invisible projects' parties never
surface. Capped at 25 hits per call (no pagination — typical lookup is
"the party I'm thinking of by name", not a browse).

Result shape carries project_title + project_reference so the picker
can disambiguate identically-named parties across cases.
2026-05-26 09:41:07 +02:00
mAi
54fb676db5 chore(templates): drop 'Frist' block from skeleton + HL-firm-skeleton (t-paliad-287)
Per m's m/paliad#119 report: the {{deadline.*}} block was leaking
internal/admin context (Frist-Bezeichnung, Fälligkeit, "berechnet aus",
Quelle) into court-bound submissions. The dedicated Frist heading and
its 4 body lines are removed from both gen-skeleton-submission-template
(_skeleton.docx) and gen-hl-skeleton-template (_firm-skeleton.docx).
The {{deadline.due_date_long_en}} reference in the locale-aware
verification footer is also dropped. {{deadline.*}} placeholders stay
resolvable in SubmissionVarsService — a custom template can still pick
them up — but the default skeletons no longer render them in the body.

Regenerated .docx files uploaded to HL/mWorkRepo:
- 6 - material/Templates/Word/Paliad/HLC/_skeleton.docx → d0ecc0e
- 6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx → 25954c9
2026-05-26 09:41:01 +02:00
mAi
cca5e72c57 ci: trigger workflow run to verify Slice A pre-deploy gate (post DOKPLOY_TOKEN setup)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 09:19:27 +02:00
mAi
4d923562f5 Merge: t-paliad-283 — /views/any filter-bar Predicates flatten fix (m/paliad#115)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-25 17:47:57 +02:00
mAi
c70914c2a0 fix(filter-bar): flatten FilterSpec.Predicates wire shape (t-paliad-283)
The bar's chip clicks POST a payload shaped as `predicates: {<source>:
<per-source>}` — flat, one entry per data source. Go declared
`Predicates map[DataSource]Predicates` — a doubled-nested wrapper where
each map value was itself a Predicates struct with named per-source
fields. The JSON shape Go expected was
`{"deadline": {"deadline": {"status": [...]}}}`; the shape the bar
emitted was `{"deadline": {"status": [...]}}`. Go silently unmarshalled
the bar's payload as `Predicates{}` (all source fields nil), so every
chip click on /views/any was a server-side no-op — the regression in
#115.

The latent contract bug was present since t-paliad-144 A1 (b516201) but
only surfaced now: /inbox uses the InboxSystemView's code-resident
predicates (built in Go directly, doubled shape works) and saved views
never carried predicates in the DB, so chip-click overlays were the
only path that exercised the wire-format wrong way. /views/any made
that path visible because all four sources need narrowing.

Fix: align Go to the flat shape the frontend already emits.

- FilterSpec.Predicates: `map[DataSource]Predicates` → `*Predicates`.
- All `spec.Predicates[SourceX]` access sites in view_service.go +
  approvalStatusMatches + allowed* helpers + system_views literals
  + tests rewritten to `spec.Predicates.X` with a nil-spec.Predicates
  guard.
- Frontend FilterSpec.predicates type tightened from
  `Partial<Record<DataSource, Predicates>>` (which silently allowed
  the wrong runtime write) to `Predicates`.

Regression coverage:

- `filter_spec_predicates_test.go` (new, Go) pins three contracts:
  the bar's exact wire payload unmarshals into a non-nil per-source
  predicate; marshalling a Go-constructed spec produces the same flat
  shape; the "Erledigt" chip's request narrows to completed deadlines.
- `compute-effective.test.ts` (new, bun:test) pins 12 chip-overlay
  cases for /views/any (every axis the saved view's sources expose).

Build hygiene:
- `go build ./...` clean.
- `go test ./... -count 1` clean (existing inbox + filter_spec tests
  updated for the new struct shape; new tests pass).
- `cd frontend && bun run build` clean.
- `cd frontend && bun test src/` — 169 pass, 0 fail.

No migration: paliad.user_views.filter_spec jsonb rows live with
`predicates: {}` or no predicates field; both unmarshal as nil
*Predicates under the new type, identical to the no-narrowing behaviour
the old map type produced for the same rows.
2026-05-25 17:46:58 +02:00
mAi
016ac2532a Merge: t-paliad-282 Slice A — CI/CD pre-deploy gate + snapshot-based migration smoke (m/paliad#114)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-25 17:42:51 +02:00
mAi
0b1653c2bf Merge: t-paliad-284 — Wave 1 Tier 1 rule additions + Q6 archived cleanup + audit FK fix (mig 132) (m/paliad#116) 2026-05-25 17:38:42 +02:00
mAi
a6cf6ff4c9 feat: t-paliad-284 Wave 1 Tier 1 deadline-rule additions (mig 132)
Add 12 Tier 1 procedural deadline rules from curie's audit §10
(docs/research-deadlines-completeness-2026-05-25.md), backfill the
UPC R.104/R.105 Interim Conference citation on upc.inf.cfi.interim
(m/paliad#116 / m's 2026-05-25 report), and fold in the audit Q6
cleanup of the 40 _archived_litigation.* rows.

New rules:
  T1.1  upc.inf.cfi.cmo_review           15d / R.333.2
  T1.2  upc.inf.cfi.confidentiality_response 14d / R.262.2 (trigger 25)
  T1.3  upc.apl.order.grounds_orders     15d / R.224.2(b)
  T1.4  upc.apl.order.response_orders    15d / R.235.2
  T1.5  upc.inf.cfi.cons_orders          2mo / R.118.4
  T1.6  upc.inf.cfi.rectification        1mo / R.353
  T1.7  upc.pi.cfi.deficiency            14d / R.207.6(a)
  T1.8  upc.pi.cfi.merits_start          31d OR 20wd (max) / R.213 + R.198.1
  T1.9  upc.inf.cfi.translation_request  1mo BEFORE oral / R.109.1
  T1.10 upc.inf.cfi.interpreter_cost     2wk BEFORE oral / R.109.4
  T1.11 upc.inf.cfi.translations_lodge   2wk / R.109.5 (trigger 113)
  T1.12 upc.pi.cfi.response              UPDATE: re-anchor on .app, court-set

T1.8 uses Wave 2 Slice A primitives (mig 128: working_days unit +
combine_op='max'). T1.9/T1.10 use timing='before' with the
backward-snap path in deadline_calculator.go.

Also drops the deadline_rule_audit.rule_id FK constraint. The mig 079
audit trigger had a latent bug — it could not log DELETEs because the
FK rejected the post-delete INSERT (count(*) WHERE action='delete'
was 0 across the entire history). Audit tables are append-only
history and should not FK-constrain on live entity tables; before_json
preserves the full row state. Unblocking this also unblocks the §13b
Q6 cleanup.

Verified on Supabase: 13 rows present in post-fix shape, all
assertions in the DO-block pass, audit log now records 11 creates +
2 updates + 40 deletes for this migration.
2026-05-25 17:29:13 +02:00
mAi
191d8e7268 Merge: t-paliad-285+286 — UPC Damages + PM appeal route deadlines (mig 133) (m/paliad#117, m/paliad#118) 2026-05-25 17:26:04 +02:00
mAi
cb44b3b8cc mAi: #117 + #118 - t-paliad-285/-286 UPC dmgs+pi court followup (mig 133)
Adds the post-submission court phase to upc.dmgs.cfi and the appeal
route to upc.pi.cfi. The Verfahrensablauf timeline currently stops at
the last party submission (dmgs.rejoin / pi.order); without these rows
the interim conference / oral hearing / decision / appeal sub-tree
never renders, even though atlas's #96 spawn mechanism is in place.

Migration 133 (single slot, coordinated with knuth's #116 on 132):

Section A — UPC Damages tree end (#117):
- upc.dmgs.cfi.interim       court-set, R.105
- upc.dmgs.cfi.oral          court-set, R.118 / R.250
- upc.dmgs.cfi.decision      court-set, R.118 / R.144
- upc.dmgs.cfi.appeal_spawn  2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits

Section B — UPC PI appeal route (#118):
- upc.pi.cfi.appeal_spawn    2mo, R.220.1(a) / R.224.1(a), spawn → upc.apl.merits
  PI orders under R.211 dispose of the urgent question and ride the
  main 2-month track; the 15-day R.220.1(c) order track does not apply.

Same shape as mig 095 inf.appeal_spawn and the upc.inf.cfi
interim/oral/decision rows from mig 012. Court-set rows reuse the
shared interim-conference / oral-hearing / decision concepts.

Citations: docs/research-deadlines-completeness-2026-05-25.md §D + Tier 4 (R.144), docs/audit-upc-rop-deadlines-2026-05-08.md §D R.144 + §F R.220.1(a)/R.224.1(a). Per-row RoP citation in the migration header.

Idempotent INSERT NOT EXISTS guards per row + post-insert DO block that RAISEs EXCEPTION if any expected row is missing or the spawn shape (is_spawn / spawn_proceeding_type_id / parent_id) is wrong.

go build ./... clean, go test ./internal/... clean, bun run build clean.
2026-05-25 17:25:19 +02:00
22 changed files with 2331 additions and 148 deletions

View File

@@ -0,0 +1,126 @@
// Unit tests for the FilterBar's computeEffective() overlay. These pin
// the contract that any chip the user clicks ends up as a predicate the
// server can see — the t-paliad-283 regression had four sources picking
// up zero narrowing for /views/any because the bar's chip click didn't
// produce a non-empty `filter.predicates` for that source.
//
// Run with `bun test`.
import { test, expect, describe } from "bun:test";
import { computeEffective } from "./index";
import type { FilterSpec, RenderSpec } from "../views/types";
import type { BarState } from "./types";
// Mirrors paliad.user_views row {slug: "any"} — the saved Custom View
// that triggered the t-paliad-283 regression report.
const ANY_VIEW_FILTER: FilterSpec = {
version: 1,
sources: ["deadline", "appointment", "project_event", "approval_request"],
scope: { projects: { mode: "all_visible" } },
time: { field: "auto", horizon: "past_30d" },
};
const ANY_VIEW_RENDER: RenderSpec = {
shape: "list",
list: { sort: "date_asc", density: "comfortable" },
};
describe("filter-bar/computeEffective — /views/any (all 4 sources)", () => {
test("empty state leaves base spec intact (no overlays)", () => {
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
expect(eff.filter.sources).toEqual([
"deadline", "appointment", "project_event", "approval_request",
]);
expect(eff.filter.time).toEqual({ field: "auto", horizon: "past_30d" });
// predicates may be {} (the bar zero-fills it) but never carries a
// stray narrowing on any source — that would silently filter
// results the user never asked to filter.
for (const src of ANY_VIEW_FILTER.sources) {
expect(eff.filter.predicates?.[src]).toBeUndefined();
}
});
test("deadline_status chip narrows deadline predicate", () => {
const state: BarState = { deadline_status: ["pending"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
});
test("appointment_type chip narrows appointment predicate", () => {
const state: BarState = { appointment_type: ["hearing"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
});
test("approval_viewer_role chip narrows approval predicate", () => {
const state: BarState = { approval_viewer_role: "any_visible" };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.viewer_role).toBe("any_visible");
});
test("approval_status chip narrows approval predicate", () => {
const state: BarState = { approval_status: ["pending", "approved"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending", "approved"]);
});
test("approval_entity_type chip narrows approval predicate", () => {
const state: BarState = { approval_entity_type: ["deadline"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.approval_request?.entity_types).toEqual(["deadline"]);
});
test("project_event_kind chip narrows project_event predicate", () => {
const state: BarState = { project_event_kind: ["deadline_created"] };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
});
test("time chip overrides base horizon", () => {
const state: BarState = { time: { horizon: "past_7d" } };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.time.horizon).toBe("past_7d");
expect(eff.filter.time.field).toBe("auto"); // preserved from base
});
test("personal_only chip flips scope flag", () => {
const state: BarState = { personal_only: true };
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.scope.personal_only).toBe(true);
});
test("multiple chips combine into the same effective spec", () => {
const state: BarState = {
time: { horizon: "past_7d" },
deadline_status: ["pending"],
appointment_type: ["hearing"],
approval_status: ["pending"],
project_event_kind: ["deadline_created"],
};
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, state);
expect(eff.filter.time.horizon).toBe("past_7d");
expect(eff.filter.predicates?.deadline?.status).toEqual(["pending"]);
expect(eff.filter.predicates?.appointment?.appointment_types).toEqual(["hearing"]);
expect(eff.filter.predicates?.approval_request?.status).toEqual(["pending"]);
expect(eff.filter.predicates?.project_event?.event_types).toEqual(["deadline_created"]);
});
test("overlay does not mutate the caller's base filter", () => {
const base: FilterSpec = JSON.parse(JSON.stringify(ANY_VIEW_FILTER));
const state: BarState = { deadline_status: ["pending"], time: { horizon: "past_7d" } };
computeEffective(base, ANY_VIEW_RENDER, state);
// The bar deep-clones; the base must come back unchanged so a
// second click doesn't compound the previous click's overlay.
expect(base).toEqual(ANY_VIEW_FILTER);
});
test("inbox-only axes do not affect a /views/any spec (no inbox axis exposed)", () => {
// /views/any's axes don't include unread_only or inbox_focus, so
// those keys never appear in state. Verify that even if they did,
// the bar's overlay doesn't silently mutate sources or predicates
// in a way that would break a 4-source Custom View.
const eff = computeEffective(ANY_VIEW_FILTER, ANY_VIEW_RENDER, {});
expect(eff.filter.sources).toHaveLength(4);
expect(eff.filter.unread_only ?? false).toBe(false);
});
});

View File

@@ -1497,7 +1497,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Aus Projekt importieren",
"submissions.draft.parties.title": "Parteien",
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
@@ -4580,7 +4580,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",

View File

@@ -137,6 +137,13 @@ interface VariableGroup {
id: string;
label: VariableLabel;
keys: string[];
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
// initial state is collapsed iff collapsedByDefault. Used for the
// Frist section which lawyers rarely need to override (the variables
// stay resolvable in the bag for the few templates that still want
// them, but render no body content by default).
collapsible?: boolean;
collapsedByDefault?: boolean;
}
const VARIABLE_LABELS: Record<string, VariableLabel> = {
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
};
// t-paliad-287 — variable groups restructured into four lawyer-facing
// sections: Mandant/Verfahren up top (the case identity), then Parteien
// (where the picker UI lives — this group only carries the manual
// {{parties.*}} overrides for power-users), then Frist collapsed by
// default (the deadline.* keys still resolve in the bag but the default
// templates don't render them in the body any more), then Sonstiges for
// the firm/date/user trim. The legacy procedural_event/rule namespaces
// fold into Mandant/Verfahren so the lawyer reads them in their natural
// context.
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "project",
label: { de: "Verfahren", en: "Proceeding" },
id: "mandant_verfahren",
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
keys: [
"project.title",
"project.case_number",
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
"project.matter_number",
"project.reference",
"project.instance_level",
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
// Manual overrides for {{parties.<role>.*}} placeholders — power-
// user escape hatch when the lawyer wants the rendered string to
// differ from the picker selection (e.g. honourific prefix on
// representative). Collapsed by default because the picker above
// is the canonical surface; these rows exist only as a safety
// valve.
collapsible: true,
collapsedByDefault: true,
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
label: { de: "Frist (intern)", en: "Deadline (internal)" },
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
// in the default skeleton body (internal context that doesn't
// belong in a court-bound submission). The values still resolve
// here so a custom template can pick them up if needed; collapsed
// because most drafts never touch them.
collapsible: true,
collapsedByDefault: true,
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
id: "sonstiges",
label: { de: "Sonstiges", en: "Other" },
keys: [
"firm.name",
"firm.signature_block",
"user.display_name",
"user.email",
"user.office",
@@ -291,6 +317,29 @@ interface State {
saveTimer: number | null;
pendingOverrides: Record<string, string> | null;
inFlight: AbortController | null;
// t-paliad-287 — per-section collapse memory. Sticky across repaints
// so autosave (which calls paintVariables) doesn't snap an open
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
collapsedGroups: Record<string, boolean>;
// t-paliad-287 — which side the Add-Party panel is currently open for
// (one panel can be open at a time; clicking the other side's button
// toggles). null means closed.
addPartyOpen: PartySide | null;
addPartyMode: "manual" | "search";
addPartySearchHits: PartySearchHit[];
addPartyBusy: boolean;
}
type PartySide = "claimant" | "defendant" | "other";
interface PartySearchHit {
id: string;
project_id: string;
project_title: string;
project_reference?: string | null;
name: string;
role?: string;
representative?: string;
}
const state: State = {
@@ -300,6 +349,11 @@ const state: State = {
saveTimer: null,
pendingOverrides: null,
inFlight: null,
collapsedGroups: {},
addPartyOpen: null,
addPartyMode: "manual",
addPartySearchHits: [],
addPartyBusy: false,
};
// ─────────────────────────────────────────────────────────────────────
@@ -607,24 +661,31 @@ function paintImportRow(): void {
btn.onclick = () => { void onImportFromProject(btn); };
}
// t-paliad-277 — multi-select party picker. Lists every party on the
// draft's project (view.available_parties), grouped by role, with one
// checkbox per party. Checked = include in the variable bag. Empty
// selection falls back to the legacy "include every party" default
// (consistent with the migration default).
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
// Party affordance per side. Lists every party on the draft's project
// (view.available_parties), grouped by role, with one checkbox per
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
// an "+ Partei hinzufügen" button that opens an inline panel with two
// modes: manual entry (creates a fresh paliad.parties row) or DB
// picker (searches every visible project, clones the row into THIS
// project on selection). Empty selection still falls back to the
// legacy "include every party" default.
function paintPartyPicker(): void {
const block = document.getElementById("submission-draft-parties");
const list = document.getElementById("submission-draft-parties-list");
if (!block || !list || !state.view) return;
const parties = state.view.available_parties ?? [];
if (!state.view.draft.project_id || parties.length === 0) {
// t-paliad-287 — picker is now shown even on empty-roster projects so
// the lawyer can use Add Party to populate. Still hidden when there
// is no project attached (no row to attach a party to).
if (!state.view.draft.project_id) {
block.style.display = "none";
list.innerHTML = "";
return;
}
block.style.display = "";
const parties = state.view.available_parties ?? [];
const selected = new Set(state.view.draft.selected_parties ?? []);
// Empty selection is the implicit "all" default — pre-check every
// party so the lawyer can see what's currently being mentioned and
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
const grouped = groupPartiesByRole(parties);
let html = "";
for (const group of grouped) {
if (group.parties.length === 0) continue;
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
html += `<legend>${escapeHtml(group.label)}</legend>`;
if (group.parties.length === 0) {
html += `<p class="submission-draft-parties-empty">${escapeHtml(
isEN() ? "No parties yet." : "Noch keine Parteien.",
)}</p>`;
}
for (const p of group.parties) {
const checked = effective.has(p.id) ? " checked" : "";
const chip = p.role
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
html += rep;
html += `</label>`;
}
html += renderAddPartyControls(group.bucket);
html += `</fieldset>`;
}
list.innerHTML = html;
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
inp.addEventListener("change", () => onPartySelectionChange());
});
wireAddPartyControls(list);
}
// renderAddPartyControls emits the per-side "+ Add party" button and
// (when expanded) the inline panel offering manual entry OR DB search.
// Sticky panel state lives in state.addPartyOpen so a repaint after
// search-fetch / autosave / language-switch doesn't snap the panel
// shut mid-edit.
function renderAddPartyControls(side: PartySide): string {
const open = state.addPartyOpen === side;
const mode = state.addPartyMode;
const sideLabel = sideLabelFor(side);
const btnLabel = isEN()
? `+ Add party (${sideLabel})`
: `+ Partei hinzufügen (${sideLabel})`;
let html = `<div class="submission-draft-addparty">`;
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
html += escapeHtml(btnLabel);
html += `</button>`;
if (!open) {
html += `</div>`;
return html;
}
// Tabs — manual / search.
html += `<div class="submission-draft-addparty-panel">`;
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
html += `</button>`;
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
html += `</button>`;
html += `</div>`;
if (mode === "manual") {
html += renderAddPartyManualForm(side);
} else {
html += renderAddPartySearchPanel(side);
}
html += `</div></div>`;
return html;
}
function renderAddPartyManualForm(side: PartySide): string {
const defaultRole = defaultRoleFor(side);
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
html += `<input type="text" name="name" required class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
html += `<input type="text" name="role" class="entity-form-input"`;
html += ` value="${escapeHtml(defaultRole)}"`;
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
html += `</label>`;
html += `<label class="submission-draft-addparty-field">`;
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
html += `<input type="text" name="representative" class="entity-form-input"`;
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
html += `</label>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
html += `</button>`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</form>`;
return html;
}
function renderAddPartySearchPanel(side: PartySide): string {
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
html += ` data-side="${side}"`;
html += ` placeholder="${escapeHtml(
isEN()
? "Search across projects (name or representative)…"
: "In allen Projekten suchen (Name oder Vertreter)…",
)}" />`;
html += renderPartySearchResultsList();
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
isEN()
? "Picking a row clones it as a fresh party on this project — no typing."
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
)}</p>`;
html += `<div class="submission-draft-addparty-actions">`;
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
html += `</button>`;
html += `</div>`;
html += `</div>`;
return html;
}
function wireAddPartyControls(root: HTMLElement): void {
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const side = (btn.dataset.side as PartySide) ?? "other";
if (state.addPartyOpen === side) {
// Toggle off.
state.addPartyOpen = null;
state.addPartySearchHits = [];
} else {
state.addPartyOpen = side;
state.addPartyMode = "manual";
state.addPartySearchHits = [];
}
paintPartyPicker();
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = btn.dataset.tab;
if (tab !== "manual" && tab !== "search") return;
state.addPartyMode = tab;
if (tab === "manual") state.addPartySearchHits = [];
paintPartyPicker();
if (tab === "search") {
// Pre-load most-recent matches with empty query so the lawyer
// sees options without typing first.
void runPartySearch("");
}
});
});
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
btn.addEventListener("click", () => {
state.addPartyOpen = null;
state.addPartySearchHits = [];
paintPartyPicker();
});
});
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
form.addEventListener("submit", (ev) => {
ev.preventDefault();
const side = (form.dataset.side as PartySide) ?? "other";
const data = new FormData(form);
const name = String(data.get("name") ?? "").trim();
if (!name) return;
const role = String(data.get("role") ?? "").trim();
const representative = String(data.get("representative") ?? "").trim();
void onAddPartyManualSubmit(side, { name, role, representative });
});
});
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
let timer: number | null = null;
inp.addEventListener("input", () => {
if (timer !== null) window.clearTimeout(timer);
timer = window.setTimeout(() => {
void runPartySearch(inp.value.trim());
}, 200);
});
// Pre-load on first render of the search tab.
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
void runPartySearch("");
}
});
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
function sideLabelFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
return isEN() ? "Other parties" : "Weitere Parteien";
}
function defaultRoleFor(side: PartySide): string {
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
return "";
}
interface PartyRoleGroup {
@@ -781,8 +1039,27 @@ function paintVariables(): void {
let html = "";
for (const group of VARIABLE_GROUPS) {
const groupLabel = isEN() ? group.label.en : group.label.de;
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
// Re-use the user's prior toggle state across paintVariables calls
// (autosave / language switch trigger a repaint). Default sticky
// state lives in state.collapsedGroups; on first render the
// collapsedByDefault flag seeds it.
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
}
const collapsed = !!state.collapsedGroups[group.id];
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
if (group.collapsible) {
html += `<button type="button" class="submission-draft-var-group-toggle"`;
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
html += `</button>`;
} else {
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
}
html += `<div class="submission-draft-var-group-body">`;
for (const key of group.keys) {
const label = labelFor(key);
const override = overrides[key];
@@ -813,10 +1090,19 @@ function paintVariables(): void {
// Visual hint: marker text appears in preview when override is "".
void mergedVal;
}
html += `</div>`;
html += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.toggleGroup;
if (!id) return;
state.collapsedGroups[id] = !state.collapsedGroups[id];
paintVariables();
});
});
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
// t-paliad-274 (B) — focus into a sidebar field highlights every
@@ -1021,6 +1307,175 @@ async function onPartySelectionChange(): Promise<void> {
}
}
async function runPartySearch(query: string): Promise<void> {
try {
const params = new URLSearchParams();
if (query) params.set("q", query);
const resp = await fetch(`/api/parties/search?${params.toString()}`);
if (!resp.ok) throw new Error(`search ${resp.status}`);
const data = (await resp.json()) as { results: PartySearchHit[] };
// Filter out parties already on THIS project — picking one of them
// would be a no-op clone that doubles the row.
const existingIDs = new Set(
(state.view?.available_parties ?? []).map((p) => p.id),
);
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
// Refresh ONLY the results <ul> in place — repainting the whole
// picker would steal focus from the search input on every
// keystroke. The input keeps its value/selection and the lawyer
// can keep typing.
const ul = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (ul) {
ul.outerHTML = renderPartySearchResultsList();
const fresh = document.querySelector<HTMLUListElement>(
".submission-draft-addparty-search-results",
);
if (fresh) {
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
li.addEventListener("click", () => {
const hitID = li.dataset.hitId;
if (!hitID) return;
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
if (!hit) return;
const side = state.addPartyOpen ?? "other";
void onAddPartySearchPick(side, hit);
});
});
}
} else {
// First load (panel just opened) — full picker paint to wire up
// every control. Subsequent keystroke updates take the cheaper
// path above.
paintPartyPicker();
}
} catch (err) {
console.error("submission-draft party-search:", err);
}
}
function renderPartySearchResultsList(): string {
let html = `<ul class="submission-draft-addparty-search-results">`;
if (state.addPartySearchHits.length === 0) {
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
isEN() ? "No matches." : "Keine Treffer.",
)}</li>`;
} else {
for (const hit of state.addPartySearchHits) {
const ref = hit.project_reference
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
: "";
const role = hit.role
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
: "";
const rep = hit.representative
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
)}</span>`
: "";
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
html += role;
html += rep;
html += `<span class="submission-draft-addparty-search-projwrap">`;
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
html += ref;
html += `</span>`;
html += `</li>`;
}
}
html += `</ul>`;
return html;
}
async function onAddPartyManualSubmit(
side: PartySide,
payload: { name: string; role: string; representative: string },
): Promise<void> {
if (!state.view) return;
const projectID = state.view.draft.project_id;
if (!projectID) return;
// Disable the submit button in-place rather than repainting the form
// mid-flight (a repaint would blow away the lawyer's typed values on
// error and reset focus). The post-success/-error repaint runs once
// the call settles.
const submitBtn = document.querySelector<HTMLButtonElement>(
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
);
if (submitBtn) submitBtn.disabled = true;
state.addPartyBusy = true;
try {
const body: Record<string, unknown> = { name: payload.name };
if (payload.role) body.role = payload.role;
if (payload.representative) body.representative = payload.representative;
const resp = await fetch(`/api/projects/${projectID}/parties`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) throw new Error(`create party ${resp.status}`);
const created = (await resp.json()) as { id: string };
await refreshDraftViewAndSelect(created.id);
state.addPartyOpen = null;
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
state.addPartyBusy = false;
paintPartyPicker();
} catch (err) {
console.error("submission-draft add-party manual:", err);
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
if (submitBtn) submitBtn.disabled = false;
state.addPartyBusy = false;
}
}
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
// DB picks clone the row into the current project — the simplest
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
// The lawyer asked for "no manual re-typing"; this honours that
// without bending the data model.
await onAddPartyManualSubmit(side, {
name: hit.name,
role: hit.role ?? defaultRoleFor(side),
representative: hit.representative ?? "",
});
}
// refreshDraftViewAndSelect refetches the editor payload (so
// available_parties picks up the new row) and ensures the newly-added
// party is checked in selected_parties. If the lawyer was on the
// implicit-all default (empty selected_parties), the new party comes
// in pre-selected via the "empty=all" rule and no PATCH is needed.
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
const view = state.view.draft.project_id
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
: await fetchGlobalView(draftID);
state.view = view;
// If the previous draft had a non-empty selected_parties subset,
// explicitly add the new party so it isn't silently dropped from the
// submission. Empty selected_parties = "all" → no PATCH needed.
const currentSel = state.view.draft.selected_parties ?? [];
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
const next = [...currentSel, newPartyID];
try {
const patched = await patchDraft({ selected_parties: next });
state.view = patched;
} catch (err) {
console.error("submission-draft select new party:", err);
}
}
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
}
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;

View File

@@ -66,7 +66,13 @@ export interface FilterSpec {
sources: DataSource[];
scope: ScopeSpec;
time: TimeSpec;
predicates?: Partial<Record<DataSource, Predicates>>;
// Per-source narrowing. Flat shape — one entry per data source. The
// Go side (internal/services/filter_spec.go: FilterSpec.Predicates)
// mirrors this exactly; the previous Partial<Record<DataSource,
// Predicates>> spelling was a latent contract bug (t-paliad-283)
// where every chip click sent a single-nested shape the server
// unmarshalled to no-op.
predicates?: Predicates;
// Inbox unread-only overlay (t-paliad-249). When true, the view
// service drops project_event rows older than the caller's
// users.inbox_seen_at cursor. Pending approval_requests always

View File

@@ -6376,6 +6376,194 @@ dialog.modal::backdrop {
margin-left: 0.25rem;
}
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
override). The toggle button is the section header; clicking it
flips state.collapsedGroups[id] and re-renders. The visible caret
rotates via the parent's --collapsed class. */
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
all: unset;
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0;
margin: 0 0 0.5rem 0;
cursor: pointer;
color: var(--color-text-muted);
}
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
.submission-draft-var-group-caret {
display: inline-block;
transition: transform 120ms ease;
font-size: 0.85em;
line-height: 1;
}
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
.submission-draft-var-group-caret {
transform: rotate(90deg);
}
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
display: none;
}
/* t-paliad-287 — Add Party affordance per side. */
.submission-draft-addparty {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-panel {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-addparty-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.submission-draft-addparty-tab {
all: unset;
cursor: pointer;
padding: 0.3rem 0.6rem;
font-size: 0.85em;
border-radius: 4px 4px 0 0;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
}
.submission-draft-addparty-tab--active {
color: var(--color-text);
border-bottom-color: var(--color-accent, #c6f41c);
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.submission-draft-addparty-form--busy {
opacity: 0.6;
pointer-events: none;
}
.submission-draft-addparty-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-addparty-field > span {
font-size: 0.82em;
color: var(--color-text-muted);
}
.submission-draft-addparty-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-search-results {
list-style: none;
padding: 0;
margin: 0;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface, #fff);
}
.submission-draft-addparty-search-row {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search-row:last-child {
border-bottom: none;
}
.submission-draft-addparty-search-row:hover {
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-search-empty {
padding: 0.6rem;
font-size: 0.85em;
color: var(--color-text-muted);
text-align: center;
}
.submission-draft-addparty-search-name {
font-weight: 500;
color: var(--color-text);
}
.submission-draft-addparty-search-rep {
font-size: 0.78em;
color: var(--color-text-muted);
}
.submission-draft-addparty-search-projwrap {
font-size: 0.78em;
color: var(--color-text-muted);
width: 100%;
}
.submission-draft-addparty-search-proj {
color: var(--color-text);
}
.submission-draft-addparty-search-projref {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
color: var(--color-text-muted);
}
.submission-draft-addparty-search-hint {
font-size: 0.78em;
color: var(--color-text-muted);
margin: 0;
}
.submission-draft-parties-empty {
font-size: 0.82em;
color: var(--color-text-muted);
margin: 0.2rem 0;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;

View File

@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
/>
</div>
{/* t-paliad-277: multi-select party picker.
{/* t-paliad-277 / t-paliad-287: multi-select party
picker plus per-side Add-Party affordance.
Populated from view.available_parties; checkbox
per party, grouped by role. Hidden when no
project or no parties on the project. */}
project is attached; visible even on empty
rosters so the lawyer can use Add Party to
populate. */}
<div
id="submission-draft-parties"
className="submission-draft-parties"

View File

@@ -0,0 +1,76 @@
-- Rollback of mig 132 (t-paliad-284 Wave 1 + m/paliad#116).
--
-- Reverses §0 (R.104/R.105 citation backfill) + §1..§11 (11 Tier 1
-- INSERTs) + §12 (T1.12 re-anchor of upc.pi.cfi.response).
--
-- Does NOT reverse §13b (Q6 archived-litigation cleanup) — those rows
-- were already in lifecycle_state='archived' before deletion and are not
-- surfaced by any product code path. Restoring them would require the
-- pre-mig-132 backup. Leaving them gone is the correct rollback choice;
-- emergency restore goes via mig 123 backup snapshot.
--
-- DOES restore §13a (re-add the deadline_rule_audit.rule_id FK) so the
-- audit-table schema returns to its pre-mig-132 shape on rollback. Any
-- orphan audit rows accumulated under mig 132 (rule_id pointing at
-- now-deleted rules) would block the FK re-add; the rollback DELETE
-- below removes them first.
SELECT set_config(
'paliad.audit_reason',
'mig 132 down: rollback Wave 1 Tier 1 rule additions + R.105 citation backfill + T1.12 re-anchor (t-paliad-284 / m/paliad#116)',
true);
-- §12 down — un-re-anchor upc.pi.cfi.response back to its broken root state.
UPDATE paliad.deadline_rules
SET parent_id = NULL,
is_court_set = false,
rule_code = NULL,
legal_source = NULL,
updated_at = now()
WHERE submission_code = 'upc.pi.cfi.response'
AND is_active = true
AND lifecycle_state = 'published'
AND is_court_set = true
AND rule_code = 'RoP.211.2';
-- §1..§11 down — delete the 11 Tier 1 INSERTs by submission_code.
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.inf.cfi.cmo_review',
'upc.inf.cfi.confidentiality_response',
'upc.apl.order.response_orders', -- delete child first (FK to grounds_orders)
'upc.apl.order.grounds_orders',
'upc.inf.cfi.cons_orders',
'upc.inf.cfi.rectification',
'upc.pi.cfi.deficiency',
'upc.pi.cfi.merits_start',
'upc.inf.cfi.translation_request',
'upc.inf.cfi.interpreter_cost',
'upc.inf.cfi.translations_lodge'
)
AND lifecycle_state = 'published';
-- §0 down — clear the R.104/R.105 citation on upc.inf.cfi.interim.
UPDATE paliad.deadline_rules
SET rule_code = NULL,
legal_source = NULL,
rule_codes = NULL,
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.interim'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.104'
AND legal_source = 'UPC.RoP.104';
-- §13a down — re-add the deadline_rule_audit.rule_id FK with the
-- original ON DELETE CASCADE shape. Purge any orphan audit rows first
-- (audit entries pointing at rule_ids that no longer exist in
-- deadline_rules) so the FK re-add doesn't fail validation.
DELETE FROM paliad.deadline_rule_audit a
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr WHERE dr.id = a.rule_id
);
ALTER TABLE paliad.deadline_rule_audit
ADD CONSTRAINT deadline_rule_audit_rule_id_fkey
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE;

View File

@@ -0,0 +1,659 @@
-- t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions
-- (12 high-frequency procedural events) + UPC RoP R.104/R.105 Interim
-- Conference citation backfill + Q6 archived-litigation cleanup.
--
-- Source: docs/research-deadlines-completeness-2026-05-25.md
-- • §10 Tier 1 table (T1.1 .. T1.12)
-- • §3.1 missing-rules catalogue (per-rule statutory citations)
-- • §9.7 / Q6 (drop the _archived_litigation.* rows — m's design ack
-- locked in 2026-05-25)
--
-- m's report (2026-05-25 17:12) also explicitly named "Zwischenverfahren /
-- Interim Conference 105" as missing a rule citation. The audit does not
-- list R.105 as a Tier 1 item (the row upc.inf.cfi.interim already exists
-- as a court-set anchor), so the fix is to BACKFILL rule_code/legal_source
-- on that row rather than to insert a new rule. Done here as a separate
-- §0 section, with both RoP.104 (Aims of the interim conference) and
-- RoP.105 (Holding of the interim conference) cited via rule_codes[].
--
-- Wave 2 Slice A primitives (mig 128: working_days unit + combine_op +
-- timing='before' backward snap in deadline_calculator.go) are used by:
-- • T1.8 upc.pi.cfi.merits_start — 31d OR 20wd, combine_op=max
-- • T1.9 upc.inf.cfi.translation_request — 1 month BEFORE oral hearing
-- • T1.10 upc.inf.cfi.interpreter_cost — 2 weeks BEFORE oral hearing
-- Wave 2 Slice A landed mig 128 (`deadline_rules_unit_check`) — these
-- rules are no longer blocked.
--
-- Slot 132 reserved: 127 brunel Wave 0, 128 knuth W2-A, 129 demeter,
-- 130 atlas, 131 artemis → 132 this migration.
--
-- Idempotency:
-- • INSERTs guarded with `WHERE NOT EXISTS (... submission_code = ...)`
-- so re-applying matches zero rows on the second run.
-- • UPDATEs guarded with `WHERE` clauses that match the pre-fix row
-- state only (mig 095 convention).
-- • DELETE guarded by lifecycle_state='archived' AND prefix — repeats
-- match zero rows after first run.
--
-- audit_reason set_config is required at the top (mig 079 trigger on
-- paliad.deadline_rules raises EXCEPTION 'audit reason required' for
-- any INSERT / UPDATE / DELETE without it).
SELECT set_config(
'paliad.audit_reason',
'mig 132: t-paliad-284 Wave 1 + m/paliad#116 — Tier 1 deadline-rule additions (12 rules) from curie''s audit §10 + UPC RoP R.104/105 Interim Conference citation backfill (m''s 2026-05-25 17:12 report) + Q6 archived-litigation cleanup (audit §9.7)',
true);
-- =============================================================================
-- §0 R.104/R.105 — Backfill citation on the existing Interim Conference row.
-- m's report flagged that `upc.inf.cfi.interim` (Zwischenverfahren) renders
-- with no rule reference at /admin/rules. The row exists as a court-set
-- anchor (duration=0, parent_id=NULL, primary_party='court'). The
-- governing UPC Rules of Procedure are:
-- • R.104 — Aims of the interim conference (the substantive rule)
-- • R.105 — Holding of the interim conference (procedural)
-- Both cited via the rule_codes[] array; rule_code/legal_source carry
-- the primary citation (R.104 — Aims).
-- =============================================================================
UPDATE paliad.deadline_rules
SET rule_code = 'RoP.104',
legal_source = 'UPC.RoP.104',
rule_codes = ARRAY['RoP.104', 'RoP.105'],
updated_at = now()
WHERE submission_code = 'upc.inf.cfi.interim'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code IS NULL
AND legal_source IS NULL;
-- =============================================================================
-- §1 T1.1 upc.inf.cfi.cmo_review — Review of case-management order.
-- 15 days from CMO service. UPC RoP R.333.2: "Any party adversely
-- affected by a case management order may within 15 days of service
-- of the order apply to the panel for a review." Routine in busy LDs
-- (Munich CMO traffic ~weekly). Anchor: the Interim Conference row,
-- which is where CMOs are typically issued.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8, -- upc.inf.cfi
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interim'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.cmo_review',
'Überprüfung Verfahrensanordnung',
'Review of Case-Management Order',
'both', 15, 'days', 'after', 'RoP.333.2', 'UPC.RoP.333.2',
'optional', false, 'published', true, 42,
'Frist 15 Tage ab Zustellung der Verfahrensanordnung (R.333.2). Jede beschwerte Partei kann beim Spruchkörper Überprüfung beantragen.',
'15-day period from service of the case-management order (R.333.2). Any adversely-affected party may apply to the panel for a review.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cmo_review'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §2 T1.2 upc.inf.cfi.confidentiality_response — Response to opposing
-- party's confidentiality application. 14 days from receipt of the
-- opposing party's R.262.2 application: "Within 14 days of service
-- … the other party may lodge an Application to the contrary."
-- Trigger event 25 (paliad.trigger_events) maps 1:1 to this rule.
-- Daily occurrence in HLC infringement work. Anchor: Statement of
-- Claim row as proceeding root — actual trigger date supplied via
-- 'Datum setzen' when the opp party files, since the confidentiality
-- app is not itself modelled as a deadline_rules row.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.soc'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.confidentiality_response',
'Erwiderung auf Vertraulichkeitsantrag',
'Response to Confidentiality Application',
'both', 14, 'days', 'after', 'RoP.262.2', 'UPC.RoP.262.2',
'optional', false, 'published', true, 8,
25,
'Frist 14 Tage ab Zustellung des Vertraulichkeitsantrags der Gegenseite (R.262.2). Datum bei Eingang des Antrags manuell setzen.',
'14-day period from service of the opposing party''s confidentiality application (R.262.2). Set trigger date manually on receipt of the application.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §3 T1.3 upc.apl.order.grounds_orders — Statement of Grounds on the
-- orders-track appeal. 15 days from service of the appealed
-- order/decision. UPC RoP R.224.2(b): "A Statement of grounds of
-- appeal shall be lodged … within 15 days of service of the
-- decision/order in cases referred to in Rule 220.1(c), Rule 220.2
-- and Rule 221.3." Existing upc.apl.order tree has the with_leave
-- notice but no separate grounds row — adding it.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 20, -- upc.apl.order
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.order'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.apl.order.grounds_orders',
'Berufungsbegründung (Orders Track)',
'Statement of Grounds (Orders Track)',
'both', 15, 'days', 'after', 'RoP.224.2.b', 'UPC.RoP.224.2.b',
'mandatory', false, 'published', true, 2,
'Frist 15 Tage ab Zustellung der angegriffenen Anordnung/Entscheidung (R.224.2(b)).',
'15-day period from service of the appealed order/decision (R.224.2(b)).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.grounds_orders'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §4 T1.4 upc.apl.order.response_orders — Statement of Response on the
-- orders-track appeal. 15 days from service of the grounds. UPC RoP
-- R.235.2: "Within 15 days of service of grounds of appeal pursuant
-- to Rule 224.2(b), any other party … may lodge a Statement of
-- response." Parent: the grounds_orders row inserted in §3, looked
-- up by submission_code so this INSERT works either against a fresh
-- DB or a partially-applied state.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 20,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.grounds_orders'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.apl.order.response_orders',
'Berufungserwiderung (Orders Track)',
'Statement of Response (Orders Track)',
'both', 15, 'days', 'after', 'RoP.235.2', 'UPC.RoP.235.2',
'optional', false, 'published', true, 3,
'Frist 15 Tage ab Zustellung der Berufungsbegründung (R.235.2).',
'15-day period from service of the Statement of grounds of appeal (R.235.2).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.response_orders'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §5 T1.5 upc.inf.cfi.cons_orders — Application for orders consequential
-- on validity. 2 months from service of the validity decision. UPC
-- RoP R.118.4: "The Court may, upon a reasoned request by one of
-- the parties, … give a decision granting consequential orders.
-- The application … shall be made within two months of service of
-- the decision …". Common after central-division revocation in
-- bifurcated UPC matters.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.decision'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.cons_orders',
'Antrag auf Folgeentscheidungen',
'Application for Consequential Orders',
'both', 2, 'months', 'after', 'RoP.118.4', 'UPC.RoP.118.4',
'optional', false, 'published', true, 60,
'Frist 2 Monate ab Zustellung der Validitätsentscheidung (R.118.4). Antrag auf Folgeentscheidungen (z.B. nach Zentralkammer-Nichtigerklärung).',
'2-month period from service of the validity decision (R.118.4). Application for orders consequential on validity (e.g. after central-division revocation).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cons_orders'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §6 T1.6 upc.inf.cfi.rectification — Application for rectification of a
-- decision/order. 1 month from delivery of the decision. UPC RoP
-- R.353: "Clerical mistakes, errors arising from any accidental
-- slip or omission and obvious errors in a decision or order of
-- the Court may be corrected by the Court of its own motion or on
-- the application of a party. The application shall be made within
-- one month of the decision or order being notified."
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.decision'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.rectification',
'Antrag auf Berichtigung',
'Application for Rectification',
'both', 1, 'months', 'after', 'RoP.353', 'UPC.RoP.353',
'optional', false, 'published', true, 70,
'Frist 1 Monat ab Zustellung der Entscheidung/Anordnung (R.353). Berichtigung von Schreib-, Rechen- oder ähnlichen Versehen.',
'1-month period from notification of the decision/order (R.353). Rectification of clerical mistakes, accidental slips or obvious errors.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.rectification'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §7 T1.7 upc.pi.cfi.deficiency — Cure of PI-application deficiency.
-- 14 days from notification of the deficiency. UPC RoP R.207.6(a):
-- "The Registry shall as soon as practicable examine the
-- Application … and notify any deficiencies to the applicant. The
-- applicant shall be invited to correct the deficiencies … within
-- 14 days." Failure to cure leads to deemed-withdrawal.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 10, -- upc.pi.cfi
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.app'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.pi.cfi.deficiency',
'Mängelbeseitigung Antrag',
'Cure of Application Deficiency',
'claimant', 14, 'days', 'after', 'RoP.207.6.a', 'UPC.RoP.207.6.a',
'mandatory', false, 'published', true, 2,
'Frist 14 Tage ab Mängelmitteilung durch die Geschäftsstelle (R.207.6(a)). Bei Nichtbehebung gilt der Antrag als zurückgenommen.',
'14-day period from notification of deficiency by the Registry (R.207.6(a)). Failure to cure leads to deemed withdrawal of the application.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.deficiency'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §8 T1.8 upc.pi.cfi.merits_start — Start proceedings on the merits.
-- 31 calendar days OR 20 working days, whichever is the longer,
-- from grant of the PI. UPC RoP R.213.1 → R.198.1: "the applicant
-- shall start proceedings leading to a decision on the merits of
-- the case … within a period not exceeding 31 calendar days or
-- 20 working days, whichever is the longer." Combine-max wiring
-- via Wave 2 Slice A primitives (mig 128: working_days unit +
-- combine_op). Failure to commence on time → PI lapses (R.213.2).
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit,
alt_duration_value, alt_duration_unit, alt_rule_code,
combine_op, timing, rule_code, legal_source, priority,
is_court_set, lifecycle_state, is_active, sequence_order,
deadline_notes, deadline_notes_en)
SELECT 10,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.order'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.pi.cfi.merits_start',
'Klage in der Hauptsache erheben',
'Start Proceedings on the Merits',
'claimant', 31, 'days',
20, 'working_days', 'RoP.198.1',
'max', 'after', 'RoP.213', 'UPC.RoP.213',
'mandatory', false, 'published', true, 3,
'Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme (R.213 i.V.m. R.198.1). Bei Versäumnis erlischt die einstweilige Maßnahme.',
'31 calendar days OR 20 working days, whichever is the longer, from grant of the provisional measure (R.213 referring to R.198.1). Failure to commence within the period causes the provisional measure to lapse.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.merits_start'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §9 T1.9 upc.inf.cfi.translation_request — Request for simultaneous
-- translation at the oral hearing. 1 month BEFORE the oral hearing.
-- UPC RoP R.109.1: "A party requiring simultaneous interpretation
-- of the oral hearing into a language other than the language of
-- proceedings shall, no later than one month before the date of
-- the oral hearing, lodge a request with the Court." timing='before'
-- uses the backward-snap path in deadline_calculator.go (mig 128).
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.oral'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.translation_request',
'Antrag auf Simultanübersetzung',
'Request for Simultaneous Translation',
'both', 1, 'months', 'before', 'RoP.109.1', 'UPC.RoP.109.1',
'optional', false, 'published', true, 45,
'Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung in eine andere Sprache als die Verfahrenssprache.',
'1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation into a language other than the language of proceedings.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translation_request'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §10 T1.10 upc.inf.cfi.interpreter_cost — Notification of interpreter
-- cost-bearing. 2 weeks BEFORE the oral hearing. UPC RoP R.109.4:
-- "Where … the party which made the request for interpretation is
-- not the party who has chosen the language of the proceedings,
-- the costs of the interpretation … shall be borne by the
-- requesting party, unless the Court orders otherwise. The party
-- shall be notified at least two weeks before the oral hearing."
-- timing='before' as in §9.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.oral'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.interpreter_cost',
'Mitteilung Dolmetscherkosten',
'Notification of Interpreter Costs',
'court', 2, 'weeks', 'before', 'RoP.109.4', 'UPC.RoP.109.4',
'mandatory', false, 'published', true, 46,
'Frist 2 Wochen VOR der mündlichen Verhandlung (R.109.4). Mitteilung, dass die antragstellende Partei die Dolmetscherkosten zu tragen hat.',
'2 weeks BEFORE the oral hearing (R.109.4). Notification to the requesting party that it shall bear the interpreter costs.'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §11 T1.11 upc.inf.cfi.translations_lodge — Lodging of translations on
-- judge-rapporteur order. 2 weeks AFTER the JR's order. UPC RoP
-- R.109.5: "If the judge-rapporteur orders, the parties shall lodge
-- a translation of any pleading or other document into the language
-- of the proceedings within two weeks." trigger_event_id=113 maps
-- to the JR translation order. Anchor: Interim Conference row, where
-- such JR orders are typically issued.
-- =============================================================================
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
primary_party, duration_value, duration_unit, timing, rule_code,
legal_source, priority, is_court_set, lifecycle_state, is_active,
sequence_order, trigger_event_id, deadline_notes, deadline_notes_en)
SELECT 8,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interim'
AND lifecycle_state = 'published' AND is_active = true
LIMIT 1),
'upc.inf.cfi.translations_lodge',
'Übersetzungen einreichen',
'Lodging of Translations',
'both', 2, 'weeks', 'after', 'RoP.109.5', 'UPC.RoP.109.5',
'mandatory', false, 'published', true, 47,
113,
'Frist 2 Wochen ab Anordnung des Berichterstatters, Übersetzungen einzureichen (R.109.5).',
'2-week period from the judge-rapporteur''s order to lodge translations (R.109.5).'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
AND lifecycle_state = 'published'
);
-- =============================================================================
-- §12 T1.12 upc.pi.cfi.response — RE-ANCHOR of the existing PI Response
-- row. Currently broken: parent_id=NULL with is_court_set=false and
-- duration=0 makes the calculator treat this as a root anchor. UPC
-- RoP R.211.2 — judge sets the inter-partes hearing date and the
-- deadline for the response. Fix: set is_court_set=true and chain
-- parent_id on upc.pi.cfi.app (the proceeding root). Duration
-- remains 0 (court-set placeholder); the lawyer fills in the actual
-- date via 'Datum setzen'.
-- =============================================================================
UPDATE paliad.deadline_rules
SET parent_id = (SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.app'
AND lifecycle_state = 'published'
AND is_active = true
LIMIT 1),
is_court_set = true,
rule_code = 'RoP.211.2',
legal_source = 'UPC.RoP.211.2',
updated_at = now()
WHERE submission_code = 'upc.pi.cfi.response'
AND is_active = true
AND lifecycle_state = 'published'
AND parent_id IS NULL
AND is_court_set = false;
-- =============================================================================
-- §13a Pre-requisite for §13b — drop the deadline_rule_audit.rule_id FK.
-- The audit trigger (mig 079) tries to INSERT an audit row on AFTER
-- DELETE pointing at OLD.id, but the existing FK constraint
-- `deadline_rule_audit_rule_id_fkey` (FOREIGN KEY rule_id REFERENCES
-- paliad.deadline_rules(id) ON DELETE CASCADE) makes that INSERT fail
-- because by the time the trigger fires the parent row is gone. As a
-- result no DELETE on paliad.deadline_rules has ever succeeded in
-- production (`SELECT count(*) FROM paliad.deadline_rule_audit
-- WHERE action='delete'` returns 0). The trigger's DELETE branch was
-- dead code.
--
-- Standard audit-table design: the audit log is append-only history
-- and should NOT FK-constrain on the live entity table — before_json
-- captures the full row state at the time of the change, which is
-- all the audit trail needs. Dropping the FK fixes the latent bug
-- and unblocks legitimate cleanup work (here: §13b, plus any future
-- hard-delete migrations against deadline_rules).
--
-- Idempotent: DROP CONSTRAINT IF EXISTS no-ops on re-run.
-- =============================================================================
ALTER TABLE paliad.deadline_rule_audit
DROP CONSTRAINT IF EXISTS deadline_rule_audit_rule_id_fkey;
-- =============================================================================
-- §13b Q6 cleanup — drop the _archived_litigation.* deadline rules.
-- 40 rows at audit §9.7 flagged as obsolete Pipeline-A residue
-- (proceeding_type id=32 '_archived_litigation' — kept for FK
-- parity but the rules are no longer surfaced anywhere in the
-- product). m's Q6 design ack 2026-05-25 locked in their removal.
-- Idempotent: prefix + lifecycle_state='archived' match zero rows
-- after first run. The proceeding_type row itself is left in place
-- (referenced by historical deadline_rule_audit before_json blobs).
-- =============================================================================
DELETE FROM paliad.deadline_rules
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
AND lifecycle_state = 'archived';
-- =============================================================================
-- Hard assertions. Each new/changed row must end up in its post-fix
-- shape. Re-running the migration is a no-op for the data but the
-- assertions still pass because they check the post-fix state.
-- =============================================================================
DO $$
DECLARE
v_count integer;
BEGIN
-- §0 R.105 interim conference backfilled
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interim'
AND is_active = true
AND lifecycle_state = 'published'
AND rule_code = 'RoP.104'
AND legal_source = 'UPC.RoP.104'
AND 'RoP.105' = ANY(rule_codes);
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 §0: upc.inf.cfi.interim citation backfill not in post-fix shape (got % matches)', v_count;
END IF;
-- §1 T1.1 cmo_review present
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cmo_review'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.333.2' AND duration_value = 15
AND duration_unit = 'days' AND timing = 'after';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.1: upc.inf.cfi.cmo_review missing or wrong shape (got % matches)', v_count;
END IF;
-- §2 T1.2 confidentiality_response
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.confidentiality_response'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.262.2' AND duration_value = 14
AND duration_unit = 'days' AND trigger_event_id = 25;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.2: upc.inf.cfi.confidentiality_response missing or wrong shape (got % matches)', v_count;
END IF;
-- §3 T1.3 grounds_orders
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.apl.order.grounds_orders'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.224.2.b' AND duration_value = 15
AND duration_unit = 'days';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.3: upc.apl.order.grounds_orders missing or wrong shape (got % matches)', v_count;
END IF;
-- §4 T1.4 response_orders chained on §3
SELECT count(*) INTO v_count
FROM paliad.deadline_rules dr
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
WHERE dr.submission_code = 'upc.apl.order.response_orders'
AND dr.is_active = true AND dr.lifecycle_state = 'published'
AND dr.rule_code = 'RoP.235.2' AND dr.duration_value = 15
AND p.submission_code = 'upc.apl.order.grounds_orders';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.4: upc.apl.order.response_orders missing or wrong parent chain (got % matches)', v_count;
END IF;
-- §5 T1.5 cons_orders
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.cons_orders'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.118.4' AND duration_value = 2
AND duration_unit = 'months';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.5: upc.inf.cfi.cons_orders missing or wrong shape (got % matches)', v_count;
END IF;
-- §6 T1.6 rectification
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.rectification'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.353' AND duration_value = 1
AND duration_unit = 'months';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.6: upc.inf.cfi.rectification missing or wrong shape (got % matches)', v_count;
END IF;
-- §7 T1.7 pi.deficiency
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.deficiency'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.207.6.a' AND duration_value = 14
AND duration_unit = 'days';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.7: upc.pi.cfi.deficiency missing or wrong shape (got % matches)', v_count;
END IF;
-- §8 T1.8 pi.merits_start — combine-max wiring
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.merits_start'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.213' AND duration_value = 31
AND duration_unit = 'days'
AND alt_duration_value = 20 AND alt_duration_unit = 'working_days'
AND alt_rule_code = 'RoP.198.1' AND combine_op = 'max';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.8: upc.pi.cfi.merits_start missing or wrong combine-max shape (got % matches)', v_count;
END IF;
-- §9 T1.9 translation_request — timing='before'
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translation_request'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.109.1' AND duration_value = 1
AND duration_unit = 'months' AND timing = 'before';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.9: upc.inf.cfi.translation_request missing or wrong timing (got % matches)', v_count;
END IF;
-- §10 T1.10 interpreter_cost — timing='before'
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.interpreter_cost'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.109.4' AND duration_value = 2
AND duration_unit = 'weeks' AND timing = 'before';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.10: upc.inf.cfi.interpreter_cost missing or wrong timing (got % matches)', v_count;
END IF;
-- §11 T1.11 translations_lodge
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code = 'upc.inf.cfi.translations_lodge'
AND is_active = true AND lifecycle_state = 'published'
AND rule_code = 'RoP.109.5' AND duration_value = 2
AND duration_unit = 'weeks' AND trigger_event_id = 113;
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.11: upc.inf.cfi.translations_lodge missing or wrong shape (got % matches)', v_count;
END IF;
-- §12 T1.12 pi.response re-anchor
SELECT count(*) INTO v_count
FROM paliad.deadline_rules dr
JOIN paliad.deadline_rules p ON p.id = dr.parent_id
WHERE dr.submission_code = 'upc.pi.cfi.response'
AND dr.is_active = true AND dr.lifecycle_state = 'published'
AND dr.is_court_set = true
AND p.submission_code = 'upc.pi.cfi.app';
IF v_count <> 1 THEN
RAISE EXCEPTION 'mig 132 T1.12: upc.pi.cfi.response not re-anchored on app (got % matches)', v_count;
END IF;
-- §13 Q6 cleanup — no archived _archived_litigation rules left
SELECT count(*) INTO v_count
FROM paliad.deadline_rules
WHERE submission_code LIKE '_archived_litigation.%' ESCAPE '\'
AND lifecycle_state = 'archived';
IF v_count <> 0 THEN
RAISE EXCEPTION 'mig 132 §13: % archived _archived_litigation.* rules still present after cleanup', v_count;
END IF;
END $$;

View File

@@ -0,0 +1,33 @@
-- Reverses mig 133. Removes the 5 new rules:
-- * upc.dmgs.cfi.interim
-- * upc.dmgs.cfi.oral
-- * upc.dmgs.cfi.decision
-- * upc.dmgs.cfi.appeal_spawn
-- * upc.pi.cfi.appeal_spawn
--
-- The audit_reason is required by the mig 079 trigger for DELETE;
-- set_config at top supplies it.
--
-- Idempotent — if a rule is already missing the DELETE matches zero
-- rows and the audit log records nothing extra.
SELECT set_config(
'paliad.audit_reason',
'mig 133 (down): revert UPC Damages tree-end rows and UPC PI appeal-spawn (t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118)',
true);
-- Delete the spawn rows first so the parent_id reference goes away
-- before the parent decision row is removed.
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.dmgs.cfi.appeal_spawn',
'upc.pi.cfi.appeal_spawn')
AND lifecycle_state = 'published';
DELETE FROM paliad.deadline_rules
WHERE submission_code IN (
'upc.dmgs.cfi.interim',
'upc.dmgs.cfi.oral',
'upc.dmgs.cfi.decision')
AND proceeding_type_id = 17
AND lifecycle_state = 'published';

View File

@@ -0,0 +1,405 @@
-- t-paliad-285 (m/paliad#117) + t-paliad-286 (m/paliad#118) —
-- post-submission court followup for UPC Damages and appeal route
-- for UPC Provisional Measures.
--
-- m's 2026-05-25 report: the upc.dmgs.cfi proceeding stops at the
-- last party submission (rejoin) — no interim conference, no oral
-- hearing, no decision row, no appeal-spawn. The upc.pi.cfi
-- proceeding has its decision row (`pi.order`) but no spawn into
-- the appeal tree. Both gaps prevent the Verfahrensablauf timeline
-- from rendering the court phase plus any downstream appeal sub-
-- tree that atlas's #96 spawn-rendering mechanism is otherwise
-- ready to surface.
--
-- Two sections in one migration (slot 133 — knuth on 132, paliadin
-- coordinated):
--
-- A. UPC Damages tree-end rows (#117)
-- A1 upc.dmgs.cfi.interim UPC RoP R.105 court-set hearing
-- A2 upc.dmgs.cfi.oral UPC RoP R.118 / R.250 court-set hearing
-- A3 upc.dmgs.cfi.decision UPC RoP R.118 / R.144 court-set decision
-- A4 upc.dmgs.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
--
-- B. UPC Provisional Measures appeal route (#118)
-- B1 upc.pi.cfi.appeal_spawn UPC RoP R.220.1(a) / R.224.1(a) 2mo, spawn → upc.apl.merits (id=11)
--
-- Source citations:
-- * docs/research-deadlines-completeness-2026-05-25.md
-- — §2.1 (upc.dmgs.cfi has only 4 rules: R.131.2 / R.137.2 / R.139)
-- — §D Damages table (R.144 tree-end row missing — listed
-- in Tier 4 as "cosmetic", upgraded to Tier-0 by m's
-- report once the wider follow-up gap was understood)
-- * docs/audit-upc-rop-deadlines-2026-05-08.md §D row R.144,
-- §F R.220.1(a) / R.224.1(a) (verified verbatim in youpc DB
-- under law_type=UPCRoP).
-- * UPC Rules of Procedure (consolidated):
-- R.105 — Interim conference (court fixes after written
-- procedure closes; same structural shape as the inf
-- interim conference, already modelled as `upc.inf.cfi.interim`).
-- R.118 — Decision after oral hearing; general rule for
-- deciding panels.
-- R.250 — Determination of damages decision; damages-
-- specific decision rule (chains R.144 indication →
-- damages award).
-- R.144 — Final decision on damages quantum (tree-end
-- anchor for §A3).
-- R.220.1(a) — Appeal lies from any final decision /
-- decision disposing of the case at first instance.
-- A PI order under R.211 disposes of the urgent question
-- and is therefore appealable on the main 2-month track
-- (not the 15-day order track of R.220.1(c), which covers
-- case-management and procedural orders requiring leave).
-- Curie's §F table confirms the main-track wiring for
-- decisions on merits / disposing orders.
-- R.224.1(a) — Statement of Appeal within 2 months of
-- service of the final decision; the deadline-notes text
-- mirrors mig 095's inf.appeal_spawn / rev.appeal_spawn.
-- R.224.2(a) — Statement of grounds within 4 months
-- (separate deadline in the spawned upc.apl.merits
-- proceeding; already present as upc.apl.merits.grounds).
--
-- Shape decisions (mirroring mig 012 / mig 095 conventions):
-- * Court-set rows (interim / oral / decision) carry
-- primary_party='court', event_type='hearing'|'decision',
-- duration_value=0, is_court_set=true, parent_id=NULL,
-- concept_id reuses the shared concepts already wired for
-- upc.inf.cfi (interim-conference / oral-hearing / decision).
-- * Spawn rows carry primary_party='both', is_spawn=true,
-- spawn_proceeding_type_id=11 (upc.apl.merits), spawn_label
-- identical to the merits spawn already in production. The
-- spawn row's parent_id is the spawning decision/order row
-- (so the audit log carries the trigger link).
-- * No condition_expr — m's F2.3 decision recorded in mig 095
-- §3: "the appeal deadline should always be triggered by a
-- decision … appeal is always a possibility." Visibility
-- filtering on the frontend hides appeals on projects where
-- no appeal is contemplated.
-- * sequence_order numbering follows the inf convention
-- (40=interim, 50=oral, 60=decision, 80=appeal_spawn) so the
-- Verfahrensablauf timeline orders consistently across
-- proceedings. For PI the existing pi.order sits at
-- sequence_order=3; the appeal_spawn lands at 10 (clear of
-- the writ phase, room for future court-phase rows).
--
-- Idempotency: every INSERT is gated by `WHERE NOT EXISTS (… same
-- submission_code, proceeding_type_id, lifecycle_state)`. Re-apply
-- against an already-migrated DB inserts zero rows and the audit
-- log carries no duplicate entries.
--
-- audit_reason set_config required at the top — the mig 079 trigger
-- on paliad.deadline_rules raises EXCEPTION 'audit reason required'
-- on INSERT/UPDATE/DELETE without it.
SELECT set_config(
'paliad.audit_reason',
'mig 133: t-paliad-285 / m/paliad#117 + t-paliad-286 / m/paliad#118 — UPC Damages tree-end rows (interim conference R.105, oral hearing R.118/R.250, decision R.118/R.144, appeal-spawn R.220.1(a)) and UPC Provisional Measures appeal-spawn R.220.1(a); see docs/research-deadlines-completeness-2026-05-25.md §D and docs/audit-upc-rop-deadlines-2026-05-08.md §D/§F',
true);
-- =============================================================================
-- A. UPC Damages — court-phase tree end (m/paliad#117)
-- =============================================================================
-- A1. upc.dmgs.cfi.interim — Interim conference (UPC RoP R.105).
-- Court-set hearing fixed by the judge-rapporteur once the
-- written procedure closes. Identical shape to
-- upc.inf.cfi.interim; reuses the shared interim-conference
-- concept node.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.interim',
'Zwischenverfahren',
'Interim Conference',
NULL,
'court',
'hearing',
0,
'months',
'after',
NULL,
'Termin vom Gericht bestimmt',
'Date set by the court',
40,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'e5071152-d408-4455-b644-9e79d86fd538'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.interim'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A2. upc.dmgs.cfi.oral — Oral hearing (UPC RoP R.118 / R.250).
-- Court-set hearing after the interim conference / close of
-- written procedure. Same shape as upc.inf.cfi.oral; reuses
-- the shared oral-hearing concept node.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.oral',
'Mündliche Verhandlung',
'Oral Hearing',
NULL,
'court',
'hearing',
0,
'months',
'after',
NULL,
NULL,
NULL,
50,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'd6e5b793-dcf1-4d83-81ff-34f42dbb3693'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.oral'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A3. upc.dmgs.cfi.decision — Damages decision (UPC RoP R.118 /
-- R.144 / R.250). Court-set decision delivered after oral
-- hearing; closes the §3.1 audit gap (R.144 tree-end). Same
-- shape as upc.inf.cfi.decision; reuses the shared decision
-- concept node.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state,
concept_id)
SELECT
17,
NULL,
'upc.dmgs.cfi.decision',
'Entscheidung',
'Decision',
NULL,
'court',
'decision',
0,
'months',
'after',
NULL,
NULL,
NULL,
60,
false,
NULL,
NULL,
true,
NULL,
false,
NULL,
'optional',
true,
'published',
'472fc32d-cc4f-4aa4-8ace-e422031812de'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.decision'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- A4. upc.dmgs.cfi.appeal_spawn — Appeal against damages decision
-- (UPC RoP R.220.1(a), 2-month main track; grounds R.224.2(a)
-- run as a separate deadline in the spawned upc.apl.merits
-- proceeding). Parent points at the freshly-inserted
-- upc.dmgs.cfi.decision; the SELECT subquery resolves it
-- after A3 lands. Same shape as the mig 095 inf.appeal_spawn.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
17,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.decision'
AND proceeding_type_id = 17
AND lifecycle_state = 'published'
AND is_active = true),
'upc.dmgs.cfi.appeal_spawn',
'Berufung gegen Schadensentscheidung',
'Appeal against damages decision',
'Berufung gegen die Entscheidung über die Schadensbemessung (R.118 / R.144). Statutarische Frist von 2 Monaten ab Zustellung der Entscheidung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren).',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der Schadensentscheidung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the damages decision lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
80,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.dmgs.cfi.appeal_spawn'
AND proceeding_type_id = 17
AND lifecycle_state = 'published');
-- =============================================================================
-- B. UPC Provisional Measures — appeal route (m/paliad#118)
-- =============================================================================
-- B1. upc.pi.cfi.appeal_spawn — Appeal against PI order (UPC RoP
-- R.220.1(a), 2-month main track). PI orders under R.211
-- dispose of the urgent question and are appealable on the
-- main 2-month track (R.220.1(a)/R.224.1(a)); the 15-day
-- order track of R.220.1(c) is for case-management /
-- procedural orders requiring leave and does not apply to
-- PI dispositions. Parent points at the existing
-- upc.pi.cfi.order (sequence_order=3) so the spawn fires
-- once the order is anchored on a project's timeline.
INSERT INTO paliad.deadline_rules
(proceeding_type_id, parent_id, submission_code, name, name_en,
description, primary_party, event_type,
duration_value, duration_unit, timing,
rule_code, deadline_notes, deadline_notes_en, sequence_order,
is_spawn, spawn_proceeding_type_id, spawn_label,
is_active, legal_source, is_bilateral,
condition_expr, priority, is_court_set, lifecycle_state)
SELECT
10,
(SELECT id FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.order'
AND proceeding_type_id = 10
AND lifecycle_state = 'published'
AND is_active = true),
'upc.pi.cfi.appeal_spawn',
'Berufung gegen Anordnung',
'Appeal against PI order',
'Berufung gegen die einstweilige Anordnung nach R.211. Eine PI-Anordnung erledigt die einstweilige Streitfrage und wird wie eine Endentscheidung im Hauptverfahren behandelt: statutarische Frist von 2 Monaten ab Zustellung (R.224.1(a)); die Berufungsbegründung folgt mit 4 Monaten ab Zustellung (R.224.2(a), eigenständige Frist im Berufungsverfahren). Die 15-Tage-Spur nach R.220.1(c) / R.220.2 gilt für Verfahrensanordnungen mit Zulassung und ist hier nicht einschlägig.',
'both',
'filing',
2,
'months',
'after',
'RoP.220.1.a',
'Innerhalb von 2 Monaten ab Zustellung der PI-Anordnung Berufungsschrift einreichen (R.224.1(a)). Die Berufungsbegründung (R.224.2(a), 4 Monate) läuft als separate Frist im Berufungsverfahren.',
'Within 2 months of service of the PI order lodge the Statement of appeal (R.224.1(a)). The Statement of grounds (R.224.2(a), 4 months) runs as an independent deadline in the appeal proceeding.',
10,
true,
11,
'Berufungsverfahren öffnen',
true,
'UPC.RoP.220.1',
false,
NULL,
'optional',
false,
'published'
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules
WHERE submission_code = 'upc.pi.cfi.appeal_spawn'
AND proceeding_type_id = 10
AND lifecycle_state = 'published');
-- =============================================================================
-- C. Post-insert verification — raise if any expected row is missing
-- (matches the mig 095 / 127 convention; protects against a future
-- re-shape of the table that silently drops one of the WHERE NOT
-- EXISTS predicates).
-- =============================================================================
DO $$
DECLARE
v_missing text;
BEGIN
SELECT string_agg(expected, ', ' ORDER BY expected)
INTO v_missing
FROM (VALUES
('upc.dmgs.cfi.interim'),
('upc.dmgs.cfi.oral'),
('upc.dmgs.cfi.decision'),
('upc.dmgs.cfi.appeal_spawn'),
('upc.pi.cfi.appeal_spawn')
) AS t(expected)
WHERE NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = t.expected
AND dr.lifecycle_state = 'published'
AND dr.is_active = true);
IF v_missing IS NOT NULL THEN
RAISE EXCEPTION
'mig 133: expected published rules missing after insert: %', v_missing;
END IF;
IF NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = 'upc.dmgs.cfi.appeal_spawn'
AND dr.proceeding_type_id = 17
AND dr.spawn_proceeding_type_id = 11
AND dr.is_spawn = true
AND dr.parent_id IS NOT NULL
AND dr.lifecycle_state = 'published'
) THEN
RAISE EXCEPTION
'mig 133: upc.dmgs.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
END IF;
IF NOT EXISTS (
SELECT 1 FROM paliad.deadline_rules dr
WHERE dr.submission_code = 'upc.pi.cfi.appeal_spawn'
AND dr.proceeding_type_id = 10
AND dr.spawn_proceeding_type_id = 11
AND dr.is_spawn = true
AND dr.parent_id IS NOT NULL
AND dr.lifecycle_state = 'published'
) THEN
RAISE EXCEPTION
'mig 133: upc.pi.cfi.appeal_spawn shape check failed (expected is_spawn=true, spawn_proceeding_type_id=11, parent_id set)';
END IF;
END $$;

View File

@@ -458,6 +458,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-139 — set unit_role on a member.
protected.HandleFunc("PATCH /api/partner-units/{id}/members/{user_id}/role", handleSetUnitMemberRole)
protected.HandleFunc("GET /api/parties/search", handlePartiesSearch)
protected.HandleFunc("DELETE /api/parties/{id}", handleDeleteParty)
// Phase F — Appointments (appointments)

View File

@@ -701,6 +701,31 @@ func handleCreateParty(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, p)
}
// GET /api/parties/search?q=...
//
// Cross-project party picker for the submission-draft editor
// (t-paliad-287). Returns up to 25 parties from every project the
// caller can see, matched by case-insensitive substring on name or
// representative. Empty q returns the 20 most-recently-updated rows so
// the picker isn't blank on first open. Visibility is enforced in the
// service layer via the same predicate every project-scoped read uses.
func handlePartiesSearch(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
q := r.URL.Query().Get("q")
hits, err := dbSvc.parties.Search(r.Context(), uid, q, 25)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"results": hits})
}
// DELETE /api/parties/{id}
func handleDeleteParty(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {

View File

@@ -51,13 +51,20 @@ const SpecVersion = 1
// can't bury an in-flight approval, per the design doc §3 carve-out).
// Set by the bar's `unread_only` axis on /inbox; other surfaces leave
// it false and the spec is a no-op.
//
// Predicates is a flat per-source narrowing record: keys at the top
// level are data sources ("deadline", "appointment", …) and values are
// the per-source predicate structs directly. The shape on the wire and
// the shape the frontend emits agree exactly — see t-paliad-283 for the
// latent contract bug (Go used to wrap each entry in another Predicates
// struct, so the frontend's overlay clicks parsed back as no-op).
type FilterSpec struct {
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates map[DataSource]Predicates `json:"predicates,omitempty"`
UnreadOnly bool `json:"unread_only,omitempty"`
Version int `json:"version"`
Sources []DataSource `json:"sources"`
Scope ScopeSpec `json:"scope"`
Time TimeSpec `json:"time"`
Predicates *Predicates `json:"predicates,omitempty"`
UnreadOnly bool `json:"unread_only,omitempty"`
}
// ScopeSpec narrows which projects contribute rows. Resolved at query
@@ -147,7 +154,8 @@ const (
)
// Predicates is the per-source narrowing payload. Empty fields mean
// "no narrowing" — never "exclude all".
// "no narrowing" — never "exclude all". One field per data source;
// the wire shape is the same: `{"deadline": {...}, "appointment": {...}}`.
type Predicates struct {
Deadline *DeadlinePredicates `json:"deadline,omitempty"`
Appointment *AppointmentPredicates `json:"appointment,omitempty"`
@@ -305,14 +313,25 @@ func (s *FilterSpec) Validate() error {
return err
}
for src, preds := range s.Predicates {
if !isKnownSource(src) {
return fmt.Errorf("%w: predicates set on unknown source %q", ErrInvalidInput, src)
if s.Predicates != nil {
// Reject predicates set on a source the spec doesn't list — we'd
// silently drop the narrowing otherwise. Walk the set fields.
type srcCheck struct {
src DataSource
present bool
}
if !seen[src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, src)
checks := []srcCheck{
{SourceDeadline, s.Predicates.Deadline != nil},
{SourceAppointment, s.Predicates.Appointment != nil},
{SourceProjectEvent, s.Predicates.ProjectEvent != nil},
{SourceApprovalRequest, s.Predicates.ApprovalRequest != nil},
}
if err := preds.validate(); err != nil {
for _, c := range checks {
if c.present && !seen[c.src] {
return fmt.Errorf("%w: predicates set on source %q which is not selected", ErrInvalidInput, c.src)
}
}
if err := s.Predicates.validate(); err != nil {
return err
}
}

View File

@@ -0,0 +1,125 @@
package services
import (
"encoding/json"
"testing"
)
// t-paliad-283 regression: the bar's chip clicks POST a `predicates`
// payload shaped as `{<source>: <per-source>}`. The Go side previously
// declared `Predicates map[DataSource]Predicates` — a doubled-nested
// shape — which silently unmarshalled the bar's payload as no-op
// narrowing. This test pins the wire shape so the contract can't drift
// again.
//
// Run with `go test ./internal/services/`.
func TestFilterSpec_FlatPredicatesWireShape(t *testing.T) {
// The shape every chip click in the FilterBar emits: predicates is
// keyed by data source, value is the per-source predicate struct
// directly. Doubled-nesting would unmarshal as empty Predicates.
const wire = `{
"version": 1,
"sources": ["deadline", "appointment", "project_event", "approval_request"],
"scope": {"projects": {"mode": "all_visible"}},
"time": {"field": "auto", "horizon": "past_30d"},
"predicates": {
"deadline": {"status": ["pending"]},
"appointment": {"appointment_types": ["hearing"]},
"project_event": {"event_types": ["deadline_created"]},
"approval_request": {"viewer_role": "any_visible", "status": ["pending"]}
}
}`
var spec FilterSpec
if err := json.Unmarshal([]byte(wire), &spec); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if err := spec.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
if spec.Predicates == nil {
t.Fatal("predicates must be non-nil after unmarshalling the bar's shape")
}
if spec.Predicates.Deadline == nil || len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "pending" {
t.Errorf("deadline.status must round-trip, got %+v", spec.Predicates.Deadline)
}
if spec.Predicates.Appointment == nil || len(spec.Predicates.Appointment.AppointmentTypes) != 1 {
t.Errorf("appointment.appointment_types must round-trip, got %+v", spec.Predicates.Appointment)
}
if spec.Predicates.ProjectEvent == nil || len(spec.Predicates.ProjectEvent.EventTypes) != 1 {
t.Errorf("project_event.event_types must round-trip, got %+v", spec.Predicates.ProjectEvent)
}
if spec.Predicates.ApprovalRequest == nil || spec.Predicates.ApprovalRequest.ViewerRole != "any_visible" {
t.Errorf("approval_request.viewer_role must round-trip, got %+v", spec.Predicates.ApprovalRequest)
}
}
// The shipped FilterSpec must marshal back to exactly the flat shape
// the frontend declares in views/types.ts. Otherwise /api/views/system
// (which serializes the InboxSystemView's Filter for the bar) returns a
// shape the frontend can't consume without translation gymnastics.
func TestFilterSpec_MarshalFlatPredicatesShape(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceDeadline},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: &Predicates{
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
},
}
b, err := json.Marshal(spec)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Parse back generically so the assertion is on the wire shape, not
// on the Go type system that produced it.
var raw map[string]json.RawMessage
if err := json.Unmarshal(b, &raw); err != nil {
t.Fatalf("re-unmarshal: %v", err)
}
var preds map[string]json.RawMessage
if err := json.Unmarshal(raw["predicates"], &preds); err != nil {
t.Fatalf("predicates re-unmarshal: %v", err)
}
dl, ok := preds["deadline"]
if !ok {
t.Fatal("predicates.deadline missing — wire shape regressed")
}
var dlBody map[string]json.RawMessage
if err := json.Unmarshal(dl, &dlBody); err != nil {
t.Fatalf("deadline body unmarshal: %v", err)
}
if _, ok := dlBody["status"]; !ok {
t.Errorf("predicates.deadline.status must be a top-level field; doubled-nesting reappeared. Body: %s", string(dl))
}
if _, ok := dlBody["deadline"]; ok {
t.Errorf("predicates.deadline must NOT wrap a nested deadline key — that's the t-paliad-283 bug. Body: %s", string(dl))
}
}
// End-to-end pin: the bar's payload after the user clicks
// "Frist-Status: Erledigt" (completed) must produce a spec whose
// runDeadlines branch narrows to completed deadlines. Without the
// t-paliad-283 fix, the unmarshal silently produced an empty Predicates
// and the SQL ran without the `status='completed'` clause.
func TestFilterSpec_BarChipPayloadNarrowsDeadlineStatus(t *testing.T) {
const barPayload = `{
"version": 1,
"sources": ["deadline"],
"scope": {"projects": {"mode": "all_visible"}},
"time": {"field": "auto", "horizon": "past_30d"},
"predicates": {"deadline": {"status": ["completed"]}}
}`
var spec FilterSpec
if err := json.Unmarshal([]byte(barPayload), &spec); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if spec.Predicates == nil || spec.Predicates.Deadline == nil {
t.Fatal("deadline predicate must survive the round-trip")
}
if len(spec.Predicates.Deadline.Status) != 1 || spec.Predicates.Deadline.Status[0] != "completed" {
t.Errorf("deadline.status must be [\"completed\"], got %+v", spec.Predicates.Deadline.Status)
}
}

View File

@@ -180,8 +180,8 @@ func TestFilterSpec_NewSymmetricHorizonsValidate(t *testing.T) {
func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceDeadline}
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}}},
s.Predicates = &Predicates{
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"hearing"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("predicates on unselected source must reject, got %v", err)
@@ -190,8 +190,8 @@ func TestFilterSpec_PredicatesRequireSourceSelected(t *testing.T) {
func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Predicates = map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"weird"}}},
s.Predicates = &Predicates{
Deadline: &DeadlinePredicates{Status: []string{"weird"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown deadline.status must reject, got %v", err)
@@ -201,8 +201,8 @@ func TestFilterSpec_DeadlineStatusEnum(t *testing.T) {
func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = append(s.Sources, SourceAppointment)
s.Predicates = map[DataSource]Predicates{
SourceAppointment: {Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}}},
s.Predicates = &Predicates{
Appointment: &AppointmentPredicates{AppointmentTypes: []string{"bogus"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown appointment_type must reject, got %v", err)
@@ -212,8 +212,8 @@ func TestFilterSpec_AppointmentTypeEnum(t *testing.T) {
func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceProjectEvent}
s.Predicates = map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}}},
s.Predicates = &Predicates{
ProjectEvent: &ProjectEventPredicates{EventTypes: []string{"unknown_kind"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown project_event kind must reject, got %v", err)
@@ -223,8 +223,8 @@ func TestFilterSpec_ProjectEventKindMustBeKnown(t *testing.T) {
func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"}},
s.Predicates = &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{ViewerRole: "everyone"},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown viewer_role must reject, got %v", err)
@@ -234,8 +234,8 @@ func TestFilterSpec_ApprovalViewerRoleEnum(t *testing.T) {
func TestFilterSpec_ApprovalRequestStatusEnum(t *testing.T) {
s := validBaseSpec()
s.Sources = []DataSource{SourceApprovalRequest}
s.Predicates = map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}}},
s.Predicates = &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{Status: []string{"weird"}},
}
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
t.Fatalf("unknown approval_request.status must reject, got %v", err)
@@ -251,15 +251,15 @@ func TestFilterSpec_RoundTripJSON(t *testing.T) {
PersonalOnly: false,
},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{
Predicates: &Predicates{
Deadline: &DeadlinePredicates{
Status: []string{"pending"},
ApprovalStatus: []string{"approved", "pending"},
}},
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
},
ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "approver_eligible",
Status: []string{"pending"},
}},
},
},
}
b, err := MarshalFilterSpec(original)

View File

@@ -38,6 +38,59 @@ type CreatePartyInput struct {
ContactInfo json.RawMessage `json:"contact_info,omitempty"`
}
// PartySearchHit is one row of the cross-project party search — a real
// paliad.parties row enriched with the parent project's title and
// reference so the picker can render context the lawyer needs to
// disambiguate identically-named parties on different cases
// (t-paliad-287).
type PartySearchHit struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
ProjectTitle string `db:"project_title" json:"project_title"`
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
Name string `db:"name" json:"name"`
Role *string `db:"role" json:"role,omitempty"`
Representative *string `db:"representative" json:"representative,omitempty"`
}
// Search returns parties from every project the caller can see, matched
// by case-insensitive substring on name OR representative. Empty query
// returns the 20 most recently-updated parties so the picker isn't
// blank on first open. Capped at 25 rows; the frontend doesn't paginate
// (the typical PA looks for one party they remember by name, not browses).
//
// Visibility is enforced inline via visibilityPredicatePositional —
// invisible projects' parties never surface in the result set.
func (s *PartyService) Search(ctx context.Context, userID uuid.UUID, query string, limit int) ([]PartySearchHit, error) {
if limit <= 0 || limit > 50 {
limit = 25
}
q := strings.TrimSpace(query)
args := []any{userID}
conds := []string{visibilityPredicatePositional("p", 1)}
if q != "" {
args = append(args, "%"+q+"%")
conds = append(conds,
fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`,
len(args), len(args)))
}
args = append(args, limit)
sqlStr := `
SELECT pa.id, pa.project_id, p.title AS project_title,
p.reference AS project_reference,
pa.name, pa.role, pa.representative
FROM paliad.parties pa
JOIN paliad.projects p ON p.id = pa.project_id
WHERE ` + strings.Join(conds, " AND ") + `
ORDER BY pa.updated_at DESC
LIMIT $` + fmt.Sprintf("%d", len(args))
hits := []PartySearchHit{}
if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil {
return nil, fmt.Errorf("search parties: %w", err)
}
return hits, nil
}
// ListForProject returns all Parties for the Project, visibility-checked.
func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) {
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {

View File

@@ -66,8 +66,8 @@ func AgendaSystemView() SystemView {
Sources: []DataSource{SourceDeadline, SourceAppointment},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonNext30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceDeadline: {Deadline: &DeadlinePredicates{Status: []string{"pending"}}},
Predicates: &Predicates{
Deadline: &DeadlinePredicates{Status: []string{"pending"}},
},
},
Render: RenderSpec{
@@ -126,14 +126,14 @@ func InboxSystemView() SystemView {
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonPast30d, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
Predicates: &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "any_visible",
Status: []string{"pending"},
}},
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
},
ProjectEvent: &ProjectEventPredicates{
EventTypes: InboxProjectEventKinds,
}},
},
},
},
Render: RenderSpec{
@@ -159,10 +159,10 @@ func InboxRequesterSystemView() SystemView {
Sources: []DataSource{SourceApprovalRequest},
Scope: ScopeSpec{Projects: ScopeProjects{Mode: ScopeAllVisible}},
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
Predicates: map[DataSource]Predicates{
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
Predicates: &Predicates{
ApprovalRequest: &ApprovalRequestPredicates{
ViewerRole: "self_requested",
}},
},
},
},
Render: RenderSpec{

View File

@@ -82,11 +82,10 @@ func TestInboxSystemView_RowActionInbox(t *testing.T) {
func TestInboxSystemView_CuratedProjectEventKinds(t *testing.T) {
sv := InboxSystemView()
preds := sv.Filter.Predicates[SourceProjectEvent]
if preds.ProjectEvent == nil {
if sv.Filter.Predicates == nil || sv.Filter.Predicates.ProjectEvent == nil {
t.Fatal("InboxSystemView must narrow project_event predicates")
}
got := preds.ProjectEvent.EventTypes
got := sv.Filter.Predicates.ProjectEvent.EventTypes
if len(got) != len(InboxProjectEventKinds) {
t.Errorf("expected %d curated kinds, got %d", len(InboxProjectEventKinds), len(got))
}

View File

@@ -234,8 +234,8 @@ func (s *EventService) runDeadlines(ctx context.Context, userID uuid.UUID, spec
uid := userID
df.CreatedBy = &uid
}
if preds, ok := spec.Predicates[SourceDeadline]; ok && preds.Deadline != nil {
dp := preds.Deadline
if spec.Predicates != nil && spec.Predicates.Deadline != nil {
dp := spec.Predicates.Deadline
// Status: ListFilter has DeadlineStatusFilter (single-value filter).
// If the spec asks for both pending+completed → no narrowing; if
// only pending → DeadlineFilterPending; only completed → Completed.
@@ -317,8 +317,8 @@ func (s *EventService) runAppointments(ctx context.Context, userID uuid.UUID, sp
}
af.From = bounds.from
af.To = bounds.to
if preds, ok := spec.Predicates[SourceAppointment]; ok && preds.Appointment != nil {
ap := preds.Appointment
if spec.Predicates != nil && spec.Predicates.Appointment != nil {
ap := spec.Predicates.Appointment
// AppointmentListFilter takes a single Type today; narrow to first
// listed value, fall back to all if multiple.
if len(ap.AppointmentTypes) == 1 {
@@ -482,21 +482,24 @@ func (s *EventService) runProjectEvents(ctx context.Context, userID uuid.UUID, s
// ApprovalService inbox queries. ViewerRole picks which underlying
// query runs.
func (s *EventService) runApprovalRequests(ctx context.Context, userID uuid.UUID, spec FilterSpec, approval *ApprovalService, bounds viewSpecBounds) ([]ViewRow, error) {
preds := spec.Predicates[SourceApprovalRequest]
var ap *ApprovalRequestPredicates
if spec.Predicates != nil {
ap = spec.Predicates.ApprovalRequest
}
role := "approver_eligible"
if preds.ApprovalRequest != nil && preds.ApprovalRequest.ViewerRole != "" {
role = preds.ApprovalRequest.ViewerRole
if ap != nil && ap.ViewerRole != "" {
role = ap.ViewerRole
}
filter := InboxFilter{}
if preds.ApprovalRequest != nil {
if ap != nil {
// InboxFilter takes a single status today. If the spec says
// only one, narrow; if multiple, leave open.
if len(preds.ApprovalRequest.Status) == 1 {
filter.Status = preds.ApprovalRequest.Status[0]
if len(ap.Status) == 1 {
filter.Status = ap.Status[0]
}
if len(preds.ApprovalRequest.EntityTypes) == 1 {
filter.EntityType = preds.ApprovalRequest.EntityTypes[0]
if len(ap.EntityTypes) == 1 {
filter.EntityType = ap.EntityTypes[0]
}
}
if spec.Scope.Projects.Mode == ScopeExplicit && len(spec.Scope.Projects.IDs) == 1 {
@@ -665,19 +668,18 @@ func explicitProjectSet(spec FilterSpec) map[uuid.UUID]bool {
// approvalStatusMatches checks the entity-side approval_status filter.
// Returns true when the row passes (no filter set → always true).
func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bool {
preds, ok := spec.Predicates[src]
if !ok {
if spec.Predicates == nil {
return true
}
var allowed []string
switch src {
case SourceDeadline:
if preds.Deadline != nil {
allowed = preds.Deadline.ApprovalStatus
if spec.Predicates.Deadline != nil {
allowed = spec.Predicates.Deadline.ApprovalStatus
}
case SourceAppointment:
if preds.Appointment != nil {
allowed = preds.Appointment.ApprovalStatus
if spec.Predicates.Appointment != nil {
allowed = spec.Predicates.Appointment.ApprovalStatus
}
}
if len(allowed) == 0 {
@@ -689,15 +691,15 @@ func approvalStatusMatches(rowStatus string, spec FilterSpec, src DataSource) bo
// allowedAppointmentTypes returns nil when the filter is open, otherwise
// a set of legal appointment_type values.
func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceAppointment]
if !ok || preds.Appointment == nil {
if spec.Predicates == nil || spec.Predicates.Appointment == nil {
return nil
}
if len(preds.Appointment.AppointmentTypes) <= 1 {
ap := spec.Predicates.Appointment
if len(ap.AppointmentTypes) <= 1 {
return nil // single-value already pushed down via AppointmentListFilter.Type
}
out := make(map[string]bool, len(preds.Appointment.AppointmentTypes))
for _, t := range preds.Appointment.AppointmentTypes {
out := make(map[string]bool, len(ap.AppointmentTypes))
for _, t := range ap.AppointmentTypes {
out[t] = true
}
return out
@@ -712,13 +714,16 @@ func allowedAppointmentTypes(spec FilterSpec) map[string]bool {
// don't want both rows showing up side-by-side. The drop applies to
// both the explicit caller list and the implicit "all kinds" path.
func allowedProjectEventKinds(spec FilterSpec) []string {
preds, ok := spec.Predicates[SourceProjectEvent]
var pe *ProjectEventPredicates
if spec.Predicates != nil {
pe = spec.Predicates.ProjectEvent
}
dedupApprovals := slices.Contains(spec.Sources, SourceApprovalRequest)
var requested []string
switch {
case ok && preds.ProjectEvent != nil && len(preds.ProjectEvent.EventTypes) > 0:
requested = preds.ProjectEvent.EventTypes
case pe != nil && len(pe.EventTypes) > 0:
requested = pe.EventTypes
case dedupApprovals:
// No explicit narrowing, but ApprovalRequest is in sources —
// rebuild the implicit "all" list so we can subtract approvals.
@@ -750,30 +755,30 @@ func isApprovalAuditKind(kind string) bool {
// allowedRequestStatuses returns nil for "no narrowing" (or "single value
// already pushed into InboxFilter.Status").
func allowedRequestStatuses(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.Status) <= 1 {
ap := spec.Predicates.ApprovalRequest
if len(ap.Status) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.Status))
for _, s := range preds.ApprovalRequest.Status {
out := make(map[string]bool, len(ap.Status))
for _, s := range ap.Status {
out[s] = true
}
return out
}
func allowedRequestEntityTypes(spec FilterSpec) map[string]bool {
preds, ok := spec.Predicates[SourceApprovalRequest]
if !ok || preds.ApprovalRequest == nil {
if spec.Predicates == nil || spec.Predicates.ApprovalRequest == nil {
return nil
}
if len(preds.ApprovalRequest.EntityTypes) <= 1 {
ap := spec.Predicates.ApprovalRequest
if len(ap.EntityTypes) <= 1 {
return nil
}
out := make(map[string]bool, len(preds.ApprovalRequest.EntityTypes))
for _, t := range preds.ApprovalRequest.EntityTypes {
out := make(map[string]bool, len(ap.EntityTypes))
for _, t := range ap.EntityTypes {
out[t] = true
}
return out

View File

@@ -13,8 +13,8 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceApprovalRequest, SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
Predicates: &Predicates{
ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
@@ -22,7 +22,7 @@ func TestAllowedProjectEventKinds_DedupsApprovalAudits(t *testing.T) {
"approval_decided",
"note_created",
},
}},
},
},
}
got := allowedProjectEventKinds(spec)
@@ -47,13 +47,13 @@ func TestAllowedProjectEventKinds_NoDedupWhenApprovalsAbsent(t *testing.T) {
spec := FilterSpec{
Version: SpecVersion,
Sources: []DataSource{SourceProjectEvent},
Predicates: map[DataSource]Predicates{
SourceProjectEvent: {ProjectEvent: &ProjectEventPredicates{
Predicates: &Predicates{
ProjectEvent: &ProjectEventPredicates{
EventTypes: []string{
"deadline_created",
"deadline_approval_requested",
},
}},
},
},
}
got := allowedProjectEventKinds(spec)

View File

@@ -315,11 +315,11 @@ func buildDocumentXML() string {
body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})")
body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}")
headerSubsection(&b, "Frist")
body0(&b, "Frist-Bezeichnung: {{deadline.title}}")
body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// for custom templates that want them, but the default HL skeleton
// no longer renders them in the submission body: the deadline is
// internal/admin context and has no place in a court-bound document.
heading(&b, "HLpat-Heading-H2", "I. Sachverhalt")
body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]")
@@ -349,7 +349,6 @@ func buildDocumentXML() string {
body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}")
body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}")
body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}")
body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}")
body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}")
body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}")

View File

@@ -137,14 +137,19 @@ const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
</w:styles>`
// Document body — a code-agnostic Schriftsatz skeleton: firm letterhead +
// case caption + parties + submission heading + deadline + a single
// neutral body block. Mirrors the variable bag from SubmissionVarsService
// (48 keys across firm.* / today.* / user.* / project.* / parties.* /
// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific
// structure. A lawyer customising this template for a UPC SoC, EPO
// opposition, or DPMA appeal replaces the [Schriftsatztext] block and
// renames the party labels — every placeholder still resolves regardless
// of the submission_code chosen.
// case caption + parties + submission heading + a single neutral body
// block. Mirrors the variable bag from SubmissionVarsService (firm.* /
// today.* / user.* / project.* / parties.* / rule.*) without baking in
// DE-LG-Klageerwiderung-specific structure. A lawyer customising this
// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the
// [Schriftsatztext] block and renames the party labels — every
// placeholder still resolves regardless of the submission_code chosen.
//
// The {{deadline.*}} placeholders are deliberately NOT rendered by the
// default skeleton (t-paliad-287). The deadline is internal context for
// the lawyer, not text that belongs in a court-bound submission. The
// keys stay resolvable in the bag so a custom template can still
// reference them where it actually wants them.
//
// Every placeholder occupies its own <w:r> run so the renderer's pass-1
// (format-preserving, single-run) substitution catches it. The
@@ -194,11 +199,12 @@ func buildDocumentXML() string {
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
heading2(&b, "Frist")
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
// t-paliad-287 — the dedicated Frist block was removed in 2026-05.
// {{deadline.*}} placeholders stay resolvable in the variable bag
// (lawyer can still drop them into a custom paragraph) but the
// default skeleton no longer renders them in the submission body:
// the deadline is internal/admin context and has no place in a
// document going out to court.
heading2(&b, "Schriftsatztext")
plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]")
@@ -217,7 +223,7 @@ func buildDocumentXML() string {
// the bare {{today}} alias. A lawyer customising the template can
// delete this block; the renderer round-trips it cleanly today.
heading2(&b, "Locale-aware variants (SKELETON)")
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
plain(&b, "EN long date: {{today.long_en}}")
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")