From cdac23adcb673cd0edf832e9a6671dba1b8e0172 Mon Sep 17 00:00:00 2001 From: mindsers Date: Mon, 20 Jul 2026 16:50:25 +0200 Subject: [PATCH 1/2] fix(events): repair day-offs miscategorized as freeform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2026-07-20 EventKind → template migration series routed `off`-kind events to the day-off template only when that template already existed per congregation. In unseeded congregations the join was a no-op, the events kept templateId=NULL, and 20260720200000_seed_system_templates then defaulted every orphan to freeform — erasing the classification. Adds a data-repair migration that moves freeform events with no parts, no service parts, and a span >= 20 hours back onto the day-off template. No shape createFreeformEvent produces matches (start and end share a single calendar date), and every shape createDayOff produces does (endDate >= startDate + 1 day). Idempotent. Symptom: legacy day-offs surfaced on /programs and were absent from /programs/days-off. --- .../migration.sql | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql diff --git a/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql b/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql new file mode 100644 index 00000000..694044a6 --- /dev/null +++ b/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql @@ -0,0 +1,54 @@ +-- Repairs day-off events that were miscategorized as freeform during the +-- EventKind → template migration series on 2026-07-20. +-- +-- Root cause: +-- 20260720100000_drop_event_kind step 2 tried to route `off`-kind events +-- to the `day-off` template via an INNER JOIN that required the template +-- to already exist in each congregation. In congregations that had never +-- been seeded via `pnpm prisma db seed`, the day-off template row did +-- not exist yet, so the JOIN produced zero rows and the events kept +-- templateId=NULL. Step 4 then dropped the `kindId` column, permanently +-- erasing the "this used to be an `off`" evidence. The follow-up +-- migration 20260720200000_seed_system_templates step 3 defaulted every +-- remaining orphan to `freeform` (comment: "we can't tell which orphaned +-- rows were originally day-offs vs. freeform-like, so route them all to +-- freeform — the safest default"). +-- +-- Symptom: legacy day-offs surface on /programs and are absent from +-- /programs/days-off, because they now carry template.key = 'freeform'. +-- +-- Recovery heuristic: a freeform event that (a) has no eventParts, (b) has +-- no eventServiceParts, and (c) spans at least 20 hours is a day-off with +-- overwhelming likelihood. Real freeform events are created by +-- createFreeformEvent (features/events/server/event-parts.server.ts), +-- which reads a single `date` plus `startTime`/`endTime` from the form and +-- combines them — start and end share the same calendar date, so the +-- interval is at most 24h and in practice a few hours. The 20h threshold +-- lets a same-day freeform event breathe while still catching every +-- multi-day range that days-off/new.tsx produces (endDate ≥ startDate + 1 +-- day, enforced by the UI's `min` on the end-date field). +-- +-- Idempotent — the second run finds no freeform events still matching the +-- heuristic because the first run already moved them onto the day-off +-- template. + +DO $$ +DECLARE + v_count int; +BEGIN + UPDATE "Event" e + SET "templateId" = dayoff."id" + FROM "EventTemplate" freeform + JOIN "EventTemplate" dayoff + ON dayoff."key" = 'day-off' + AND dayoff."congregationId" = freeform."congregationId" + WHERE freeform."key" = 'freeform' + AND e."templateId" = freeform."id" + AND e."endDate" - e."startDate" >= interval '20 hours' + AND NOT EXISTS (SELECT 1 FROM "EventPart" p WHERE p."eventId" = e."id") + AND NOT EXISTS (SELECT 1 FROM "EventServicePart" sp WHERE sp."eventId" = e."id"); + GET DIAGNOSTICS v_count = ROW_COUNT; + IF v_count > 0 THEN + RAISE NOTICE 'Day-off recovery: re-linked % freeform event(s) with the day-off template — see 20260721000000_repair_orphaned_day_offs for the heuristic.', v_count; + END IF; +END $$; From 3e09185b1d5aba8e44099711fed4146aad338264 Mon Sep 17 00:00:00 2001 From: mindsers Date: Mon, 20 Jul 2026 17:40:03 +0200 Subject: [PATCH 2/2] fix(events): backfill hasConflict, tighten migration comment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses PR #274 review: - Comment now credits both migrations' freeform fallbacks (drop_event_kind step 3 and seed_system_templates step 3) and refers to handleFreeformMode as the caller that combines date + times. - Reframes "idempotent" as a one-shot repair — the migration table blocks re-execution, but a manual re-run against a DB with new long-freeform events would misclassify them. - Adds a browser-only caveat on the >= 1 day floor (createDayOff only rejects startDate > endDate; the min attribute is HTML5). - Notes the in-flight-import assumption. Adds a second DO block that flips EventPart.hasConflict and EventServicePart.hasConflict from false to true wherever an assignment's event overlaps a day-off owned by the same member. While the day-offs lived on the freeform template, refreshConflictFlags filtered them out via template.key = 'day-off', so those overlaps never flipped the flag. Re-linking (step 1) does not retrigger the refresh, so without this backfill the phantom greens would persist until the member's next absence create/delete. Runs against every day-off (not only the ones re-linked) so any pre-existing drift also gets repaired. Only false -> true, never clears a legitimately-set true. Verified against local fixtures: 5-day orphan freeform re-links to day-off, 2-hour freeform stays put, overlapping assignment for the day-off creator flips to hasConflict=true, idempotent on second run. --- .../migration.sql | 139 ++++++++++++++---- 1 file changed, 112 insertions(+), 27 deletions(-) diff --git a/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql b/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql index 694044a6..fa8ad2c0 100644 --- a/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql +++ b/app/database/migrations/20260721000000_repair_orphaned_day_offs/migration.sql @@ -1,37 +1,54 @@ -- Repairs day-off events that were miscategorized as freeform during the --- EventKind → template migration series on 2026-07-20. +-- EventKind → template migration series on 2026-07-20, and backfills the +-- hasConflict flag on any part/service-part assignment that overlaps a +-- day-off owned by the same member (the miscategorisation had also hidden +-- those overlaps from refreshConflictFlags). -- --- Root cause: --- 20260720100000_drop_event_kind step 2 tried to route `off`-kind events --- to the `day-off` template via an INNER JOIN that required the template --- to already exist in each congregation. In congregations that had never --- been seeded via `pnpm prisma db seed`, the day-off template row did --- not exist yet, so the JOIN produced zero rows and the events kept --- templateId=NULL. Step 4 then dropped the `kindId` column, permanently --- erasing the "this used to be an `off`" evidence. The follow-up --- migration 20260720200000_seed_system_templates step 3 defaulted every --- remaining orphan to `freeform` (comment: "we can't tell which orphaned --- rows were originally day-offs vs. freeform-like, so route them all to --- freeform — the safest default"). +-- Root cause: both 2026-07-20 migrations end with a "route orphans to +-- freeform" fallback (20260720100000_drop_event_kind step 3 when the +-- freeform template happens to exist, 20260720200000_seed_system_templates +-- step 3 after it seeds one). Congregations that had never been seeded via +-- `pnpm prisma db seed` had NEITHER system template, so +-- drop_event_kind step 2 (off → day-off) was a no-op, its own step 3 +-- (any → freeform) was a no-op too, and the events kept templateId=NULL. +-- The kindId column was dropped between the two migrations, permanently +-- erasing the "this used to be an `off`" signal. seed_system_templates +-- then seeded both templates and its step 3 defaulted every remaining +-- orphan to freeform — that's the write that actually stamped the wrong +-- template in production (`we can't tell which orphaned rows were +-- originally day-offs vs. freeform-like, so route them all to freeform — +-- the safest default`). -- -- Symptom: legacy day-offs surface on /programs and are absent from -- /programs/days-off, because they now carry template.key = 'freeform'. -- --- Recovery heuristic: a freeform event that (a) has no eventParts, (b) has --- no eventServiceParts, and (c) spans at least 20 hours is a day-off with --- overwhelming likelihood. Real freeform events are created by --- createFreeformEvent (features/events/server/event-parts.server.ts), --- which reads a single `date` plus `startTime`/`endTime` from the form and --- combines them — start and end share the same calendar date, so the --- interval is at most 24h and in practice a few hours. The 20h threshold --- lets a same-day freeform event breathe while still catching every --- multi-day range that days-off/new.tsx produces (endDate ≥ startDate + 1 --- day, enforced by the UI's `min` on the end-date field). +-- Recovery heuristic: a freeform event that (a) has no eventParts, +-- (b) has no eventServiceParts, and (c) spans at least 20 hours is a +-- day-off with overwhelming likelihood as of 2026-07-21. No in-app path +-- produces a freeform event with startDate/endDate on different calendar +-- days: handleFreeformMode (features/events/routes/programs/new.tsx) +-- passes a single `date` twice to combineLocalDateTime before calling +-- createFreeformEvent, so a well-formed interval is < 24 h. Day-offs +-- from /me/days-off run through createDayOff; the form's end-date `min` +-- attribute enforces endDate ≥ startDate + 1 day, though the enforcement +-- is browser-only (createDayOff only rejects startDate > endDate), so a +-- scripted POST could slip a same-day day-off past the server — the 20 h +-- floor would then miss that row in the repair, accepted as a limitation. -- --- Idempotent — the second run finds no freeform events still matching the --- heuristic because the first run already moved them onto the day-off --- template. +-- One-shot repair, not idempotent for future writes: a legitimate +-- multi-day freeform event created between two runs (e.g. an overnight +-- assembly entered as freeform) would match the heuristic and get +-- converted to a day-off. Postgres records this migration as applied +-- after the first successful run so `prisma migrate deploy` will not +-- re-execute it; do not manually re-run. +-- +-- Assumes no in-flight imports: importEvents (features/settings/server/ +-- import-events.server.ts) writes events before importEventParts adds +-- their parts, so a snapshot taken between the two would show a legit +-- templated event matching the heuristic. Migration deploys are not +-- concurrent with imports today. +-- 1. Re-link freeform events that match the day-off shape. DO $$ DECLARE v_count int; @@ -49,6 +66,74 @@ BEGIN AND NOT EXISTS (SELECT 1 FROM "EventServicePart" sp WHERE sp."eventId" = e."id"); GET DIAGNOSTICS v_count = ROW_COUNT; IF v_count > 0 THEN - RAISE NOTICE 'Day-off recovery: re-linked % freeform event(s) with the day-off template — see 20260721000000_repair_orphaned_day_offs for the heuristic.', v_count; + RAISE NOTICE 'Day-off recovery: re-linked % freeform event(s) with the day-off template.', v_count; + END IF; +END $$; + +-- 2. Backfill hasConflict on parts/services whose event overlaps a day-off +-- owned by the same member. +-- +-- While the day-offs above lived on the freeform template, +-- refreshConflictFlags and checkDayOffConflict (features/events/server/ +-- event-part-assignments.server.ts) filtered them out via +-- `NOT: { template: { key: 'day-off' } }`, so every overlap for a +-- misclassified day-off was skipped and hasConflict stayed false when +-- it should have been true. Re-linking the template (step 1) does not +-- retrigger those refreshes, so the flags stay stale until the member's +-- next day-off create/delete. +-- +-- Runs against every day-off (not only the ones re-linked in step 1) — +-- the invariant "any assignment overlapping a day-off must have +-- hasConflict = true" is unconditional, and constraining to the +-- just-repaired set would leave any pre-existing drift in place. Only +-- flips false → true; a legitimately-set true from a co-participant +-- conflict is never cleared. +DO $$ +DECLARE + v_parts int; + v_services int; +BEGIN + -- Comma-join in FROM (rather than explicit JOIN) so the target-table + -- alias `ep` remains reachable from the join predicate — Postgres does + -- not allow the UPDATE target to be referenced inside an explicit JOIN + -- clause. Same story for the EventServicePart update below. + UPDATE "EventPart" ep + SET "hasConflict" = true, "updatedAt" = NOW() + FROM "Event" dayoff, + "EventTemplate" doTpl, + "UserAccount" creator, + "Event" host + WHERE doTpl."id" = dayoff."templateId" + AND doTpl."key" = 'day-off' + AND creator."id" = dayoff."createdById" + AND host."id" = ep."eventId" + AND creator."memberId" IS NOT NULL + AND (ep."assigneeId" = creator."memberId" OR ep."assistantId" = creator."memberId") + AND host."startDate" <= dayoff."endDate" + AND host."endDate" >= dayoff."startDate" + AND host."id" <> dayoff."id" + AND ep."hasConflict" = false; + GET DIAGNOSTICS v_parts = ROW_COUNT; + + UPDATE "EventServicePart" esp + SET "hasConflict" = true, "updatedAt" = NOW() + FROM "Event" dayoff, + "EventTemplate" doTpl, + "UserAccount" creator, + "Event" host + WHERE doTpl."id" = dayoff."templateId" + AND doTpl."key" = 'day-off' + AND creator."id" = dayoff."createdById" + AND host."id" = esp."eventId" + AND creator."memberId" IS NOT NULL + AND esp."assigneeId" = creator."memberId" + AND host."startDate" <= dayoff."endDate" + AND host."endDate" >= dayoff."startDate" + AND host."id" <> dayoff."id" + AND esp."hasConflict" = false; + GET DIAGNOSTICS v_services = ROW_COUNT; + + IF v_parts > 0 OR v_services > 0 THEN + RAISE NOTICE 'Conflict backfill: flipped hasConflict → true on % event part(s) and % service part(s) overlapping a member''s day-off.', v_parts, v_services; END IF; END $$;