From dccbdf5031c978689dc6c67712ba11db0e473aaa Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:57:12 +0200 Subject: [PATCH 1/2] Enforce normalised user emails with a check constraint Every stored address is lower-cased and trimmed since the email case fix and the manual merge of the case-variant duplicates. The check keeps it that way: with the existing unique index on email, an address can only exist once in any case, and a write path that forgets to normalise fails instead of creating a second account. --- .../migration.sql | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql diff --git a/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql b/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql new file mode 100644 index 00000000..e8ec97a9 --- /dev/null +++ b/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql @@ -0,0 +1,18 @@ +-- One address, one account, enforced by the database. +-- +-- The application writes addresses trimmed and lower-cased and looks them up +-- ignoring case (common/email.util.ts), and 20261019120000_normalize_user_emails +-- rewrote the existing rows. Accounts that only differed in case have been +-- merged by hand since, so every stored address is now normalised. +-- +-- This check keeps it that way: together with the existing unique index on +-- "email", an address that is stored normalised can only exist once in any +-- case. A write path that forgets to normalise fails loudly here instead of +-- silently creating a second account. +-- +-- A CHECK constraint rather than a unique index on lower("email"): Prisma +-- cannot describe expression indexes and would try to drop one on the next +-- `migrate dev`, while it leaves check constraints alone. +ALTER TABLE "users" + ADD CONSTRAINT "users_email_normalised_check" + CHECK ("email" = lower(btrim("email"))); From 90a65589f8e0f89174bb19e23add1bb32a323fda Mon Sep 17 00:00:00 2001 From: "helpcode.ai" <340500827+helpcodeai@users.noreply.github.com> Date: Mon, 19 Oct 2026 20:57:32 +0200 Subject: [PATCH 2/2] Skip the email check when legacy rows would violate it A failing migration keeps the app from starting. Self-hosted installs can still hold case-variant duplicates, so the constraint is only added when every row satisfies it, and a notice explains how to add it later. --- .../migration.sql | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql b/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql index e8ec97a9..8e41da84 100644 --- a/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql +++ b/packages/backend/prisma/migrations/20261020090000_users_email_normalised/migration.sql @@ -13,6 +13,21 @@ -- A CHECK constraint rather than a unique index on lower("email"): Prisma -- cannot describe expression indexes and would try to drop one on the next -- `migrate dev`, while it leaves check constraints alone. -ALTER TABLE "users" - ADD CONSTRAINT "users_email_normalised_check" - CHECK ("email" = lower(btrim("email"))); +-- +-- Only added when every row already satisfies it. A self-hosted install can +-- still hold case-variant duplicates that the previous migration left alone +-- on purpose; a failing migration would keep the app from starting, so there +-- it is skipped with a notice. Merge the rows listed by +-- scripts/report-duplicate-emails.sql, then add the constraint by hand with +-- the ALTER TABLE below. +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM "users" WHERE "email" <> lower(btrim("email"))) THEN + RAISE NOTICE 'users_email_normalised_check not added: some addresses are not normalised (see scripts/report-duplicate-emails.sql)'; + ELSIF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'users_email_normalised_check') THEN + ALTER TABLE "users" + ADD CONSTRAINT "users_email_normalised_check" + CHECK ("email" = lower(btrim("email"))); + END IF; +END +$$;