Merge: t-paliad-340 B0 — Scenario DB foundation (m/paliad#153)
This commit is contained in:
@@ -246,6 +246,10 @@ func main() {
|
||||
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
|
||||
// rendering and per-rule selection state (`rule:<uuid>` 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
|
||||
|
||||
@@ -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;
|
||||
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
500
internal/db/migrations/157_scenario_builder_foundation.up.sql
Normal file
@@ -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;
|
||||
@@ -142,6 +142,12 @@ type Services struct {
|
||||
// and per-rule selection state (`rule:<uuid>` 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
589
internal/handlers/scenario_builder.go
Normal file
589
internal/handlers/scenario_builder.go
Normal file
@@ -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=<active|archived|promoted|all>
|
||||
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": "<uuid>"}
|
||||
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 = `<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Scenario Builder — Dev Test (B0)</title>
|
||||
<style>
|
||||
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
|
||||
padding: 0 1em; color: #222; background: #fafaf7; }
|
||||
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
|
||||
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
|
||||
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
|
||||
padding: 1em 1.2em; margin: 1em 0; }
|
||||
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
|
||||
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
|
||||
input[type="text"], input[type="number"], textarea { width: 100%; }
|
||||
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
|
||||
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
|
||||
button.secondary { background: #eee; border-color: #ccc; }
|
||||
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
|
||||
overflow: auto; max-height: 30em; font-size: .85em; }
|
||||
.note { color: #777; font-size: .9em; }
|
||||
.row { display: flex; gap: .5em; }
|
||||
.row > * { flex: 1; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Scenario Builder — Dev Test (B0)</h1>
|
||||
<p class="note">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.</p>
|
||||
|
||||
<section>
|
||||
<h2>1. Liste meine Szenarien</h2>
|
||||
<label>Status filter</label>
|
||||
<select id="list-status">
|
||||
<option value="">(default: alle)</option>
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
<option value="promoted">promoted</option>
|
||||
<option value="all">all (explicit)</option>
|
||||
</select>
|
||||
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
|
||||
<pre class="out" id="list-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>2. Szenario anlegen</h2>
|
||||
<label>Name</label>
|
||||
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea id="create-notes" rows="2"></textarea>
|
||||
<button onclick="createScenario()">POST /api/builder/scenarios</button>
|
||||
<pre class="out" id="create-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>3. Szenario abrufen (deep)</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="get-id">
|
||||
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
|
||||
<pre class="out" id="get-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>4. Verfahren hinzufügen</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="proc-sid">
|
||||
<label>proceeding_type_id (integer)</label>
|
||||
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
|
||||
<label>primary_party</label>
|
||||
<select id="proc-party">
|
||||
<option value="">(none)</option>
|
||||
<option value="claimant">claimant</option>
|
||||
<option value="defendant">defendant</option>
|
||||
</select>
|
||||
<button onclick="addProceeding()">POST .../proceedings</button>
|
||||
<pre class="out" id="proc-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>5. Event-Karte hinzufügen</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="ev-sid">
|
||||
<label>Proceeding ID</label>
|
||||
<input type="text" id="ev-pid">
|
||||
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
|
||||
<input type="text" id="ev-label" placeholder="freitext-Karte">
|
||||
<label>state</label>
|
||||
<select id="ev-state">
|
||||
<option value="planned">planned</option>
|
||||
<option value="filed">filed</option>
|
||||
<option value="skipped">skipped</option>
|
||||
</select>
|
||||
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
|
||||
<pre class="out" id="ev-out"></pre>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>6. Status patchen (archive / restore)</h2>
|
||||
<label>Scenario ID</label>
|
||||
<input type="text" id="patch-sid">
|
||||
<label>new status</label>
|
||||
<select id="patch-status">
|
||||
<option value="active">active</option>
|
||||
<option value="archived">archived</option>
|
||||
</select>
|
||||
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
|
||||
<pre class="out" id="patch-out"></pre>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const j = (id, payload) =>
|
||||
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
|
||||
|
||||
async function call(method, url, body) {
|
||||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||||
if (body !== undefined) opts.body = JSON.stringify(body);
|
||||
const r = await fetch(url, opts);
|
||||
const text = await r.text();
|
||||
let parsed = text;
|
||||
try { parsed = JSON.parse(text); } catch (_) {}
|
||||
return { status: r.status, body: parsed };
|
||||
}
|
||||
|
||||
async function listScenarios() {
|
||||
const status = document.getElementById('list-status').value;
|
||||
const q = status ? '?status=' + encodeURIComponent(status) : '';
|
||||
j('list-out', await call('GET', '/api/builder/scenarios' + q));
|
||||
}
|
||||
|
||||
async function createScenario() {
|
||||
const name = document.getElementById('create-name').value;
|
||||
const notes = document.getElementById('create-notes').value;
|
||||
const body = {};
|
||||
if (name) body.name = name;
|
||||
if (notes) body.notes = notes;
|
||||
j('create-out', await call('POST', '/api/builder/scenarios', body));
|
||||
}
|
||||
|
||||
async function getScenario() {
|
||||
const id = document.getElementById('get-id').value.trim();
|
||||
if (!id) return j('get-out', { error: 'ID erforderlich' });
|
||||
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
|
||||
}
|
||||
|
||||
async function addProceeding() {
|
||||
const sid = document.getElementById('proc-sid').value.trim();
|
||||
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
|
||||
const party = document.getElementById('proc-party').value;
|
||||
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
|
||||
const body = { proceeding_type_id: ptID };
|
||||
if (party) body.primary_party = party;
|
||||
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
|
||||
}
|
||||
|
||||
async function addEvent() {
|
||||
const sid = document.getElementById('ev-sid').value.trim();
|
||||
const pid = document.getElementById('ev-pid').value.trim();
|
||||
const label = document.getElementById('ev-label').value.trim();
|
||||
const state = document.getElementById('ev-state').value;
|
||||
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
|
||||
j('ev-out', await call('POST',
|
||||
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
|
||||
{ custom_label: label, state }));
|
||||
}
|
||||
|
||||
async function patchStatus() {
|
||||
const sid = document.getElementById('patch-sid').value.trim();
|
||||
const status = document.getElementById('patch-status').value;
|
||||
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
|
||||
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>`
|
||||
936
internal/services/scenario_builder_service.go
Normal file
936
internal/services/scenario_builder_service.go
Normal file
@@ -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
|
||||
}
|
||||
220
internal/services/scenario_builder_service_test.go
Normal file
220
internal/services/scenario_builder_service_test.go
Normal file
@@ -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.)
|
||||
Reference in New Issue
Block a user