Compare commits

...

1 Commits

Author SHA1 Message Date
mAi
4fc3005db8 mAi: #109 - t-paliad-277 submission generator party selector + import-from-project
Multi-select party picker on the dedicated submission draft editor —
lawyer picks which of the project's parties to mention in this
specific submission. Adds the t-paliad-277 variable-bag multi-party
shape ({{parties.claimants}}, {{parties.claimant.0.name}}) while
keeping the legacy flat aliases ({{parties.claimant.name}}) for every
existing .docx template authored before the rename.

Surfaces an explicit "Aus Projekt importieren" button + last-imported
timestamp at the top of the variable sidebar so the lawyer can re-pull
project-derived variables (project.*, parties.*, deadline.*,
procedural_event.*, rule.*) when the project data drifts away from the
saved draft overrides. firm.*, today.*, user.* overrides survive the
import — those values aren't sourced from the project record.

Schema: mig 131 adds two columns to paliad.submission_drafts:
  - selected_parties uuid[] DEFAULT '{}'::uuid[]
    Empty = include every party (legacy default).
    Non-empty = restrict to the subset, grouped by role at substitution.
  - last_imported_at timestamptz NULL
    Bumped each "Aus Projekt importieren" click; surfaced in UI.

Backend:
  - SubmissionVarsContext gains SelectedParties — filterPartiesBySelection
    restricts the resolved bag before role bucketing.
  - addPartyVars emits THREE coexisting forms per role: comma-joined
    (parties.claimants), indexed (parties.claimant.0.name), and flat
    legacy (parties.claimant.name → first selected claimant). Flat
    aliases are kept forever per the issue's backward-compat contract.
  - SubmissionDraftService.ImportFromProject strips overrides for
    project-derived prefixes and bumps last_imported_at; rejects
    project-less drafts (nothing to import from).
  - New endpoint POST /api/submission-drafts/{id}/import-from-project.
  - DraftPatch + PATCH handlers accept selected_parties.
  - submissionDraftView now ships available_parties so the editor can
    render the picker without an extra round-trip.

Frontend:
  - submission-draft.tsx: new import-row + parties block in the sidebar.
  - client/submission-draft.ts: paintImportRow / paintPartyPicker /
    onPartySelectionChange / onImportFromProject; group parties by
    role bucket (claimant / defendant / other) with DE+EN role-string
    matching to mirror the backend bucketing.
  - 3 new i18n keys (DE+EN): import.button, parties.title, parties.hint.
  - CSS for the picker + import row in global.css.

Tests: 6 new unit tests in submission_vars_parties_test.go covering
the multi-party bag emission, German role-string bucketing, flat-alias
first-of-role resolution, empty-selection-means-all default, non-empty
restriction, and the isProjectDerivedKey policy that powers the
import path.

Build hygiene: go build/vet clean; go test -short ./internal/... pass;
bun run build clean (2876 i18n keys, scan clean).
2026-05-25 16:51:35 +02:00
12 changed files with 990 additions and 95 deletions

View File

@@ -1473,6 +1473,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
"submissions.draft.preview.title": "Vorschau",
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
// 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.",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -4521,6 +4525,10 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.name.placeholder": "Name of this draft",
"submissions.draft.preview.title": "Preview",
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
// 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.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",

View File

