Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions migrations/20260708134528-mail-accounts-partial-unique-user-id.js
Original file line number Diff line number Diff line change
@@ -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',
});
},
};
27 changes: 27 additions & 0 deletions migrations/20260708134529-mail-addresses-partial-unique-address.js
Original file line number Diff line number Diff line change
@@ -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',
});
},
};
42 changes: 42 additions & 0 deletions migrations/20260708134530-mail-provider-accounts-partial-unique.js
Original file line number Diff line number Diff line change
@@ -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,
});
},
};
15 changes: 15 additions & 0 deletions src/modules/account/account.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
12 changes: 9 additions & 3 deletions src/modules/account/account.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
10 changes: 8 additions & 2 deletions src/modules/account/models/mail-account.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import {
Model,
PrimaryKey,
Table,
Unique,
} from 'sequelize-typescript';
import { MailAddressModel } from './mail-address.model.js';

Expand All @@ -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
Expand All @@ -24,7 +31,6 @@ export class MailAccountModel extends Model {
declare id: string;

@AllowNull(false)
@Unique
@Column(DataType.UUID)
declare userId: string;

Expand Down
10 changes: 8 additions & 2 deletions src/modules/account/models/mail-address.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand All @@ -35,7 +42,6 @@ export class MailAddressModel extends Model {
declare mailAccountId: string;

@AllowNull(false)
@Unique
@Column(DataType.STRING(255))
declare address: string;

Expand Down
17 changes: 14 additions & 3 deletions src/modules/account/models/mail-provider-account.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Model,
PrimaryKey,
Table,
Unique,
} from 'sequelize-typescript';
import { MailAddressModel } from './mail-address.model.js';

Expand All @@ -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
Expand All @@ -26,7 +38,6 @@ export class MailProviderAccountModel extends Model {
declare id: string;

@AllowNull(false)
@Unique
@ForeignKey(() => MailAddressModel)
@Column(DataType.UUID)
declare mailAddressId: string;
Expand Down
Loading