From ffba34f3544ee91fca8fdf8943380cf89a154972 Mon Sep 17 00:00:00 2001 From: Prem bharne Date: Tue, 31 Mar 2026 22:35:52 +0530 Subject: [PATCH 1/3] fix(db): prevent duplicate member segment affiliations Adds a pre-insert SELECT check in TypeScript and a concrete UNIQUE index migration on memberSegmentAffiliations robustly resolving duplicate affiliation logging problems. Signed-off-by: Prem bharne --- ...00000__fix-member-segment-affiliations.sql | 24 +++++++++++++++++ .../memberSegmentAffiliationRepository.ts | 26 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql diff --git a/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql b/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql new file mode 100644 index 0000000000..ce04336c47 --- /dev/null +++ b/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql @@ -0,0 +1,24 @@ +-- 1. Deduplicate existing memberSegmentAffiliations +DELETE FROM "memberSegmentAffiliations" a USING ( + SELECT MIN(id) as keep_id, "memberId", "segmentId", "organizationId", "dateStart", "dateEnd" + FROM "memberSegmentAffiliations" + GROUP BY "memberId", "segmentId", "organizationId", "dateStart", "dateEnd" + HAVING COUNT(*) > 1 +) b +WHERE a."memberId" = b."memberId" +AND a."segmentId" = b."segmentId" +AND a."organizationId" IS NOT DISTINCT FROM b."organizationId" +AND a."dateStart" IS NOT DISTINCT FROM b."dateStart" +AND a."dateEnd" IS NOT DISTINCT FROM b."dateEnd" +AND a.id <> b.keep_id; + +-- 2. Add an index to prevent exact duplicates in the future +-- Using COALESCE ensures that NULL values are logically treated as equal +-- across all supported PostgreSQL versions for the sake of uniqueness. +CREATE UNIQUE INDEX "uq_member_segment_affiliations" ON "memberSegmentAffiliations" ( + "memberId", + "segmentId", + COALESCE("organizationId", '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE("dateStart", '1970-01-01T00:00:00Z'::timestamp), + COALESCE("dateEnd", '1970-01-01T00:00:00Z'::timestamp) +); diff --git a/backend/src/database/repositories/memberSegmentAffiliationRepository.ts b/backend/src/database/repositories/memberSegmentAffiliationRepository.ts index b55bf6d5ec..c838db3cb1 100644 --- a/backend/src/database/repositories/memberSegmentAffiliationRepository.ts +++ b/backend/src/database/repositories/memberSegmentAffiliationRepository.ts @@ -44,6 +44,32 @@ class MemberSegmentAffiliationRepository extends RepositoryBase< const transaction = this.transaction + const existing = await this.options.database.sequelize.query( + `SELECT "id" FROM "memberSegmentAffiliations" + WHERE "memberId" = :memberId + AND "segmentId" = :segmentId + AND "organizationId" IS NOT DISTINCT FROM :organizationId + AND "dateStart" IS NOT DISTINCT FROM :dateStart + AND "dateEnd" IS NOT DISTINCT FROM :dateEnd + LIMIT 1`, + { + replacements: { + memberId: data.memberId, + segmentId: data.segmentId, + organizationId: data.organizationId, + dateStart: data.dateStart || null, + dateEnd: data.dateEnd || null, + }, + type: QueryTypes.SELECT, + transaction, + } + ) + + if (existing.length > 0) { + await this.updateAffiliation(data.memberId, data.segmentId, data.organizationId) + return this.findById((existing[0] as any).id) + } + const affiliationInsertResult = await this.options.database.sequelize.query( `INSERT INTO "memberSegmentAffiliations" ("id", "memberId", "segmentId", "organizationId", "dateStart", "dateEnd") VALUES From 911022f8b2c2a835dfb07e4a49c1c0377a1682f8 Mon Sep 17 00:00:00 2001 From: Prem bharne Date: Tue, 31 Mar 2026 22:41:47 +0530 Subject: [PATCH 2/3] fix(db): correct timestamptz type in unique index sentinel values Signed-off-by: Prem bharne --- .../V1775000000__fix-member-segment-affiliations.sql | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql b/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql index ce04336c47..009a98dc69 100644 --- a/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql +++ b/backend/src/database/migrations/V1775000000__fix-member-segment-affiliations.sql @@ -19,6 +19,6 @@ CREATE UNIQUE INDEX "uq_member_segment_affiliations" ON "memberSegmentAffiliatio "memberId", "segmentId", COALESCE("organizationId", '00000000-0000-0000-0000-000000000000'::uuid), - COALESCE("dateStart", '1970-01-01T00:00:00Z'::timestamp), - COALESCE("dateEnd", '1970-01-01T00:00:00Z'::timestamp) + COALESCE("dateStart", '1970-01-01T00:00:00Z'::timestamptz), + COALESCE("dateEnd", '1970-01-01T00:00:00Z'::timestamptz) ); From 83d74772b6929275e733c7255bfa638f73420084 Mon Sep 17 00:00:00 2001 From: Prem bharne Date: Tue, 31 Mar 2026 22:58:48 +0530 Subject: [PATCH 3/3] fix(db): use atomic INSERT ON CONFLICT instead of SELECT-then-INSERT Eliminates race condition under READ COMMITTED isolation and fixes COALESCE sentinel mismatch between the unique index and the application-level query. Signed-off-by: Prem bharne --- .../memberSegmentAffiliationRepository.ts | 34 +++++-------------- 1 file changed, 8 insertions(+), 26 deletions(-) diff --git a/backend/src/database/repositories/memberSegmentAffiliationRepository.ts b/backend/src/database/repositories/memberSegmentAffiliationRepository.ts index c838db3cb1..9f516c4a68 100644 --- a/backend/src/database/repositories/memberSegmentAffiliationRepository.ts +++ b/backend/src/database/repositories/memberSegmentAffiliationRepository.ts @@ -44,36 +44,18 @@ class MemberSegmentAffiliationRepository extends RepositoryBase< const transaction = this.transaction - const existing = await this.options.database.sequelize.query( - `SELECT "id" FROM "memberSegmentAffiliations" - WHERE "memberId" = :memberId - AND "segmentId" = :segmentId - AND "organizationId" IS NOT DISTINCT FROM :organizationId - AND "dateStart" IS NOT DISTINCT FROM :dateStart - AND "dateEnd" IS NOT DISTINCT FROM :dateEnd - LIMIT 1`, - { - replacements: { - memberId: data.memberId, - segmentId: data.segmentId, - organizationId: data.organizationId, - dateStart: data.dateStart || null, - dateEnd: data.dateEnd || null, - }, - type: QueryTypes.SELECT, - transaction, - } - ) - - if (existing.length > 0) { - await this.updateAffiliation(data.memberId, data.segmentId, data.organizationId) - return this.findById((existing[0] as any).id) - } - const affiliationInsertResult = await this.options.database.sequelize.query( `INSERT INTO "memberSegmentAffiliations" ("id", "memberId", "segmentId", "organizationId", "dateStart", "dateEnd") VALUES (:id, :memberId, :segmentId, :organizationId, :dateStart, :dateEnd) + ON CONFLICT ( + "memberId", + "segmentId", + COALESCE("organizationId", '00000000-0000-0000-0000-000000000000'::uuid), + COALESCE("dateStart", '1970-01-01T00:00:00Z'::timestamptz), + COALESCE("dateEnd", '1970-01-01T00:00:00Z'::timestamptz) + ) + DO UPDATE SET "organizationId" = EXCLUDED."organizationId" RETURNING "id" `, {