From f93c02a9e53907be15565d4b46b42b24ba5a7f92 Mon Sep 17 00:00:00 2001 From: Blume1977 Date: Tue, 9 Jun 2026 16:46:30 +0200 Subject: [PATCH] fix(user): unique index on LOWER(mail) to enforce e-mail uniqueness Follow-up to #3855. Replaces the non-unique LOWER(mail) index with a UNIQUE partial index so duplicate accounts for the same address can no longer be created. GATED: must not be deployed until existing duplicate mails are resolved, otherwise CREATE UNIQUE INDEX fails. The predicate mirrors the getUsersByMail dedup set and excludes Merged/Blocked rows, which legitimately retain their mail. --- ...6011873-AddUserDataMailLowerUniqueIndex.js | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 migration/1781016011873-AddUserDataMailLowerUniqueIndex.js diff --git a/migration/1781016011873-AddUserDataMailLowerUniqueIndex.js b/migration/1781016011873-AddUserDataMailLowerUniqueIndex.js new file mode 100644 index 0000000000..5ca6edcc99 --- /dev/null +++ b/migration/1781016011873-AddUserDataMailLowerUniqueIndex.js @@ -0,0 +1,35 @@ +// Replace the non-unique LOWER(mail) index (added in #3855) with a UNIQUE one to permanently +// prevent duplicate accounts for the same e-mail address. +// +// GATING: this migration MUST NOT be merged/deployed until all existing case-insensitive +// duplicate mails have been resolved (merged or nulled). Otherwise `CREATE UNIQUE INDEX` fails. +// Pre-check (must return zero rows): +// SELECT LOWER(mail), array_agg(id), count(*) FROM user_data WHERE mail IS NOT NULL +// GROUP BY LOWER(mail) HAVING count(*) > 1; +// +// PREDICATE: the partial index intentionally mirrors the dedup set used by +// `getUsersByMail(onlyValidUser)` and excludes 'Merged' and 'Blocked' rows. Merged slaves retain +// their mail (mergeUserData does not null it), and blocked duplicates may keep theirs for audit; +// a bare `WHERE mail IS NOT NULL` predicate would break on historical merges and on every future +// merge. Scoping to the active set keeps uniqueness consistent with the lookup that enforces it. + +/** + * @typedef {import('typeorm').MigrationInterface} MigrationInterface + * @typedef {import('typeorm').QueryRunner} QueryRunner + */ + +module.exports = class AddUserDataMailLowerUniqueIndex1781016011873 { + name = 'AddUserDataMailLowerUniqueIndex1781016011873'; + + async up(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_user_data_mail_lower"`); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_user_data_mail_lower" ON "user_data" (LOWER("mail")) WHERE "mail" IS NOT NULL AND "status" IN ('Active', 'NA', 'KycOnly', 'Deactivated')`, + ); + } + + async down(queryRunner) { + await queryRunner.query(`DROP INDEX "public"."IDX_user_data_mail_lower"`); + await queryRunner.query(`CREATE INDEX "IDX_user_data_mail_lower" ON "user_data" (LOWER("mail"))`); + } +};