From a905911cf4f189985e0d511df36929200f177d4b Mon Sep 17 00:00:00 2001 From: mAi Date: Thu, 28 May 2026 00:47:08 +0200 Subject: [PATCH 1/2] fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two SELECTs still referenced paliad.deadlines.rule_id after mig 140 (Slice B.4) dropped that column in favour of sequencing_rule_id: - internal/services/deadline_service.go:268 — DeadlineService. ListVisibleForUser. Powers /api/events?type=deadline (dashboard deadline rail, /deadlines page, every status bucket). Threw `pq: column f.rule_id does not exist` on every request → 500 for any authenticated user hitting the dashboard. - internal/services/projection_service.go:1250 — collectActualsForOverrides. Same column on `paliad.deadlines d`. Logged once per projection pass (`ERROR service: projection: deadlines: ...`); aliased the rename to `rule_id` so the receiving struct tag still scans. Live container logs confirmed the failure mode — a 60-row burst of `pq: column f.rule_id does not exist at position 3:36 (42703)` starting the minute the post-B0 container came up (mig 140 had applied to the DB but the SELECT still used the dropped name). EXPLAIN against the live schema after the edit plans cleanly; the LEFT JOIN to paliad.deadline_rules_unified on sequencing_rule_id was already correct (only the SELECT projection was stale). Root cause: mig 140 commit (1129bab) renamed the JOIN to `f.sequencing_rule_id` but left the SELECT clause on the older name. The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so the wire shape is unchanged — only the column reference flips. bun build clean, go vet ./... clean, go test ./... green. --- internal/services/deadline_service.go | 2 +- internal/services/projection_service.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index 70327e1..8d6319c 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU query := ` SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date, - f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at, + f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at, f.caldav_uid, f.caldav_etag, f.notes, f.created_by, f.created_at, f.updated_at, f.approval_status, f.pending_request_id, f.approved_by, f.approved_at, diff --git a/internal/services/projection_service.go b/internal/services/projection_service.go index a51f383..fe0150e 100644 --- a/internal/services/projection_service.go +++ b/internal/services/projection_service.go @@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides( } var dRows []drow scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly) - q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status + q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status FROM paliad.deadlines d WHERE ` + scopeFilter if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil { From a4b865d6bdc06325bff9535a511e14e59f222c39 Mon Sep 17 00:00:00 2001 From: mAi Date: Thu, 28 May 2026 00:47:19 +0200 Subject: [PATCH 2/2] fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GetScenarioDeep returned nil slices for proceedings/events/shares when a scenario had zero rows, which Go's encoding/json serialises as `null` rather than `[]`. The builder's renderCanvas then unconditionally calls `state.active.proceedings.filter(...)` on a null and dies with `procedures.js:101 TypeError: Cannot read properties of null (reading 'filter')` — every cold-open scenario crashed the page before the empty canvas could render. Backend (root cause): initialise Proceedings / Events / Shares to empty slices in BuilderScenarioDeep before SelectContext, so the wire shape is always arrays. Existing rows still load via SelectContext, which truncates the placeholder and refills from the DB. Frontend (defence in depth): on loadScenario(), normalise each of the three arrays to `[]` if the server response is not an array. Catches a future regression (or an older deployed build) without re-introducing the same crash class. bun build clean, go vet + go test ./... green. --- frontend/src/client/builder.ts | 7 +++++++ internal/services/scenario_builder_service.go | 10 +++++++++- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/frontend/src/client/builder.ts b/frontend/src/client/builder.ts index c92f49a..ffb56df 100644 --- a/frontend/src/client/builder.ts +++ b/frontend/src/client/builder.ts @@ -815,6 +815,13 @@ async function loadScenario(id: string): Promise { setSaveState("error"); return; } + // Defensive: Go's encoding/json serialises a nil slice as `null`, not + // `[]`. The server initialises these arrays today, but normalising on + // the client too means a future regression (or an older deployed + // build) can't crash renderCanvas with `null.filter(...)`. + if (!Array.isArray(deep.proceedings)) deep.proceedings = []; + if (!Array.isArray(deep.events)) deep.events = []; + if (!Array.isArray(deep.shares)) deep.shares = []; state.active = deep; state.pending = {}; writeScenarioToUrl(id); diff --git a/internal/services/scenario_builder_service.go b/internal/services/scenario_builder_service.go index df3c6da..86986a2 100644 --- a/internal/services/scenario_builder_service.go +++ b/internal/services/scenario_builder_service.go @@ -204,7 +204,15 @@ func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, sc return nil, ErrScenarioBuilderNotVisible } - deep := &BuilderScenarioDeep{BuilderScenario: *sc} + deep := &BuilderScenarioDeep{ + BuilderScenario: *sc, + // Initialise to empty so the JSON response always carries arrays, + // not null — the builder frontend's renderCanvas calls .filter on + // proceedings/events unconditionally once state.active is set. + Proceedings: []BuilderProceeding{}, + Events: []BuilderEvent{}, + Shares: []BuilderShare{}, + } if err := s.db.SelectContext(ctx, &deep.Proceedings, ` SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,