feat(builder): B4 — Akte mode + project-backed scenarios (m/paliad#153)
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:
@@ -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_
|
||||
|
||||
@@ -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.)
|
||||
|
||||
Reference in New Issue
Block a user