@@ -21,12 +21,21 @@ interface SubmissionDraftJSON {
user_id: string;
name: string;
variables: Record<string, string>;
selected_parties: string[];
last_exported_at?: string | null;
last_exported_sha?: string | null;
last_imported_at?: string | null;
created_at: string;
updated_at: string;
}
interface AvailablePartyJSON {
id: string;
name: string;
role?: string;
representative?: string;
}
interface SubmissionRuleSummary {
name: string;
name_en: string;
@@ -46,6 +55,7 @@ interface SubmissionDraftView {
lang: string;
has_template: boolean;
template_missing?: boolean;
available_parties: AvailablePartyJSON[];
}
interface SubmissionDraftListResponse {
@@ -401,7 +411,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[] }): Promise<SubmissionDraftView> {
const p = state.parsed;
if (!p.draftID) throw new Error("no draft id");
if (state.inFlight) {
@@ -451,6 +461,8 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
}
@@ -562,6 +574,135 @@ function paintNameRow(): void {
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
}
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
// Hidden when the draft has no project (no project state to import).
function paintImportRow(): void {
const row = document.getElementById("submission-draft-import-row");
const btn = document.getElementById("submission-draft-import-btn") as HTMLButtonElement | null;
const stamp = document.getElementById("submission-draft-import-stamp");
if (!row || !btn || !stamp || !state.view) return;
if (!state.view.draft.project_id) {
row.style.display = "none";
return;
}
row.style.display = "";
const last = state.view.draft.last_imported_at;
if (last) {
stamp.textContent = (isEN() ? "Last imported: " : "Zuletzt importiert: ") + formatStamp(last);
} else {
stamp.textContent = isEN() ? "Never imported" : "Noch nicht importiert";
}
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).
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) {
block.style.display = "none";
list.innerHTML = "";
return;
}
block.style.display = "";
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
// then deselect what they want to drop. This matches the issue's
// "default = all parties on the project, lawyer can deselect" line.
const effective = selected.size === 0
? new Set(parties.map((p) => p.id))
: selected;
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>`;
for (const p of group.parties) {
const checked = effective.has(p.id) ? " checked" : "";
const chip = p.role
? `<span class="submission-draft-party-chip">${escapeHtml(p.role)}</span>`
: "";
const rep = p.representative
? `<span class="submission-draft-party-rep">${escapeHtml(
(isEN() ? "Repr.: " : "Vertr.: ") + p.representative,
)}</span>`
: "";
html += `<label class="submission-draft-party-row">`;
html += `<input type="checkbox" class="submission-draft-party-check"`;
html += ` data-party-id="${escapeHtml(p.id)}"${checked} />`;
html += `<span class="submission-draft-party-name">${escapeHtml(p.name)}</span>`;
html += chip;
html += rep;
html += `</label>`;
}
html += `</fieldset>`;
}
list.innerHTML = html;
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
inp.addEventListener("change", () => onPartySelectionChange());
});
}
interface PartyRoleGroup {
bucket: "claimant" | "defendant" | "other";
label: string;
parties: AvailablePartyJSON[];
}
function groupPartiesByRole(parties: AvailablePartyJSON[]): PartyRoleGroup[] {
const claimants: AvailablePartyJSON[] = [];
const defendants: AvailablePartyJSON[] = [];
const others: AvailablePartyJSON[] = [];
for (const p of parties) {
const role = (p.role ?? "").trim().toLowerCase();
if (role === "claimant" || role === "kläger" || role === "klaeger"
|| role === "klägerin" || role === "klaegerin") {
claimants.push(p);
} else if (role === "defendant" || role === "beklagter" || role === "beklagte") {
defendants.push(p);
} else {
others.push(p);
}
}
return [
{
bucket: "claimant",
label: isEN() ? "Claimants" : "Klägerinnen",
parties: claimants,
},
{
bucket: "defendant",
label: isEN() ? "Defendants" : "Beklagte",
parties: defendants,
},
{
bucket: "other",
label: isEN() ? "Other parties" : "Weitere Parteien",
parties: others,
},
];
}
function formatStamp(iso: string): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
}
function paintVariables(): void {
const host = document.getElementById("submission-draft-variables");
if (!host || !state.view) return;
@@ -710,6 +851,69 @@ function flashVarRow(input: HTMLElement): void {
// Event handlers
// ─────────────────────────────────────────────────────────────────────
async function onPartySelectionChange(): Promise<void> {
if (!state.view) return;
const host = document.getElementById("submission-draft-parties-list");
if (!host) return;
const checks = host.querySelectorAll<HTMLInputElement>(".submission-draft-party-check");
const selectedIDs: string[] = [];
checks.forEach((c) => {
if (c.checked && c.dataset.partyId) selectedIDs.push(c.dataset.partyId);
});
// If the lawyer has checked every party, persist that as an empty
// array so the row matches the "implicit all" default semantics — a
// future party added to the project will then be picked up
// automatically rather than silently dropped from this submission.
// If they've unchecked some, persist the actual subset.
const available = state.view.available_parties ?? [];
const allChecked = selectedIDs.length === available.length;
const payload = allChecked ? [] : selectedIDs;
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
try {
const view = await patchDraft({ selected_parties: payload });
state.view = view;
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
} catch (err) {
if ((err as Error).name === "AbortError") return;
console.error("submission-draft party selection:", err);
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
}
}
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
const originalLabel = btn.textContent ?? "";
btn.disabled = true;
btn.textContent = isEN() ? "Importing…" : "Importiert…";
setSaveStatus(isEN() ? "Importing from project…" : "Importiere aus Projekt…");
try {
const resp = await fetch(`/api/submission-drafts/${draftID}/import-from-project`, {
method: "POST",
});
if (!resp.ok) throw new Error(`import ${resp.status}`);
const view = (await resp.json()) as SubmissionDraftView;
state.view = view;
paintImportRow();
paintPartyPicker();
paintVariables();
paintPreview();
setSaveStatus(isEN() ? "Imported" : "Importiert");
} catch (err) {
console.error("submission-draft import-from-project:", err);
setSaveStatus(isEN() ? "Import failed" : "Import fehlgeschlagen", true);
} finally {
btn.disabled = false;
btn.textContent = originalLabel;
}
}
function onVarChange(input: HTMLInputElement): void {
const key = input.dataset.var;
if (!key || !state.view) return;

View File

@@ -2599,9 +2599,12 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.import.button"
| "submissions.draft.loading"
| "submissions.draft.name.placeholder"
| "submissions.draft.notfound"
| "submissions.draft.parties.hint"
| "submissions.draft.parties.title"
| "submissions.draft.preview.hint"
| "submissions.draft.preview.title"
| "submissions.draft.switcher.label"

View File

@@ -6049,6 +6049,96 @@ dialog.modal::backdrop {
font-size: 0.92rem;
}
/* t-paliad-277 — "Aus Projekt importieren" row + multi-select party
picker block on the submission draft editor sidebar. */
.submission-draft-import-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.6rem;
flex-wrap: wrap;
padding: 0.5rem 0.6rem;
margin-bottom: 0.75rem;
background: var(--color-surface-alt, #f7f7f0);
border: 1px solid var(--color-border);
border-radius: 6px;
}
.submission-draft-import-stamp {
font-size: 0.8em;
color: var(--color-text-muted);
}
.submission-draft-parties {
border-top: 1px solid var(--color-border);
padding-top: 0.75rem;
margin-bottom: 0.75rem;
}
.submission-draft-parties-hint {
font-size: 0.85em;
color: var(--color-text-muted);
margin: 0 0 0.6rem;
}
.submission-draft-parties-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-parties-group {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.4rem 0.6rem 0.5rem;
margin: 0;
}
.submission-draft-parties-group > legend {
padding: 0 0.4rem;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.submission-draft-party-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.4rem;
padding: 0.25rem 0;
cursor: pointer;
}
.submission-draft-party-check {
margin: 0;
accent-color: var(--color-accent, #c6f41c);
}
.submission-draft-party-name {
font-size: 0.92em;
color: var(--color-text);
}
.submission-draft-party-chip {
font-size: 0.72em;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1em 0.5em;
border-radius: 999px;
background: var(--color-bg-lime-tint, #f0fac6);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.submission-draft-party-rep {
font-size: 0.78em;
color: var(--color-text-muted);
margin-left: 0.25rem;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;

View File

@@ -111,6 +111,50 @@ export function renderSubmissionDraft(): string {
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
{/* t-paliad-277: "Aus Projekt importieren" + last-
imported-at timestamp. Only visible when the
draft has a project_id attached. */}
<div
id="submission-draft-import-row"
className="submission-draft-import-row"
style="display:none">
<button
type="button"
id="submission-draft-import-btn"
className="btn-small btn-secondary"
data-i18n="submissions.draft.import.button">
Aus Projekt importieren
</button>
<span
id="submission-draft-import-stamp"
className="submission-draft-import-stamp"
/>
</div>
{/* t-paliad-277: multi-select party picker.
Populated from view.available_parties; checkbox
per party, grouped by role. Hidden when no
project or no parties on the project. */}
<div
id="submission-draft-parties"
className="submission-draft-parties"
style="display:none">
<h3
className="submission-draft-var-group-title"
data-i18n="submissions.draft.parties.title">
Parteien
</h3>
<p
className="submission-draft-parties-hint"
data-i18n="submissions.draft.parties.hint">
Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.
</p>
<div
id="submission-draft-parties-list"
className="submission-draft-parties-list"
/>
</div>
<div className="submission-draft-variables" id="submission-draft-variables" />
</aside>

View File

@@ -0,0 +1,4 @@
-- t-paliad-277 rollback.
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS selected_parties,
DROP COLUMN IF EXISTS last_imported_at;

View File

@@ -0,0 +1,32 @@
-- t-paliad-277 / m/paliad#109: per-draft party selection + import provenance.
--
-- Adds two columns to paliad.submission_drafts:
--
-- selected_parties uuid[] — IDs of paliad.parties rows the lawyer
-- has chosen to mention in this specific submission. An empty
-- array (the default) means "include every party on the project"
-- so all existing drafts keep their current rendering. Non-empty
-- restricts the variable bag to the chosen subset, grouped by
-- role in SubmissionVarsService.
--
-- last_imported_at timestamptz — when the lawyer last clicked
-- "Aus Projekt importieren" on the draft editor (or NULL if they
-- never did). The frontend surfaces this timestamp next to the
-- button so a stale draft is obvious at a glance.
--
-- Both columns are purely additive and nullable / default-bearing —
-- the migration is safe to apply with active drafts in the table.
-- No FK on selected_parties: paliad.parties is project-scoped and we
-- prune stale references on read inside SubmissionVarsService rather
-- than chasing FK cascades across two tables (the variable bag silently
-- drops any uuid that no longer matches a row in paliad.parties).
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS selected_parties uuid[] NOT NULL DEFAULT '{}'::uuid[],
ADD COLUMN IF NOT EXISTS last_imported_at timestamptz;
COMMENT ON COLUMN paliad.submission_drafts.selected_parties IS
't-paliad-277: party IDs (paliad.parties) the lawyer has chosen to mention in this submission. Empty array = include every party on the project (backward-compat default). Non-empty = restrict to subset, grouped by role.';
COMMENT ON COLUMN paliad.submission_drafts.last_imported_at IS
't-paliad-277: timestamp of the last "Aus Projekt importieren" click — surfaced next to the button so the lawyer can see staleness at a glance. NULL = never imported.';

View File

@@ -355,6 +355,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
// the draft. Strips overrides for project.* / parties.* / deadline.*
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/import-from-project", handleImportFromProject)
// /counterclaim creates a CCR sub-project linked via the new
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)

View File

@@ -60,38 +60,53 @@ const submissionDraftExportTimeout = 30 * time.Second
// raw row plus the resolved bag and the rule metadata the sidebar uses
// to label each variable group.
type submissionDraftView struct {
Draft submissionDraftJSON `json:"draft"`
Rule *submissionRuleSummary `json:"rule,omitempty"`
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
MergedBag services.PlaceholderMap `json:"merged_bag"`
PreviewHTML string `json:"preview_html"`
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
Draft submissionDraftJSON `json:"draft"`
Rule *submissionRuleSummary `json:"rule,omitempty"`
ResolvedBag services.PlaceholderMap `json:"resolved_bag"`
MergedBag services.PlaceholderMap `json:"merged_bag"`
PreviewHTML string `json:"preview_html"`
Lang string `json:"lang"`
HasTemplate bool `json:"has_template"`
TemplateMissing bool `json:"template_missing,omitempty"`
// AvailableParties is the project's full party roster (t-paliad-277)
// so the frontend can render the multi-select picker in one round-
// trip. Empty when the draft has no project attached.
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
}
// submissionDraftPartyJSON is the minimal party row the editor sidebar
// needs to render a checkbox + role chip per party.
type submissionDraftPartyJSON struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Role string `json:"role,omitempty"`
Representative string `json:"representative,omitempty"`
}
type submissionDraftJSON struct {
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id"`
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Variables services.PlaceholderMap `json:"variables"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id"`
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
Variables services.PlaceholderMap `json:"variables"`
SelectedParties []uuid.UUID `json:"selected_parties"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type submissionRuleSummary struct {
Name string `json:"name"`
NameEN string `json:"name_en"`
SubmissionCode string `json:"submission_code"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
Name string `json:"name"`
NameEN string `json:"name_en"`
SubmissionCode string `json:"submission_code"`
PrimaryParty string `json:"primary_party,omitempty"`
EventType string `json:"event_type,omitempty"`
LegalSource string `json:"legal_source,omitempty"`
LegalSourcePretty string `json:"legal_source_pretty,omitempty"`
LegalSourcePrettyEN string `json:"legal_source_pretty_en,omitempty"`
}
type submissionDraftListResponse struct {
@@ -101,8 +116,9 @@ type submissionDraftListResponse struct {
}
type submissionDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
}
// ─────────────────────────────────────────────────────────────────────
@@ -337,7 +353,7 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables}
patch := services.DraftPatch{Name: input.Name, Variables: input.Variables, SelectedParties: input.SelectedParties}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -675,13 +691,17 @@ type globalDraftPatchInput struct {
// "set to null". Set by the custom UnmarshalJSON below.
ProjectID *uuid.UUID `json:"project_id,omitempty"`
projectIDProvided bool
// SelectedParties: present-but-empty array resets to "all parties",
// present non-empty array restricts to subset, absent = no change.
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -690,6 +710,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.Name = a.Name
g.Variables = a.Variables
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
// Detect whether "project_id" was present in the JSON object.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
@@ -726,7 +747,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables, SelectedParties: in.SelectedParties}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
@@ -748,6 +769,48 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, view)
}
// handleImportFromProject re-pulls every project-derived variable on
// the draft and bumps last_imported_at (t-paliad-277). The service-
// layer call strips overrides for project.* / parties.* / deadline.* /
// procedural_event.* / rule.* prefixes; firm.* / today.* / user.*
// overrides survive because those values aren't sourced from the
// project record.
//
// Idempotent on repeat clicks. Returns the full editor view so the
// frontend can refresh in one round-trip.
func handleImportFromProject(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
d, err := dbSvc.submissionDraft.ImportFromProject(r.Context(), uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
user, _ := dbSvc.users.GetByID(r.Context(), uid)
lang := userLang(user)
view, err := buildSubmissionDraftView(r.Context(), d, lang)
if err != nil {
log.Printf("submission_drafts: build view after import (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, view)
}
// handleGlobalDeleteSubmissionDraft removes a draft by id.
func handleGlobalDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
@@ -859,9 +922,10 @@ func serveSubmissionDraftNotFound(w http.ResponseWriter) {
// per-rule heading.
func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft, lang string) (*submissionDraftView, error) {
view := &submissionDraftView{
Draft: draftToJSON(d),
Lang: lang,
HasTemplate: true,
Draft: draftToJSON(d),
Lang: lang,
HasTemplate: true,
AvailableParties: []submissionDraftPartyJSON{},
}
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
@@ -873,14 +937,27 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
if resolved.Lang != "" {
view.Lang = resolved.Lang
}
if len(resolved.Parties) > 0 {
view.AvailableParties = make([]submissionDraftPartyJSON, 0, len(resolved.Parties))
for _, p := range resolved.Parties {
row := submissionDraftPartyJSON{ID: p.ID, Name: p.Name}
if p.Role != nil {
row.Role = *p.Role
}
if p.Representative != nil {
row.Representative = *p.Representative
}
view.AvailableParties = append(view.AvailableParties, row)
}
}
if resolved.Rule != nil {
view.Rule = &submissionRuleSummary{
Name: derefStringHandler(resolved.Rule.SubmissionCode),
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
NameEN: resolved.Rule.NameEN,
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
EventType: derefStringHandler(resolved.Rule.EventType),
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
Name: derefStringHandler(resolved.Rule.SubmissionCode),
SubmissionCode: derefStringHandler(resolved.Rule.SubmissionCode),
NameEN: resolved.Rule.NameEN,
PrimaryParty: derefStringHandler(resolved.Rule.PrimaryParty),
EventType: derefStringHandler(resolved.Rule.EventType),
LegalSource: derefStringHandler(resolved.Rule.LegalSource),
}
view.Rule.Name = resolved.Rule.Name
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
@@ -958,6 +1035,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if vars == nil {
vars = services.PlaceholderMap{}
}
selected := d.SelectedParties
if selected == nil {
selected = []uuid.UUID{}
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
@@ -965,8 +1046,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
UserID: d.UserID,
Name: d.Name,
Variables: vars,
SelectedParties: selected,
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}

View File

@@ -30,6 +30,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
)
@@ -42,20 +43,28 @@ import (
// parties / deadline state to resolve). All callers must check for nil
// before treating it as a uuid.
type SubmissionDraft struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
VariablesRaw []byte `db:"variables" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
UserID uuid.UUID `db:"user_id" json:"user_id"`
Name string `db:"name" json:"name"`
VariablesRaw []byte `db:"variables" json:"-"`
SelectedPartiesRaw pq.StringArray `db:"selected_parties" json:"-"`
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `db:"last_imported_at" json:"last_imported_at,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
// Variables is the decoded overrides map; populated on read by the
// service so callers don't have to unmarshal manually.
Variables PlaceholderMap `json:"variables"`
// SelectedParties is the parsed uuid form of SelectedPartiesRaw —
// populated on read by decodeSelectedParties(). An empty slice keeps
// the backward-compat "include every party" behaviour; a non-empty
// slice restricts the variable bag to the listed paliad.parties rows.
SelectedParties []uuid.UUID `json:"selected_parties"`
}
// SubmissionDraftService handles CRUD on submission_drafts and exposes
@@ -94,6 +103,11 @@ type DraftPatch struct {
Name *string
Variables *PlaceholderMap
ProjectID **uuid.UUID
// SelectedParties: nil = no change. A non-nil pointer always writes
// the column; pass *p = nil or an empty slice to reset to "include
// every party on the project" (the backward-compat default).
SelectedParties *[]uuid.UUID
}
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
@@ -107,7 +121,9 @@ var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already tak
// draftColumns is the canonical select list — kept in one place so
// every fetch stays in sync.
const draftColumns = `id, project_id, submission_code, user_id, name,
variables, last_exported_at, last_exported_sha,
variables, selected_parties,
last_exported_at, last_exported_sha,
last_imported_at,
created_at, updated_at`
// List returns every draft for (project, submission_code, user)
@@ -127,7 +143,7 @@ func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uui
return nil, fmt.Errorf("list submission drafts: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
if err := rows[i].decode(); err != nil {
return nil, err
}
}
@@ -158,7 +174,8 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
var rows []DraftWithProject
err := s.db.SelectContext(ctx, &rows,
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
d.variables, d.last_exported_at, d.last_exported_sha,
d.variables, d.selected_parties,
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
d.created_at, d.updated_at,
p.title AS project_title,
p.reference AS project_reference
@@ -175,7 +192,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
}
for i := range rows {
if err := rows[i].decodeVariables(); err != nil {
if err := rows[i].decode(); err != nil {
return nil, err
}
}
@@ -213,7 +230,7 @@ func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.U
return nil, err
}
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -241,7 +258,7 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
if err != nil {
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -273,7 +290,7 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
if err != nil {
return nil, fmt.Errorf("create submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -394,6 +411,17 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
idx++
}
if patch.SelectedParties != nil {
ids := *patch.SelectedParties
strs := make([]string, 0, len(ids))
for _, id := range ids {
strs = append(strs, id.String())
}
setParts = append(setParts, fmt.Sprintf("selected_parties = $%d::uuid[]", idx))
args = append(args, pq.StringArray(strs))
idx++
}
if len(setParts) == 0 {
return existing, nil
}
@@ -415,7 +443,7 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
if err != nil {
return nil, fmt.Errorf("update submission draft: %w", err)
}
if err := d.decodeVariables(); err != nil {
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
@@ -436,6 +464,82 @@ func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uui
return nil
}
// ImportFromProject re-pulls every project-derived variable on the
// draft by stripping the lawyer's overrides for those keys and bumping
// `last_imported_at`. Project-derived prefixes today are project.*,
// parties.*, deadline.* and (because the rule is keyed on
// submission_code) procedural_event.* / rule.*; the lawyer's overrides
// for firm.*, today.*, user.* survive because those values aren't
// "imported from the project" in any meaningful sense.
//
// Idempotent on repeat clicks: nothing else mutates on the second
// call apart from the new timestamp. The draft must be owned by the
// caller (Get() applies the same ErrNotFound semantics as the rest of
// the service).
func (s *SubmissionDraftService) ImportFromProject(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
existing, err := s.Get(ctx, userID, draftID)
if err != nil {
return nil, err
}
if existing.ProjectID == nil {
// No project to import from — surface as 400 via ErrInvalidInput.
return nil, fmt.Errorf("%w: cannot import from project on a project-less draft", ErrInvalidInput)
}
// Strip overrides that came from project state.
cleaned := PlaceholderMap{}
for k, v := range existing.Variables {
if isProjectDerivedKey(k) {
continue
}
cleaned[k] = v
}
raw, err := json.Marshal(cleaned)
if err != nil {
return nil, fmt.Errorf("marshal variables: %w", err)
}
var d SubmissionDraft
err = s.db.GetContext(ctx, &d,
`UPDATE paliad.submission_drafts
SET variables = $1::jsonb,
last_imported_at = now()
WHERE id = $2 AND user_id = $3
RETURNING `+draftColumns,
string(raw), draftID, userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrSubmissionDraftNotFound
}
if err != nil {
return nil, fmt.Errorf("import from project: %w", err)
}
if err := d.decode(); err != nil {
return nil, err
}
return &d, nil
}
// isProjectDerivedKey reports whether a placeholder key sources its
// value from the project record (rather than firm-wide or user-wide
// state). The "Aus Projekt importieren" affordance strips overrides
// for exactly these keys so the lawyer's manual edits don't survive
// a re-pull.
func isProjectDerivedKey(key string) bool {
switch {
case strings.HasPrefix(key, "project."):
return true
case strings.HasPrefix(key, "parties."):
return true
case strings.HasPrefix(key, "deadline."):
return true
case strings.HasPrefix(key, "procedural_event."):
return true
case strings.HasPrefix(key, "rule."):
return true
}
return false
}
// MarkExported updates the last_exported_* columns after a successful
// export. Background-context safe.
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
@@ -461,9 +565,9 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
//
// Override semantics:
//
// variables[key] = "" → delete the key (force [KEIN WERT: key])
// variables[key] = "X" → bag[key] = "X"
// key absent → bag[key] unchanged (falls back to resolved value)
// variables[key] = "" → delete the key (force [KEIN WERT: key])
// variables[key] = "X" → bag[key] = "X"
// key absent → bag[key] unchanged (falls back to resolved value)
//
// Returns the final PlaceholderMap along with the SubmissionVarsResult
// so callers (export, file naming) get the resolved entities too. A
@@ -473,9 +577,10 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
// lawyer's overrides fill the rest.
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
UserID: draft.UserID,
ProjectID: draft.ProjectID,
SubmissionCode: draft.SubmissionCode,
SelectedParties: draft.SelectedParties,
})
if err != nil {
return nil, nil, err
@@ -547,8 +652,17 @@ func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, us
return out, resolved, nil
}
// decode fills the parsed views (Variables, SelectedParties) from the
// raw scan fields. Called by every fetch path so the caller sees both
// populated together.
func (d *SubmissionDraft) decode() error {
if err := d.decodeVariables(); err != nil {
return err
}
return d.decodeSelectedParties()
}
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
// Called by every fetch path so the caller sees a populated Variables.
func (d *SubmissionDraft) decodeVariables() error {
if len(d.VariablesRaw) == 0 {
d.Variables = PlaceholderMap{}
@@ -562,6 +676,28 @@ func (d *SubmissionDraft) decodeVariables() error {
return nil
}
// decodeSelectedParties parses the uuid[] payload from pq.StringArray
// into []uuid.UUID. Unparseable entries are dropped so a single bad
// row never bricks the fetch — the worst case is one extra party
// silently dropped from the selection, which surfaces as it not being
// rendered in the merged document.
func (d *SubmissionDraft) decodeSelectedParties() error {
if len(d.SelectedPartiesRaw) == 0 {
d.SelectedParties = nil
return nil
}
out := make([]uuid.UUID, 0, len(d.SelectedPartiesRaw))
for _, s := range d.SelectedPartiesRaw {
id, err := uuid.Parse(s)
if err != nil {
continue
}
out = append(out, id)
}
d.SelectedParties = out
return nil
}
// Compile-time guard: ensure the *models.User reference in the import
// graph doesn't get optimised away by linters. The service doesn't
// dereference User directly — that happens in SubmissionVarsService —

View File

@@ -72,10 +72,17 @@ func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *Pa
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
// started from /submissions/new without picking a project carries
// nil here and the project / parties / deadline lookups are skipped.
//
// SelectedParties is the t-paliad-277 multi-party selection: an empty
// or nil slice means "include every party on the project" (the
// backward-compat default that every legacy draft renders with); a
// non-empty slice restricts the variable bag to the listed parties so
// the submission only mentions the chosen subset.
type SubmissionVarsContext struct {
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
UserID uuid.UUID
ProjectID *uuid.UUID
SubmissionCode string
SelectedParties []uuid.UUID
}
// SubmissionVarsResult bundles the placeholder map with the lookup
@@ -174,7 +181,7 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
}
addProjectVars(bag, project, pt, lang)
addPartyVars(bag, parties)
addPartyVars(bag, filterPartiesBySelection(parties, in.SelectedParties))
addDeadlineVars(bag, next, project, lang)
out.Project = project
@@ -184,6 +191,30 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return out, nil
}
// filterPartiesBySelection returns the subset of parties whose IDs
// appear in selected. An empty or nil `selected` slice is the
// backward-compat default — every party flows through unchanged. A
// non-empty slice preserves the input ordering of `parties` (which is
// stable by name from PartyService.ListForProject) so the bag's
// "first claimant / first defendant / first other" picks remain
// deterministic for a given project state.
func filterPartiesBySelection(parties []models.Party, selected []uuid.UUID) []models.Party {
if len(selected) == 0 {
return parties
}
allowed := make(map[uuid.UUID]struct{}, len(selected))
for _, id := range selected {
allowed[id] = struct{}{}
}
out := make([]models.Party, 0, len(parties))
for _, p := range parties {
if _, ok := allowed[p.ID]; ok {
out = append(out, p)
}
}
return out
}
// loadPublishedRule fetches the published procedural-event template
// (paliad.deadline_rules row) keyed by submission_code. Restricts to
// lifecycle_state='published' so drafts never end up shaping a real
@@ -324,42 +355,98 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
}
}
// addPartyVars populates parties.* using the first row of each role.
// Multi-claimant / multi-defendant suits use the first row in Slice 1
// per design §13.6; expanded grouping is Phase 2.
// addPartyVars populates the parties.* namespace from the (already
// filtered) list of parties.
//
// Three forms coexist per role (claimant / defendant / other) so
// templates authored against any of them keep merging correctly:
//
// - Comma-joined list (t-paliad-277, primary form for multi-party
// suits):
//
// {{parties.claimants}} — all claimants' names
// {{parties.claimants.representatives}}
// {{parties.defendants}} / .representatives
// {{parties.others}} / .representatives
//
// - Indexed access (templates that need the primary individually):
//
// {{parties.claimant.0.name}} / .representative
// {{parties.defendant.0.name}} / .representative
// {{parties.other.0.name}} / .representative
//
// - Flat legacy (kept forever per the issue's backward-compat
// contract; resolves to the FIRST selected party of each role):
//
// {{parties.claimant.name}} / .representative
// {{parties.defendant.name}} / .representative
// {{parties.other.name}} / .representative
//
// Role bucketing matches the prior shape: German strings ("Kläger",
// "Beklagte") and their English equivalents fold into claimant /
// defendant; everything else (Streithelfer, Patentinhaberin, …) flows
// into "other".
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
var claimant, defendant, other *models.Party
var claimants, defendants, others []models.Party
for i := range parties {
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
switch role {
case "claimant", "kläger", "klaeger":
if claimant == nil {
claimant = &parties[i]
}
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
claimants = append(claimants, parties[i])
case "defendant", "beklagter", "beklagte":
if defendant == nil {
defendant = &parties[i]
}
defendants = append(defendants, parties[i])
default:
if other == nil {
other = &parties[i]
}
others = append(others, parties[i])
}
}
if claimant != nil {
bag["parties.claimant.name"] = claimant.Name
bag["parties.claimant.representative"] = derefString(claimant.Representative)
emitPartyGroup(bag, "claimant", "claimants", claimants)
emitPartyGroup(bag, "defendant", "defendants", defendants)
emitPartyGroup(bag, "other", "others", others)
}
// emitPartyGroup writes the three forms (joined list, indexed access,
// flat legacy first-of-role) for a single role bucket. `singular` is
// the legacy/indexed prefix (claimant / defendant / other); `plural`
// is the joined-list prefix (claimants / defendants / others).
func emitPartyGroup(bag PlaceholderMap, singular, plural string, group []models.Party) {
names := make([]string, 0, len(group))
reps := make([]string, 0, len(group))
for _, p := range group {
names = append(names, p.Name)
reps = append(reps, derefString(p.Representative))
}
if defendant != nil {
bag["parties.defendant.name"] = defendant.Name
bag["parties.defendant.representative"] = derefString(defendant.Representative)
bag["parties."+plural] = strings.Join(names, ", ")
bag["parties."+plural+".representatives"] = joinNonEmpty(reps, ", ")
for i, p := range group {
idx := fmt.Sprintf("parties.%s.%d", singular, i)
bag[idx+".name"] = p.Name
bag[idx+".representative"] = derefString(p.Representative)
}
if other != nil {
bag["parties.other.name"] = other.Name
bag["parties.other.representative"] = derefString(other.Representative)
if len(group) > 0 {
first := group[0]
bag["parties."+singular+".name"] = first.Name
bag["parties."+singular+".representative"] = derefString(first.Representative)
}
}
// joinNonEmpty joins a slice with sep but skips empty entries so a
// list of representatives where one party has no representative reads
// as "A, B" instead of "A, , B".
func joinNonEmpty(parts []string, sep string) string {
out := make([]string, 0, len(parts))
for _, p := range parts {
if strings.TrimSpace(p) == "" {
continue
}
out = append(out, p)
}
return strings.Join(out, sep)
}
// addRuleVars populates the procedural-event variable namespace —
// code, name(_en), legal_source (+ pretty form), primary_party, kind.
//

View File

@@ -0,0 +1,200 @@
package services
// Multi-party variable bag tests (t-paliad-277 / m/paliad#109).
//
// Pins the three coexisting forms that addPartyVars emits per role:
//
// - Comma-joined list: parties.claimants / .defendants / .others
// - Indexed access: parties.claimant.0.name, parties.defendant.0.name, …
// - Flat legacy (first-of): parties.claimant.name, parties.defendant.name, …
//
// Also covers filterPartiesBySelection — the empty-selection default
// (every party included) and the non-empty restriction.
import (
"strings"
"testing"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
)
func mkParty(name, role, rep string) models.Party {
p := models.Party{
ID: uuid.New(),
Name: name,
}
if role != "" {
r := role
p.Role = &r
}
if rep != "" {
r := rep
p.Representative = &r
}
return p
}
func TestAddPartyVars_MultiPartyMixedRoles(t *testing.T) {
t.Parallel()
parties := []models.Party{
mkParty("Acme Inc.", "claimant", "Maria Schmidt"),
mkParty("Globex GmbH", "claimant", ""),
mkParty("Initech", "defendant", "John Doe"),
mkParty("Streithelferin", "intervenor", ""),
}
bag := PlaceholderMap{}
addPartyVars(bag, parties)
wants := map[string]string{
// Comma-joined per role.
"parties.claimants": "Acme Inc., Globex GmbH",
"parties.claimants.representatives": "Maria Schmidt", // Globex has no rep → skipped from join.
"parties.defendants": "Initech",
"parties.defendants.representatives": "John Doe",
"parties.others": "Streithelferin",
"parties.others.representatives": "",
// Indexed access.
"parties.claimant.0.name": "Acme Inc.",
"parties.claimant.0.representative": "Maria Schmidt",
"parties.claimant.1.name": "Globex GmbH",
"parties.claimant.1.representative": "",
"parties.defendant.0.name": "Initech",
"parties.defendant.0.representative": "John Doe",
"parties.other.0.name": "Streithelferin",
// Flat legacy: first-of-role.
"parties.claimant.name": "Acme Inc.",
"parties.claimant.representative": "Maria Schmidt",
"parties.defendant.name": "Initech",
"parties.defendant.representative": "John Doe",
"parties.other.name": "Streithelferin",
}
for key, want := range wants {
got, ok := bag[key]
if !ok {
t.Errorf("missing key %q in bag", key)
continue
}
if got != want {
t.Errorf("bag[%q] = %q, want %q", key, got, want)
}
}
}
func TestAddPartyVars_GermanRoleStrings(t *testing.T) {
t.Parallel()
// German role strings on real-world data must bucket the same as
// the English equivalents — "Kläger" / "Klägerin" → claimants.
parties := []models.Party{
mkParty("Erika Musterfrau", "Klägerin", ""),
mkParty("Max Mustermann", "Beklagter", ""),
}
bag := PlaceholderMap{}
addPartyVars(bag, parties)
if got := bag["parties.claimants"]; got != "Erika Musterfrau" {
t.Errorf("parties.claimants = %q, want %q", got, "Erika Musterfrau")
}
if got := bag["parties.defendants"]; got != "Max Mustermann" {
t.Errorf("parties.defendants = %q, want %q", got, "Max Mustermann")
}
// Backward-compat: legacy flat alias resolves to the first row of
// the German-bucketed group.
if got := bag["parties.claimant.name"]; got != "Erika Musterfrau" {
t.Errorf("parties.claimant.name = %q, want %q", got, "Erika Musterfrau")
}
}
func TestAddPartyVars_BackwardCompatFlatAliasResolvesFirstRow(t *testing.T) {
t.Parallel()
// Critical guarantee from m/paliad#109: templates that say
// {{parties.claimant.name}} (old shape) must keep merging — they
// resolve to the FIRST selected claimant. Pinning this stops a
// future refactor silently dropping the alias and breaking every
// .docx in the repo.
parties := []models.Party{
mkParty("FirstCo", "claimant", "Repr A"),
mkParty("SecondCo", "claimant", "Repr B"),
}
bag := PlaceholderMap{}
addPartyVars(bag, parties)
if got := bag["parties.claimant.name"]; got != "FirstCo" {
t.Errorf("parties.claimant.name (flat alias) = %q, want %q (first selected claimant)",
got, "FirstCo")
}
if got := bag["parties.claimant.representative"]; got != "Repr A" {
t.Errorf("parties.claimant.representative (flat alias) = %q, want %q",
got, "Repr A")
}
}
func TestFilterPartiesBySelection_EmptyMeansAll(t *testing.T) {
t.Parallel()
parties := []models.Party{
mkParty("A", "claimant", ""),
mkParty("B", "defendant", ""),
}
got := filterPartiesBySelection(parties, nil)
if len(got) != 2 {
t.Fatalf("empty selection should include every party, got %d/%d", len(got), len(parties))
}
got = filterPartiesBySelection(parties, []uuid.UUID{})
if len(got) != 2 {
t.Fatalf("empty []uuid selection should include every party, got %d/%d", len(got), len(parties))
}
}
func TestFilterPartiesBySelection_NonEmptyRestricts(t *testing.T) {
t.Parallel()
a := mkParty("Acme", "claimant", "")
b := mkParty("Initech", "defendant", "")
c := mkParty("Globex", "claimant", "")
parties := []models.Party{a, b, c}
got := filterPartiesBySelection(parties, []uuid.UUID{a.ID, c.ID})
if len(got) != 2 {
t.Fatalf("got %d parties, want 2", len(got))
}
// Order must match the input order (PartyService.ListForProject
// returns by name ascending; we preserve that to keep "first
// claimant" deterministic across renders).
if got[0].ID != a.ID || got[1].ID != c.ID {
t.Errorf("selection lost input order: got %v", []string{got[0].Name, got[1].Name})
}
// The "Initech" defendant was deselected; the bag should not list
// it under defendants.
bag := PlaceholderMap{}
addPartyVars(bag, got)
if v, ok := bag["parties.defendants"]; ok && v != "" {
t.Errorf("parties.defendants = %q after deselecting Initech, want empty", v)
}
if !strings.Contains(bag["parties.claimants"], "Acme") || !strings.Contains(bag["parties.claimants"], "Globex") {
t.Errorf("parties.claimants = %q, want both Acme and Globex", bag["parties.claimants"])
}
}
func TestIsProjectDerivedKey(t *testing.T) {
t.Parallel()
derived := []string{
"project.title", "project.proceeding.name",
"parties.claimants", "parties.claimant.0.name",
"deadline.due_date",
"procedural_event.name", "rule.name",
}
for _, k := range derived {
if !isProjectDerivedKey(k) {
t.Errorf("expected %q to be project-derived", k)
}
}
survives := []string{
"firm.name", "today", "today.long_de",
"user.email", "user.display_name",
}
for _, k := range survives {
if isProjectDerivedKey(k) {
t.Errorf("expected %q to survive Import-from-project (firm/today/user namespace)", k)
}
}
}