From 61f2dca421a07a2fcf0013979be0ec59984d0dc5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 21 May 2026 11:19:44 +0200 Subject: [PATCH] fix(prisma): add unique constraint to medication_intake_events for projection race MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migration 0073 adds a UNIQUE constraint on (user_id, medication_id, scheduled_for, source) for medication_intake_events. Pre-fix the table only had an INDEX on those three columns, so the two newly-projecting routes — the intake route (PR #191) and the dashboard summary route (v1.4.39.4) — could pass their existence probes simultaneously and mint the same REMINDER row twice when the iOS app hydrates both endpoints in parallel. `source` is in the key so the reminder worker's REMINDER row doesn't collide with manual user inserts (WEB / TELEGRAM / IMPORT) on the same slot — distinct intake sources at the same scheduled instant are a legitimate shape (Telegram-bot logged take + a Web-UI correction on the same dose). The legacy (user_id, medication_id, scheduled_for) index stays in place because most reads filter on those three columns without caring about source. The new constraint becomes the structural backstop; the createMany skipDuplicates: true flag added in the two routes is the defense-in-depth layer. Additive only. DO $$ … EXCEPTION-on-duplicate guard mirrors 0067 / 0071 so re-runs are idempotent. --- .../migration.sql | 57 +++++++++++++++++++ prisma/schema.prisma | 9 +++ 2 files changed, 66 insertions(+) create mode 100644 prisma/migrations/0073_v1439_medication_intake_unique_per_source/migration.sql diff --git a/prisma/migrations/0073_v1439_medication_intake_unique_per_source/migration.sql b/prisma/migrations/0073_v1439_medication_intake_unique_per_source/migration.sql new file mode 100644 index 000000000..df174c3f3 --- /dev/null +++ b/prisma/migrations/0073_v1439_medication_intake_unique_per_source/migration.sql @@ -0,0 +1,57 @@ +-- v1.4.39 W-SERVER-FIX-2 — unique constraint on +-- (user_id, medication_id, scheduled_for, source) for +-- medication_intake_events so the projection-then-backfill code path +-- can't mint duplicate REMINDER rows under a concurrent two-route +-- hit. +-- +-- Background. Two read routes now project active schedules through +-- `expandTodayIntakes` and idempotently `createMany`-backfill any +-- missing rows: +-- +-- GET /api/medications/intake?scope=today +-- GET /api/dashboard/summary +-- +-- The iOS Dashboard mounts both at roughly the same wall-clock when +-- the compliance ring + Erfassen sheet hydrate together. Pre-fix the +-- table carried only an INDEX on `(user_id, medication_id, +-- scheduled_for)`, NOT a UNIQUE constraint, so both routes could pass +-- their existence probes simultaneously and `createMany` the same +-- `(medicationId, scheduledFor, REMINDER)` row twice — corrupting the +-- compliance bucket counts on the next read. +-- +-- `source` is part of the key so the reminder worker's REMINDER row +-- doesn't collide with manual user inserts (WEB / TELEGRAM / IMPORT) +-- on the same slot. Distinct intake sources at the same scheduled +-- instant are a legitimate shape — e.g. a Telegram-bot logged take + +-- a Web-UI correction on the same dose. +-- +-- The query-side `(user_id, medication_id, scheduled_for)` index +-- stays in place — most reads filter on those three columns without +-- caring about `source`, so the legacy index keeps its read-path +-- value even after the unique constraint creates its own +-- (user_id, medication_id, scheduled_for, source) covering index. +-- +-- Production data check. The reminder worker is the only writer that +-- ever produces REMINDER-source rows pre-this-fix; it queries pending +-- rows by `(medicationId, scheduledFor)` before each insert so a +-- self-double-mint is structurally impossible. The new projection +-- code paths (intake route + dashboard route) are the only new +-- sources of potential duplicates, and both ship with the +-- `skipDuplicates: true` flag alongside this constraint. If +-- `migrate deploy` reports a unique-constraint violation, a follow-up +-- one-shot data-cleanup pass would dedup the offending rows by +-- (user_id, medication_id, scheduled_for, source) keeping the +-- lowest-id row; that cleanup is intentionally NOT bundled here so +-- the migration stays additive + reviewable. +-- +-- Additive only. IF NOT EXISTS / EXCEPTION-on-duplicate guards +-- mirror the patterns in 0067 / 0071. + +DO $$ BEGIN + ALTER TABLE "medication_intake_events" + ADD CONSTRAINT "medication_intake_events_user_id_medication_id_scheduled_for_source_key" + UNIQUE ("user_id", "medication_id", "scheduled_for", "source"); +EXCEPTION + WHEN duplicate_object THEN NULL; + WHEN duplicate_table THEN NULL; +END $$; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 328e04373..b0509aee8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -838,6 +838,15 @@ model MedicationIntakeEvent { user User @relation(fields: [userId], references: [id], onDelete: Cascade) medication Medication @relation(fields: [medicationId], references: [id], onDelete: Cascade) + /// v1.4.39 W-SERVER-FIX-2 — both the intake route and the dashboard + /// summary route project active schedules through `expandTodayIntakes` + /// and idempotently `createMany`-backfill missing rows. Without a + /// unique constraint the two reads could race a duplicate REMINDER + /// row in for the same `(medicationId, scheduledFor)` between the + /// existence probe and the insert. `source` is in the key so the + /// reminder worker (REMINDER) doesn't collide with manual user + /// inserts (WEB / TELEGRAM / IMPORT) on the same slot. + @@unique([userId, medicationId, scheduledFor, source]) @@index([userId, medicationId, scheduledFor]) @@map("medication_intake_events") }