diff --git a/migrations/20260708134528-mail-accounts-partial-unique-user-id.js b/migrations/20260708134528-mail-accounts-partial-unique-user-id.js new file mode 100644 index 0000000..07fa5ae --- /dev/null +++ b/migrations/20260708134528-mail-accounts-partial-unique-user-id.js @@ -0,0 +1,30 @@ +'use strict'; + +const TABLE_NAME = 'mail_accounts'; +const INDEX_NAME = 'mail_accounts_user_id_active_unique'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT IF EXISTS mail_accounts_drive_user_uuid_key`, + ); + await queryInterface.sequelize.query( + `ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT IF EXISTS mail_accounts_user_id_key`, + ); + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX ${INDEX_NAME} + ON ${TABLE_NAME} (user_id) + WHERE deleted_at IS NULL`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${INDEX_NAME}`); + await queryInterface.addConstraint(TABLE_NAME, { + fields: ['user_id'], + type: 'unique', + name: 'mail_accounts_user_id_key', + }); + }, +}; diff --git a/migrations/20260708134529-mail-addresses-partial-unique-address.js b/migrations/20260708134529-mail-addresses-partial-unique-address.js new file mode 100644 index 0000000..3e80831 --- /dev/null +++ b/migrations/20260708134529-mail-addresses-partial-unique-address.js @@ -0,0 +1,27 @@ +'use strict'; + +const TABLE_NAME = 'mail_addresses'; +const INDEX_NAME = 'mail_addresses_address_active_unique'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT IF EXISTS mail_addresses_address_key`, + ); + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX ${INDEX_NAME} + ON ${TABLE_NAME} (address) + WHERE deleted_at IS NULL`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${INDEX_NAME}`); + await queryInterface.addConstraint(TABLE_NAME, { + fields: ['address'], + type: 'unique', + name: 'mail_addresses_address_key', + }); + }, +}; diff --git a/migrations/20260708134530-mail-provider-accounts-partial-unique.js b/migrations/20260708134530-mail-provider-accounts-partial-unique.js new file mode 100644 index 0000000..2641ead --- /dev/null +++ b/migrations/20260708134530-mail-provider-accounts-partial-unique.js @@ -0,0 +1,42 @@ +'use strict'; + +const TABLE_NAME = 'mail_provider_accounts'; +const ADDRESS_INDEX = 'mail_provider_accounts_mail_address_id_active_unique'; +const PROVIDER_INDEX = 'mail_provider_accounts_provider_external_id_active_unique'; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface) { + await queryInterface.sequelize.query( + `ALTER TABLE ${TABLE_NAME} DROP CONSTRAINT IF EXISTS mail_provider_accounts_mail_address_id_key`, + ); + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX ${ADDRESS_INDEX} + ON ${TABLE_NAME} (mail_address_id) + WHERE deleted_at IS NULL`, + ); + + await queryInterface.sequelize.query( + `DROP INDEX IF EXISTS mail_provider_accounts_provider_external_id`, + ); + await queryInterface.sequelize.query( + `CREATE UNIQUE INDEX ${PROVIDER_INDEX} + ON ${TABLE_NAME} (provider, external_id) + WHERE deleted_at IS NULL`, + ); + }, + + async down(queryInterface) { + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${ADDRESS_INDEX}`); + await queryInterface.addConstraint(TABLE_NAME, { + fields: ['mail_address_id'], + type: 'unique', + name: 'mail_provider_accounts_mail_address_id_key', + }); + + await queryInterface.sequelize.query(`DROP INDEX IF EXISTS ${PROVIDER_INDEX}`); + await queryInterface.addIndex(TABLE_NAME, ['provider', 'external_id'], { + unique: true, + }); + }, +}; diff --git a/src/modules/account/account.service.spec.ts b/src/modules/account/account.service.spec.ts index fc9bc31..823f26c 100644 --- a/src/modules/account/account.service.spec.ts +++ b/src/modules/account/account.service.spec.ts @@ -592,6 +592,21 @@ describe('AccountService', () => { expect(result).toBe(existingAccount); expect(provider.createAccount).not.toHaveBeenCalled(); }); + + it('when unique collision occurs but no account is visible, then throws a conflict', async () => { + const uniqueError = new Error('Unique constraint violated'); + uniqueError.name = 'SequelizeUniqueConstraintError'; + + domains.findByDomain.mockResolvedValue(domain); + addresses.findByAddress.mockResolvedValue(null); + accounts.findByUserId.mockResolvedValue(null); + accounts.create.mockRejectedValue(uniqueError); + + await expect(service.provisionAccount(params)).rejects.toThrow( + ConflictException, + ); + expect(provider.createAccount).not.toHaveBeenCalled(); + }); }); describe('deleteAccount', () => { diff --git a/src/modules/account/account.service.ts b/src/modules/account/account.service.ts index 457282b..00023e1 100644 --- a/src/modules/account/account.service.ts +++ b/src/modules/account/account.service.ts @@ -195,10 +195,16 @@ export class AccountService { error instanceof Error && error.name === 'SequelizeUniqueConstraintError' ) { - this.logger.warn( - `Concurrent provisioning for '${params.userId}', returning existing`, + const existing = await this.accounts.findByUserId(params.userId); + if (existing) { + this.logger.warn( + `Concurrent provisioning for '${params.userId}', returning existing`, + ); + return existing; + } + throw new ConflictException( + `Account for user '${params.userId}' already exists`, ); - return this.getAccountOrFail(params.userId); } throw error; } diff --git a/src/modules/account/models/mail-account.model.ts b/src/modules/account/models/mail-account.model.ts index 4f08414..503536f 100644 --- a/src/modules/account/models/mail-account.model.ts +++ b/src/modules/account/models/mail-account.model.ts @@ -7,7 +7,6 @@ import { Model, PrimaryKey, Table, - Unique, } from 'sequelize-typescript'; import { MailAddressModel } from './mail-address.model.js'; @@ -16,6 +15,14 @@ import { MailAddressModel } from './mail-address.model.js'; timestamps: true, paranoid: true, tableName: 'mail_accounts', + indexes: [ + { + name: 'mail_accounts_user_id_active_unique', + unique: true, + fields: ['user_id'], + where: { deleted_at: null }, + }, + ], }) export class MailAccountModel extends Model { @PrimaryKey @@ -24,7 +31,6 @@ export class MailAccountModel extends Model { declare id: string; @AllowNull(false) - @Unique @Column(DataType.UUID) declare userId: string; diff --git a/src/modules/account/models/mail-address.model.ts b/src/modules/account/models/mail-address.model.ts index 429a606..6ed9acf 100644 --- a/src/modules/account/models/mail-address.model.ts +++ b/src/modules/account/models/mail-address.model.ts @@ -10,7 +10,6 @@ import { Model, PrimaryKey, Table, - Unique, } from 'sequelize-typescript'; import { MailAccountModel } from './mail-account.model.js'; import { MailDomainModel } from './mail-domain.model.js'; @@ -21,6 +20,14 @@ import { MailProviderAccountModel } from './mail-provider-account.model.js'; timestamps: true, paranoid: true, tableName: 'mail_addresses', + indexes: [ + { + name: 'mail_addresses_address_active_unique', + unique: true, + fields: ['address'], + where: { deleted_at: null }, + }, + ], }) export class MailAddressModel extends Model { @PrimaryKey @@ -35,7 +42,6 @@ export class MailAddressModel extends Model { declare mailAccountId: string; @AllowNull(false) - @Unique @Column(DataType.STRING(255)) declare address: string; diff --git a/src/modules/account/models/mail-provider-account.model.ts b/src/modules/account/models/mail-provider-account.model.ts index 26a41ee..8c8e47f 100644 --- a/src/modules/account/models/mail-provider-account.model.ts +++ b/src/modules/account/models/mail-provider-account.model.ts @@ -8,7 +8,6 @@ import { Model, PrimaryKey, Table, - Unique, } from 'sequelize-typescript'; import { MailAddressModel } from './mail-address.model.js'; @@ -17,7 +16,20 @@ import { MailAddressModel } from './mail-address.model.js'; timestamps: true, paranoid: true, tableName: 'mail_provider_accounts', - indexes: [{ unique: true, fields: ['provider', 'external_id'] }], + indexes: [ + { + name: 'mail_provider_accounts_provider_external_id_active_unique', + unique: true, + fields: ['provider', 'external_id'], + where: { deleted_at: null }, + }, + { + name: 'mail_provider_accounts_mail_address_id_active_unique', + unique: true, + fields: ['mail_address_id'], + where: { deleted_at: null }, + }, + ], }) export class MailProviderAccountModel extends Model { @PrimaryKey @@ -26,7 +38,6 @@ export class MailProviderAccountModel extends Model { declare id: string; @AllowNull(false) - @Unique @ForeignKey(() => MailAddressModel) @Column(DataType.UUID) declare mailAddressId: string;