diff --git a/src/Core/Entities/Collection.cs b/src/Core/Entities/Collection.cs index 275cd80d2f..c92e4dad4e 100644 --- a/src/Core/Entities/Collection.cs +++ b/src/Core/Entities/Collection.cs @@ -17,6 +17,7 @@ public class Collection : ITableObject public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public CollectionType Type { get; set; } = CollectionType.SharedCollection; public string? DefaultUserCollectionEmail { get; set; } + public Guid? DefaultCollectionOwner { get; set; } public void SetNewId() { diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 5327e89165..8e9156656a 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -462,8 +462,8 @@ public class CollectionRepository : Repository, ICollectionRep CreationDate = DateTime.UtcNow, RevisionDate = DateTime.UtcNow, Type = CollectionType.DefaultUserCollection, - DefaultUserCollectionEmail = null - + DefaultUserCollectionEmail = null, + DefaultCollectionOwner = orgUserId }); collectionUsers.Add(new CollectionUser diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 5f1c6f00d8..bbb0de743b 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -826,76 +826,56 @@ public class CollectionRepository : Repository + try { - // Use SERIALIZABLE isolation level to prevent race conditions during concurrent calls - using var transaction = await dbContext.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable); + // Create new default collection + var collectionId = CoreHelpers.GenerateComb(); + var now = DateTime.UtcNow; - try + var collection = new Collection { - // Check if this organization user already has a default collection - // SERIALIZABLE ensures this SELECT acquires range locks - var existingDefaultCollection = await ( - from c in dbContext.Collections - join cu in dbContext.CollectionUsers on c.Id equals cu.CollectionId - where cu.OrganizationUserId == organizationUserId - && c.OrganizationId == organizationId - && c.Type == CollectionType.DefaultUserCollection - select c - ).FirstOrDefaultAsync(); + Id = collectionId, + OrganizationId = organizationId, + Name = defaultCollectionName, + ExternalId = null, + CreationDate = now, + RevisionDate = now, + Type = CollectionType.DefaultUserCollection, + DefaultUserCollectionEmail = null, + DefaultCollectionOwner = organizationUserId + }; - // If collection already exists, return false (not created) - if (existingDefaultCollection != null) - { - await transaction.CommitAsync(); - return false; - } - - // Create new default collection - var collectionId = CoreHelpers.GenerateComb(); - var now = DateTime.UtcNow; - - var collection = new Collection - { - Id = collectionId, - OrganizationId = organizationId, - Name = defaultCollectionName, - ExternalId = null, - CreationDate = now, - RevisionDate = now, - Type = CollectionType.DefaultUserCollection, - DefaultUserCollectionEmail = null - }; - - var collectionUser = new CollectionUser - { - CollectionId = collectionId, - OrganizationUserId = organizationUserId, - ReadOnly = false, - HidePasswords = false, - Manage = true - }; - - await dbContext.Collections.AddAsync(collection); - await dbContext.CollectionUsers.AddAsync(collectionUser); - await dbContext.SaveChangesAsync(); - - // Bump user account revision dates - await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collectionId, organizationId); - await dbContext.SaveChangesAsync(); - - await transaction.CommitAsync(); - return true; - } - catch + var collectionUser = new CollectionUser { - await transaction.RollbackAsync(); - throw; - } - }); + CollectionId = collectionId, + OrganizationUserId = organizationUserId, + ReadOnly = false, + HidePasswords = false, + Manage = true + }; + + await dbContext.Collections.AddAsync(collection); + await dbContext.CollectionUsers.AddAsync(collectionUser); + await dbContext.SaveChangesAsync(); + + // Bump user account revision dates + await dbContext.UserBumpAccountRevisionDateByCollectionIdAsync(collectionId, organizationId); + await dbContext.SaveChangesAsync(); + + return true; + } + catch (DbUpdateException ex) when (IsUniqueConstraintViolation(ex)) + { + // Collection already exists, return false + return false; + } + } + + private static bool IsUniqueConstraintViolation(DbUpdateException ex) + { + // Check if the inner exception is a SqlException with error 2601 or 2627 + return ex.InnerException is Microsoft.Data.SqlClient.SqlException sqlEx + && (sqlEx.Number == 2601 || sqlEx.Number == 2627); } private async Task> GetOrgUserIdsWithDefaultCollectionAsync(DatabaseContext dbContext, Guid organizationId) @@ -938,8 +918,8 @@ public class CollectionRepository : Repository 0 - ROLLBACK TRANSACTION; - THROW; + -- Check if error is unique constraint violation (error 2601 or 2627) + IF ERROR_NUMBER() IN (2601, 2627) + BEGIN + -- Collection already exists, return gracefully + SET @WasCreated = 0; + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + END + ELSE + BEGIN + -- Unexpected error, rollback and re-throw + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END END CATCH END GO diff --git a/src/Sql/dbo/Tables/Collection.sql b/src/Sql/dbo/Tables/Collection.sql index 2f0d3b943b..be9a39278e 100644 --- a/src/Sql/dbo/Tables/Collection.sql +++ b/src/Sql/dbo/Tables/Collection.sql @@ -7,8 +7,10 @@ [RevisionDate] DATETIME2 (7) NOT NULL, [DefaultUserCollectionEmail] NVARCHAR(256) NULL, [Type] TINYINT NOT NULL DEFAULT(0), + [DefaultCollectionOwner] UNIQUEIDENTIFIER NULL, CONSTRAINT [PK_Collection] PRIMARY KEY CLUSTERED ([Id] ASC), - CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE + CONSTRAINT [FK_Collection_Organization] FOREIGN KEY ([OrganizationId]) REFERENCES [dbo].[Organization] ([Id]) ON DELETE CASCADE, + CONSTRAINT [FK_Collection_OrganizationUser] FOREIGN KEY ([DefaultCollectionOwner]) REFERENCES [dbo].[OrganizationUser] ([Id]) ON DELETE SET NULL ); GO @@ -17,3 +19,8 @@ CREATE NONCLUSTERED INDEX [IX_Collection_OrganizationId_IncludeAll] INCLUDE([CreationDate], [Name], [RevisionDate], [Type]); GO +CREATE UNIQUE NONCLUSTERED INDEX [IX_Collection_DefaultCollectionOwner_OrganizationId_Type] + ON [dbo].[Collection]([DefaultCollectionOwner], [OrganizationId], [Type]) + WHERE [Type] = 1; +GO + diff --git a/util/Migrator/DbScripts/2025-12-20_00_Collection_AddDefaultCollectionOwnerAndConstraint.sql b/util/Migrator/DbScripts/2025-12-20_00_Collection_AddDefaultCollectionOwnerAndConstraint.sql new file mode 100644 index 0000000000..5144d0515f --- /dev/null +++ b/util/Migrator/DbScripts/2025-12-20_00_Collection_AddDefaultCollectionOwnerAndConstraint.sql @@ -0,0 +1,76 @@ +-- Add DefaultCollectionOwner column to Collection table for Type=1 collections +-- This enables a filtered unique constraint to prevent duplicate default collections + +IF NOT EXISTS ( + SELECT * + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'dbo' + AND TABLE_NAME = 'Collection' + AND COLUMN_NAME = 'DefaultCollectionOwner' +) +BEGIN + ALTER TABLE [dbo].[Collection] + ADD [DefaultCollectionOwner] UNIQUEIDENTIFIER NULL +END +GO + +-- Add foreign key constraint to OrganizationUser +IF NOT EXISTS ( + SELECT * + FROM sys.foreign_keys + WHERE name = 'FK_Collection_OrganizationUser' + AND parent_object_id = OBJECT_ID('[dbo].[Collection]') +) +BEGIN + ALTER TABLE [dbo].[Collection] + ADD CONSTRAINT [FK_Collection_OrganizationUser] + FOREIGN KEY ([DefaultCollectionOwner]) + REFERENCES [dbo].[OrganizationUser] ([Id]) + ON DELETE SET NULL +END +GO + +-- Create filtered unique index to prevent duplicate default collections per user +IF NOT EXISTS ( + SELECT * + FROM sys.indexes + WHERE name = 'IX_Collection_DefaultCollectionOwner_OrganizationId_Type' + AND object_id = OBJECT_ID('[dbo].[Collection]') +) +BEGIN + CREATE UNIQUE NONCLUSTERED INDEX [IX_Collection_DefaultCollectionOwner_OrganizationId_Type] + ON [dbo].[Collection]([DefaultCollectionOwner], [OrganizationId], [Type]) + WHERE [Type] = 1; +END +GO + +-- Refresh dependent views to include new column +IF OBJECT_ID('[dbo].[CollectionView]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[CollectionView]'; +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadById]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[Collection_ReadById]'; +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadByIds]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[Collection_ReadByIds]'; +END +GO + +IF OBJECT_ID('[dbo].[Collection_ReadByOrganizationId]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[Collection_ReadByOrganizationId]'; +END +GO + +IF OBJECT_ID('[dbo].[UserCollectionDetails]') IS NOT NULL +BEGIN + EXECUTE sp_refreshsqlmodule N'[dbo].[UserCollectionDetails]'; +END +GO diff --git a/util/Migrator/DbScripts/2025-12-02_00_Collection_UpsertDefaultCollection.sql b/util/Migrator/DbScripts/2025-12-20_01_Collection_UpsertDefaultCollection.sql similarity index 60% rename from util/Migrator/DbScripts/2025-12-02_00_Collection_UpsertDefaultCollection.sql rename to util/Migrator/DbScripts/2025-12-20_01_Collection_UpsertDefaultCollection.sql index 160d4e3cc7..a5343dfa2e 100644 --- a/util/Migrator/DbScripts/2025-12-02_00_Collection_UpsertDefaultCollection.sql +++ b/util/Migrator/DbScripts/2025-12-20_01_Collection_UpsertDefaultCollection.sql @@ -1,6 +1,6 @@ -- Create the idempotent stored procedure for creating default collections --- This procedure prevents duplicate "My Items" collections for users by checking --- if a default collection already exists before attempting to create one. +-- This procedure prevents duplicate "My Items" collections for users using +-- a filtered unique constraint on (DefaultCollectionOwner, OrganizationId, Type) WHERE Type = 1. CREATE OR ALTER PROCEDURE [dbo].[Collection_UpsertDefaultCollection] @CollectionId UNIQUEIDENTIFIER, @@ -14,34 +14,12 @@ AS BEGIN SET NOCOUNT ON - -- Use SERIALIZABLE isolation level to prevent race conditions during concurrent calls - SET TRANSACTION ISOLATION LEVEL SERIALIZABLE; BEGIN TRANSACTION; BEGIN TRY - DECLARE @ExistingCollectionId UNIQUEIDENTIFIER; - - -- Check if this organization user already has a default collection - -- SERIALIZABLE ensures range locks prevent concurrent insertions - SELECT @ExistingCollectionId = c.Id - FROM [dbo].[Collection] c - INNER JOIN [dbo].[CollectionUser] cu ON cu.CollectionId = c.Id - WHERE cu.OrganizationUserId = @OrganizationUserId - AND c.OrganizationId = @OrganizationId - AND c.Type = 1; -- CollectionType.DefaultUserCollection - - -- If collection already exists, return early - IF @ExistingCollectionId IS NOT NULL - BEGIN - SET @WasCreated = 0; - COMMIT TRANSACTION; - RETURN; - END - - -- Create new default collection SET @WasCreated = 1; - -- Insert Collection + -- Insert Collection with DefaultCollectionOwner populated for constraint enforcement INSERT INTO [dbo].[Collection] ( [Id], @@ -51,7 +29,8 @@ BEGIN [CreationDate], [RevisionDate], [DefaultUserCollectionEmail], - [Type] + [Type], + [DefaultCollectionOwner] ) VALUES ( @@ -62,7 +41,8 @@ BEGIN @CreationDate, @RevisionDate, NULL, -- DefaultUserCollectionEmail - 1 -- CollectionType.DefaultUserCollection + 1, -- CollectionType.DefaultUserCollection + @OrganizationUserId ); -- Insert CollectionUser @@ -89,9 +69,21 @@ BEGIN COMMIT TRANSACTION; END TRY BEGIN CATCH - IF @@TRANCOUNT > 0 - ROLLBACK TRANSACTION; - THROW; + -- Check if error is unique constraint violation (error 2601 or 2627) + IF ERROR_NUMBER() IN (2601, 2627) + BEGIN + -- Collection already exists, return gracefully + SET @WasCreated = 0; + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + END + ELSE + BEGIN + -- Unexpected error, rollback and re-throw + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + THROW; + END END CATCH END GO