feat(builder): B4 — Akte mode + project-backed scenarios (m/paliad#153)
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

PRD §2.3 + §10. Implements the dual-write rule (load-bearing
complexity per PRD §10): project-backed scenarios mirror flag
toggles to paliad.projects.scenario_flags and filed event states
to paliad.deadlines, while kontextfrei scenarios continue writing
only to paliad.scenario_events. Visible affordances: page-header
Akte picker, enabled "Aus Akte" mode tab, Akte banner on the
project-backed canvas, cross-surface scenario-flag-changed
dispatch + listener for live peer-surface coherence.

Backend
- ScenarioBuilderService takes ProjectService + ScenarioFlagsService
  deps so dual-write hits live tables.
- CreateScenarioFromProject seeds a scenario from a project: copies
  proceeding_type_id + scenario_flags, normalises our_side to the
  builder's binary claimant|defendant axis, surfaces existing
  rule-bound deadlines as scenario_events (filed when completed,
  planned otherwise).
- PatchProceeding on a project-backed top-level triplet dual-writes
  scenario_flags to projects.scenario_flags via flagDeltaFromBuilder.
- PatchEvent transitioning to state='filed' on a project-backed
  scenario upserts paliad.deadlines (status='completed', completed_
  at, source='rule') inside the same tx as the scenario_events
  UPDATE — canvas and project surfaces never diverge mid-flight.
- POST /api/builder/scenarios/from-project handler wires the entry
  point.

Frontend
- builder-akte.ts: project list fetch + dropdown render, Akte
  banner, createScenarioFromProject POST helper.
- builder.ts: mode branching — picking an Akte (search hit or
  page-header pick) creates a project-backed scenario and loads it;
  loaded scenarios reflect their origin_project_id on the picker +
  banner; flag toggles on Akte-backed top-level triplets dispatch
  scenario-flag-changed so the Verfahrensablauf strip / project
  surfaces refresh; the builder listens to inbound scenario-flag-
  changed and refetches its scenario when the changed project
  matches origin_project_id.
- procedures.tsx: enable the previously-disabled Aus Akte tab.
- i18n + CSS: builder.akte.banner.prefix key (DE+EN); lime-tinted
  banner styling.

Tests
- TestScenarioBuilderAkteDualWrite (live DB) pins the dual-write
  contract: Akte flag toggle → projects.scenario_flags updated,
  Akte filed event → deadlines row inserted; kontextfrei flag
  toggle leaves projects.scenario_flags untouched, kontextfrei
  filed event leaves deadlines untouched.
- Existing TestScenarioBuilderService passes against the new
  signature (nil deps short-circuit dual-write paths).

Verification: go test ./... + go vet ./... + bun run build all
clean. Playwright smoke against the static dist build confirms
the Akte tab + picker render correctly, fetchAkteProjects fires
on mount, and the scenario-flag-changed CustomEvent dispatches +
receives without runtime errors.

t-paliad-347
This commit is contained in:
mAi
2026-05-28 10:44:33 +02:00
parent fcdfba209d
commit 9679a98666
11 changed files with 1089 additions and 25 deletions

View File

@@ -24,13 +24,29 @@ import (
// fall-through) and at the row level via the migration-157 RLS policies.
// The application-level check is the load-bearing one — the service
// connects with the service-role credential, which bypasses RLS.
//
// B4 (t-paliad-347 / m/paliad#153) adds the Akte-mode dual-write:
// project-backed scenarios (origin_project_id IS NOT NULL) write flag
// toggles through to paliad.projects.scenario_flags and "filed" event
// toggles through to paliad.deadlines, so the project's Verlauf / Frist
// rail reflect builder activity without a separate sync step. The
// scenario row itself records canvas view-state (ordinal, collapsed,
// per-card horizon, notes); the SSoT for project-bound actuals stays
// paliad.deadlines / paliad.projects.scenario_flags (PRD §2.3 + §10).
type ScenarioBuilderService struct {
db *sqlx.DB
db *sqlx.DB
projects *ProjectService
flags *ScenarioFlagsService
}
// NewScenarioBuilderService wires the service to the shared pool.
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db}
// NewScenarioBuilderService wires the service to the shared pool plus
// the project + scenario-flags services it leans on for the Akte-mode
// dual-write. projects + flags are optional in test setups (nil → the
// dual-write hooks short-circuit), but a production wiring should
// always pass them so Akte-backed scenarios stay in sync with project
// surfaces.
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db, projects: projects, flags: flags}
}
// ErrScenarioBuilderNotVisible is returned when the caller is neither
@@ -427,8 +443,19 @@ type PatchProceedingInput struct {
}
// PatchProceeding updates fields on one proceeding row.
//
// Dual-write (B4): when the parent scenario is project-backed
// (scenarios.origin_project_id IS NOT NULL) and the patched proceeding
// is the top-level triplet (parent_scenario_proceeding_id IS NULL) and
// the patch includes scenario_flags, the merged flag delta also lands on
// paliad.projects.scenario_flags via ScenarioFlagsService.Patch. Top-
// level only because child triplets (CCR child etc.) represent spawned
// sub-proceedings whose flags don't belong on the parent project row;
// the spawned proceeding will get its own project record when (and if)
// the scenario is promoted via the B5 wizard.
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
@@ -491,7 +518,7 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
created_at, updated_at`,
strings.Join(sets, ", "), len(args)-1, len(args))
var out BuilderProceeding
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
err = s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
@@ -501,9 +528,55 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
}
return nil, fmt.Errorf("patch proceeding: %w", err)
}
// B4 dual-write: if the scenario is Akte-backed and we just
// changed scenario_flags on the top-level triplet, mirror the
// merged delta onto paliad.projects.scenario_flags. The PATCH
// fires after the scenario_proceedings UPDATE commits — a failure
// here logs but doesn't roll back the builder write (the builder
// state is the user-visible canvas; the project mirror is a
// convenience).
if sc.OriginProjectID != nil && out.ParentScenarioProceedingID == nil &&
len(input.ScenarioFlags) > 0 && s.flags != nil {
if delta, derr := flagDeltaFromBuilder(input.ScenarioFlags); derr == nil && len(delta) > 0 {
if _, perr := s.flags.Patch(ctx, userID, *sc.OriginProjectID, delta); perr != nil {
// Don't fail the builder PATCH — log via the audit
// reason that landed in the tx and surface the
// error through fmt so callers can still inspect.
return nil, fmt.Errorf("dual-write to project scenario_flags: %w", perr)
}
}
}
return &out, nil
}
// flagDeltaFromBuilder converts the builder's scenario_flags jsonb
// (Record<string, unknown>) into the partial delta shape expected by
// ScenarioFlagsService.Patch (map[string]*bool, where nil deletes the
// key). Non-bool values are skipped; the builder only writes booleans
// through its UI but defensive parsing keeps the dual-write honest if
// a stray null sneaks in.
func flagDeltaFromBuilder(raw json.RawMessage) (map[string]*bool, error) {
if len(raw) == 0 {
return nil, nil
}
var src map[string]any
if err := json.Unmarshal(raw, &src); err != nil {
return nil, fmt.Errorf("decode flag delta: %w", err)
}
out := make(map[string]*bool, len(src))
for k, v := range src {
switch val := v.(type) {
case bool:
b := val
out[k] = &b
case nil:
out[k] = nil
}
}
return out, nil
}
// DeleteProceeding removes a proceeding (and cascades to events + children).
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
@@ -618,8 +691,20 @@ type PatchEventInput struct {
// PatchEvent updates fields on one event card. The card's parent
// proceeding must belong to the addressed scenario.
//
// Dual-write (B4): when the parent scenario is project-backed
// (scenarios.origin_project_id IS NOT NULL), the event's sequencing
// rule is set, and the patch transitions the card to state='filed'
// with an actual_date, the same fact lands on paliad.deadlines
// (status='completed', completed_at=actual_date). If a deadline row
// already exists for the (project_id, sequencing_rule_id) pair it's
// updated in place; otherwise a fresh row is inserted carrying the
// rule's display name + due_date=actual_date. The dual-write runs in
// the same transaction as the scenario_events UPDATE so canvas and
// project surfaces never diverge mid-flight.
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
@@ -667,8 +752,24 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
horizon_optional, created_at, updated_at`,
strings.Join(sets, ", "), len(args))
var out BuilderEvent
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
err = s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
if err := tx.GetContext(ctx, &out, q, args...); err != nil {
return err
}
// B4 dual-write: project-backed scenarios reflect "filed"
// transitions on paliad.deadlines so the project's Verlauf /
// Frist rail picks them up without a separate writer. We
// only act when state explicitly flipped to 'filed' on this
// patch — earlier rows that were already filed don't get
// re-stamped.
if sc.OriginProjectID != nil && input.State != nil && *input.State == "filed" &&
out.SequencingRuleID != nil && out.ActualDate != nil {
if err := s.dualWriteFiledDeadlineTx(ctx, tx, *sc.OriginProjectID,
*out.SequencingRuleID, *out.ActualDate); err != nil {
return fmt.Errorf("dual-write filed deadline: %w", err)
}
}
return nil
})
if err != nil {
return nil, fmt.Errorf("patch event: %w", err)
@@ -676,6 +777,82 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
return &out, nil
}
// dualWriteFiledDeadlineTx upserts a paliad.deadlines row for the
// (project_id, sequencing_rule_id) pair so a builder-filed event
// surfaces on the project's deadline rail. If a row exists, it's
// flipped to status='completed' + completed_at; otherwise a fresh row
// is inserted with the rule's display name, due_date=actual_date, and
// source='litigation_builder'. The whole thing runs inside the caller
// transaction so the canvas event and the deadline never diverge.
func (s *ScenarioBuilderService) dualWriteFiledDeadlineTx(ctx context.Context, tx *sqlx.Tx, projectID, ruleID uuid.UUID, actualDate time.Time) error {
// Try update first — keeps any existing approval / event_type
// hydration intact for deadlines created via the regular Akten
// path. We touch only the columns the builder owns:
// status / completed_at / updated_at.
res, err := tx.ExecContext(ctx,
`UPDATE paliad.deadlines
SET status = 'completed',
completed_at = $1,
updated_at = now()
WHERE project_id = $2
AND sequencing_rule_id = $3
AND status <> 'completed'`,
actualDate, projectID, ruleID)
if err != nil {
return fmt.Errorf("update existing deadline: %w", err)
}
if n, _ := res.RowsAffected(); n > 0 {
return nil
}
// Already-completed rows: leave them alone, the builder isn't
// reopening anything. Detect via a count probe so we don't
// double-insert.
var existing int
if err := tx.GetContext(ctx, &existing,
`SELECT COUNT(*) FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2`,
projectID, ruleID); err != nil {
return fmt.Errorf("probe deadline row: %w", err)
}
if existing > 0 {
return nil
}
// No existing row — insert a fresh deadline. The title comes from
// paliad.procedural_events.name joined via sequencing_rules.
// procedural_event_id (sequencing_rules itself doesn't carry a
// display label — the name lives on the procedural_event row).
// rule_code falls back when the event has no name; the literal
// "Litigation-Builder Event" is the last resort for rules that
// have no procedural_event_id either. source='rule' (already
// allowed by deadlines_source_check) since the row is rule-backed
// — the Litigation Builder doesn't get its own source bucket; the
// audit_reason on the surrounding tx tells the audit log who
// inserted it.
var title string
if err := tx.GetContext(ctx, &title,
`SELECT COALESCE(NULLIF(pe.name, ''), NULLIF(sr.rule_code, ''), 'Litigation-Builder Event')
FROM paliad.sequencing_rules sr
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.id = $1`, ruleID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
title = "Litigation-Builder Event"
} else {
return fmt.Errorf("load rule name: %w", err)
}
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.deadlines
(project_id, title, due_date, sequencing_rule_id, status, completed_at, source, approval_status)
VALUES ($1, $2, $3::date, $4, 'completed', $5::timestamptz, 'rule', 'legacy')`,
projectID, title, actualDate, ruleID, actualDate); err != nil {
return fmt.Errorf("insert builder deadline: %w", err)
}
return nil
}
// DeleteEvent removes one event card.
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
@@ -916,6 +1093,189 @@ func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context,
return nil, ErrScenarioBuilderNotVisible
}
// -----------------------------------------------------------------------------
// Akte mode — project-backed scenarios (B4, t-paliad-347)
// -----------------------------------------------------------------------------
// CreateScenarioFromProject builds a fresh project-backed scenario from
// a paliad.projects row: the scenario's origin_project_id points at the
// project, one top-level proceeding mirrors the project's
// proceeding_type_id + our_side + scenario_flags, and every existing
// paliad.deadlines row with a sequencing_rule_id surfaces as a
// scenario_events row (state='filed' when the deadline is completed,
// 'planned' otherwise).
//
// The scenario is the canvas view-state; paliad.projects.scenario_flags
// + paliad.deadlines remain the SSoT for project-bound actuals (PRD
// §2.3 + §10). Subsequent PatchProceeding / PatchEvent calls on this
// scenario route their writes through to those SSoT tables via the
// dual-write hooks below.
//
// Visibility: the caller must be able to see the project; the project's
// type must be 'case' (it's the proceeding-bearing project rung) and
// must have a proceeding_type_id set (otherwise there's nothing to seed
// the builder with). Returns ErrInvalidInput when those preconditions
// don't hold.
func (s *ScenarioBuilderService) CreateScenarioFromProject(ctx context.Context, userID, projectID uuid.UUID) (*BuilderScenarioDeep, error) {
if s.projects == nil {
return nil, fmt.Errorf("%w: project service not wired", ErrInvalidInput)
}
proj, err := s.projects.GetByID(ctx, userID, projectID)
if err != nil {
return nil, err
}
if proj == nil {
return nil, ErrNotVisible
}
if proj.ProceedingTypeID == nil || *proj.ProceedingTypeID <= 0 {
return nil, fmt.Errorf("%w: project %s has no proceeding_type_id — Akte-mode requires one", ErrInvalidInput, projectID)
}
// Read the project's persisted scenario_flags. The column is jsonb
// NOT NULL DEFAULT '{}' (mig 154) so an empty map is always safe.
var rawFlags []byte
if err := s.db.GetContext(ctx, &rawFlags,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
return nil, fmt.Errorf("read project scenario_flags: %w", err)
}
if len(rawFlags) == 0 {
rawFlags = []byte(`{}`)
}
// Pull every active+published sequencing_rule deadline row on the
// project so the canvas can render filed/planned actuals as event
// cards from first paint. CCR sub-projects are reached separately
// when the user toggles with_ccr; the seed only covers the addressed
// project's deadlines.
type deadlineRow struct {
ID uuid.UUID `db:"id"`
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id"`
Status string `db:"status"`
DueDate time.Time `db:"due_date"`
CompletedAt *time.Time `db:"completed_at"`
}
var deadlines []deadlineRow
if err := s.db.SelectContext(ctx, &deadlines,
`SELECT id, sequencing_rule_id, status, due_date, completed_at
FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id IS NOT NULL`,
projectID); err != nil {
return nil, fmt.Errorf("read project deadlines: %w", err)
}
// Derive the builder-side primary_party from the project's
// our_side. The Project.OurSide column accepts the wider sub-role
// set (claimant / applicant / appellant; defendant / respondent;
// third_party / other) but the builder triplet has a binary
// claimant|defendant axis per PRD §3.3 — fold the wider set down,
// drop third_party / other to NULL (no perspective preselected).
primaryParty := mapProjectOurSideToTripletParty(proj.OurSide)
name := strings.TrimSpace(proj.Title)
if name == "" {
name = "Akte"
}
deep := &BuilderScenarioDeep{
Proceedings: []BuilderProceeding{},
Events: []BuilderEvent{},
Shares: []BuilderShare{},
}
err = s.withAuditTx(ctx, "scenario_builder: create from project", func(tx *sqlx.Tx) error {
// 1. Insert the scenario header. origin_project_id pins the
// Akte link; promotion later overwrites promoted_project_id
// independently.
if err := tx.GetContext(ctx, &deep.BuilderScenario,
`INSERT INTO paliad.scenarios
(owner_id, name, status, origin_project_id)
VALUES ($1, $2, 'active', $3)
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at`,
userID, name, projectID); err != nil {
return fmt.Errorf("insert scenario row: %w", err)
}
// 2. Insert one top-level proceeding mirroring the project's
// procedural shape + flags. scenario_flags is copied
// verbatim from the project — subsequent toggles on the
// builder propagate back via PatchProceeding's dual-write.
var proc BuilderProceeding
if err := tx.GetContext(ctx, &proc,
`INSERT INTO paliad.scenario_proceedings
(scenario_id, proceeding_type_id, primary_party, scenario_flags, ordinal, detailgrad)
VALUES ($1, $2, $3, $4::jsonb, 0, 'selected')
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at`,
deep.BuilderScenario.ID, *proj.ProceedingTypeID, primaryParty, rawFlags); err != nil {
return fmt.Errorf("insert seed proceeding: %w", err)
}
deep.Proceedings = append(deep.Proceedings, proc)
// 3. One scenario_events row per project deadline. Filed
// deadlines render with state='filed' + actual_date =
// completed_at (falling back to due_date when the column
// was never set). Pending / approved deadlines render
// planned. Skipped is not derivable from the deadline row
// shape; users mark skip on the canvas via PatchEvent.
for _, d := range deadlines {
state := "planned"
var actualDate *time.Time
if d.Status == "completed" {
state = "filed"
if d.CompletedAt != nil {
actualDate = d.CompletedAt
} else {
due := d.DueDate
actualDate = &due
}
}
var ev BuilderEvent
if err := tx.GetContext(ctx, &ev,
`INSERT INTO paliad.scenario_events
(scenario_proceeding_id, sequencing_rule_id, state, actual_date)
VALUES ($1, $2, $3, $4)
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at`,
proc.ID, *d.SequencingRuleID, state, actualDate); err != nil {
return fmt.Errorf("insert seed event: %w", err)
}
deep.Events = append(deep.Events, ev)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("create scenario from project: %w", err)
}
return deep, nil
}
// mapProjectOurSideToTripletParty folds paliad.projects.our_side (which
// allows the wider claimant/applicant/appellant + defendant/respondent
// + third_party/other set, mig 112) down to the builder triplet's
// binary claimant|defendant axis (PRD §3.3). Returns nil when the
// project hasn't picked a side or the role doesn't map (third_party /
// other) — the canvas shows both columns equally in that case.
func mapProjectOurSideToTripletParty(side *string) *string {
if side == nil {
return nil
}
switch *side {
case "claimant", "applicant", "appellant":
s := "claimant"
return &s
case "defendant", "respondent":
s := "defendant"
return &s
}
return nil
}
// withAuditTx opens a transaction, stamps paliad.audit_reason via
// set_config(..., true) so the reason persists for the duration of the
// tx (matching the mig-079 audit-trigger pattern used by event_choice_

View File

@@ -6,6 +6,7 @@ import (
"errors"
"os"
"testing"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
@@ -82,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) {
t.Fatalf("look up upc.inf id: %v", err)
}
svc := NewScenarioBuilderService(pool)
svc := NewScenarioBuilderService(pool, nil, nil)
// 1. Create a scenario for the owner. Empty name should default.
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
@@ -216,5 +217,258 @@ func TestScenarioBuilderService(t *testing.T) {
}
}
// TestScenarioBuilderAkteDualWrite pins B4's load-bearing contract
// (m/paliad#153 / t-paliad-347 / PRD §2.3 + §10):
//
// - PatchProceeding on a project-backed scenario (origin_project_id
// IS NOT NULL) MUST mirror scenario_flags onto
// paliad.projects.scenario_flags;
// - PatchEvent flipping state→'filed' on a project-backed scenario
// MUST upsert a paliad.deadlines row (status='completed',
// completed_at=actual_date);
// - PatchProceeding/PatchEvent on a non-Akte (kontextfrei) scenario
// MUST NOT touch paliad.projects.scenario_flags or
// paliad.deadlines.
//
// Skipped without TEST_DATABASE_URL. Mirrors the live-DB pattern used
// by TestScenarioBuilderService above.
func TestScenarioBuilderAkteDualWrite(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
owner := uuid.New()
cleanup := func() {
pool.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM paliad.projects WHERE created_by = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id = $1`, owner)
pool.ExecContext(ctx,
`DELETE FROM auth.users WHERE id = $1`, owner)
}
cleanup()
defer cleanup()
// Seed owner.
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
owner, "builder-akte-test@hlc.com"); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang, global_role)
VALUES ($1, $2, $3, 'munich', 'de', 'global_admin')`,
owner, "builder-akte-test@hlc.com", "Builder Akte Owner"); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// Look up a real proceeding_type_id + a sequencing_rule_id on that
// proceeding so the deadline upsert has a real rule to point at.
var ptID int
if err := pool.GetContext(ctx, &ptID,
`SELECT id FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true LIMIT 1`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up upc.inf id: %v", err)
}
var ruleID uuid.UUID
if err := pool.GetContext(ctx, &ruleID,
`SELECT id FROM paliad.sequencing_rules
WHERE proceeding_type_id = $1
AND is_active = true
AND lifecycle_state = 'published'
ORDER BY sequence_order NULLS LAST, id LIMIT 1`, ptID); err != nil {
t.Fatalf("look up sequencing_rule: %v", err)
}
// Seed a paliad.projects (type='case') row pinned to that
// proceeding_type. our_side='defendant' so the builder triplet's
// primary_party derives from it.
projectID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, title, status, proceeding_type_id, our_side, created_by)
VALUES ($1, 'case', 'Builder Akte Test Project', 'active', $2, 'defendant', $3)`,
projectID, ptID, owner); err != nil {
t.Fatalf("seed project: %v", err)
}
// Place the owner on the project team so visibility checks pass.
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited)
VALUES ($1, $2, 'lead', 'lead', false)`, projectID, owner); err != nil {
t.Fatalf("seed project_teams: %v", err)
}
// Wire up the service with the real project + flag deps so dual-
// write hits live tables. NewProjectService + NewScenarioFlags
// match the production wiring in cmd/server/main.go.
userSvc := NewUserService(pool)
projSvc := NewProjectService(pool, userSvc)
flagsSvc := NewScenarioFlagsService(pool, projSvc)
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc)
// ──────────────────────────────────────────────────────────────────
// Phase A — Akte-backed scenario writes through to project tables.
// ──────────────────────────────────────────────────────────────────
akte, err := svc.CreateScenarioFromProject(ctx, owner, projectID)
if err != nil {
t.Fatalf("CreateScenarioFromProject: %v", err)
}
if akte.OriginProjectID == nil || *akte.OriginProjectID != projectID {
t.Fatalf("origin_project_id = %v, want %v", akte.OriginProjectID, projectID)
}
if len(akte.Proceedings) != 1 {
t.Fatalf("seed proceedings = %d, want 1", len(akte.Proceedings))
}
procID := akte.Proceedings[0].ID
if akte.Proceedings[0].PrimaryParty == nil || *akte.Proceedings[0].PrimaryParty != "defendant" {
t.Errorf("primary_party = %v, want defendant", akte.Proceedings[0].PrimaryParty)
}
// Toggle with_ccr=true via PatchProceeding. Dual-write should land
// the same key on projects.scenario_flags.
if _, err := svc.PatchProceeding(ctx, owner, akte.ID, procID, PatchProceedingInput{
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
}); err != nil {
t.Fatalf("PatchProceeding (Akte): %v", err)
}
var rawProjFlags []byte
if err := pool.GetContext(ctx, &rawProjFlags,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
t.Fatalf("read project scenario_flags: %v", err)
}
var projFlags map[string]any
if err := json.Unmarshal(rawProjFlags, &projFlags); err != nil {
t.Fatalf("decode project scenario_flags: %v", err)
}
if v, ok := projFlags["with_ccr"].(bool); !ok || !v {
t.Errorf("after Akte PatchProceeding, projects.scenario_flags.with_ccr = %v, want true", projFlags["with_ccr"])
}
// Add an event card backed by a real sequencing rule, then PATCH
// state='filed' with actual_date. Dual-write should insert a
// paliad.deadlines row (status='completed', completed_at=actual_date).
ev, err := svc.AddEvent(ctx, owner, akte.ID, procID, AddEventInput{
SequencingRuleID: &ruleID,
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent (Akte): %v", err)
}
filedDate := mustDate(t, "2026-04-15")
if _, err := svc.PatchEvent(ctx, owner, akte.ID, ev.ID, PatchEventInput{
State: ptrString("filed"),
ActualDate: &filedDate,
}); err != nil {
t.Fatalf("PatchEvent filed (Akte): %v", err)
}
var deadlineCount int
if err := pool.GetContext(ctx, &deadlineCount,
`SELECT COUNT(*) FROM paliad.deadlines
WHERE project_id = $1 AND sequencing_rule_id = $2
AND status = 'completed'`,
projectID, ruleID); err != nil {
t.Fatalf("count deadlines: %v", err)
}
if deadlineCount != 1 {
t.Errorf("after Akte PatchEvent filed, deadlines rows = %d, want 1", deadlineCount)
}
// ──────────────────────────────────────────────────────────────────
// Phase B — kontextfrei scenario does NOT touch project surfaces.
// ──────────────────────────────────────────────────────────────────
// Capture project scenario_flags + deadline count before the
// kontextfrei mutations so we can assert no change.
var beforeFlagsRaw []byte
_ = pool.GetContext(ctx, &beforeFlagsRaw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
var beforeDeadlines int
_ = pool.GetContext(ctx, &beforeDeadlines,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID)
kf, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
if err != nil {
t.Fatalf("CreateScenario (kontextfrei): %v", err)
}
if kf.OriginProjectID != nil {
t.Fatalf("kontextfrei origin_project_id = %v, want nil", kf.OriginProjectID)
}
kfProc, err := svc.AddProceeding(ctx, owner, kf.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("claimant"),
})
if err != nil {
t.Fatalf("AddProceeding (kontextfrei): %v", err)
}
// Flag toggle on a kontextfrei scenario MUST NOT mutate the
// project's scenario_flags.
if _, err := svc.PatchProceeding(ctx, owner, kf.ID, kfProc.ID, PatchProceedingInput{
ScenarioFlags: json.RawMessage(`{"with_amend": true}`),
}); err != nil {
t.Fatalf("PatchProceeding (kontextfrei): %v", err)
}
var afterFlagsRaw []byte
if err := pool.GetContext(ctx, &afterFlagsRaw,
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
t.Fatalf("re-read project scenario_flags: %v", err)
}
if string(beforeFlagsRaw) != string(afterFlagsRaw) {
t.Errorf("kontextfrei PatchProceeding leaked into projects.scenario_flags: before=%s after=%s",
beforeFlagsRaw, afterFlagsRaw)
}
// Filed-state event on a kontextfrei scenario MUST NOT touch
// paliad.deadlines.
kfEv, err := svc.AddEvent(ctx, owner, kf.ID, kfProc.ID, AddEventInput{
SequencingRuleID: &ruleID,
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent (kontextfrei): %v", err)
}
kfDate := mustDate(t, "2026-04-16")
if _, err := svc.PatchEvent(ctx, owner, kf.ID, kfEv.ID, PatchEventInput{
State: ptrString("filed"),
ActualDate: &kfDate,
}); err != nil {
t.Fatalf("PatchEvent filed (kontextfrei): %v", err)
}
var afterDeadlines int
if err := pool.GetContext(ctx, &afterDeadlines,
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, projectID); err != nil {
t.Fatalf("re-count deadlines: %v", err)
}
if afterDeadlines != beforeDeadlines {
t.Errorf("kontextfrei PatchEvent filed leaked into deadlines: before=%d after=%d",
beforeDeadlines, afterDeadlines)
}
}
// mustDate parses an ISO date or fails the test. Helper for the
// dual-write test above.
func mustDate(t *testing.T, s string) time.Time {
t.Helper()
d, err := time.Parse("2006-01-02", s)
if err != nil {
t.Fatalf("parse date %q: %v", s, err)
}
return d
}
// (Note: ptrString lives in rule_editor_service_test.go in this package
// and is reused here. No second declaration needed.)