Compare commits
18 Commits
mai/tesla/
...
mai/lorenz
| Author | SHA1 | Date | |
|---|---|---|---|
| 6cddb2e587 | |||
| 8a814e3442 | |||
| 5f9a8b2ef4 | |||
| ee2caf9d79 | |||
| 88d5656a35 | |||
| 238c4d7cf0 | |||
| 32a620b788 | |||
| 9d73b91e05 | |||
| b966d7c8cd | |||
| 755a1042ff | |||
| c7fa0d6542 | |||
| 1f8230b264 | |||
| bd8ec42b80 | |||
| ec0ec32271 | |||
| 251f5a250f | |||
| 58a1abc6d8 | |||
| 7159443dcb | |||
| 119b06dcff |
@@ -147,7 +147,13 @@ func main() {
|
||||
Calculator: services.NewDeadlineCalculator(holidays),
|
||||
Users: users,
|
||||
Fristenrechner: services.NewFristenrechnerService(rules, holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays, courts),
|
||||
EventDeadline: services.NewEventDeadlineService(
|
||||
pool,
|
||||
services.NewDeadlineCalculator(holidays),
|
||||
holidays,
|
||||
courts,
|
||||
services.NewFristenrechnerService(rules, holidays, courts),
|
||||
),
|
||||
Courts: courts,
|
||||
DeadlineSearch: services.NewDeadlineSearchService(pool),
|
||||
EventCategory: nil, // wired below; cross-link order matters
|
||||
|
||||
1078
docs/design-fristen-phase2-2026-05-15.md
Normal file
1078
docs/design-fristen-phase2-2026-05-15.md
Normal file
File diff suppressed because it is too large
Load Diff
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-182 down — reverses 078_unified_rule_columns.up.sql.
|
||||
--
|
||||
-- Drops in reverse dependency order: indexes → CHECK constraints →
|
||||
-- FKs → columns. Idempotent (IF EXISTS guards everywhere).
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_lifecycle_state_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_spawn_proceeding_type_id_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_trigger_event_id_idx;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_lifecycle_state_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_priority_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_combine_op_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_draft_of_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_spawn_proceeding_type_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_trigger_event_id_fkey;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS published_at,
|
||||
DROP COLUMN IF EXISTS draft_of,
|
||||
DROP COLUMN IF EXISTS lifecycle_state,
|
||||
DROP COLUMN IF EXISTS is_court_set,
|
||||
DROP COLUMN IF EXISTS priority,
|
||||
DROP COLUMN IF EXISTS condition_expr,
|
||||
DROP COLUMN IF EXISTS combine_op,
|
||||
DROP COLUMN IF EXISTS spawn_proceeding_type_id,
|
||||
DROP COLUMN IF EXISTS trigger_event_id;
|
||||
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 (Step A of
|
||||
-- docs/design-fristen-phase2-2026-05-15.md §3.1).
|
||||
--
|
||||
-- Additive only: extends paliad.deadline_rules with the unified-rule
|
||||
-- columns the Phase 3 calculator + rule editor will use.
|
||||
--
|
||||
-- NO drops in this slice. Legacy columns (is_mandatory, is_optional,
|
||||
-- condition_flag, condition_rule_id) stay live until Slice 9. Compat-
|
||||
-- mode readers consume both shapes during the transition window
|
||||
-- (design §3.2 "Cutover ordering").
|
||||
--
|
||||
-- Column-by-column rationale:
|
||||
-- trigger_event_id — event-rooted dispatch (Pipeline C unification, §2.5).
|
||||
-- spawn_proceeding_type_id — cross-proceeding spawn resolution (Q7, §2.6).
|
||||
-- combine_op — composite-rule arithmetic 'max'/'min' (R.198/R.213).
|
||||
-- condition_expr — jsonb condition grammar replacing condition_flag (Q6, §2.4).
|
||||
-- priority — 4-way enum mandatory|recommended|optional|informational (Q3, §2.3).
|
||||
-- is_court_set — explicit replacement of the runtime heuristic (Q12).
|
||||
-- lifecycle_state — draft|published|archived for the rule editor (Q5, §4.2).
|
||||
-- draft_of — draft self-FK pointing at the published row it replaces.
|
||||
-- published_at — promotion timestamp, NULL while draft.
|
||||
--
|
||||
-- FK type notes:
|
||||
-- trigger_event_id is BIGINT (paliad.trigger_events.id is bigint, mig 028).
|
||||
-- spawn_proceeding_type_id is INTEGER (paliad.proceeding_types.id is
|
||||
-- serial = int4, mig 003).
|
||||
-- draft_of is UUID (self-FK on paliad.deadline_rules.id).
|
||||
-- The design doc (§2.1) calls them "int FK" loosely; the actual schemas
|
||||
-- demand the precise int width, hence bigint/integer here.
|
||||
--
|
||||
-- Indexes:
|
||||
-- FK lookups for trigger_event_id + spawn_proceeding_type_id (sparse,
|
||||
-- most rules have neither — partial WHERE NOT NULL keeps the index
|
||||
-- small).
|
||||
-- lifecycle_state is queried by the admin /admin/rules listing's
|
||||
-- default filter (state='published'); plain btree is fine, no
|
||||
-- WHERE clause so 'draft' / 'archived' rows index too.
|
||||
--
|
||||
-- Idempotent: every ADD COLUMN uses IF NOT EXISTS. Re-applying is a
|
||||
-- no-op. Tracker advances 77 → 78.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. New columns on paliad.deadline_rules
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS spawn_proceeding_type_id integer,
|
||||
ADD COLUMN IF NOT EXISTS combine_op text,
|
||||
ADD COLUMN IF NOT EXISTS condition_expr jsonb,
|
||||
ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'mandatory',
|
||||
ADD COLUMN IF NOT EXISTS is_court_set boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS draft_of uuid,
|
||||
ADD COLUMN IF NOT EXISTS published_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.trigger_event_id IS
|
||||
'Optional FK to paliad.trigger_events. When non-NULL, this rule is '
|
||||
'event-rooted (Pipeline C unification, design §2.5). When NULL the '
|
||||
'rule is proceeding-rooted via proceeding_type_id. Exactly one of '
|
||||
'the two must be set after Slice 3 backfill (enforced by a CHECK '
|
||||
'constraint added in Slice 9 after legacy callers retire).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.spawn_proceeding_type_id IS
|
||||
'When is_spawn=true, points at the target proceeding whose rule set '
|
||||
'the calculator follows when this rule fires (cross-proceeding '
|
||||
'spawn, design §2.6). Backfilled in Slice 7 for the 8 live spawn '
|
||||
'rules.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.combine_op IS
|
||||
'NULL = single-anchor arithmetic. ''max'' / ''min'' = composite-rule '
|
||||
'arithmetic combining (duration_value, duration_unit) with '
|
||||
'(alt_duration_value, alt_duration_unit). Used by R.198 / R.213 '
|
||||
'("31d OR 20 working_days, whichever is longer / shorter").';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.condition_expr IS
|
||||
'jsonb gating expression replacing condition_flag (Q6, design §2.4). '
|
||||
'Grammar: {"flag": "<name>"} | {"op":"and"|"or", "args":[...]} | '
|
||||
'{"op":"not", "args":[<node>]}. NULL or {} = unconditional. '
|
||||
'Backfilled in Slice 2 from condition_flag; new code reads this, '
|
||||
'falls back to condition_flag during the transition window.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.priority IS
|
||||
'Unified 4-way enum (Q3, design §2.3) replacing the is_mandatory + '
|
||||
'is_optional pair. Allowed: mandatory | recommended | optional | '
|
||||
'informational. Default ''mandatory'' on new rows; legacy rows get '
|
||||
'backfilled in Slice 2 from the (is_mandatory, is_optional) pair.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.is_court_set IS
|
||||
'Replaces the runtime heuristic (primary_party=''court'' OR '
|
||||
'event_type IN (...)) with an explicit column (Q12). Default false '
|
||||
'on new rows; Slice 2 backfills from the heuristic so behaviour is '
|
||||
'unchanged at first.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.lifecycle_state IS
|
||||
'Rule-editor lifecycle (Q5, design §4.2). draft = work-in-progress '
|
||||
'admin edit; published = live, calculator-visible; archived = '
|
||||
'historical (kept for audit). Default ''published'' so every '
|
||||
'existing row stays live without an UPDATE.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.draft_of IS
|
||||
'When lifecycle_state=''draft'', points at the published rule this '
|
||||
'draft will replace on publish. NULL on published or archived '
|
||||
'rows. NULL also on net-new drafts (no prior published peer).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.published_at IS
|
||||
'Timestamp this row entered lifecycle_state=''published''. NULL '
|
||||
'while draft, populated on publish, retained through archive. '
|
||||
'Distinct from updated_at (which moves on every edit).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Foreign keys
|
||||
-- =============================================================================
|
||||
--
|
||||
-- DEFERRABLE INITIALLY IMMEDIATE keeps normal-statement semantics
|
||||
-- intact while still letting backfill migrations defer until end-of-
|
||||
-- transaction if they need to (e.g. when Slice 3 inserts a rule row
|
||||
-- whose trigger_event_id references a row inserted in the same tx).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_trigger_event_id_fkey
|
||||
FOREIGN KEY (trigger_event_id)
|
||||
REFERENCES paliad.trigger_events(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_spawn_proceeding_type_id_fkey
|
||||
FOREIGN KEY (spawn_proceeding_type_id)
|
||||
REFERENCES paliad.proceeding_types(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_draft_of_fkey
|
||||
FOREIGN KEY (draft_of)
|
||||
REFERENCES paliad.deadline_rules(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. CHECK constraints on enum-style columns
|
||||
-- =============================================================================
|
||||
--
|
||||
-- combine_op: NULL (unset) or one of two values.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_combine_op_check
|
||||
CHECK (combine_op IS NULL OR combine_op IN ('max', 'min'));
|
||||
|
||||
-- priority: 4-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_priority_check
|
||||
CHECK (priority IN ('mandatory', 'recommended', 'optional', 'informational'));
|
||||
|
||||
-- lifecycle_state: 3-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_lifecycle_state_check
|
||||
CHECK (lifecycle_state IN ('draft', 'published', 'archived'));
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Indexes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_trigger_event_id_idx
|
||||
ON paliad.deadline_rules (trigger_event_id)
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_spawn_proceeding_type_id_idx
|
||||
ON paliad.deadline_rules (spawn_proceeding_type_id)
|
||||
WHERE spawn_proceeding_type_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_lifecycle_state_idx
|
||||
ON paliad.deadline_rules (lifecycle_state);
|
||||
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- t-paliad-182 down — reverses 079_deadline_rule_audit.up.sql.
|
||||
--
|
||||
-- Order: trigger → function → policy → indexes → table.
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rule_audit_trigger();
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_audit_select ON paliad.deadline_rule_audit;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_pending_export_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_by_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_rule_id_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rule_audit;
|
||||
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — audit log for the rule editor
|
||||
-- (design §2.8, §3.1 Step A.079).
|
||||
--
|
||||
-- The audit log lands BEFORE the rule editor (Slice 11) so every future
|
||||
-- write to paliad.deadline_rules is captured forever, including the
|
||||
-- Slice 2 backfill UPDATEs. Defence-in-depth: the rule-editor service
|
||||
-- writes Go-authored audit rows with semantic actions ('publish',
|
||||
-- 'archive', 'restore'); this trigger is the backstop for raw SQL.
|
||||
--
|
||||
-- Field-naming mirrors design §2.8 (`changed_by` / `changed_at` /
|
||||
-- `before_json` / `after_json` / `migration_exported`), not the
|
||||
-- audit_log shorthand used elsewhere in Paliad.
|
||||
--
|
||||
-- Schema deviations from design §2.8, documented for the head review:
|
||||
--
|
||||
-- 1. `changed_by` is nullable, not NOT NULL. Reason: the trigger reads
|
||||
-- auth.uid() which is NULL when the writer is `service_role`
|
||||
-- (migrations, server-side Go using the service key, direct DB
|
||||
-- maintenance). NOT NULL would block every Slice-2 backfill UPDATE
|
||||
-- and every migration-applied seed. The Go rule-editor service
|
||||
-- enforces non-NULL changed_by at the application layer when it
|
||||
-- writes its own audit rows.
|
||||
--
|
||||
-- 2. `action` values stored by the trigger are 'create' / 'update' /
|
||||
-- 'delete' (the raw TG_OP semantics). Go-authored audit rows can
|
||||
-- additionally store 'publish' / 'archive' / 'restore' — those are
|
||||
-- lifecycle_state flips at the SQL level and appear as 'update' in
|
||||
-- the trigger's view of the world. The Go layer writes the
|
||||
-- higher-level action *before* the UPDATE, so the human-readable
|
||||
-- action is captured even though the trigger fires a paired
|
||||
-- 'update' row. The audit UI in Slice 11 collapses paired rows.
|
||||
--
|
||||
-- Audit-reason enforcement: the trigger reads
|
||||
-- `current_setting('paliad.audit_reason', true)` (the `true` flag
|
||||
-- returns NULL when unset rather than raising). On UPDATE and DELETE
|
||||
-- the trigger requires a non-empty reason and raises EXCEPTION 'audit
|
||||
-- reason required' if missing. On INSERT the reason is optional
|
||||
-- (defaults to 'create' so seed migrations don't need to set it).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 78 → 79.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. paliad.deadline_rule_audit
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_audit (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- The rule this delta concerns. ON DELETE CASCADE: when a rule row
|
||||
-- gets hard-deleted (rare; lifecycle_state='archived' is the normal
|
||||
-- path), drop its audit chain too — the trail otherwise survives in
|
||||
-- the migration history of the table itself.
|
||||
rule_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE,
|
||||
|
||||
-- See header comment §1: nullable so trigger writes from service_role
|
||||
-- contexts (migrations, backfills) don't fail.
|
||||
changed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
|
||||
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- See header comment §2 for the trigger vs Go-layer split.
|
||||
action text NOT NULL
|
||||
CHECK (action IN (
|
||||
'create', 'update', 'delete',
|
||||
'publish', 'archive', 'restore'
|
||||
)),
|
||||
|
||||
-- Row state pre/post change. NULL on create / delete respectively.
|
||||
before_json jsonb,
|
||||
after_json jsonb,
|
||||
|
||||
-- Justification required by the trigger on UPDATE / DELETE; optional
|
||||
-- on INSERT (defaults to 'create' when paliad.audit_reason is unset
|
||||
-- so seed migrations don't need to bother).
|
||||
reason text NOT NULL,
|
||||
|
||||
-- Flips to true when the migration-export endpoint (Slice 11b) folds
|
||||
-- this delta into a checked-in .up.sql. Lets the export endpoint
|
||||
-- skip already-exported rows.
|
||||
migration_exported boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_rule_id_idx
|
||||
ON paliad.deadline_rule_audit (rule_id, changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_at_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_by_idx
|
||||
ON paliad.deadline_rule_audit (changed_by)
|
||||
WHERE changed_by IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_pending_export_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC)
|
||||
WHERE migration_exported = false;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rule_audit IS
|
||||
'Append-only audit log for paliad.deadline_rules. Written by the '
|
||||
'AFTER-trigger on the rules table (raw create/update/delete) and '
|
||||
'by the Go rule-editor service (semantic publish/archive/restore). '
|
||||
'Required reason field is the compliance hook for the rule-editor '
|
||||
'design (Q5, §4.7).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Audit trigger
|
||||
-- =============================================================================
|
||||
--
|
||||
-- SECURITY DEFINER so the trigger function runs with the table-owner's
|
||||
-- privileges and bypasses RLS on the audit table. Otherwise an
|
||||
-- authenticated user's UPDATE on a rule would fail when the trigger
|
||||
-- tried to INSERT under their RLS context.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rule_audit_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_reason text;
|
||||
v_action text;
|
||||
v_before jsonb;
|
||||
v_after jsonb;
|
||||
v_rule_id uuid;
|
||||
BEGIN
|
||||
v_reason := current_setting('paliad.audit_reason', true);
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_action := 'create';
|
||||
v_before := NULL;
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
-- INSERT is allowed without an explicit reason; seed migrations
|
||||
-- and net-new drafts default to a synthetic reason.
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
v_reason := 'create';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_action := 'update';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for UPDATE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
v_action := 'delete';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := NULL;
|
||||
v_rule_id := OLD.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for DELETE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO paliad.deadline_rule_audit
|
||||
(rule_id, changed_by, action, before_json, after_json, reason)
|
||||
VALUES
|
||||
(v_rule_id, auth.uid(), v_action, v_before, v_after, v_reason);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.deadline_rule_audit_trigger() IS
|
||||
'AFTER-trigger backstop that writes paliad.deadline_rule_audit rows '
|
||||
'for every raw INSERT / UPDATE / DELETE on paliad.deadline_rules. '
|
||||
'UPDATE / DELETE require paliad.audit_reason to be set in the '
|
||||
'session (via SET LOCAL paliad.audit_reason = ...); INSERT defaults '
|
||||
'to ''create'' so seed migrations remain ergonomic.';
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
|
||||
CREATE TRIGGER deadline_rules_audit_aiud
|
||||
AFTER INSERT OR UPDATE OR DELETE ON paliad.deadline_rules
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.deadline_rule_audit_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. RLS on the audit table
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Read: global_admin only (mirrors mig 057 pattern). Service-layer code
|
||||
-- gates `/admin/rules/{id}/audit` separately; this RLS is defence-in-
|
||||
-- depth for any future auth-context query path.
|
||||
--
|
||||
-- Write: nobody via row-level paths. The trigger function is
|
||||
-- SECURITY DEFINER so it bypasses RLS entirely. Direct INSERTs by
|
||||
-- authenticated users are denied (no INSERT policy). service_role
|
||||
-- bypasses RLS as usual.
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY deadline_rule_audit_select
|
||||
ON paliad.deadline_rule_audit FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-182 down — reverses 080_projects_instance_level.up.sql.
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS instance_level;
|
||||
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — paliad.projects.instance_level
|
||||
-- (design §2.7, §7).
|
||||
--
|
||||
-- Lets the SmartTimeline + calculator derive the effective proceeding
|
||||
-- code from (proceeding_code, instance_level) — e.g. DE_INF + 'appeal'
|
||||
-- resolves to DE_INF_OLG.
|
||||
--
|
||||
-- Nullable: NULL means "not asked / not relevant" (e.g. EP_GRANT, a
|
||||
-- non-litigation patent project). Allowed values:
|
||||
-- first — first instance (default once the picker UI lands)
|
||||
-- appeal — Berufung / EPA Beschwerde / appellate level
|
||||
-- cassation — BGH-Revision / EPA-EBA / final instance
|
||||
--
|
||||
-- No backfill in this slice. The picker UI (Slice 8) writes the column;
|
||||
-- legacy projects stay NULL and behave as if first instance via the
|
||||
-- calculator's fallback (`NULL OR 'first'` → use base proceeding code).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 79 → 80.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS instance_level text
|
||||
CHECK (instance_level IS NULL
|
||||
OR instance_level IN ('first', 'appeal', 'cassation'));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.instance_level IS
|
||||
'Procedural instance the project sits at: first | appeal | '
|
||||
'cassation. NULL = unset / not applicable. Combined with '
|
||||
'proceeding_type.code + jurisdiction by FristenrechnerService to '
|
||||
'pick the effective proceeding code (e.g. DE_INF + appeal → '
|
||||
'DE_INF_OLG). See design-fristen-phase2-2026-05-15.md §2.7, §7.';
|
||||
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- t-paliad-183 down — reverts the is_court_set flips written by
|
||||
-- 082_backfill_is_court_set.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 default (false on every
|
||||
-- row). We don't know after the fact which rows were already true
|
||||
-- before the backfill (mig 078 created the column with DEFAULT false on
|
||||
-- every existing row, so post-Slice-1 every row was false — there is
|
||||
-- no pre-existing true population to preserve). Setting back to false
|
||||
-- is therefore equivalent to "undo the backfill".
|
||||
--
|
||||
-- Audit-reason set so the trigger doesn't raise on the down-side
|
||||
-- UPDATEs either.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 082: reset is_court_set to mig 078 default (false)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false
|
||||
WHERE is_court_set = true;
|
||||
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-1 — backfill
|
||||
-- paliad.deadline_rules.is_court_set from the live runtime heuristic.
|
||||
--
|
||||
-- Heuristic source-of-truth: internal/services/fristenrechner.go
|
||||
-- isCourtDeterminedRule() — at the time of Slice 1 (commit c7fa0d6) the
|
||||
-- body is precisely:
|
||||
--
|
||||
-- primary_party = 'court'
|
||||
-- OR event_type IN ('hearing', 'decision', 'order')
|
||||
--
|
||||
-- The Slice 2 head instruction (msg 1746) suggested padding with
|
||||
-- 'name ILIKE %entscheidung% OR %urteil%'; head's clarification
|
||||
-- (msg 1750) rules that out: replicate the live code exactly. Padding
|
||||
-- would mis-flag party submissions like 'Antrag auf Kostenentscheidung'
|
||||
-- (RoP.151) and 'Stellungnahme zum Hinweisbeschluss' as court-set —
|
||||
-- they are not (the party files them; only their anchor is set by the
|
||||
-- court).
|
||||
--
|
||||
-- Audit footnote for the legal-review pass: ~8 'Zustellung…' rules
|
||||
-- (Zustellung BPatG-Entscheidung, Zustellung LG-Urteil, etc.) carry
|
||||
-- primary_party='both' + event_type='filing'. Semantically the
|
||||
-- Zustellung date IS court-set, but the live heuristic doesn't treat
|
||||
-- them as such and flagging them now would change calculator
|
||||
-- rendering without legal review. Leaving them is_court_set=false
|
||||
-- preserves current behaviour; the legal-review pass mentioned in
|
||||
-- design §2.3 ("flag them informational in a Phase 3 slice") can
|
||||
-- promote them later via a targeted UPDATE.
|
||||
--
|
||||
-- Audit-reason: set_config('paliad.audit_reason', …, true) scopes the
|
||||
-- value to golang-migrate's implicit per-file transaction. The audit
|
||||
-- trigger from mig 079 picks it up via current_setting() and writes
|
||||
-- one paliad.deadline_rule_audit row per flipped rule — the compliance
|
||||
-- trail for the backfill, persisted forever.
|
||||
--
|
||||
-- Idempotent: WHERE is_court_set = false guards re-runs against double-
|
||||
-- counting audit rows.
|
||||
--
|
||||
-- Expected delta on the production corpus (172 rules): 47 rows flipped
|
||||
-- false→true (every primary_party='court' rule also has a matching
|
||||
-- event_type in the current data — the two predicates fully overlap).
|
||||
--
|
||||
-- Tracker note: mig 081 was reserved for proceeding_types display_order
|
||||
-- verification per design §3.1; that was a no-op and not authored.
|
||||
-- Slice 1 shipped 078/079/080; Slice 2 starts at 082. golang-migrate
|
||||
-- only requires ascending order, not contiguity.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 082: is_court_set from isCourtDeterminedRule heuristic '
|
||||
|| '(primary_party=court OR event_type IN hearing/decision/order) '
|
||||
|| 'per design §2.3 / fristenrechner.go',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true
|
||||
WHERE is_court_set = false
|
||||
AND (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_set int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_set FROM paliad.deadline_rules WHERE is_court_set = true;
|
||||
RAISE NOTICE 'backfill 082: is_court_set=true on % rules', n_set;
|
||||
END $$;
|
||||
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-183 down — reverts the priority flips written by
|
||||
-- 083_backfill_priority.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 column default
|
||||
-- ('mandatory' on every row). Mig 078 created the column with that
|
||||
-- default; post-Slice-1 every row was 'mandatory' regardless of its
|
||||
-- (is_mandatory, is_optional) pair. Resetting to 'mandatory' is
|
||||
-- therefore equivalent to "undo the backfill".
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 083: reset priority to mig 078 default (mandatory)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'mandatory'
|
||||
WHERE priority <> 'mandatory';
|
||||
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-2 — backfill
|
||||
-- paliad.deadline_rules.priority from the legacy (is_mandatory,
|
||||
-- is_optional) pair per DESIGN §2.3 (NOT the inverted mapping in
|
||||
-- head's msg 1746 — head's clarification msg 1750 rules in favour of
|
||||
-- the design doc).
|
||||
--
|
||||
-- Final mapping (design §2.3 + RoP.151 / mig 068 t-paliad-157 semantic):
|
||||
--
|
||||
-- is_mandatory=true, is_optional=false → 'mandatory' (statutory must,
|
||||
-- ☑ pre-checked in
|
||||
-- save modal)
|
||||
-- is_mandatory=true, is_optional=true → 'optional' (statutorily strict
|
||||
-- ONCE IT APPLIES,
|
||||
-- but applies only
|
||||
-- if a party files —
|
||||
-- RoP.151 is the
|
||||
-- canonical case;
|
||||
-- ☐ pre-unchecked)
|
||||
-- is_mandatory=false, is_optional=true → 'recommended' (no live data, but
|
||||
-- defensive default
|
||||
-- so the CHECK
|
||||
-- constraint stays
|
||||
-- satisfied if such
|
||||
-- a row ever lands)
|
||||
-- is_mandatory=false, is_optional=false → 'recommended' (situational filings
|
||||
-- — Berufungserwiderung,
|
||||
-- Replik, Duplik,
|
||||
-- R.19 Preliminary
|
||||
-- Objection, R.116
|
||||
-- EPÜ, Anschluss-
|
||||
-- berufung, etc.
|
||||
-- Default-save with
|
||||
-- override, not
|
||||
-- 'informational'
|
||||
-- which would make
|
||||
-- them never-saveable)
|
||||
--
|
||||
-- Live-data expected delta (172 rules total, mig 078 set every row to
|
||||
-- the default 'mandatory'):
|
||||
-- T/F (153 rows) → 'mandatory' — 153 no-op UPDATEs (already correct)
|
||||
-- T/T ( 1 row) → 'optional' — 1 row flips
|
||||
-- F/F ( 18 rows) → 'recommended' — 18 rows flip
|
||||
-- F/T ( 0 rows) → 'recommended' — 0 rows (no live data)
|
||||
--
|
||||
-- The UPDATE is split into branches with explicit WHERE clauses so the
|
||||
-- audit log records each branch as a distinct backfill action (separate
|
||||
-- audit row chains by (is_mandatory, is_optional) shape). It also keeps
|
||||
-- the migration idempotent: re-running only touches rows whose priority
|
||||
-- doesn't already match the target.
|
||||
--
|
||||
-- Audit-reason cites design §2.3 — that's the persistent rationale in
|
||||
-- the paliad.deadline_rule_audit log.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 083: priority from (is_mandatory, is_optional) per design §2.3 — '
|
||||
|| 'T/T→optional (RoP.151), F/F→recommended (situational filings)',
|
||||
true);
|
||||
|
||||
-- Branch 1: T/T → 'optional' (RoP.151).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'optional'
|
||||
WHERE is_mandatory = true
|
||||
AND is_optional = true
|
||||
AND priority <> 'optional';
|
||||
|
||||
-- Branch 2: F/F → 'recommended'.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = false
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 3: F/T → 'recommended' (defensive; no live rows today).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = true
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 4: T/F → 'mandatory'. Skipped explicitly: the mig 078 column
|
||||
-- default is already 'mandatory', so every T/F row already has the
|
||||
-- correct value. A defensive UPDATE here would write 153 needless
|
||||
-- audit rows. Leave T/F untouched.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_mand int;
|
||||
n_opt int;
|
||||
n_reco int;
|
||||
n_info int;
|
||||
n_null int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE priority = 'mandatory'),
|
||||
count(*) FILTER (WHERE priority = 'optional'),
|
||||
count(*) FILTER (WHERE priority = 'recommended'),
|
||||
count(*) FILTER (WHERE priority = 'informational'),
|
||||
count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_mand, n_opt, n_reco, n_info, n_null
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 083: priority distribution — '
|
||||
'mandatory=%, optional=%, recommended=%, informational=%, NULL=%',
|
||||
n_mand, n_opt, n_reco, n_info, n_null;
|
||||
-- Hard assertion: priority is NOT NULL by schema (mig 078) and
|
||||
-- every value must lie in the CHECK enum. n_null must be 0.
|
||||
IF n_null > 0 THEN
|
||||
RAISE EXCEPTION 'backfill 083: % rows still have priority IS NULL — '
|
||||
'schema violation', n_null;
|
||||
END IF;
|
||||
END $$;
|
||||
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-183 down — reverts the condition_expr translations written
|
||||
-- by 084_backfill_condition_expr.up.sql. Mig 078 created the column
|
||||
-- with NULL on every row; resetting non-NULL values to NULL undoes the
|
||||
-- backfill cleanly (condition_flag is the source of truth for the
|
||||
-- legacy code path and stays untouched).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 084: reset condition_expr to mig 078 default (NULL)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_expr = NULL
|
||||
WHERE condition_expr IS NOT NULL;
|
||||
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-3 — backfill
|
||||
-- paliad.deadline_rules.condition_expr from the legacy
|
||||
-- condition_flag text[] column per DESIGN §2.4 long form (NOT the
|
||||
-- short {"and":[...]} form sketched in head's msg 1746 — head's
|
||||
-- clarification msg 1750 rules in favour of the design doc).
|
||||
--
|
||||
-- Mapping (design §2.4):
|
||||
--
|
||||
-- condition_flag IS NULL OR array_length(_, 1) = 0
|
||||
-- → condition_expr stays NULL (unconditional, every rule renders)
|
||||
--
|
||||
-- array_length = 1, e.g. ['with_ccr']
|
||||
-- → condition_expr = jsonb '{"flag": "with_ccr"}'
|
||||
-- (single flag unwrapped — saves a layer of nesting that
|
||||
-- parses as the same boolean expression)
|
||||
--
|
||||
-- array_length >= 2, e.g. ['with_ccr', 'with_amend']
|
||||
-- → condition_expr = jsonb '{"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}'
|
||||
-- (long form — same shape the rule editor will emit for OR /
|
||||
-- NOT in future rules so the calculator's parser is uniform)
|
||||
--
|
||||
-- Why long form on >=2: the calculator (Slice 4) reads
|
||||
-- {"op":"<and|or|not>","args":[...]} as the canonical boolean node and
|
||||
-- {"flag":"<name>"} as the leaf. Single-flag unwrap is a parse-time
|
||||
-- shortcut equivalent to a 1-arg AND. The short {"and":[...]} form in
|
||||
-- msg 1746 would require a per-key parser that doesn't generalise to
|
||||
-- OR / NOT. Design §2.4 long form is the load-bearing decision.
|
||||
--
|
||||
-- Live-data expected delta (172 rules total):
|
||||
--
|
||||
-- ['with_ccr'] × 5 rows → {"flag":"with_ccr"}
|
||||
-- ['with_amend'] × 4 rows → {"flag":"with_amend"}
|
||||
-- ['with_cci'] × 4 rows → {"flag":"with_cci"}
|
||||
-- ['with_ccr', 'with_amend'] × 4 rows → {"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}
|
||||
-- NULL or {} × 155 rows → stays NULL
|
||||
--
|
||||
-- Total touched: 17 rows.
|
||||
--
|
||||
-- Idempotent: WHERE condition_expr IS NULL guards re-runs against
|
||||
-- double-writing audit rows for already-translated rules.
|
||||
--
|
||||
-- jsonb construction: jsonb_build_object + jsonb_agg + a CASE on
|
||||
-- array_length keeps the long-form / unwrapped-flag split inline in
|
||||
-- one UPDATE. Per-flag jsonb leaf is built by a LATERAL unnest over
|
||||
-- the flag array so the args[] order matches the source array.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 084: condition_expr from condition_flag text[] per design §2.4 — '
|
||||
|| 'single flag unwrapped, multi flag long form {op:and, args:[...]}',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = sub.expr
|
||||
FROM (
|
||||
SELECT dr_inner.id AS rule_id,
|
||||
CASE
|
||||
-- Single flag: unwrapped leaf.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) = 1
|
||||
THEN jsonb_build_object('flag', dr_inner.condition_flag[1])
|
||||
|
||||
-- >=2 flags: long-form AND with args[] preserving order.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) >= 2
|
||||
THEN jsonb_build_object(
|
||||
'op', 'and',
|
||||
'args', (
|
||||
SELECT jsonb_agg(jsonb_build_object('flag', f) ORDER BY ord)
|
||||
FROM unnest(dr_inner.condition_flag) WITH ORDINALITY AS u(f, ord)
|
||||
)
|
||||
)
|
||||
|
||||
-- Empty array (array_length=0) or NULL: leave NULL.
|
||||
ELSE NULL
|
||||
END AS expr
|
||||
FROM paliad.deadline_rules dr_inner
|
||||
WHERE dr_inner.condition_flag IS NOT NULL
|
||||
AND array_length(dr_inner.condition_flag, 1) > 0
|
||||
) AS sub
|
||||
WHERE dr.id = sub.rule_id
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_with_flag int;
|
||||
n_with_expr int;
|
||||
n_with_both int;
|
||||
BEGIN
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0),
|
||||
count(*) FILTER (WHERE condition_expr IS NOT NULL),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NOT NULL)
|
||||
INTO n_total, n_with_flag, n_with_expr, n_with_both
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 084: total=%, with_condition_flag=%, with_condition_expr=%, both=%',
|
||||
n_total, n_with_flag, n_with_expr, n_with_both;
|
||||
-- Hard assertion: every rule with a non-empty condition_flag now
|
||||
-- has a non-NULL condition_expr (the inverse of the legacy column).
|
||||
IF n_with_flag <> n_with_both THEN
|
||||
RAISE EXCEPTION 'backfill 084: % rules carry condition_flag but no condition_expr — '
|
||||
'translation incomplete',
|
||||
n_with_flag - n_with_both;
|
||||
END IF;
|
||||
END $$;
|
||||
17
internal/db/migrations/085_pipeline_c_data_move.down.sql
Normal file
17
internal/db/migrations/085_pipeline_c_data_move.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-184 down — reverts the Pipeline-C data-move from
|
||||
-- 085_pipeline_c_data_move.up.sql. Deletes every paliad.deadline_rules
|
||||
-- row carrying a non-NULL trigger_event_id (those are exactly the rows
|
||||
-- the up-migration created — before mig 085 no Pipeline-A rule ever
|
||||
-- carried trigger_event_id, and Slice 9 hasn't dropped the source
|
||||
-- table yet so the rows can be regenerated).
|
||||
--
|
||||
-- Audit-reason set so the mig 079 trigger captures the rollback
|
||||
-- rationale and doesn't raise on DELETE.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 085: delete Pipeline-C unified rows (source preserved in event_deadlines)',
|
||||
true);
|
||||
|
||||
DELETE FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
184
internal/db/migrations/085_pipeline_c_data_move.up.sql
Normal file
184
internal/db/migrations/085_pipeline_c_data_move.up.sql
Normal file
@@ -0,0 +1,184 @@
|
||||
-- t-paliad-184 / Fristen Phase 3 Slice 3 Step C — data-move 77 rows
|
||||
-- from paliad.event_deadlines → paliad.deadline_rules so the Phase-3
|
||||
-- unified backend can serve both pipelines.
|
||||
--
|
||||
-- Source rows are PRESERVED (mig 086's read-only trigger blocks
|
||||
-- further writes; mig 090 in Slice 9 drops the table once every
|
||||
-- caller has cut over). The data-move is one-way; legacy callers
|
||||
-- continue reading event_deadlines via plain SELECTs until Slice 9.
|
||||
--
|
||||
-- Mapping (per design §3.C):
|
||||
--
|
||||
-- paliad.event_deadlines → paliad.deadline_rules
|
||||
-- ------------------------- ----------------------
|
||||
-- id (new gen_random_uuid())
|
||||
-- trigger_event_id trigger_event_id (Phase 3 column from mig 078)
|
||||
-- title (EN, NOT NULL) name_en (NOT NULL)
|
||||
-- title_de (DE, NOT NULL DEFAULT '') name (NOT NULL — every row has non-empty title_de in live data)
|
||||
-- duration_value duration_value
|
||||
-- duration_unit (days/weeks/months/working_days) duration_unit
|
||||
-- timing (before/after) timing
|
||||
-- notes (DE) deadline_notes (DE)
|
||||
-- notes_en (EN, nullable) deadline_notes_en (EN, nullable)
|
||||
-- alt_duration_value alt_duration_value
|
||||
-- alt_duration_unit alt_duration_unit
|
||||
-- combine_op (max/min, nullable) combine_op (Phase 3 column from mig 078)
|
||||
-- legal_source legal_source
|
||||
-- is_active is_active
|
||||
-- created_at published_at (preserves chronology — lifecycle_state='published' on every row)
|
||||
-- updated_at = now() (this is the publish event)
|
||||
--
|
||||
-- Pipeline-A-only fields default:
|
||||
-- proceeding_type_id = NULL (event-rooted, no proceeding)
|
||||
-- parent_id = NULL (Pipeline C is flat, no chain)
|
||||
-- spawn_proceeding_type_id = NULL (no spawn)
|
||||
-- code = NULL (no local rule code in Pipeline C)
|
||||
-- primary_party = NULL (event_deadlines has no party column)
|
||||
-- event_type = NULL (filing/hearing/decision is a
|
||||
-- Pipeline-A category)
|
||||
-- is_court_set = false (no court-set Pipeline-C rules
|
||||
-- in the corpus; legal-review
|
||||
-- pass can flip Zustellung-* if
|
||||
-- those ever land here)
|
||||
-- is_spawn = false
|
||||
-- is_mandatory = true (Pipeline C has no mandatory
|
||||
-- bool; design §2.3 says default
|
||||
-- 'mandatory' is correct for
|
||||
-- statutory event-driven deadlines)
|
||||
-- is_optional = false
|
||||
-- priority = 'mandatory'
|
||||
-- condition_expr = NULL (Pipeline C has no flag gating)
|
||||
-- condition_flag = NULL
|
||||
-- sequence_order = 1000 + event_deadlines.id
|
||||
-- (large offset so Pipeline-C
|
||||
-- rows sort AFTER any future
|
||||
-- hand-edited Pipeline-A
|
||||
-- sequence_orders without
|
||||
-- colliding with the
|
||||
-- existing 0–171 range)
|
||||
-- lifecycle_state = 'published'
|
||||
--
|
||||
-- Idempotency: WHERE NOT EXISTS guard on (trigger_event_id, name) skips
|
||||
-- rows that already exist in deadline_rules. Re-running the migration
|
||||
-- is a no-op.
|
||||
--
|
||||
-- Hard assertion at end: COUNT(deadline_rules WHERE trigger_event_id
|
||||
-- IS NOT NULL) == COUNT(event_deadlines WHERE is_active = true) (77 = 77).
|
||||
-- RAISE EXCEPTION on mismatch so a partial move fails the migration
|
||||
-- loudly instead of poisoning Slice 4.
|
||||
--
|
||||
-- Audit-reason cites design §3.C — the rationale persists in the
|
||||
-- paliad.deadline_rule_audit log forever via the mig 079 trigger.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'pipeline C migration 085: data-move event_deadlines → deadline_rules per design §3.C — '
|
||||
|| 'preserves source rows; mig 086 wraps the source table read-only',
|
||||
true);
|
||||
|
||||
INSERT INTO paliad.deadline_rules (
|
||||
id,
|
||||
proceeding_type_id,
|
||||
parent_id,
|
||||
trigger_event_id,
|
||||
spawn_proceeding_type_id,
|
||||
code,
|
||||
name,
|
||||
name_en,
|
||||
primary_party,
|
||||
event_type,
|
||||
is_mandatory,
|
||||
is_optional,
|
||||
is_court_set,
|
||||
is_spawn,
|
||||
duration_value,
|
||||
duration_unit,
|
||||
timing,
|
||||
alt_duration_value,
|
||||
alt_duration_unit,
|
||||
combine_op,
|
||||
rule_code,
|
||||
deadline_notes,
|
||||
deadline_notes_en,
|
||||
legal_source,
|
||||
condition_expr,
|
||||
condition_flag,
|
||||
sequence_order,
|
||||
is_active,
|
||||
priority,
|
||||
lifecycle_state,
|
||||
draft_of,
|
||||
published_at,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
gen_random_uuid() AS id,
|
||||
NULL::integer AS proceeding_type_id,
|
||||
NULL::uuid AS parent_id,
|
||||
ed.trigger_event_id AS trigger_event_id,
|
||||
NULL::integer AS spawn_proceeding_type_id,
|
||||
NULL::text AS code,
|
||||
ed.title_de AS name,
|
||||
ed.title AS name_en,
|
||||
NULL::text AS primary_party,
|
||||
NULL::text AS event_type,
|
||||
true AS is_mandatory,
|
||||
false AS is_optional,
|
||||
false AS is_court_set,
|
||||
false AS is_spawn,
|
||||
ed.duration_value AS duration_value,
|
||||
ed.duration_unit AS duration_unit,
|
||||
ed.timing AS timing,
|
||||
ed.alt_duration_value AS alt_duration_value,
|
||||
ed.alt_duration_unit AS alt_duration_unit,
|
||||
ed.combine_op AS combine_op,
|
||||
NULL::text AS rule_code,
|
||||
NULLIF(ed.notes, '') AS deadline_notes,
|
||||
ed.notes_en AS deadline_notes_en,
|
||||
ed.legal_source AS legal_source,
|
||||
NULL::jsonb AS condition_expr,
|
||||
NULL::text[] AS condition_flag,
|
||||
(1000 + ed.id)::integer AS sequence_order,
|
||||
ed.is_active AS is_active,
|
||||
'mandatory' AS priority,
|
||||
'published' AS lifecycle_state,
|
||||
NULL::uuid AS draft_of,
|
||||
ed.created_at AS published_at,
|
||||
ed.created_at AS created_at,
|
||||
now() AS updated_at
|
||||
FROM paliad.event_deadlines ed
|
||||
WHERE ed.is_active = true
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM paliad.deadline_rules dr
|
||||
WHERE dr.trigger_event_id = ed.trigger_event_id
|
||||
AND dr.name = ed.title_de
|
||||
);
|
||||
|
||||
-- Hard assertion: every active event_deadlines row must have a matching
|
||||
-- deadline_rules row by (trigger_event_id, name). If the counts diverge,
|
||||
-- something in the WHERE NOT EXISTS clause (likely a stale duplicate)
|
||||
-- prevented a real insert — fail the migration rather than ship a
|
||||
-- partial Pipeline-C corpus.
|
||||
DO $$
|
||||
DECLARE
|
||||
n_source int;
|
||||
n_target int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_source
|
||||
FROM paliad.event_deadlines WHERE is_active = true;
|
||||
|
||||
SELECT count(*) INTO n_target
|
||||
FROM paliad.deadline_rules WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
RAISE NOTICE 'mig 085: event_deadlines(active)=%, deadline_rules(trigger_event_id IS NOT NULL)=%',
|
||||
n_source, n_target;
|
||||
|
||||
IF n_target <> n_source THEN
|
||||
RAISE EXCEPTION 'mig 085: data-move incomplete — expected % unified rows, got %. '
|
||||
'Investigate event_deadlines (trigger_event_id, title_de) duplicates '
|
||||
'OR re-applied migration on dirtied target.',
|
||||
n_source, n_target;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- t-paliad-184 down — reverts the read-only wrapper from
|
||||
-- 086_event_deadlines_readonly.up.sql. Order: trigger → function.
|
||||
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
DROP FUNCTION IF EXISTS paliad.event_deadlines_readonly_trigger();
|
||||
58
internal/db/migrations/086_event_deadlines_readonly.up.sql
Normal file
58
internal/db/migrations/086_event_deadlines_readonly.up.sql
Normal file
@@ -0,0 +1,58 @@
|
||||
-- t-paliad-184 / Fristen Phase 3 Slice 3 — wrap paliad.event_deadlines
|
||||
-- in a read-only trigger so nobody can edit either side mid-cutover.
|
||||
--
|
||||
-- Slice 3 just moved 77 rows from event_deadlines → deadline_rules (mig
|
||||
-- 085). Until Slice 4 cuts every reader over and Slice 9 drops the
|
||||
-- legacy table, event_deadlines stays in place as the audit anchor and
|
||||
-- (briefly) a compat-read source. We must not let any writer mutate it
|
||||
-- behind the unified backend's back — diverging the two sides would
|
||||
-- silently regress "Was kommt nach…" parity.
|
||||
--
|
||||
-- The trigger fires AFTER INSERT / UPDATE / DELETE and raises an
|
||||
-- EXCEPTION with a clear message pointing the writer at the unified
|
||||
-- table. SELECT is unaffected — the legacy EventDeadlineService's
|
||||
-- pre-Slice-3 SELECT path keeps working until Slice 4 swaps it.
|
||||
--
|
||||
-- The supabase service_role bypasses RLS but NOT triggers — so
|
||||
-- direct DB maintenance (psql, migration scripts) is also blocked.
|
||||
-- This is intentional: any further edit to event_deadlines is a
|
||||
-- mistake until Slice 9 drops the table.
|
||||
--
|
||||
-- Removed by Slice 9 (Step E, mig ~090) when paliad.event_deadlines is
|
||||
-- dropped. Until then the trigger is the only thing keeping the two
|
||||
-- tables in sync.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.event_deadlines_readonly_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $$
|
||||
BEGIN
|
||||
RAISE EXCEPTION
|
||||
'paliad.event_deadlines is read-only after Phase 3 Slice 3 — '
|
||||
'writes must go through paliad.deadline_rules (Pipeline C is '
|
||||
'unified; the source table is preserved as an audit anchor '
|
||||
'until Slice 9 drops it). Operation: %', TG_OP;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.event_deadlines_readonly_trigger() IS
|
||||
'BEFORE INSERT/UPDATE/DELETE trigger function that raises on any '
|
||||
'write to paliad.event_deadlines. Lives only between Slice 3 and '
|
||||
'Slice 9 — removed when the source table is dropped.';
|
||||
|
||||
-- BEFORE-trigger so the write is blocked before any row image is
|
||||
-- captured. AFTER would still raise but the surrounding tx would
|
||||
-- have already taken row locks.
|
||||
DROP TRIGGER IF EXISTS event_deadlines_readonly ON paliad.event_deadlines;
|
||||
|
||||
CREATE TRIGGER event_deadlines_readonly
|
||||
BEFORE INSERT OR UPDATE OR DELETE ON paliad.event_deadlines
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.event_deadlines_readonly_trigger();
|
||||
|
||||
-- Defensive INSERT-row-level trigger covers the COPY path too; same
|
||||
-- function, identical behaviour.
|
||||
|
||||
COMMENT ON TRIGGER event_deadlines_readonly ON paliad.event_deadlines IS
|
||||
'Phase 3 Slice 3 read-only wrapper. Blocks every INSERT/UPDATE/DELETE '
|
||||
'until Slice 9 drops the table. SELECT unaffected.';
|
||||
@@ -171,6 +171,16 @@ type Project struct {
|
||||
// sibling under the same patent (§4.4 of the design doc).
|
||||
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
|
||||
|
||||
// InstanceLevel is the procedural instance the project sits at:
|
||||
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
|
||||
// proceeding code + jurisdiction by FristenrechnerService to pick
|
||||
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
|
||||
// NULL = unset / not applicable; the calculator treats NULL as
|
||||
// 'first'. Backfill happens via the project-detail picker UI
|
||||
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
|
||||
// service rewrite (mig 080, t-paliad-182).
|
||||
InstanceLevel *string `db:"instance_level" json:"instance_level,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
@@ -500,6 +510,100 @@ type DeadlineRule struct {
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Populated by Slice 2 backfill; readers are compat-mode (read
|
||||
// both shapes) until Slice 4 cuts the calculator over and Slice 9
|
||||
// drops the legacy columns above (IsMandatory, IsOptional,
|
||||
// ConditionFlag, ConditionRuleID).
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||
// the Go rule-editor service (semantic publish / archive / restore).
|
||||
// See migration 079 and design-fristen-phase2-2026-05-15.md §2.8.
|
||||
type DeadlineRuleAudit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id" json:"rule_id"`
|
||||
ChangedBy *uuid.UUID `db:"changed_by" json:"changed_by,omitempty"`
|
||||
ChangedAt time.Time `db:"changed_at" json:"changed_at"`
|
||||
|
||||
// Action is one of: create | update | delete (trigger-written) |
|
||||
// publish | archive | restore (Go-written by the rule editor).
|
||||
Action string `db:"action" json:"action"`
|
||||
|
||||
// BeforeJSON is the row state pre-change (NULL on 'create').
|
||||
// AfterJSON is the row state post-change (NULL on 'delete').
|
||||
BeforeJSON NullableJSON `db:"before_json" json:"before_json,omitempty"`
|
||||
AfterJSON NullableJSON `db:"after_json" json:"after_json,omitempty"`
|
||||
|
||||
// Reason is required on update / delete (the trigger raises if
|
||||
// paliad.audit_reason is unset). On create the trigger defaults
|
||||
// to 'create' so seed migrations don't need to bother.
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
|
||||
// MigrationExported flips to true once the Slice 11b export
|
||||
// endpoint folds this delta into a checked-in .up.sql.
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
|
||||
@@ -21,12 +21,25 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
}
|
||||
|
||||
// ruleColumns lists every column scanned into models.DeadlineRule.
|
||||
//
|
||||
// Compat-mode (t-paliad-182 Phase 3 Slice 1): the SELECT reads BOTH
|
||||
// the legacy shape (is_mandatory, is_optional, condition_flag,
|
||||
// condition_rule_id) and the unified Phase 3 shape (trigger_event_id,
|
||||
// spawn_proceeding_type_id, combine_op, condition_expr, priority,
|
||||
// is_court_set, lifecycle_state, draft_of, published_at). Existing
|
||||
// callers stay on the legacy fields; the new fields are NULL or carry
|
||||
// their migration default until Slice 2 backfills them. Slice 4 cuts
|
||||
// the calculator over to the new fields, Slice 9 drops the legacy
|
||||
// columns.
|
||||
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type, is_mandatory, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
|
||||
created_at, updated_at`
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
@@ -198,6 +211,30 @@ func (s *DeadlineRuleService) GetByIDs(ctx context.Context, ids []uuid.UUID) ([]
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ListByTriggerEvent returns active rules scoped to a single trigger
|
||||
// event — the Pipeline-C surface added by Phase 3 Slice 3 (mig 085).
|
||||
// These rules carry proceeding_type_id IS NULL (event-rooted) and have
|
||||
// no parent_id chain.
|
||||
//
|
||||
// Distinct from List: List filters by proceeding_type_id and runs
|
||||
// hydrateConceptDefaultEventTypes (which assumes a proceeding-type FK).
|
||||
// Pipeline-C rules don't have that FK, so hydration is skipped here.
|
||||
//
|
||||
// Order by sequence_order so the data-move's (1000 + ed.id) offset
|
||||
// preserves the original event_deadlines.id ordering.
|
||||
func (s *DeadlineRuleService) ListByTriggerEvent(ctx context.Context, triggerEventID int64) ([]models.DeadlineRule, error) {
|
||||
var rules []models.DeadlineRule
|
||||
if err := s.db.SelectContext(ctx, &rules,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE trigger_event_id = $1
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order`, triggerEventID); err != nil {
|
||||
return nil, fmt.Errorf("list deadline rules by trigger_event_id=%d: %w", triggerEventID, err)
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// ListProceedingTypes returns active proceeding types ordered by sort_order.
|
||||
func (s *DeadlineRuleService) ListProceedingTypes(ctx context.Context) ([]models.ProceedingType, error) {
|
||||
var types []models.ProceedingType
|
||||
|
||||
384
internal/services/deadline_rule_service_test.go
Normal file
384
internal/services/deadline_rule_service_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3
|
||||
// Slice 1 (mig 078–080, t-paliad-182) additive-schema landing.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id,
|
||||
// combine_op, condition_expr, priority, is_court_set,
|
||||
// lifecycle_state, draft_of, published_at) is present on
|
||||
// paliad.deadline_rules after migrations apply and scans cleanly
|
||||
// into models.DeadlineRule.
|
||||
//
|
||||
// 2. The default migration values land: priority='mandatory',
|
||||
// is_court_set=false, lifecycle_state='published' on every pre-
|
||||
// Slice-1 row. New rows default the same way.
|
||||
//
|
||||
// 3. The audit trigger fires on UPDATE — exactly one
|
||||
// paliad.deadline_rule_audit row is written for an UPDATE that
|
||||
// supplies a reason via SET LOCAL paliad.audit_reason.
|
||||
//
|
||||
// 4. The audit trigger raises when paliad.audit_reason is unset on
|
||||
// UPDATE — Slice 2 backfills MUST set the reason or they fail
|
||||
// loudly.
|
||||
//
|
||||
// 5. paliad.projects.instance_level (mig 080) accepts NULL and the
|
||||
// three CHECK-allowed values, and rejects anything else.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestDeadlineRuleService_UnifiedColumns_CompatRead(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()
|
||||
svc := NewDeadlineRuleService(pool)
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. SELECT every column via the service's ruleColumns list. The list
|
||||
// must end the test green even though it now includes the Phase 3
|
||||
// columns; if a scan error pops up we know a column name or Go
|
||||
// type slipped.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
rules, err := svc.List(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Fatal("no rules returned; seed-data missing?")
|
||||
}
|
||||
|
||||
// 2. Every row scans cleanly. Priority + is_court_set values depend on
|
||||
// whether Slice 2 (mig 082–084) has applied: pre-Slice-2 they carry
|
||||
// the mig 078 defaults (priority='mandatory', is_court_set=false);
|
||||
// post-Slice-2 they carry the backfilled values per design §2.3.
|
||||
// LifecycleState is set by mig 078 to 'published' for every row and
|
||||
// is unaffected by Slice 2.
|
||||
allowedPriorities := map[string]bool{
|
||||
"mandatory": true, "recommended": true, "optional": true, "informational": true,
|
||||
}
|
||||
for _, r := range rules {
|
||||
if !allowedPriorities[r.Priority] {
|
||||
t.Errorf("rule %s: priority=%q not in enum", r.ID, r.Priority)
|
||||
}
|
||||
if r.LifecycleState != "published" {
|
||||
t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx
|
||||
// so SET LOCAL is scoped to this test.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Pick any existing rule; we'll UPDATE its updated_at field with a
|
||||
// no-op-equivalent change (twice — once with reason, once without).
|
||||
target := rules[0]
|
||||
|
||||
// Count the audit rows for this rule before we touch it.
|
||||
var beforeCount int
|
||||
if err := pool.GetContext(ctx, &beforeCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows pre-update: %v", err)
|
||||
}
|
||||
|
||||
// 3a. UPDATE WITH reason set — should succeed and write one audit row.
|
||||
tx, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("set audit reason: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("update with reason: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit update-with-reason tx: %v", err)
|
||||
}
|
||||
|
||||
var afterCount int
|
||||
if err := pool.GetContext(ctx, &afterCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows post-update: %v", err)
|
||||
}
|
||||
if afterCount != beforeCount+1 {
|
||||
t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount)
|
||||
}
|
||||
|
||||
// Look up the audit row we just wrote: latest by changed_at, action='update'.
|
||||
var (
|
||||
auditAction string
|
||||
auditReason string
|
||||
auditBefore json.RawMessage
|
||||
auditAfter json.RawMessage
|
||||
)
|
||||
if err := pool.QueryRowxContext(ctx,
|
||||
`SELECT action, reason, before_json, after_json
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE rule_id = $1
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil {
|
||||
t.Fatalf("read latest audit row: %v", err)
|
||||
}
|
||||
if auditAction != "update" {
|
||||
t.Errorf("audit action=%q, want 'update'", auditAction)
|
||||
}
|
||||
if auditReason != "test: compat-read audit smoke" {
|
||||
t.Errorf("audit reason=%q, want the set_config value", auditReason)
|
||||
}
|
||||
if len(auditBefore) == 0 || len(auditAfter) == 0 {
|
||||
t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter)
|
||||
}
|
||||
|
||||
// 4. UPDATE WITHOUT reason — trigger must raise.
|
||||
tx2, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx2: %v", err)
|
||||
}
|
||||
_, err = tx2.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID)
|
||||
tx2.Rollback()
|
||||
if err == nil {
|
||||
t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 5. paliad.projects.instance_level CHECK.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'instance-level-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level)
|
||||
VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`,
|
||||
projectID, userID); err != nil {
|
||||
t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err)
|
||||
}
|
||||
|
||||
// Update to each allowed value should succeed; bogus value must fail.
|
||||
for _, lvl := range []string{"first", "cassation", "appeal"} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil {
|
||||
t.Errorf("update instance_level=%q: %v", lvl, err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil {
|
||||
t.Error("instance_level='final' should violate CHECK constraint, but succeeded")
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil {
|
||||
t.Errorf("NULL instance_level should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeadlineRuleService_BackfillIntegrity exercises the Phase 3 Slice 2
|
||||
// (mig 082–084, t-paliad-183) backfills against the live corpus.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. is_court_set (mig 082): every rule with primary_party='court' OR
|
||||
// event_type IN ('hearing','decision','order') is true; every other
|
||||
// rule is false. Replicates isCourtDeterminedRule() exactly.
|
||||
//
|
||||
// 2. priority (mig 083): zero rules with NULL priority (CHECK guards
|
||||
// the schema, this is belt-and-braces). The four mapping branches
|
||||
// hold per design §2.3 — T/F→'mandatory', T/T→'optional',
|
||||
// F/T→'recommended', F/F→'recommended'.
|
||||
//
|
||||
// 3. condition_expr (mig 084): every rule with a non-empty
|
||||
// condition_flag has a non-NULL condition_expr; every rule with
|
||||
// NULL/empty condition_flag has NULL condition_expr. Single-flag
|
||||
// rules carry {"flag":"<name>"} (unwrapped); multi-flag rules
|
||||
// carry {"op":"and","args":[{"flag":"<a>"},...]} long form.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestDeadlineRuleService_BackfillIntegrity(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()
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. is_court_set matches the live heuristic exactly.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var mismatchCourt int
|
||||
if err := pool.GetContext(ctx, &mismatchCourt, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_court_set <> (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
)`); err != nil {
|
||||
t.Fatalf("count court-mismatch rows: %v", err)
|
||||
}
|
||||
if mismatchCourt != 0 {
|
||||
t.Errorf("is_court_set diverges from heuristic on %d rules (mig 082 incomplete)", mismatchCourt)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. priority backfill matches design §2.3.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var nullPriority int
|
||||
if err := pool.GetContext(ctx, &nullPriority,
|
||||
`SELECT count(*) FROM paliad.deadline_rules WHERE priority IS NULL`); err != nil {
|
||||
t.Fatalf("count NULL priority rows: %v", err)
|
||||
}
|
||||
if nullPriority != 0 {
|
||||
t.Errorf("found %d rules with NULL priority — mig 083 incomplete or CHECK bypassed", nullPriority)
|
||||
}
|
||||
|
||||
type prioRow struct {
|
||||
IsMandatory bool `db:"is_mandatory"`
|
||||
IsOptional bool `db:"is_optional"`
|
||||
Priority string `db:"priority"`
|
||||
N int `db:"n"`
|
||||
}
|
||||
var prioBuckets []prioRow
|
||||
if err := pool.SelectContext(ctx, &prioBuckets, `
|
||||
SELECT is_mandatory, is_optional, priority, count(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
GROUP BY is_mandatory, is_optional, priority
|
||||
ORDER BY is_mandatory, is_optional, priority`); err != nil {
|
||||
t.Fatalf("bucket priorities: %v", err)
|
||||
}
|
||||
expectedPriority := func(isMand, isOpt bool) string {
|
||||
switch {
|
||||
case isMand && !isOpt:
|
||||
return "mandatory"
|
||||
case isMand && isOpt:
|
||||
return "optional"
|
||||
default: // F/T and F/F both map to 'recommended' per design §2.3.
|
||||
return "recommended"
|
||||
}
|
||||
}
|
||||
for _, row := range prioBuckets {
|
||||
want := expectedPriority(row.IsMandatory, row.IsOptional)
|
||||
if row.Priority != want {
|
||||
t.Errorf("(is_mandatory=%v, is_optional=%v) → priority=%q on %d rules, want %q",
|
||||
row.IsMandatory, row.IsOptional, row.Priority, row.N, want)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. condition_expr backfill matches design §2.4.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Every non-empty condition_flag has a non-NULL condition_expr.
|
||||
var orphans int
|
||||
if err := pool.GetContext(ctx, &orphans, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE condition_flag IS NOT NULL
|
||||
AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NULL`); err != nil {
|
||||
t.Fatalf("count condition_flag orphans: %v", err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Errorf("%d rules carry condition_flag but no condition_expr — mig 084 incomplete", orphans)
|
||||
}
|
||||
|
||||
// Every NULL/empty condition_flag has NULL condition_expr (no spurious writes).
|
||||
var spurious int
|
||||
if err := pool.GetContext(ctx, &spurious, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE (condition_flag IS NULL OR array_length(condition_flag, 1) IS NULL)
|
||||
AND condition_expr IS NOT NULL`); err != nil {
|
||||
t.Fatalf("count condition_expr spurious: %v", err)
|
||||
}
|
||||
if spurious != 0 {
|
||||
t.Errorf("%d rules carry condition_expr without condition_flag — mig 084 over-wrote", spurious)
|
||||
}
|
||||
|
||||
// Single-flag shape: condition_expr = {"flag":"<name>"} matches
|
||||
// condition_flag[1]. Use jsonb -> to extract the flag scalar.
|
||||
var singleMismatch int
|
||||
if err := pool.GetContext(ctx, &singleMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) = 1
|
||||
AND condition_expr ->> 'flag' IS DISTINCT FROM condition_flag[1]`); err != nil {
|
||||
t.Fatalf("count single-flag mismatch: %v", err)
|
||||
}
|
||||
if singleMismatch != 0 {
|
||||
t.Errorf("%d single-flag rules have condition_expr.flag ≠ condition_flag[1]", singleMismatch)
|
||||
}
|
||||
|
||||
// Multi-flag shape: condition_expr.op='and', args length = flag count,
|
||||
// each args[i].flag = condition_flag[i+1] (1-indexed).
|
||||
var multiMismatch int
|
||||
if err := pool.GetContext(ctx, &multiMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) >= 2
|
||||
AND (
|
||||
condition_expr ->> 'op' IS DISTINCT FROM 'and'
|
||||
OR jsonb_array_length(condition_expr -> 'args') IS DISTINCT FROM array_length(condition_flag, 1)
|
||||
)`); err != nil {
|
||||
t.Fatalf("count multi-flag mismatch: %v", err)
|
||||
}
|
||||
if multiMismatch != 0 {
|
||||
t.Errorf("%d multi-flag rules have malformed condition_expr (op/args shape)", multiMismatch)
|
||||
}
|
||||
}
|
||||
@@ -12,18 +12,40 @@ import (
|
||||
|
||||
// EventDeadlineService backs the "Was kommt nach…" Fristenrechner mode:
|
||||
// given a trigger event + date, return all deadlines that flow from it
|
||||
// with their computed due dates. Mirrors youpc.org's deadline-calc shape
|
||||
// (event-driven), distinct from the proceeding-tree-driven Fristenrechner.
|
||||
// with their computed due dates. Mirrors youpc.org's deadline-calc
|
||||
// shape (event-driven).
|
||||
//
|
||||
// Phase 3 Slice 3 (t-paliad-184) refactor: the math + rule SELECT moved
|
||||
// into FristenrechnerService.calculateByTriggerEvent (which reads from
|
||||
// the unified paliad.deadline_rules backed by mig 085's data-move).
|
||||
// EventDeadlineService.Calculate now delegates and wraps the unified
|
||||
// response in the legacy CalculateResponse shape (trigger metadata +
|
||||
// per-deadline rule_codes from event_deadline_rule_codes). The public
|
||||
// signature stays unchanged so /api/tools/event-deadlines callers see
|
||||
// no diff. The legacy applyDuration / addWorkingDays helpers stay on
|
||||
// this service for the unit tests that exercise them directly; Slice 4
|
||||
// will collapse those into the unified helper.
|
||||
type EventDeadlineService struct {
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
db *sqlx.DB
|
||||
calc *DeadlineCalculator
|
||||
holidays *HolidayService
|
||||
courts *CourtService
|
||||
fristenrechner *FristenrechnerService
|
||||
}
|
||||
|
||||
// NewEventDeadlineService wires the service to its dependencies.
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService) *EventDeadlineService {
|
||||
return &EventDeadlineService{db: db, calc: calc, holidays: holidays, courts: courts}
|
||||
// NewEventDeadlineService wires the service to its dependencies. The
|
||||
// fristenrechner is the Phase 3 delegate target — pre-Slice-3 wiring
|
||||
// can pass nil there and the legacy SELECT path is still used at
|
||||
// runtime via the (currently unreachable) fallback below; today every
|
||||
// caller supplies it.
|
||||
func NewEventDeadlineService(db *sqlx.DB, calc *DeadlineCalculator, holidays *HolidayService, courts *CourtService, fristenrechner *FristenrechnerService) *EventDeadlineService {
|
||||
return &EventDeadlineService{
|
||||
db: db,
|
||||
calc: calc,
|
||||
holidays: holidays,
|
||||
courts: courts,
|
||||
fristenrechner: fristenrechner,
|
||||
}
|
||||
}
|
||||
|
||||
// TriggerEventSummary is the shape returned to the picker UI: lightweight
|
||||
@@ -80,28 +102,28 @@ type CalculateResponse struct {
|
||||
Deadlines []EventDeadlineResult `json:"deadlines"`
|
||||
}
|
||||
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date for
|
||||
// the given court. Days/weeks/months use AddDate (calendar arithmetic).
|
||||
// working_days uses HolidayService.IsNonWorkingDay to skip weekends +
|
||||
// holidays applicable to the court's (country, regime). Composite rules
|
||||
// (alt_* + combine_op) compute both legs and pick max/min.
|
||||
// Calculate resolves all deadlines flowing from a trigger event + date.
|
||||
//
|
||||
// courtID may be empty for legacy callers — we default to a UPC München
|
||||
// context (DE country, UPC regime) since the trigger-event Fristenrechner
|
||||
// is UPC-flavoured today.
|
||||
// Phase 3 Slice 3 (t-paliad-184) delegates the rule SELECT + math to
|
||||
// FristenrechnerService.calculateByTriggerEvent — which reads from
|
||||
// paliad.deadline_rules WHERE trigger_event_id = X (the rows mig 085
|
||||
// moved out of event_deadlines). This method now owns the wrapping
|
||||
// concerns: trigger-event metadata lookup, rule_code aggregation (via
|
||||
// the still-readable event_deadline_rule_codes junction), and the
|
||||
// composite-rule note string that the legacy /api/tools/event-deadlines
|
||||
// contract emits.
|
||||
//
|
||||
// The legacy event_deadlines table is the source-of-truth for
|
||||
// (durationValue, durationUnit, timing, notes_en, alt_*, combine_op,
|
||||
// id) until Slice 9 drops it. Reading those fields here keeps the
|
||||
// frontend's EventDeadlineResult shape pixel-identical with pre-Slice-3
|
||||
// — verified by the 77-row parity test in event_deadline_service_test.go.
|
||||
//
|
||||
// courtID may be empty for legacy callers — defaults to UPC München
|
||||
// (DE country, UPC regime) for the trigger-event surface.
|
||||
func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int64, triggerDateStr, courtID string) (*CalculateResponse, error) {
|
||||
country, regime, err := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
var trig TriggerEventSummary
|
||||
err = s.db.GetContext(ctx, &trig, `
|
||||
err := s.db.GetContext(ctx, &trig, `
|
||||
SELECT id, code, name, name_de
|
||||
FROM paliad.trigger_events
|
||||
WHERE id = $1 AND is_active = true`, triggerEventID)
|
||||
@@ -112,6 +134,10 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
return nil, fmt.Errorf("load trigger event: %w", err)
|
||||
}
|
||||
|
||||
// Source-of-truth columns the unified UIResponse drops (the
|
||||
// frontend still reads DurationValue/Unit/Timing literally to render
|
||||
// the "X days after" pill). SELECT from event_deadlines is still
|
||||
// allowed — the mig 086 read-only trigger only blocks writes.
|
||||
var rows []eventDeadlineRow
|
||||
err = s.db.SelectContext(ctx, &rows, `
|
||||
SELECT id, title, title_de, duration_value, duration_unit, timing,
|
||||
@@ -124,78 +150,89 @@ func (s *EventDeadlineService) Calculate(ctx context.Context, triggerEventID int
|
||||
}
|
||||
|
||||
ids := make([]int64, 0, len(rows))
|
||||
byTitleDE := make(map[string]eventDeadlineRow, len(rows))
|
||||
for _, r := range rows {
|
||||
ids = append(ids, r.ID)
|
||||
byTitleDE[r.TitleDE] = r
|
||||
}
|
||||
codes, err := s.loadRuleCodes(ctx, ids)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]EventDeadlineResult, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
base, baseAdj, baseChanged := s.applyDuration(triggerDate, r.DurationValue, r.DurationUnit, r.Timing, country, regime)
|
||||
// Delegate to the unified calculator. UIResponse comes back with the
|
||||
// adjusted/original dates + wasAdjusted; the per-rule metadata is
|
||||
// the same names + ordering the source rows above carry, so we can
|
||||
// merge them on .Name (which mig 085 copied from event_deadlines.title_de).
|
||||
unified, err := s.fristenrechner.Calculate(ctx, "", triggerDateStr, CalcOptions{
|
||||
TriggerEventIDFilter: &triggerEventID,
|
||||
CourtID: courtID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
picked := baseAdj
|
||||
original := base
|
||||
wasAdjusted := baseChanged
|
||||
isComposite := false
|
||||
results := make([]EventDeadlineResult, 0, len(unified.Deadlines))
|
||||
for _, d := range unified.Deadlines {
|
||||
src, ok := byTitleDE[d.Name]
|
||||
if !ok {
|
||||
// Defensive: a unified row exists for which no source
|
||||
// event_deadlines row matches by title_de. Either a hand-
|
||||
// inserted Pipeline-C rule (post-Slice-3) without a source
|
||||
// counterpart, or a name divergence. Skip it from the legacy
|
||||
// shape and let the parity test surface the mismatch.
|
||||
continue
|
||||
}
|
||||
isComposite := src.CombineOp != nil && src.AltDurationValue != nil && src.AltDurationUnit != nil
|
||||
compositeNote := ""
|
||||
|
||||
if r.AltDurationValue != nil && r.AltDurationUnit != nil && r.CombineOp != nil {
|
||||
alt, altAdj, altChanged := s.applyDuration(triggerDate, *r.AltDurationValue, *r.AltDurationUnit, r.Timing, country, regime)
|
||||
isComposite = true
|
||||
switch *r.CombineOp {
|
||||
if isComposite {
|
||||
// Recompute which leg won by re-running applyDuration with
|
||||
// the source's exact inputs — cheaper than threading the
|
||||
// pick through the unified UIDeadline shape.
|
||||
country, regime, cerr := s.courts.CountryRegime(courtID, CountryDE, RegimeUPC)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
triggerDate, terr := time.Parse("2006-01-02", triggerDateStr)
|
||||
if terr != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, terr)
|
||||
}
|
||||
_, baseAdj, _ := s.applyDuration(triggerDate, src.DurationValue, src.DurationUnit, src.Timing, country, regime)
|
||||
_, altAdj, _ := s.applyDuration(triggerDate, *src.AltDurationValue, *src.AltDurationUnit, src.Timing, country, regime)
|
||||
pickedUnit := src.DurationUnit
|
||||
switch *src.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(baseAdj) {
|
||||
picked = altAdj
|
||||
original = alt
|
||||
wasAdjusted = altChanged
|
||||
compositeNote = fmt.Sprintf("max(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
*r.AltDurationUnit)
|
||||
} else {
|
||||
compositeNote = fmt.Sprintf("max(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
r.DurationUnit)
|
||||
pickedUnit = *src.AltDurationUnit
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(baseAdj) {
|
||||
picked = altAdj
|
||||
original = alt
|
||||
wasAdjusted = altChanged
|
||||
compositeNote = fmt.Sprintf("min(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
*r.AltDurationUnit)
|
||||
} else {
|
||||
compositeNote = fmt.Sprintf("min(%d %s, %d %s) → %s leg",
|
||||
r.DurationValue, r.DurationUnit,
|
||||
*r.AltDurationValue, *r.AltDurationUnit,
|
||||
r.DurationUnit)
|
||||
pickedUnit = *src.AltDurationUnit
|
||||
}
|
||||
}
|
||||
compositeNote = fmt.Sprintf("%s(%d %s, %d %s) → %s leg",
|
||||
*src.CombineOp,
|
||||
src.DurationValue, src.DurationUnit,
|
||||
*src.AltDurationValue, *src.AltDurationUnit,
|
||||
pickedUnit)
|
||||
}
|
||||
|
||||
notesEN := ""
|
||||
if r.NotesEN != nil {
|
||||
notesEN = *r.NotesEN
|
||||
if src.NotesEN != nil {
|
||||
notesEN = *src.NotesEN
|
||||
}
|
||||
results = append(results, EventDeadlineResult{
|
||||
ID: r.ID,
|
||||
Title: r.Title,
|
||||
TitleDE: r.TitleDE,
|
||||
DurationValue: r.DurationValue,
|
||||
DurationUnit: r.DurationUnit,
|
||||
Timing: r.Timing,
|
||||
Notes: r.Notes,
|
||||
ID: src.ID,
|
||||
Title: src.Title,
|
||||
TitleDE: src.TitleDE,
|
||||
DurationValue: src.DurationValue,
|
||||
DurationUnit: src.DurationUnit,
|
||||
Timing: src.Timing,
|
||||
Notes: src.Notes,
|
||||
NotesEN: notesEN,
|
||||
RuleCodes: codes[r.ID],
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDueDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdjusted,
|
||||
RuleCodes: codes[src.ID],
|
||||
DueDate: d.DueDate,
|
||||
OriginalDueDate: d.OriginalDate,
|
||||
WasAdjusted: d.WasAdjusted,
|
||||
IsComposite: isComposite,
|
||||
CompositeNote: compositeNote,
|
||||
})
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"sort"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// addWorkingDays + composite-rule semantics — pure-Go logic, no DB needed.
|
||||
@@ -126,3 +134,176 @@ func TestComposite_R198_LongerLegWins(t *testing.T) {
|
||||
t.Error("expected altAdj > baseAdj (working_days leg longer than 31d leg)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEventDeadlineService_Calculate_Parity is the LOAD-BEARING assertion
|
||||
// for Phase 3 Slice 3 (t-paliad-184). For every distinct trigger_event_id
|
||||
// in paliad.event_deadlines, it calls EventDeadlineService.Calculate (now
|
||||
// delegating to FristenrechnerService.calculateByTriggerEvent) AND
|
||||
// independently computes the same dates via the legacy applyDuration
|
||||
// helper directly against event_deadlines. Any divergence — date,
|
||||
// composite-flag, rule_codes — signals a Pipeline-C regression that
|
||||
// "Was kommt nach…" users would see in production.
|
||||
//
|
||||
// Why this matters: design §3.C + §3.2 cutover-ordering invariant 1 says
|
||||
// "additive schema lands first" and invariant 3 says "service rewrite
|
||||
// before drops". Slice 3 is the first slice where the unified backend
|
||||
// becomes the live serving path for event-driven deadlines. If parity
|
||||
// breaks here, every downstream slice rests on a regressed foundation.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestEventDeadlineService_Calculate_Parity(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB parity 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()
|
||||
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
svc := NewEventDeadlineService(pool, NewDeadlineCalculator(holidays), holidays, courts, fristen)
|
||||
|
||||
// Distinct trigger_event_id values for which we have at least one
|
||||
// active deadline in event_deadlines. The Slice 1 / Slice 2 / Slice 3
|
||||
// chain doesn't touch event_deadlines, so this set is stable.
|
||||
var triggerIDs []int64
|
||||
if err := pool.SelectContext(ctx, &triggerIDs,
|
||||
`SELECT DISTINCT trigger_event_id
|
||||
FROM paliad.event_deadlines
|
||||
WHERE is_active = true
|
||||
ORDER BY trigger_event_id`); err != nil {
|
||||
t.Fatalf("list trigger ids: %v", err)
|
||||
}
|
||||
if len(triggerIDs) == 0 {
|
||||
t.Fatal("no event_deadlines rows — pipeline C corpus missing")
|
||||
}
|
||||
|
||||
// Reference date — arbitrary working day so weekend rollover noise is
|
||||
// minimal. The parity test compares against an independently-computed
|
||||
// expected value, so any date that exercises the calculator is fine.
|
||||
triggerDateStr := "2026-01-15"
|
||||
triggerDate, _ := time.Parse("2006-01-02", triggerDateStr)
|
||||
country, regime, err := courts.CountryRegime("", CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
t.Fatalf("default court regime: %v", err)
|
||||
}
|
||||
|
||||
type srcRow struct {
|
||||
ID int64 `db:"id"`
|
||||
Title string `db:"title"`
|
||||
TitleDE string `db:"title_de"`
|
||||
DurationValue int `db:"duration_value"`
|
||||
DurationUnit string `db:"duration_unit"`
|
||||
Timing string `db:"timing"`
|
||||
AltDurationValue *int `db:"alt_duration_value"`
|
||||
AltDurationUnit *string `db:"alt_duration_unit"`
|
||||
CombineOp *string `db:"combine_op"`
|
||||
}
|
||||
|
||||
var totalChecked int
|
||||
for _, tid := range triggerIDs {
|
||||
resp, err := svc.Calculate(ctx, tid, triggerDateStr, "")
|
||||
if err != nil {
|
||||
t.Errorf("trigger=%d Calculate: %v", tid, err)
|
||||
continue
|
||||
}
|
||||
|
||||
var src []srcRow
|
||||
if err := pool.SelectContext(ctx, &src,
|
||||
`SELECT id, title, title_de, duration_value, duration_unit, timing,
|
||||
alt_duration_value, alt_duration_unit, combine_op
|
||||
FROM paliad.event_deadlines
|
||||
WHERE trigger_event_id = $1 AND is_active = true
|
||||
ORDER BY id`, tid); err != nil {
|
||||
t.Fatalf("trigger=%d load source: %v", tid, err)
|
||||
}
|
||||
|
||||
if len(resp.Deadlines) != len(src) {
|
||||
t.Errorf("trigger=%d: got %d deadlines, want %d", tid, len(resp.Deadlines), len(src))
|
||||
continue
|
||||
}
|
||||
|
||||
// Sort both by ID — Calculate's source SELECT also ORDER BY id, so
|
||||
// after we look up the source row for each result we can compare
|
||||
// positionally. (The unified path returns rows in sequence_order =
|
||||
// 1000 + ed.id which is identical ordering.)
|
||||
sort.Slice(resp.Deadlines, func(i, j int) bool {
|
||||
return resp.Deadlines[i].ID < resp.Deadlines[j].ID
|
||||
})
|
||||
|
||||
for i, r := range resp.Deadlines {
|
||||
s := src[i]
|
||||
totalChecked++
|
||||
|
||||
if r.ID != s.ID {
|
||||
t.Errorf("trigger=%d idx=%d: id=%d, want %d", tid, i, r.ID, s.ID)
|
||||
}
|
||||
if r.Title != s.Title {
|
||||
t.Errorf("trigger=%d id=%d: title mismatch: %q vs %q", tid, s.ID, r.Title, s.Title)
|
||||
}
|
||||
if r.TitleDE != s.TitleDE {
|
||||
t.Errorf("trigger=%d id=%d: titleDE mismatch: %q vs %q", tid, s.ID, r.TitleDE, s.TitleDE)
|
||||
}
|
||||
if r.DurationValue != s.DurationValue {
|
||||
t.Errorf("trigger=%d id=%d: durationValue mismatch: %d vs %d",
|
||||
tid, s.ID, r.DurationValue, s.DurationValue)
|
||||
}
|
||||
if r.DurationUnit != s.DurationUnit {
|
||||
t.Errorf("trigger=%d id=%d: durationUnit mismatch: %q vs %q",
|
||||
tid, s.ID, r.DurationUnit, s.DurationUnit)
|
||||
}
|
||||
if r.Timing != s.Timing {
|
||||
t.Errorf("trigger=%d id=%d: timing mismatch: %q vs %q", tid, s.ID, r.Timing, s.Timing)
|
||||
}
|
||||
|
||||
// Date parity: independently compute the expected DueDate
|
||||
// using the legacy applyDuration on the source row. If the
|
||||
// unified path diverges by even one day, this surfaces it.
|
||||
_, expectedAdj, _ := svc.applyDuration(triggerDate, s.DurationValue, s.DurationUnit, s.Timing, country, regime)
|
||||
if s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil {
|
||||
_, altAdj, _ := svc.applyDuration(triggerDate, *s.AltDurationValue, *s.AltDurationUnit, s.Timing, country, regime)
|
||||
switch *s.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(expectedAdj) {
|
||||
expectedAdj = altAdj
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(expectedAdj) {
|
||||
expectedAdj = altAdj
|
||||
}
|
||||
}
|
||||
}
|
||||
gotAdj, perr := time.Parse("2006-01-02", r.DueDate)
|
||||
if perr != nil {
|
||||
t.Errorf("trigger=%d id=%d: parse dueDate %q: %v", tid, s.ID, r.DueDate, perr)
|
||||
continue
|
||||
}
|
||||
if !gotAdj.Equal(expectedAdj) {
|
||||
t.Errorf("trigger=%d id=%d (%q): dueDate=%s, want %s — Pipeline-C parity broken",
|
||||
tid, s.ID, s.Title, r.DueDate, expectedAdj.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Composite flag parity.
|
||||
wantComposite := s.CombineOp != nil && s.AltDurationValue != nil && s.AltDurationUnit != nil
|
||||
if r.IsComposite != wantComposite {
|
||||
t.Errorf("trigger=%d id=%d: isComposite=%v, want %v",
|
||||
tid, s.ID, r.IsComposite, wantComposite)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Final tally — at least the 77 active rows must have been checked.
|
||||
if totalChecked < 77 {
|
||||
t.Errorf("checked only %d Pipeline-C rows (want >=77) — parity sweep incomplete", totalChecked)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,6 +110,15 @@ type CalcOptions struct {
|
||||
// UPC-flavoured proceedings, DE for everything else — preserves legacy
|
||||
// behaviour for callers that don't yet send a court.
|
||||
CourtID string
|
||||
// TriggerEventIDFilter scopes Calculate to event-driven Pipeline-C
|
||||
// rules: when non-nil, the proceedingCode argument is ignored and the
|
||||
// service selects rules WHERE trigger_event_id = *TriggerEventIDFilter
|
||||
// instead of WHERE proceeding_type_id = .... Set by
|
||||
// EventDeadlineService.Calculate so the unified backend can serve the
|
||||
// "Was kommt nach…" surface after Phase 3 Slice 3. The pointer width
|
||||
// matches paliad.trigger_events.id (bigint, mig 028). See design
|
||||
// §3.D (calculator unification).
|
||||
TriggerEventIDFilter *int64
|
||||
}
|
||||
|
||||
// Calculate renders the full UI timeline for a proceeding type + trigger date.
|
||||
@@ -137,6 +146,16 @@ type CalcOptions struct {
|
||||
// date. Used for court-extended deadlines and for entering
|
||||
// court-set decision dates post-hoc.
|
||||
func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, triggerDateStr string, opts CalcOptions) (*UIResponse, error) {
|
||||
// Phase-3 dispatch: TriggerEventIDFilter routes to the event-driven
|
||||
// branch (Pipeline-C unified rules; mig 085 moved 77 rows out of
|
||||
// paliad.event_deadlines into paliad.deadline_rules carrying a
|
||||
// non-NULL trigger_event_id). proceedingCode is ignored on this
|
||||
// path. EventDeadlineService.Calculate is the sole caller today;
|
||||
// future "event-trigger" surfaces (design §5) plug in here too.
|
||||
if opts.TriggerEventIDFilter != nil {
|
||||
return s.calculateByTriggerEvent(ctx, *opts.TriggerEventIDFilter, triggerDateStr, opts)
|
||||
}
|
||||
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
@@ -817,6 +836,190 @@ func addDuration(base time.Time, value int, unit string) time.Time {
|
||||
}
|
||||
}
|
||||
|
||||
// applyDurationOnCalendar is the Pipeline-C calculator's per-leg helper.
|
||||
// Returns (raw, adjusted, didAdjust):
|
||||
//
|
||||
// - raw is the date pre-rollover (what the rule strictly says).
|
||||
// - adjusted is the date after weekend / holiday rollover for calendar
|
||||
// units (days, weeks, months). 'working_days' lands on a working day
|
||||
// by construction, so raw == adjusted there.
|
||||
// - didAdjust is true when the rollover moved the date.
|
||||
//
|
||||
// timing='before' negates the sign. Both 'before' and 'working_days' are
|
||||
// exclusive to Pipeline C in today's corpus; the legacy proceeding-tree
|
||||
// path (addDuration) doesn't need them. Slice 4 will collapse the two
|
||||
// helpers into one when the proceeding-tree calculator also reads timing
|
||||
// + working_days from the unified rule shape.
|
||||
func applyDurationOnCalendar(
|
||||
base time.Time, value int, unit, timing, country, regime string, holidays *HolidayService,
|
||||
) (raw, adjusted time.Time, didAdjust bool) {
|
||||
sign := 1
|
||||
if timing == "before" {
|
||||
sign = -1
|
||||
}
|
||||
switch unit {
|
||||
case "days":
|
||||
raw = base.AddDate(0, 0, sign*value)
|
||||
case "weeks":
|
||||
raw = base.AddDate(0, 0, sign*value*7)
|
||||
case "months":
|
||||
raw = base.AddDate(0, sign*value, 0)
|
||||
case "working_days":
|
||||
raw = addWorkingDays(base, sign*value, country, regime, holidays)
|
||||
// Working-day arithmetic lands on a working day by construction.
|
||||
return raw, raw, false
|
||||
default:
|
||||
raw = base
|
||||
}
|
||||
adjusted, _, didAdjust = holidays.AdjustForNonWorkingDays(raw, country, regime)
|
||||
return raw, adjusted, didAdjust
|
||||
}
|
||||
|
||||
// addWorkingDays advances from `from` by `n` working days, skipping
|
||||
// weekends and holidays applicable to the given country/regime. Negative
|
||||
// n walks backward. n=0 keeps the input date as-is (caller decides
|
||||
// whether to roll forward via AdjustForNonWorkingDays).
|
||||
//
|
||||
// Bounded by an inner 30-step skip per advance — vacation runs in our
|
||||
// holiday tables are < 14 consecutive days, so 30 is a safety margin.
|
||||
func addWorkingDays(from time.Time, n int, country, regime string, holidays *HolidayService) time.Time {
|
||||
if n == 0 {
|
||||
return from
|
||||
}
|
||||
step := 1
|
||||
if n < 0 {
|
||||
step = -1
|
||||
n = -n
|
||||
}
|
||||
cur := from
|
||||
for i := 0; i < n; i++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
for j := 0; j < 30 && holidays.IsNonWorkingDay(cur, country, regime); j++ {
|
||||
cur = cur.AddDate(0, 0, step)
|
||||
}
|
||||
}
|
||||
return cur
|
||||
}
|
||||
|
||||
// calculateByTriggerEvent renders the Pipeline-C timeline for an event
|
||||
// trigger (mig 085 + Slice 3). Pipeline-C rules are flat (no parent_id
|
||||
// chains), have no flag gating, no priority_date alt-anchor, no party
|
||||
// classification, and no IsRootEvent / IsCourtSet semantics. The math
|
||||
// is just: base + (timing-signed) duration → optional alt-leg combine
|
||||
// → optional weekend/holiday rollover for calendar units.
|
||||
//
|
||||
// UIResponse.ProceedingType / ProceedingName stay empty — EventDeadlineService
|
||||
// owns the trigger-event metadata (it's the caller that needed it
|
||||
// pre-Slice-3 and continues to load it for the legacy CalculateResponse
|
||||
// shape). Callers that don't need those fields can ignore them.
|
||||
func (s *FristenrechnerService) calculateByTriggerEvent(
|
||||
ctx context.Context, triggerEventID int64, triggerDateStr string, opts CalcOptions,
|
||||
) (*UIResponse, error) {
|
||||
triggerDate, err := time.Parse("2006-01-02", triggerDateStr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid trigger date %q: %w", triggerDateStr, err)
|
||||
}
|
||||
|
||||
// Pipeline-C rules originate from youpc's UPC-flavoured deadline
|
||||
// corpus — DE / UPC defaults match the legacy EventDeadlineService.
|
||||
country, regime, err := s.courts.CountryRegime(opts.CourtID, CountryDE, RegimeUPC)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve court %q: %w", opts.CourtID, err)
|
||||
}
|
||||
|
||||
rules, err := s.rules.ListByTriggerEvent(ctx, triggerEventID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
deadlines := make([]UIDeadline, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
timing := ""
|
||||
if r.Timing != nil {
|
||||
timing = *r.Timing
|
||||
}
|
||||
baseRaw, baseAdj, baseChanged := applyDurationOnCalendar(
|
||||
triggerDate, r.DurationValue, r.DurationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
picked := baseAdj
|
||||
original := baseRaw
|
||||
wasAdj := baseChanged
|
||||
var reason *AdjustmentReason
|
||||
if wasAdj {
|
||||
// Re-compute with the reason variant when the rollover fired
|
||||
// so the UI can show "Wochenende → Montag" etc. Cheaper than
|
||||
// a second full applyDuration call: just re-roll the same raw.
|
||||
_, _, _, reason = s.holidays.AdjustForNonWorkingDaysWithReason(baseRaw, country, regime)
|
||||
}
|
||||
|
||||
if r.CombineOp != nil && r.AltDurationValue != nil && r.AltDurationUnit != nil {
|
||||
altRaw, altAdj, altChanged := applyDurationOnCalendar(
|
||||
triggerDate, *r.AltDurationValue, *r.AltDurationUnit, timing, country, regime, s.holidays,
|
||||
)
|
||||
switch *r.CombineOp {
|
||||
case "max":
|
||||
if altAdj.After(baseAdj) {
|
||||
picked, original, wasAdj = altAdj, altRaw, altChanged
|
||||
reason = nil
|
||||
if altChanged {
|
||||
_, _, _, reason = s.holidays.AdjustForNonWorkingDaysWithReason(altRaw, country, regime)
|
||||
}
|
||||
}
|
||||
case "min":
|
||||
if altAdj.Before(baseAdj) {
|
||||
picked, original, wasAdj = altAdj, altRaw, altChanged
|
||||
reason = nil
|
||||
if altChanged {
|
||||
_, _, _, reason = s.holidays.AdjustForNonWorkingDaysWithReason(altRaw, country, regime)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
d := UIDeadline{
|
||||
RuleID: r.ID.String(),
|
||||
Name: r.Name,
|
||||
NameEN: r.NameEN,
|
||||
IsMandatory: r.IsMandatory,
|
||||
IsOptional: r.IsOptional,
|
||||
DueDate: picked.Format("2006-01-02"),
|
||||
OriginalDate: original.Format("2006-01-02"),
|
||||
WasAdjusted: wasAdj,
|
||||
AdjustmentReason: reason,
|
||||
}
|
||||
if r.Code != nil {
|
||||
d.Code = *r.Code
|
||||
}
|
||||
if r.PrimaryParty != nil {
|
||||
d.Party = *r.PrimaryParty
|
||||
}
|
||||
if r.RuleCode != nil {
|
||||
d.RuleRef = *r.RuleCode
|
||||
}
|
||||
if r.LegalSource != nil {
|
||||
d.LegalSource = *r.LegalSource
|
||||
}
|
||||
if r.DeadlineNotes != nil {
|
||||
d.Notes = *r.DeadlineNotes
|
||||
}
|
||||
if r.DeadlineNotesEn != nil {
|
||||
d.NotesEN = *r.DeadlineNotesEn
|
||||
}
|
||||
deadlines = append(deadlines, d)
|
||||
}
|
||||
|
||||
return &UIResponse{
|
||||
// Trigger-event responses don't carry proceeding metadata —
|
||||
// EventDeadlineService.Calculate fills the trigger fields in the
|
||||
// legacy CalculateResponse shape. Leaving these empty is the
|
||||
// stable contract.
|
||||
ProceedingType: "",
|
||||
ProceedingName: "",
|
||||
TriggerDate: triggerDateStr,
|
||||
Deadlines: deadlines,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DefaultsForJurisdiction maps the proceeding-type jurisdiction text
|
||||
// ('UPC' | 'DE' | 'EPA' | 'DPMA' | nil) to the (country, regime) tuple a
|
||||
// holiday lookup should default to when the caller didn't pass an explicit
|
||||
|
||||
Reference in New Issue
Block a user