Files
HealthLog/prisma/migrations/0159_v11616_consent_active_unique/migration.sql
T
Marc-André Bombeck 32d275b4aa Harden consent receipts and document iOS series + ai-provider contract
Document GET /api/measurements/series and GET /api/user/ai-provider in the
OpenAPI registry — both are consumed by the iOS client (charts and Coach
visibility gating) but were absent from the spec.

Break migration 0159's active-receipt dedup on (created_at, id) so an exact
created_at tie still leaves one survivor, keeping the unique index build from
aborting 23505 on affected tenants.

Catch P2002 in createReceipt: two concurrent first grants both reach create,
the loser re-reads the winning active receipt and returns it instead of a 500.
2026-06-14 17:42:00 +02:00

48 lines
2.4 KiB
SQL

-- v1.16.16 — at most one active consent receipt per (user, kind).
--
-- The web-grant heal mint reads `latestActiveReceipt` then `createReceipt`
-- non-atomically. Two concurrent web mounts could each see "no active
-- receipt" and both insert an active `ai_full` row, leaving duplicate
-- active grants the revoke path then has to chase one at a time.
--
-- The application wraps the mint in a transaction with a re-check, but the
-- structural guarantee lives here: a partial unique index over
-- (user_id, kind) restricted to non-revoked rows. Revoked rows carry a
-- non-NULL `revoked_at` and fall outside the predicate, so the append-only
-- grant/revoke history is unaffected — only two *active* rows for the same
-- (user, kind) collide.
-- Pre-flight dedup. The bug this migration closes already produces duplicate
-- active rows on affected tenants; `CREATE UNIQUE INDEX` scans existing rows
-- and would abort (23505) on any such pair, failing the whole deploy on
-- exactly the tenants the fix targets. Revoke all-but-the-newest active row
-- per (user_id, kind) first so the index can build. The newest active row
-- (latest `created_at`) is the one `latestActiveReceipt` already returns, so
-- the surviving active grant matches current read behaviour; the superseded
-- duplicates get a `revoked_at` marker and stay in the audit history.
--
-- The sibling predicate breaks on `(created_at, id)`, not `created_at` alone:
-- two active rows with an EXACT `created_at` tie would otherwise leave each
-- without a strictly-greater sibling, so neither is revoked and the
-- `CREATE UNIQUE INDEX` below aborts (23505), failing the deploy on exactly
-- the tenants this migration heals. The `id` tie-break is total (PK), so each
-- duplicate set keeps precisely one survivor — the row with the greatest
-- `(created_at, id)`, preserving the "newest survives" intent.
UPDATE "consent_receipts" c
SET "revoked_at" = now()
WHERE "revoked_at" IS NULL
AND EXISTS (
SELECT 1 FROM "consent_receipts" n
WHERE n."user_id" = c."user_id"
AND n."kind" = c."kind"
AND n."revoked_at" IS NULL
AND (
n."created_at" > c."created_at"
OR (n."created_at" = c."created_at" AND n."id" > c."id")
)
);
CREATE UNIQUE INDEX IF NOT EXISTS "consent_receipts_user_id_kind_active_key"
ON "consent_receipts" ("user_id", "kind")
WHERE "revoked_at" IS NULL;