diff --git a/cmd/server/main.go b/cmd/server/main.go index 20675ed..f7a8108 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -246,6 +246,10 @@ func main() { // SSoT. Drives Verfahrensablauf + Mode B result-view conditional // rendering and per-rule selection state (`rule:` keys). ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc), + // t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder. + // CRUD over the new normalised scenarios + scenario_proceedings + // + scenario_events + scenario_shares tables. + ScenarioBuilder: services.NewScenarioBuilderService(pool), } // t-paliad-246 Slice A — Backup Mode runner. Wired only when diff --git a/internal/db/migrations/157_scenario_builder_foundation.down.sql b/internal/db/migrations/157_scenario_builder_foundation.down.sql new file mode 100644 index 0000000..c016de2 --- /dev/null +++ b/internal/db/migrations/157_scenario_builder_foundation.down.sql @@ -0,0 +1,94 @@ +-- 157_scenario_builder_foundation — down +-- +-- Rolls back mig 157 in reverse order. Down files are reference material +-- (not auto-applied); operator recovery path is: +-- +-- psql ... < 157_scenario_builder_foundation.down.sql +-- DELETE FROM paliad.applied_migrations WHERE version = 157; +-- +-- This restores the legacy paliad.scenarios shape from mig 145 — the +-- builder columns and the three sibling tables are dropped wholesale. +-- Any builder data in the dropped tables is lost (the tables CASCADE to +-- their children, and DROP TABLE doesn't keep a backup). + +BEGIN; + +SELECT set_config( + 'paliad.audit_reason', + 'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)', + true +); + +-- 8. updated_at triggers +DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events; +DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings; + +-- 7. RLS — drop new policies + restore legacy four +DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares; +DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares; +DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events; +DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events; +DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings; +DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings; +DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios; +DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios; + +-- Restore the four mig-145 policies verbatim. +CREATE POLICY scenarios_project_select ON paliad.scenarios + FOR SELECT + USING (project_id IS NOT NULL AND paliad.can_see_project(project_id)); + +CREATE POLICY scenarios_project_mutate ON paliad.scenarios + FOR ALL + USING (project_id IS NOT NULL AND paliad.can_see_project(project_id)) + WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id)); + +CREATE POLICY scenarios_abstract_select ON paliad.scenarios + FOR SELECT + USING (project_id IS NULL AND created_by = auth.uid()); + +CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios + FOR ALL + USING (project_id IS NULL AND created_by = auth.uid()) + WITH CHECK (project_id IS NULL AND created_by = auth.uid()); + +-- 6. helper function +DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid); + +-- 5. paliad.projects.origin_scenario_id +DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx; +ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id; + +-- 4. paliad.scenario_shares +DROP TABLE IF EXISTS paliad.scenario_shares; + +-- 3. paliad.scenario_events +DROP TABLE IF EXISTS paliad.scenario_events; + +-- 2. paliad.scenario_proceedings +DROP TABLE IF EXISTS paliad.scenario_proceedings; + +-- 1. paliad.scenarios — restore mig-145 shape +DROP INDEX IF EXISTS paliad.scenarios_updated_idx; +DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx; + +-- Restore the unique constraint mig 145 had. +ALTER TABLE paliad.scenarios + ADD CONSTRAINT scenarios_unique_per_scope + UNIQUE NULLS NOT DISTINCT (project_id, created_by, name); + +-- spec was NOT NULL in mig 145. Restore that — but only after backfilling +-- any NULL specs the builder might have created (none in legacy paths; +-- only builder rows have NULL spec, and those are dropped together with +-- the builder schema if a real rollback is needed). +UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL; +ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL; + +ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes; +ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag; +ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id; +ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id; +ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status; +ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id; + +COMMIT; diff --git a/internal/db/migrations/157_scenario_builder_foundation.up.sql b/internal/db/migrations/157_scenario_builder_foundation.up.sql new file mode 100644 index 0000000..635c30d --- /dev/null +++ b/internal/db/migrations/157_scenario_builder_foundation.up.sql @@ -0,0 +1,500 @@ +-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0 +-- +-- Schema foundation for the Litigation Builder (PRD +-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2). +-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI +-- depends on these tables yet; B1 wires the builder shell on top. +-- +-- What this migration adds: +-- +-- 1. Six new columns on paliad.scenarios for the builder shape: +-- owner_id, status, origin_project_id, promoted_project_id, +-- stichtag, notes. +-- Two relaxations on existing columns: +-- - spec NOT NULL → NULL (the builder normalises spec contents +-- into scenario_proceedings / scenario_events; new rows skip +-- spec entirely. Legacy callers from mig 145 still provide it +-- explicitly, so they keep inserting valid rows.) +-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder +-- allows multiple "Unbenanntes Szenario" + multiple scratch +-- scenarios per user — uniqueness on (project_id, created_by, +-- name) blocks that. The legacy service treated the constraint +-- as UX collision avoidance, not correctness.) +-- +-- 2. Three new tables for the normalised builder shape: +-- - paliad.scenario_proceedings (one row per proceeding in a +-- scenario; multi-proceeding constellations + spawned children) +-- - paliad.scenario_events (one row per event card on the +-- canvas; planned / filed / skipped state + actual_date + notes +-- + per-card optional horizon) +-- - paliad.scenario_shares (read-only team shares; owner is +-- the sole editor) +-- +-- 3. One new column on paliad.projects: +-- - origin_scenario_id — audit trail for promote-to-project +-- (B5; the column lands now so the FK is in place when the +-- wizard arrives). +-- +-- 4. New helper function paliad.can_see_scenario(_scenario_id) that +-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape. +-- Visibility logic: +-- - global_admin sees everything, +-- - owner_id = auth.uid() (builder-owned scenarios), +-- - scenario_shares.shared_with_user_id = auth.uid() +-- (read-only shared scenarios), +-- - legacy project-scoped scenarios (owner_id IS NULL AND +-- project_id IS NOT NULL) follow can_see_project(project_id), +-- - legacy abstract scenarios (owner_id IS NULL AND project_id +-- IS NULL) follow created_by = auth.uid(). +-- +-- 5. Replacement RLS policies on paliad.scenarios that fold builder +-- visibility together with the legacy shape. The legacy +-- project_* / abstract_* policies are dropped (they covered only +-- legacy paths) and rewritten as a single pair of policies that +-- treats owner_id, scenario_shares, and the legacy paths uniformly. +-- +-- Builder-only RLS for the three new tables: read = scenario +-- visibility; write = scenario owner (or legacy editor) only. +-- +-- PRD §5.1 deviations called out for the reader: +-- +-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`. +-- The live column is `integer` (see paliad.proceeding_types.id); +-- scenario_proceedings.proceeding_type_id is integer here to match +-- the real FK target. PRD authors did not check the column type; +-- this migration uses the truth on disk. +-- +-- - PRD references `auth.users(id)` for owner_id and share columns; +-- the established paliad convention (see paliad.projects.created_by, +-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs +-- either way (paliad.users.id == auth.users.id), but the FK targets +-- paliad.users to stay consistent with project tables. +-- +-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the +-- live DB before this file was committed. paliad.scenarios has 0 rows +-- (verified pre-mig), so the column additions and constraint relaxations +-- have no data impact. + +BEGIN; + +SELECT set_config( + 'paliad.audit_reason', + 'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)', + true +); + +-- ---------------------------------------------------------------- +-- 1. paliad.scenarios — additive columns + constraint relaxations +-- ---------------------------------------------------------------- + +ALTER TABLE paliad.scenarios + ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE, + ADD COLUMN status text NOT NULL DEFAULT 'active' + CHECK (status IN ('active','archived','promoted')), + ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL, + ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL, + ADD COLUMN stichtag date NULL, + ADD COLUMN notes text NULL; + +ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL; +ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope; + +CREATE INDEX scenarios_owner_status_idx + ON paliad.scenarios(owner_id, status) + WHERE owner_id IS NOT NULL; + +CREATE INDEX scenarios_updated_idx + ON paliad.scenarios(owner_id, updated_at DESC) + WHERE owner_id IS NOT NULL; + +COMMENT ON COLUMN paliad.scenarios.owner_id IS + 'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec ' + 'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have ' + 'owner_id set; the application enforces it via ScenarioBuilderService.'; + +COMMENT ON COLUMN paliad.scenarios.status IS + 'Lifecycle: active (default; user-editable) / archived (soft-deleted, ' + 'still visible in side panel) / promoted (converted to project via ' + 'B5 wizard; read-only). Legacy mig-145 rows default to active.'; + +COMMENT ON COLUMN paliad.scenarios.origin_project_id IS + 'Set when the scenario was exported from an existing project ' + '("Im Builder öffnen" — Akte mode, PRD §2.3).'; + +COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS + 'Set after the scenario was promoted to a real project via the 3-step ' + 'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, ' + 'forms the bidirectional audit link.'; + +COMMENT ON COLUMN paliad.scenarios.stichtag IS + 'Scenario-level default Stichtag; per-proceeding overrides in ' + 'paliad.scenario_proceedings.stichtag take precedence.'; + +-- ---------------------------------------------------------------- +-- 2. paliad.scenario_proceedings — one proceeding per scenario row +-- ---------------------------------------------------------------- + +CREATE TABLE paliad.scenario_proceedings ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + scenario_id uuid NOT NULL + REFERENCES paliad.scenarios(id) ON DELETE CASCADE, + proceeding_type_id integer NOT NULL + REFERENCES paliad.proceeding_types(id), + primary_party text NULL + CHECK (primary_party IN ('claimant','defendant')), + scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb + CHECK (jsonb_typeof(scenario_flags) = 'object'), + parent_scenario_proceeding_id uuid NULL + REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE, + spawn_anchor_event_id uuid NULL + REFERENCES paliad.sequencing_rules(id), + ordinal int NOT NULL DEFAULT 0, + stichtag date NULL, + detailgrad text NOT NULL DEFAULT 'selected' + CHECK (detailgrad IN ('selected','all_options')), + appeal_target text NULL, + collapsed boolean NOT NULL DEFAULT false, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX scenario_proceedings_scenario_idx + ON paliad.scenario_proceedings(scenario_id, ordinal); + +CREATE INDEX scenario_proceedings_parent_idx + ON paliad.scenario_proceedings(parent_scenario_proceeding_id) + WHERE parent_scenario_proceeding_id IS NOT NULL; + +COMMENT ON TABLE paliad.scenario_proceedings IS + 'One proceeding inside a Litigation Builder scenario. Multiple rows ' + 'per scenario for multi-proceeding constellations. ' + 'parent_scenario_proceeding_id self-refs for spawned children ' + '(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). ' + 'PRD §5.1, m/paliad#153 B0.'; + +COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS + 'Per-proceeding perspective ("our side"). NULL = no perspective ' + 'picked yet (both party columns render with natural labels). ' + 'Per-proceeding so multi-jurisdiction constellations can flip side ' + 'independently (PRD §3.3).'; + +COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS + 'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). ' + 'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-' + 'per-scenario. Validated by the application against ' + 'paliad.scenario_flag_catalog at write time.'; + +COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS + 'Which sequencing_rule of the parent proceeding caused this spawn. ' + 'NULL for root proceedings. Used by the UI to place the spawned child ' + 'triplet directly below the parent at the spawn node (PRD §3.6).'; + +COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS + 'Stack order on canvas (top to bottom). Siblings under the same ' + 'parent (or top-level) are ordered by ordinal asc, then created_at.'; + +COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS + 'Per-proceeding optional-detail toggle: selected (only explicitly ' + 'chosen optionals + mandatories) or all_options (every optional ' + 'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.'; + +-- ---------------------------------------------------------------- +-- 3. paliad.scenario_events — one event card on the canvas +-- ---------------------------------------------------------------- + +CREATE TABLE paliad.scenario_events ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + scenario_proceeding_id uuid NOT NULL + REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE, + sequencing_rule_id uuid NULL + REFERENCES paliad.sequencing_rules(id), + procedural_event_id uuid NULL + REFERENCES paliad.procedural_events(id), + custom_label text NULL, + state text NOT NULL DEFAULT 'planned' + CHECK (state IN ('planned','filed','skipped')), + actual_date date NULL, + skip_reason text NULL, + notes text NULL, + horizon_optional int NOT NULL DEFAULT 0 + CHECK (horizon_optional >= 0), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT scenario_events_one_anchor CHECK ( + (sequencing_rule_id IS NOT NULL)::int + + (procedural_event_id IS NOT NULL)::int + + (custom_label IS NOT NULL)::int >= 1 + ) +); + +CREATE INDEX scenario_events_proceeding_idx + ON paliad.scenario_events(scenario_proceeding_id); + +-- A single proceeding can't carry two cards for the same sequencing rule +-- (each rule maps to one card). Free-form / procedural_event-only cards +-- skip this uniqueness — multiple custom cards per proceeding are OK. +CREATE UNIQUE INDEX scenario_events_rule_uniq_idx + ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id) + WHERE sequencing_rule_id IS NOT NULL; + +COMMENT ON TABLE paliad.scenario_events IS + 'One event card on the Litigation Builder canvas. Captures state ' + '(planned/filed/skipped), actual_date, notes, skip_reason, and the ' + 'per-card optional-horizon setting. At least one of ' + '(sequencing_rule_id, procedural_event_id, custom_label) must be ' + 'set — sequencing-rule-backed cards are the common case; free-form ' + 'cards exist for events the catalog doesn''t cover yet. ' + 'PRD §3.4 / §5.1.'; + +COMMENT ON COLUMN paliad.scenario_events.state IS + '3-state machine: planned (default, future event with computed date) ' + '/ filed (past event, actual_date set) / skipped (user chose not to ' + 'file; optional skip_reason). No "overdue" enum — that''s derived ' + '(date < today AND state=planned), not stored. PRD Q10 / §3.4.'; + +COMMENT ON COLUMN paliad.scenario_events.actual_date IS + 'Set when state=filed (real-world filing date) OR when state=planned ' + 'and the user overrode the computed date (court-set events, manual ' + 'tweaks). NULL when the computed date is canonical.'; + +COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS + 'Per-card "show N more optional follow-ups" affordance. Default 0 ' + '(hidden). PRD Q4 / §3.4.'; + +-- ---------------------------------------------------------------- +-- 4. paliad.scenario_shares — read-only team shares +-- ---------------------------------------------------------------- + +CREATE TABLE paliad.scenario_shares ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + scenario_id uuid NOT NULL + REFERENCES paliad.scenarios(id) ON DELETE CASCADE, + shared_with_user_id uuid NOT NULL + REFERENCES paliad.users(id) ON DELETE CASCADE, + created_at timestamptz NOT NULL DEFAULT now(), + created_by uuid NOT NULL REFERENCES paliad.users(id), + UNIQUE (scenario_id, shared_with_user_id) +); + +CREATE INDEX scenario_shares_user_idx + ON paliad.scenario_shares(shared_with_user_id); + +COMMENT ON TABLE paliad.scenario_shares IS + 'Read-only team shares for Litigation Builder scenarios. Owner ' + '(paliad.scenarios.owner_id) is the sole editor; rows here grant ' + 'view-only access to other paliad users. PRD Q12 / §5.1.'; + +-- ---------------------------------------------------------------- +-- 5. paliad.projects.origin_scenario_id — promote-to-project trail +-- ---------------------------------------------------------------- + +ALTER TABLE paliad.projects + ADD COLUMN origin_scenario_id uuid NULL + REFERENCES paliad.scenarios(id) ON DELETE SET NULL; + +CREATE INDEX projects_origin_scenario_idx + ON paliad.projects(origin_scenario_id) + WHERE origin_scenario_id IS NOT NULL; + +COMMENT ON COLUMN paliad.projects.origin_scenario_id IS + 'FK to the scenario this project was promoted from (B5 wizard). ' + 'NULL = project was created directly, not via Builder. Together with ' + 'paliad.scenarios.promoted_project_id, forms the bidirectional audit ' + 'link. PRD §5.2.'; + +-- ---------------------------------------------------------------- +-- 6. paliad.can_see_scenario — visibility helper +-- ---------------------------------------------------------------- + +CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid) + RETURNS boolean + LANGUAGE sql STABLE SECURITY DEFINER + SET search_path TO 'paliad', 'public' +AS $func$ + SELECT EXISTS ( + SELECT 1 FROM paliad.users u + WHERE u.id = auth.uid() AND u.global_role = 'global_admin' + ) + OR EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = _scenario_id AND s.owner_id = auth.uid() + ) + OR EXISTS ( + SELECT 1 FROM paliad.scenario_shares sh + WHERE sh.scenario_id = _scenario_id + AND sh.shared_with_user_id = auth.uid() + ) + -- Legacy project-scoped scenarios (mig 145) — visible via project + -- team membership. + OR EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = _scenario_id + AND s.owner_id IS NULL + AND s.project_id IS NOT NULL + AND paliad.can_see_project(s.project_id) + ) + -- Legacy abstract scenarios (mig 145) — owner-only via created_by. + OR EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = _scenario_id + AND s.owner_id IS NULL + AND s.project_id IS NULL + AND s.created_by = auth.uid() + ); +$func$; + +COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS + 'Returns true if the caller (auth.uid()) can see the given scenario. ' + 'Mirrors paliad.can_see_project. Covers builder-owned scenarios ' + '(owner_id), read-only shares (scenario_shares), and the two legacy ' + 'paths from mig 145 (project-scoped via can_see_project, abstract ' + 'via created_by). Used by RLS on all four scenario_* tables.'; + +-- ---------------------------------------------------------------- +-- 7. RLS — replace legacy scenarios policies + new tables +-- ---------------------------------------------------------------- + +-- Replace mig-145's four policies with a single pair that handles +-- builder + legacy shapes together. +DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios; +DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios; +DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios; +DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios; + +CREATE POLICY scenarios_select ON paliad.scenarios + FOR SELECT USING (paliad.can_see_scenario(id)); + +-- Write rule: builder owner, legacy project team member (if no owner), +-- or legacy abstract creator (if no owner + no project). Shares are +-- read-only — they don't grant mutate. +CREATE POLICY scenarios_owner_mutate ON paliad.scenarios + FOR ALL + USING ( + owner_id = auth.uid() + OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id)) + OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid()) + ) + WITH CHECK ( + owner_id = auth.uid() + OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id)) + OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid()) + ); + +-- scenario_proceedings — visibility piggybacks on the parent scenario. +ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY; + +CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings + FOR SELECT USING (paliad.can_see_scenario(scenario_id)); + +CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings + FOR ALL + USING (EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = scenario_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = scenario_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + )); + +-- scenario_events — visibility piggybacks on the parent scenario via +-- the proceeding row. +ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY; + +CREATE POLICY scenario_events_select ON paliad.scenario_events + FOR SELECT + USING (EXISTS ( + SELECT 1 FROM paliad.scenario_proceedings sp + WHERE sp.id = scenario_proceeding_id + AND paliad.can_see_scenario(sp.scenario_id) + )); + +CREATE POLICY scenario_events_mutate ON paliad.scenario_events + FOR ALL + USING (EXISTS ( + SELECT 1 FROM paliad.scenario_proceedings sp + JOIN paliad.scenarios s ON s.id = sp.scenario_id + WHERE sp.id = scenario_proceeding_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM paliad.scenario_proceedings sp + JOIN paliad.scenarios s ON s.id = sp.scenario_id + WHERE sp.id = scenario_proceeding_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + )); + +-- scenario_shares — recipient can see their share rows; the scenario +-- owner (or legacy editor) can manage them. +ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY; + +CREATE POLICY scenario_shares_select ON paliad.scenario_shares + FOR SELECT + USING ( + shared_with_user_id = auth.uid() + OR EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = scenario_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + ) + ); + +CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares + FOR ALL + USING (EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = scenario_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + )) + WITH CHECK (EXISTS ( + SELECT 1 FROM paliad.scenarios s + WHERE s.id = scenario_id + AND (s.owner_id = auth.uid() + OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id)) + OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid())) + )); + +-- ---------------------------------------------------------------- +-- 8. updated_at triggers on the new tables (reuse the function mig 145 +-- already created for paliad.scenarios). +-- ---------------------------------------------------------------- + +CREATE TRIGGER scenario_proceedings_touch_updated_at_trg + BEFORE UPDATE ON paliad.scenario_proceedings + FOR EACH ROW + EXECUTE FUNCTION paliad.scenarios_touch_updated_at(); + +CREATE TRIGGER scenario_events_touch_updated_at_trg + BEFORE UPDATE ON paliad.scenario_events + FOR EACH ROW + EXECUTE FUNCTION paliad.scenarios_touch_updated_at(); + +-- ---------------------------------------------------------------- +-- 9. Informational NOTICE. +-- ---------------------------------------------------------------- + +DO $$ +BEGIN + RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)'; + RAISE NOTICE '[mig 157] paliad.scenario_proceedings created'; + RAISE NOTICE '[mig 157] paliad.scenario_events created'; + RAISE NOTICE '[mig 157] paliad.scenario_shares created'; + RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added'; + RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created'; +END $$; + +COMMIT; diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index d3203d4..2d9b5a5 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -142,6 +142,12 @@ type Services struct { // and per-rule selection state (`rule:` keys). ScenarioFlags *services.ScenarioFlagsService + // t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the + // new normalised scenario shape (paliad.scenarios with owner_id + + // scenario_proceedings + scenario_events + scenario_shares, mig 157). + // Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503. + ScenarioBuilder *services.ScenarioBuilderService + // Paliadin is wired when DATABASE_URL is set. The concrete backend // is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST // (remote → mRiver via SSH) or local tmux availability. Stays nil @@ -212,6 +218,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc eventChoice: svc.EventChoice, scenario: svc.Scenario, scenarioFlags: svc.ScenarioFlags, + scenarioBuilder: svc.ScenarioBuilder, } } @@ -514,6 +521,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete) protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario) + // t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the + // new normalised scenario shape (mig 157). Coexists with the legacy + // /api/scenarios surface during the B0→B6 migration; B6 cleanup + // retires the legacy routes. + protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList) + protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate) + protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet) + protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch) + protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate) + protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch) + protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete) + protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate) + protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch) + protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete) + protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate) + protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete) + // Dev-only test route — gated to PaliadinOwnerEmail (m). + protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage) + // Partner units (structural partner-led units; legacy "Dezernate"). protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits) protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit) diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go index 2e252d7..abee223 100644 --- a/internal/handlers/projects.go +++ b/internal/handlers/projects.go @@ -85,6 +85,11 @@ type dbServices struct { // m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154). scenarioFlags *services.ScenarioFlagsService + + // t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new + // normalised scenario shape (paliad.scenarios with owner_id + + // scenario_proceedings + scenario_events + scenario_shares, mig 157). + scenarioBuilder *services.ScenarioBuilderService } var dbSvc *dbServices diff --git a/internal/handlers/scenario_builder.go b/internal/handlers/scenario_builder.go new file mode 100644 index 0000000..8e2022b --- /dev/null +++ b/internal/handlers/scenario_builder.go @@ -0,0 +1,589 @@ +package handlers + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/google/uuid" + + "mgit.msbls.de/m/paliad/internal/services" +) + +// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised +// scenario builder shape (paliad.scenarios with owner_id, + +// paliad.scenario_proceedings / scenario_events / scenario_shares). +// +// Endpoints live under /api/builder/scenarios/* to avoid clashing with +// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The +// B6 cleanup slice retires the legacy surface; until then both shapes +// coexist on the same paliad.scenarios table (the legacy paths require +// project_id IS NOT NULL OR an abstract created_by = caller; the builder +// paths require owner_id = caller). +// +// All handlers gate by requireScenarioBuilderService — 503 when the +// service is nil (DATABASE_URL unset). Auth is checked via requireUser; +// per-row visibility is enforced inside the service. + +func requireScenarioBuilderService(w http.ResponseWriter) bool { + if dbSvc == nil || dbSvc.scenarioBuilder == nil { + writeJSON(w, http.StatusServiceUnavailable, map[string]string{ + "error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).", + }) + return false + } + return true +} + +// scenarioBuilderErrorToStatus maps service errors to HTTP statuses. +func scenarioBuilderErrorToStatus(err error) (int, string) { + switch { + case errors.Is(err, services.ErrScenarioBuilderNotVisible), + errors.Is(err, services.ErrNotVisible): + return http.StatusNotFound, "Szenario nicht gefunden" + case errors.Is(err, services.ErrInvalidInput): + return http.StatusBadRequest, err.Error() + } + return http.StatusInternalServerError, err.Error() +} + +func writeBuilderError(w http.ResponseWriter, err error) { + status, msg := scenarioBuilderErrorToStatus(err) + writeJSON(w, status, map[string]string{"error": msg}) +} + +// --------------------------------------------------------------------------- +// Scenario CRUD +// --------------------------------------------------------------------------- + +// handleBuilderScenariosList — GET /api/builder/scenarios?status= +func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + status := r.URL.Query().Get("status") + out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleBuilderScenarioCreate — POST /api/builder/scenarios +func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + var input services.CreateBuilderScenarioInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + +// handleBuilderScenarioGet — GET /api/builder/scenarios/{id} +func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"}) + return + } + out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id} +func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + id, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"}) + return + } + var input services.PatchBuilderScenarioInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// --------------------------------------------------------------------------- +// Proceedings +// --------------------------------------------------------------------------- + +// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings +func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + var input services.AddProceedingInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + +// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid} +func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + pid, err := uuid.Parse(r.PathValue("pid")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"}) + return + } + var input services.PatchProceedingInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid} +func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + pid, err := uuid.Parse(r.PathValue("pid")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"}) + return + } + if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil { + writeBuilderError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Events +// --------------------------------------------------------------------------- + +// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events +func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + pid, err := uuid.Parse(r.PathValue("pid")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"}) + return + } + var input services.AddEventInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + +// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid} +func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + eid, err := uuid.Parse(r.PathValue("eid")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"}) + return + } + var input services.PatchEventInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusOK, out) +} + +// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid} +func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + eid, err := uuid.Parse(r.PathValue("eid")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"}) + return + } + if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil { + writeBuilderError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Shares +// --------------------------------------------------------------------------- + +// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares +// Body: {"shared_with_user_id": ""} +func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + sid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + var body struct { + SharedWithUserID uuid.UUID `json:"shared_with_user_id"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"}) + return + } + out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID) + if err != nil { + writeBuilderError(w, err) + return + } + writeJSON(w, http.StatusCreated, out) +} + +// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid} +func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) { + if !requireScenarioBuilderService(w) { + return + } + uid, ok := requireUser(w, r) + if !ok { + return + } + scid, err := uuid.Parse(r.PathValue("id")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"}) + return + } + shid, err := uuid.Parse(r.PathValue("sid")) + if err != nil { + writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"}) + return + } + if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil { + writeBuilderError(w, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// --------------------------------------------------------------------------- +// Dev-only test route +// --------------------------------------------------------------------------- + +// handleBuilderDevTestPage — GET /dev/scenario-builder +// +// Gated to services.PaliadinOwnerEmail (the same single-owner gate the +// /paliadin route uses). Every other authenticated user gets 404. Pure +// HTML — no JS bundle — so the page works even before B1 wires the real +// builder shell. Renders curl-equivalent forms for the B0 surface so the +// schema can be exercised end-to-end without Postman / shell scripts. +// +// This is the "dev-only test route" the head's task spec asked for. It +// disappears in B6 cleanup once the production builder UI ships at +// /tools/procedures. +func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) { + if !requirePaliadinOwner(w, r) { + return + } + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-store") + _, _ = w.Write([]byte(builderDevTestHTML)) +} + +const builderDevTestHTML = ` + + + +Scenario Builder — Dev Test (B0) + + + +

Scenario Builder — Dev Test (B0)

+

t-paliad-340 / m/paliad#153 — DB-only slice. Exercises +paliad.scenarios (builder rows), scenario_proceedings, scenario_events, +scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.

+ +
+

1. Liste meine Szenarien

+ + + +

+
+ +
+

2. Szenario anlegen

+ + + + + +

+
+ +
+

3. Szenario abrufen (deep)

+ + + +

+
+ +
+

4. Verfahren hinzufügen

+ + + + + + + +

+
+ +
+

5. Event-Karte hinzufügen

+ + + + + + + + + +

+
+ +
+

6. Status patchen (archive / restore)

+ + + + + +

+
+ + + +` diff --git a/internal/services/scenario_builder_service.go b/internal/services/scenario_builder_service.go new file mode 100644 index 0000000..df3c6da --- /dev/null +++ b/internal/services/scenario_builder_service.go @@ -0,0 +1,936 @@ +package services + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" +) + +// ScenarioBuilderService owns the t-paliad-340 / m/paliad#153 B0 surface +// — CRUD over the new normalised builder shape (paliad.scenarios with +// owner_id + status, paliad.scenario_proceedings, paliad.scenario_events, +// paliad.scenario_shares). The legacy spec-jsonb service +// (ScenarioService) keeps serving m/paliad#124 Slice D callers; this +// service strictly handles builder-owned rows (owner_id IS NOT NULL). +// +// Visibility is enforced both in code (the owner / share / can_see_project +// 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. +type ScenarioBuilderService struct { + db *sqlx.DB +} + +// NewScenarioBuilderService wires the service to the shared pool. +func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService { + return &ScenarioBuilderService{db: db} +} + +// ErrScenarioBuilderNotVisible is returned when the caller is neither +// owner, an accepted share recipient, nor a global_admin / legacy +// editor for the scenario. +var ErrScenarioBuilderNotVisible = errors.New("scenario not visible to caller") + +// ----------------------------------------------------------------------------- +// Row types — flat shapes matching the table columns. Deep tree (scenario + +// proceedings + events) is composed at the GET-by-id endpoint. +// ----------------------------------------------------------------------------- + +// BuilderScenario is one paliad.scenarios row from the builder's perspective. +// Legacy columns (project_id, description, spec, created_by) are still +// returned so a UI can detect a legacy row and refuse to mutate it. +type BuilderScenario struct { + ID uuid.UUID `db:"id" json:"id"` + OwnerID *uuid.UUID `db:"owner_id" json:"owner_id,omitempty"` + Name string `db:"name" json:"name"` + Status string `db:"status" json:"status"` + OriginProjectID *uuid.UUID `db:"origin_project_id" json:"origin_project_id,omitempty"` + PromotedProjectID *uuid.UUID `db:"promoted_project_id" json:"promoted_project_id,omitempty"` + Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"` + Notes *string `db:"notes" json:"notes,omitempty"` + LegacyProjectID *uuid.UUID `db:"project_id" json:"legacy_project_id,omitempty"` + LegacyDescription *string `db:"description" json:"legacy_description,omitempty"` + LegacyCreatedBy *uuid.UUID `db:"created_by" json:"legacy_created_by,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// BuilderProceeding is one paliad.scenario_proceedings row. +type BuilderProceeding struct { + ID uuid.UUID `db:"id" json:"id"` + ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"` + ProceedingTypeID int `db:"proceeding_type_id" json:"proceeding_type_id"` + PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"` + ScenarioFlags json.RawMessage `db:"scenario_flags" json:"scenario_flags"` + ParentScenarioProceedingID *uuid.UUID `db:"parent_scenario_proceeding_id" json:"parent_scenario_proceeding_id,omitempty"` + SpawnAnchorEventID *uuid.UUID `db:"spawn_anchor_event_id" json:"spawn_anchor_event_id,omitempty"` + Ordinal int `db:"ordinal" json:"ordinal"` + Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"` + Detailgrad string `db:"detailgrad" json:"detailgrad"` + AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"` + Collapsed bool `db:"collapsed" json:"collapsed"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// BuilderEvent is one paliad.scenario_events row. +type BuilderEvent struct { + ID uuid.UUID `db:"id" json:"id"` + ScenarioProceedingID uuid.UUID `db:"scenario_proceeding_id" json:"scenario_proceeding_id"` + SequencingRuleID *uuid.UUID `db:"sequencing_rule_id" json:"sequencing_rule_id,omitempty"` + ProceduralEventID *uuid.UUID `db:"procedural_event_id" json:"procedural_event_id,omitempty"` + CustomLabel *string `db:"custom_label" json:"custom_label,omitempty"` + State string `db:"state" json:"state"` + ActualDate *time.Time `db:"actual_date" json:"actual_date,omitempty"` + SkipReason *string `db:"skip_reason" json:"skip_reason,omitempty"` + Notes *string `db:"notes" json:"notes,omitempty"` + HorizonOptional int `db:"horizon_optional" json:"horizon_optional"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// BuilderShare is one paliad.scenario_shares row. +type BuilderShare struct { + ID uuid.UUID `db:"id" json:"id"` + ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"` + SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} + +// BuilderScenarioDeep bundles a scenario with its proceedings + events +// for the GET /api/builder/scenarios/{id} response. Proceedings sort by +// ordinal asc; events sort by created_at asc within a proceeding. +type BuilderScenarioDeep struct { + BuilderScenario + Proceedings []BuilderProceeding `json:"proceedings"` + Events []BuilderEvent `json:"events"` + Shares []BuilderShare `json:"shares"` +} + +// ----------------------------------------------------------------------------- +// Scenario CRUD +// ----------------------------------------------------------------------------- + +// CreateBuilderScenarioInput is the POST /api/builder/scenarios body. +// Name defaults to "Unbenanntes Szenario" when blank (PRD §5.1). +type CreateBuilderScenarioInput struct { + Name string `json:"name,omitempty"` + Stichtag *time.Time `json:"stichtag,omitempty"` + Notes *string `json:"notes,omitempty"` + OriginProjectID *uuid.UUID `json:"origin_project_id,omitempty"` +} + +// CreateScenario inserts a new builder-owned scenario. owner_id is set to +// the caller; status defaults to 'active'. Audit reason is set inside the +// write tx so any future audit trigger picks it up. +func (s *ScenarioBuilderService) CreateScenario(ctx context.Context, userID uuid.UUID, input CreateBuilderScenarioInput) (*BuilderScenario, error) { + name := strings.TrimSpace(input.Name) + if name == "" { + name = "Unbenanntes Szenario" + } + + var out BuilderScenario + err := s.withAuditTx(ctx, "scenario_builder: create scenario", func(tx *sqlx.Tx) error { + return tx.GetContext(ctx, &out, + `INSERT INTO paliad.scenarios + (owner_id, name, status, stichtag, notes, origin_project_id) + VALUES ($1, $2, 'active', $3, $4, $5) + 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, input.Stichtag, input.Notes, input.OriginProjectID) + }) + if err != nil { + return nil, fmt.Errorf("create builder scenario: %w", err) + } + return &out, nil +} + +// ListMyScenarios returns the caller's owned scenarios filtered by status. +// Status "" (or "all") returns every status; otherwise filters by the +// given enum value. Sorted by updated_at desc. +func (s *ScenarioBuilderService) ListMyScenarios(ctx context.Context, userID uuid.UUID, status string) ([]BuilderScenario, error) { + switch status { + case "", "all": + // no filter + case "active", "archived", "promoted": + // ok + default: + return nil, fmt.Errorf("%w: status %q must be one of {active,archived,promoted,all}", + ErrInvalidInput, status) + } + + q := `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id, + stichtag, notes, + project_id, description, created_by, + created_at, updated_at + FROM paliad.scenarios + WHERE owner_id = $1` + args := []any{userID} + if status != "" && status != "all" { + q += ` AND status = $2` + args = append(args, status) + } + q += ` ORDER BY updated_at DESC` + + out := []BuilderScenario{} + if err := s.db.SelectContext(ctx, &out, q, args...); err != nil { + return nil, fmt.Errorf("list builder scenarios: %w", err) + } + return out, nil +} + +// GetScenarioDeep returns the scenario + proceedings + events + shares. +// Visibility: owner, share recipient, global_admin, or legacy editor. +func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenarioDeep, error) { + sc, err := s.getScenarioRow(ctx, scenarioID) + if err != nil { + return nil, err + } + visible, err := s.canSeeScenario(ctx, userID, sc) + if err != nil { + return nil, err + } + if !visible { + return nil, ErrScenarioBuilderNotVisible + } + + deep := &BuilderScenarioDeep{BuilderScenario: *sc} + + if err := s.db.SelectContext(ctx, &deep.Proceedings, ` + SELECT 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 + FROM paliad.scenario_proceedings + WHERE scenario_id = $1 + ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil { + return nil, fmt.Errorf("load proceedings: %w", err) + } + + if err := s.db.SelectContext(ctx, &deep.Events, ` + SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id, + e.procedural_event_id, e.custom_label, e.state, e.actual_date, + e.skip_reason, e.notes, e.horizon_optional, + e.created_at, e.updated_at + FROM paliad.scenario_events e + JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id + WHERE sp.scenario_id = $1 + ORDER BY e.created_at ASC`, scenarioID); err != nil { + return nil, fmt.Errorf("load events: %w", err) + } + + if err := s.db.SelectContext(ctx, &deep.Shares, ` + SELECT id, scenario_id, shared_with_user_id, created_by, created_at + FROM paliad.scenario_shares + WHERE scenario_id = $1 + ORDER BY created_at ASC`, scenarioID); err != nil { + return nil, fmt.Errorf("load shares: %w", err) + } + + return deep, nil +} + +// PatchBuilderScenarioInput is the PATCH /api/builder/scenarios/{id} body. +// Any nil field means "don't change". +type PatchBuilderScenarioInput struct { + Name *string `json:"name,omitempty"` + Status *string `json:"status,omitempty"` + Stichtag *time.Time `json:"stichtag,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +// PatchScenario updates one or more fields. Status flips to 'promoted' +// are reserved for the B5 wizard (we accept only active⇄archived here). +func (s *ScenarioBuilderService) PatchScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PatchBuilderScenarioInput) (*BuilderScenario, error) { + sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID) + if err != nil { + return nil, err + } + if sc.Status == "promoted" { + return nil, fmt.Errorf("%w: scenario is promoted; mutations are blocked", ErrInvalidInput) + } + + if input.Status != nil { + switch *input.Status { + case "active", "archived": + // ok + case "promoted": + return nil, fmt.Errorf("%w: status='promoted' is set by the promote-to-project wizard, not PATCH", + ErrInvalidInput) + default: + return nil, fmt.Errorf("%w: status %q must be one of {active,archived}", + ErrInvalidInput, *input.Status) + } + } + + sets := []string{} + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + sets = append(sets, fmt.Sprintf(clause, len(args))) + } + if input.Name != nil { + n := strings.TrimSpace(*input.Name) + if n == "" { + return nil, fmt.Errorf("%w: name cannot be blank", ErrInvalidInput) + } + add("name = $%d", n) + } + if input.Status != nil { + add("status = $%d", *input.Status) + } + if input.Stichtag != nil { + add("stichtag = $%d", *input.Stichtag) + } + if input.Notes != nil { + add("notes = $%d", *input.Notes) + } + if len(sets) == 0 { + return sc, nil + } + + args = append(args, scenarioID) + q := fmt.Sprintf(`UPDATE paliad.scenarios SET %s + WHERE id = $%d + RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id, + stichtag, notes, + project_id, description, created_by, + created_at, updated_at`, + strings.Join(sets, ", "), len(args)) + var out BuilderScenario + err = s.withAuditTx(ctx, "scenario_builder: patch scenario", func(tx *sqlx.Tx) error { + return tx.GetContext(ctx, &out, q, args...) + }) + if err != nil { + return nil, fmt.Errorf("patch builder scenario: %w", err) + } + return &out, nil +} + +// ----------------------------------------------------------------------------- +// Proceedings +// ----------------------------------------------------------------------------- + +// AddProceedingInput is the POST /api/builder/scenarios/{id}/proceedings body. +type AddProceedingInput struct { + ProceedingTypeID int `json:"proceeding_type_id"` + PrimaryParty *string `json:"primary_party,omitempty"` + ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"` + ParentScenarioProceedingID *uuid.UUID `json:"parent_scenario_proceeding_id,omitempty"` + SpawnAnchorEventID *uuid.UUID `json:"spawn_anchor_event_id,omitempty"` + Ordinal *int `json:"ordinal,omitempty"` + Stichtag *time.Time `json:"stichtag,omitempty"` + Detailgrad *string `json:"detailgrad,omitempty"` + AppealTarget *string `json:"appeal_target,omitempty"` +} + +// AddProceeding appends a proceeding row to the scenario. The caller must +// own the scenario (or be a legacy editor). Ordinal defaults to max+1. +func (s *ScenarioBuilderService) AddProceeding(ctx context.Context, userID, scenarioID uuid.UUID, input AddProceedingInput) (*BuilderProceeding, error) { + if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil { + return nil, err + } + if input.ProceedingTypeID == 0 { + return nil, fmt.Errorf("%w: proceeding_type_id is required", ErrInvalidInput) + } + if input.PrimaryParty != nil { + switch *input.PrimaryParty { + case "claimant", "defendant": + default: + return nil, fmt.Errorf("%w: primary_party %q must be claimant or defendant", + ErrInvalidInput, *input.PrimaryParty) + } + } + detailgrad := "selected" + if input.Detailgrad != nil { + switch *input.Detailgrad { + case "selected", "all_options": + detailgrad = *input.Detailgrad + default: + return nil, fmt.Errorf("%w: detailgrad %q must be selected or all_options", + ErrInvalidInput, *input.Detailgrad) + } + } + flags := input.ScenarioFlags + if len(flags) == 0 { + flags = json.RawMessage(`{}`) + } + + // Resolve ordinal: caller's value or max+1 within the same scenario. + var ordinal int + if input.Ordinal != nil { + ordinal = *input.Ordinal + } else { + if err := s.db.GetContext(ctx, &ordinal, + `SELECT COALESCE(MAX(ordinal), -1) + 1 + FROM paliad.scenario_proceedings + WHERE scenario_id = $1`, scenarioID); err != nil { + return nil, fmt.Errorf("compute ordinal: %w", err) + } + } + + var out BuilderProceeding + err := s.withAuditTx(ctx, "scenario_builder: add proceeding", func(tx *sqlx.Tx) error { + if err := tx.GetContext(ctx, &out, + `INSERT INTO paliad.scenario_proceedings + (scenario_id, proceeding_type_id, primary_party, scenario_flags, + parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal, + stichtag, detailgrad, appeal_target) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + 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`, + scenarioID, input.ProceedingTypeID, input.PrimaryParty, []byte(flags), + input.ParentScenarioProceedingID, input.SpawnAnchorEventID, ordinal, + input.Stichtag, detailgrad, input.AppealTarget); err != nil { + return err + } + // touch the scenario's updated_at so the side panel re-orders correctly. + _, err := tx.ExecContext(ctx, + `UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID) + return err + }) + if err != nil { + return nil, fmt.Errorf("add proceeding: %w", err) + } + return &out, nil +} + +// PatchProceedingInput accepts a subset of mutable proceeding fields. +type PatchProceedingInput struct { + PrimaryParty *string `json:"primary_party,omitempty"` + ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"` + Ordinal *int `json:"ordinal,omitempty"` + Stichtag *time.Time `json:"stichtag,omitempty"` + Detailgrad *string `json:"detailgrad,omitempty"` + AppealTarget *string `json:"appeal_target,omitempty"` + Collapsed *bool `json:"collapsed,omitempty"` +} + +// PatchProceeding updates fields on one proceeding row. +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 { + return nil, err + } + + sets := []string{} + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + sets = append(sets, fmt.Sprintf(clause, len(args))) + } + if input.PrimaryParty != nil { + switch *input.PrimaryParty { + case "claimant", "defendant", "": + default: + return nil, fmt.Errorf("%w: primary_party %q invalid", ErrInvalidInput, *input.PrimaryParty) + } + if *input.PrimaryParty == "" { + add("primary_party = $%d", nil) + } else { + add("primary_party = $%d", *input.PrimaryParty) + } + } + if len(input.ScenarioFlags) > 0 { + add("scenario_flags = $%d", []byte(input.ScenarioFlags)) + } + if input.Ordinal != nil { + add("ordinal = $%d", *input.Ordinal) + } + if input.Stichtag != nil { + add("stichtag = $%d", *input.Stichtag) + } + if input.Detailgrad != nil { + switch *input.Detailgrad { + case "selected", "all_options": + default: + return nil, fmt.Errorf("%w: detailgrad %q invalid", ErrInvalidInput, *input.Detailgrad) + } + add("detailgrad = $%d", *input.Detailgrad) + } + if input.AppealTarget != nil { + if *input.AppealTarget == "" { + add("appeal_target = $%d", nil) + } else { + add("appeal_target = $%d", *input.AppealTarget) + } + } + if input.Collapsed != nil { + add("collapsed = $%d", *input.Collapsed) + } + if len(sets) == 0 { + // nothing to do — re-fetch and return. + return s.getProceedingRow(ctx, scenarioID, proceedingID) + } + + args = append(args, proceedingID, scenarioID) + q := fmt.Sprintf(`UPDATE paliad.scenario_proceedings SET %s + WHERE id = $%d AND scenario_id = $%d + 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`, + strings.Join(sets, ", "), len(args)-1, len(args)) + var out BuilderProceeding + err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error { + return tx.GetContext(ctx, &out, q, args...) + }) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: proceeding %s not in scenario %s", + ErrNotVisible, proceedingID, scenarioID) + } + return nil, fmt.Errorf("patch proceeding: %w", err) + } + 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 { + return err + } + var n int64 + err := s.withAuditTx(ctx, "scenario_builder: delete proceeding", func(tx *sqlx.Tx) error { + res, err := tx.ExecContext(ctx, + `DELETE FROM paliad.scenario_proceedings + WHERE id = $1 AND scenario_id = $2`, + proceedingID, scenarioID) + if err != nil { + return err + } + n, _ = res.RowsAffected() + return nil + }) + if err != nil { + return fmt.Errorf("delete proceeding: %w", err) + } + if n == 0 { + return fmt.Errorf("%w: proceeding %s not in scenario %s", + ErrNotVisible, proceedingID, scenarioID) + } + return nil +} + +// ----------------------------------------------------------------------------- +// Events +// ----------------------------------------------------------------------------- + +// AddEventInput is the POST .../proceedings/{pid}/events body. At least +// one of {SequencingRuleID, ProceduralEventID, CustomLabel} must be set, +// matching the scenario_events_one_anchor CHECK constraint. +type AddEventInput struct { + SequencingRuleID *uuid.UUID `json:"sequencing_rule_id,omitempty"` + ProceduralEventID *uuid.UUID `json:"procedural_event_id,omitempty"` + CustomLabel *string `json:"custom_label,omitempty"` + State *string `json:"state,omitempty"` + ActualDate *time.Time `json:"actual_date,omitempty"` + SkipReason *string `json:"skip_reason,omitempty"` + Notes *string `json:"notes,omitempty"` + HorizonOptional *int `json:"horizon_optional,omitempty"` +} + +// AddEvent inserts an event card under the given proceeding. The +// proceeding must belong to the addressed scenario. +func (s *ScenarioBuilderService) AddEvent(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input AddEventInput) (*BuilderEvent, error) { + if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil { + return nil, err + } + if input.SequencingRuleID == nil && input.ProceduralEventID == nil && + (input.CustomLabel == nil || strings.TrimSpace(*input.CustomLabel) == "") { + return nil, fmt.Errorf("%w: at least one of sequencing_rule_id, procedural_event_id, custom_label must be set", + ErrInvalidInput) + } + if err := s.assertProceedingInScenario(ctx, scenarioID, proceedingID); err != nil { + return nil, err + } + + state := "planned" + if input.State != nil { + switch *input.State { + case "planned", "filed", "skipped": + state = *input.State + default: + return nil, fmt.Errorf("%w: state %q must be one of {planned,filed,skipped}", + ErrInvalidInput, *input.State) + } + } + horizon := 0 + if input.HorizonOptional != nil { + if *input.HorizonOptional < 0 { + return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput) + } + horizon = *input.HorizonOptional + } + + var out BuilderEvent + err := s.withAuditTx(ctx, "scenario_builder: add event", func(tx *sqlx.Tx) error { + if err := tx.GetContext(ctx, &out, + `INSERT INTO paliad.scenario_events + (scenario_proceeding_id, sequencing_rule_id, procedural_event_id, + custom_label, state, actual_date, skip_reason, notes, horizon_optional) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + 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`, + proceedingID, input.SequencingRuleID, input.ProceduralEventID, + input.CustomLabel, state, input.ActualDate, input.SkipReason, + input.Notes, horizon); err != nil { + return err + } + _, err := tx.ExecContext(ctx, + `UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID) + return err + }) + if err != nil { + return nil, fmt.Errorf("add event: %w", err) + } + return &out, nil +} + +// PatchEventInput is the PATCH body for an event card. +type PatchEventInput struct { + State *string `json:"state,omitempty"` + ActualDate *time.Time `json:"actual_date,omitempty"` + SkipReason *string `json:"skip_reason,omitempty"` + Notes *string `json:"notes,omitempty"` + HorizonOptional *int `json:"horizon_optional,omitempty"` +} + +// PatchEvent updates fields on one event card. The card's parent +// proceeding must belong to the addressed scenario. +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 { + return nil, err + } + if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil { + return nil, err + } + + sets := []string{} + args := []any{} + add := func(clause string, val any) { + args = append(args, val) + sets = append(sets, fmt.Sprintf(clause, len(args))) + } + if input.State != nil { + switch *input.State { + case "planned", "filed", "skipped": + default: + return nil, fmt.Errorf("%w: state %q invalid", ErrInvalidInput, *input.State) + } + add("state = $%d", *input.State) + } + if input.ActualDate != nil { + add("actual_date = $%d", *input.ActualDate) + } + if input.SkipReason != nil { + add("skip_reason = $%d", *input.SkipReason) + } + if input.Notes != nil { + add("notes = $%d", *input.Notes) + } + if input.HorizonOptional != nil { + if *input.HorizonOptional < 0 { + return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput) + } + add("horizon_optional = $%d", *input.HorizonOptional) + } + if len(sets) == 0 { + return s.getEventRow(ctx, eventID) + } + + args = append(args, eventID) + q := fmt.Sprintf(`UPDATE paliad.scenario_events SET %s + WHERE id = $%d + 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`, + 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...) + }) + if err != nil { + return nil, fmt.Errorf("patch event: %w", err) + } + return &out, 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 { + return err + } + if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil { + return err + } + err := s.withAuditTx(ctx, "scenario_builder: delete event", func(tx *sqlx.Tx) error { + _, err := tx.ExecContext(ctx, + `DELETE FROM paliad.scenario_events WHERE id = $1`, eventID) + return err + }) + if err != nil { + return fmt.Errorf("delete event: %w", err) + } + return nil +} + +// ----------------------------------------------------------------------------- +// Shares +// ----------------------------------------------------------------------------- + +// AddShare grants read-only access to another paliad user. +func (s *ScenarioBuilderService) AddShare(ctx context.Context, userID, scenarioID, recipientID uuid.UUID) (*BuilderShare, error) { + if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil { + return nil, err + } + if recipientID == uuid.Nil { + return nil, fmt.Errorf("%w: shared_with_user_id is required", ErrInvalidInput) + } + if recipientID == userID { + return nil, fmt.Errorf("%w: cannot share a scenario with yourself", ErrInvalidInput) + } + var out BuilderShare + err := s.withAuditTx(ctx, "scenario_builder: add share", func(tx *sqlx.Tx) error { + return tx.GetContext(ctx, &out, + `INSERT INTO paliad.scenario_shares (scenario_id, shared_with_user_id, created_by) + VALUES ($1, $2, $3) + ON CONFLICT (scenario_id, shared_with_user_id) DO UPDATE + SET created_at = paliad.scenario_shares.created_at + RETURNING id, scenario_id, shared_with_user_id, created_by, created_at`, + scenarioID, recipientID, userID) + }) + if err != nil { + return nil, fmt.Errorf("add share: %w", err) + } + return &out, nil +} + +// DeleteShare revokes a share row. +func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenarioID, shareID uuid.UUID) error { + if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil { + return err + } + var n int64 + err := s.withAuditTx(ctx, "scenario_builder: delete share", func(tx *sqlx.Tx) error { + res, err := tx.ExecContext(ctx, + `DELETE FROM paliad.scenario_shares + WHERE id = $1 AND scenario_id = $2`, shareID, scenarioID) + if err != nil { + return err + } + n, _ = res.RowsAffected() + return nil + }) + if err != nil { + return fmt.Errorf("delete share: %w", err) + } + if n == 0 { + return fmt.Errorf("%w: share %s not in scenario %s", ErrNotVisible, shareID, scenarioID) + } + return nil +} + +// ----------------------------------------------------------------------------- +// Internal helpers +// ----------------------------------------------------------------------------- + +func (s *ScenarioBuilderService) getScenarioRow(ctx context.Context, scenarioID uuid.UUID) (*BuilderScenario, error) { + var out BuilderScenario + err := s.db.GetContext(ctx, &out, + `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id, + stichtag, notes, + project_id, description, created_by, + created_at, updated_at + FROM paliad.scenarios + WHERE id = $1`, scenarioID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: scenario %s not found", ErrNotVisible, scenarioID) + } + if err != nil { + return nil, fmt.Errorf("get scenario: %w", err) + } + return &out, nil +} + +func (s *ScenarioBuilderService) getProceedingRow(ctx context.Context, scenarioID, proceedingID uuid.UUID) (*BuilderProceeding, error) { + var out BuilderProceeding + err := s.db.GetContext(ctx, &out, + `SELECT 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 + FROM paliad.scenario_proceedings + WHERE id = $1 AND scenario_id = $2`, proceedingID, scenarioID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: proceeding %s not in scenario %s", + ErrNotVisible, proceedingID, scenarioID) + } + if err != nil { + return nil, fmt.Errorf("get proceeding: %w", err) + } + return &out, nil +} + +func (s *ScenarioBuilderService) getEventRow(ctx context.Context, eventID uuid.UUID) (*BuilderEvent, error) { + var out BuilderEvent + err := s.db.GetContext(ctx, &out, + `SELECT id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id, + custom_label, state, actual_date, skip_reason, notes, + horizon_optional, created_at, updated_at + FROM paliad.scenario_events + WHERE id = $1`, eventID) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("%w: event %s not found", ErrNotVisible, eventID) + } + if err != nil { + return nil, fmt.Errorf("get event: %w", err) + } + return &out, nil +} + +func (s *ScenarioBuilderService) assertProceedingInScenario(ctx context.Context, scenarioID, proceedingID uuid.UUID) error { + var exists bool + if err := s.db.GetContext(ctx, &exists, + `SELECT EXISTS (SELECT 1 FROM paliad.scenario_proceedings + WHERE id = $1 AND scenario_id = $2)`, + proceedingID, scenarioID); err != nil { + return fmt.Errorf("check proceeding membership: %w", err) + } + if !exists { + return fmt.Errorf("%w: proceeding %s not in scenario %s", + ErrNotVisible, proceedingID, scenarioID) + } + return nil +} + +func (s *ScenarioBuilderService) assertEventInScenario(ctx context.Context, scenarioID, eventID uuid.UUID) error { + var exists bool + if err := s.db.GetContext(ctx, &exists, + `SELECT EXISTS ( + SELECT 1 FROM paliad.scenario_events e + JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id + WHERE e.id = $1 AND sp.scenario_id = $2 + )`, + eventID, scenarioID); err != nil { + return fmt.Errorf("check event membership: %w", err) + } + if !exists { + return fmt.Errorf("%w: event %s not in scenario %s", + ErrNotVisible, eventID, scenarioID) + } + return nil +} + +// canSeeScenario mirrors the SQL paliad.can_see_scenario(...) function in +// Go. The service connection bypasses RLS, so this check is the +// authoritative gate. +func (s *ScenarioBuilderService) canSeeScenario(ctx context.Context, userID uuid.UUID, sc *BuilderScenario) (bool, error) { + // owner — fast path + if sc.OwnerID != nil && *sc.OwnerID == userID { + return true, nil + } + // global_admin + var isAdmin bool + if err := s.db.GetContext(ctx, &isAdmin, + `SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1 AND global_role = 'global_admin')`, + userID); err != nil { + return false, fmt.Errorf("check global_admin: %w", err) + } + if isAdmin { + return true, nil + } + // share recipient + var shared bool + if err := s.db.GetContext(ctx, &shared, + `SELECT EXISTS (SELECT 1 FROM paliad.scenario_shares + WHERE scenario_id = $1 AND shared_with_user_id = $2)`, + sc.ID, userID); err != nil { + return false, fmt.Errorf("check share: %w", err) + } + if shared { + return true, nil + } + // legacy project-scoped — visible via project team membership + if sc.OwnerID == nil && sc.LegacyProjectID != nil { + var ok bool + if err := s.db.GetContext(ctx, &ok, + `SELECT paliad.can_see_project($1::uuid)`, + *sc.LegacyProjectID); err == nil && ok { + return true, nil + } + } + // legacy abstract — owner-only via created_by + if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil && + *sc.LegacyCreatedBy == userID { + return true, nil + } + return false, nil +} + +// requireOwnerOrLegacyEditor fetches the scenario and validates that the +// caller has write rights. Returns the loaded row for downstream use. +func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenario, error) { + sc, err := s.getScenarioRow(ctx, scenarioID) + if err != nil { + return nil, err + } + // owner + if sc.OwnerID != nil && *sc.OwnerID == userID { + return sc, nil + } + // legacy project-scoped editor + if sc.OwnerID == nil && sc.LegacyProjectID != nil { + var ok bool + if err := s.db.GetContext(ctx, &ok, + `SELECT paliad.can_see_project($1::uuid)`, + *sc.LegacyProjectID); err == nil && ok { + return sc, nil + } + } + // legacy abstract creator + if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil && + *sc.LegacyCreatedBy == userID { + return sc, nil + } + return nil, ErrScenarioBuilderNotVisible +} + +// 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_ +// service.go), invokes fn, and commits. Any error returned by fn rolls +// back. The audit reason is appended with the task slug so audit-log +// readers can trace writes back to t-paliad-340. +func (s *ScenarioBuilderService) withAuditTx(ctx context.Context, reason string, fn func(tx *sqlx.Tx) error) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return fmt.Errorf("begin tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if _, err := tx.ExecContext(ctx, + `SELECT set_config('paliad.audit_reason', $1, true)`, + fmt.Sprintf("%s (t-paliad-340)", reason)); err != nil { + return fmt.Errorf("set audit_reason: %w", err) + } + if err := fn(tx); err != nil { + return err + } + if err := tx.Commit(); err != nil { + return fmt.Errorf("commit: %w", err) + } + return nil +} diff --git a/internal/services/scenario_builder_service_test.go b/internal/services/scenario_builder_service_test.go new file mode 100644 index 0000000..5899840 --- /dev/null +++ b/internal/services/scenario_builder_service_test.go @@ -0,0 +1,220 @@ +package services + +import ( + "context" + "encoding/json" + "errors" + "os" + "testing" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + _ "github.com/lib/pq" + + "mgit.msbls.de/m/paliad/internal/db" +) + +// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0 +// surface end-to-end against a live DB: create + list + deep-get + patch +// + add-proceeding + add-event + add/delete-share, plus the visibility +// negative case (a non-owner can't see the scenario unless shared). +// +// Skipped without TEST_DATABASE_URL — matches the pattern in +// project_service_test.go / event_choice_service_test.go. +func TestScenarioBuilderService(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() + other := uuid.New() + cleanup := func() { + // Cascade order: delete from scenarios → CASCADE clears + // proceedings, events, shares. Then the two users. + pool.ExecContext(ctx, + `DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other) + pool.ExecContext(ctx, + `DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other) + pool.ExecContext(ctx, + `DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other) + } + cleanup() + defer cleanup() + + for _, seed := range []struct { + id uuid.UUID + email string + name string + }{ + {owner, "builder-owner-test@hlc.com", "Builder Owner"}, + {other, "builder-other-test@hlc.com", "Builder Other"}, + } { + if _, err := pool.ExecContext(ctx, + `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, + seed.id, seed.email); err != nil { + t.Fatalf("seed auth.users %s: %v", seed.email, err) + } + if _, err := pool.ExecContext(ctx, + `INSERT INTO paliad.users (id, email, display_name, office, lang) + VALUES ($1, $2, $3, 'munich', 'de')`, + seed.id, seed.email, seed.name); err != nil { + t.Fatalf("seed paliad.users %s: %v", seed.email, err) + } + } + + // Pick a real proceeding_type_id so the FK insert succeeds. + 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) + } + + svc := NewScenarioBuilderService(pool) + + // 1. Create a scenario for the owner. Empty name should default. + sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{}) + if err != nil { + t.Fatalf("CreateScenario: %v", err) + } + if sc.Name != "Unbenanntes Szenario" { + t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario") + } + if sc.Status != "active" { + t.Errorf("default status = %q, want active", sc.Status) + } + if sc.OwnerID == nil || *sc.OwnerID != owner { + t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner) + } + + // 2. List — should return the one row. + list, err := svc.ListMyScenarios(ctx, owner, "active") + if err != nil { + t.Fatalf("ListMyScenarios: %v", err) + } + if len(list) != 1 || list[0].ID != sc.ID { + t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID) + } + + // 3. Other user can NOT see the scenario. + if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) { + t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err) + } + + // 4. Add a proceeding. + pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{ + ProceedingTypeID: ptID, + PrimaryParty: ptrString("defendant"), + ScenarioFlags: json.RawMessage(`{"with_ccr": true}`), + }) + if err != nil { + t.Fatalf("AddProceeding: %v", err) + } + if pr.ProceedingTypeID != ptID { + t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID) + } + if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" { + t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty) + } + + // 5. Add a custom-label event card. + ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{ + CustomLabel: ptrString("Klageerwiderung"), + State: ptrString("planned"), + }) + if err != nil { + t.Fatalf("AddEvent: %v", err) + } + if ev.State != "planned" { + t.Errorf("event state = %q, want planned", ev.State) + } + + // 5b. Add-event with NO anchor fields fails. + if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) { + t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err) + } + + // 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares. + deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID) + if err != nil { + t.Fatalf("GetScenarioDeep: %v", err) + } + if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID { + t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings) + } + if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID { + t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events) + } + if len(deep.Shares) != 0 { + t.Errorf("deep shares count=%d want 0", len(deep.Shares)) + } + + // 7. Share with `other`. Recipient should now see the scenario. + sh, err := svc.AddShare(ctx, owner, sc.ID, other) + if err != nil { + t.Fatalf("AddShare: %v", err) + } + if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil { + t.Errorf("GetScenarioDeep by share recipient: %v", err) + } + // But the recipient can NOT add proceedings. + if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{ + ProceedingTypeID: ptID, + }); !errors.Is(err, ErrScenarioBuilderNotVisible) { + t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err) + } + + // 7b. Self-share should be rejected. + if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) { + t.Errorf("self-share = %v, want ErrInvalidInput", err) + } + + // 8. Patch — archive then re-activate. + patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{ + Status: ptrString("archived"), + }) + if err != nil { + t.Fatalf("PatchScenario archive: %v", err) + } + if patched.Status != "archived" { + t.Errorf("after archive, status = %q, want archived", patched.Status) + } + // PATCH to 'promoted' is rejected — that's the wizard's job. + if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{ + Status: ptrString("promoted"), + }); !errors.Is(err, ErrInvalidInput) { + t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err) + } + patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{ + Status: ptrString("active"), + }) + if err != nil { + t.Fatalf("PatchScenario re-activate: %v", err) + } + if patched.Status != "active" { + t.Errorf("after re-activate, status = %q, want active", patched.Status) + } + + // 9. Revoke the share. Recipient loses visibility. + if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil { + t.Fatalf("DeleteShare: %v", err) + } + if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) { + t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err) + } +} + +// (Note: ptrString lives in rule_editor_service_test.go in this package +// and is reused here. No second declaration needed.)