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