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).
201 lines
6.5 KiB
Go
201 lines
6.5 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|