From 8d67715e1fd0fdf35a51e461d94627039055aa0c Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Thu, 2 Apr 2026 16:37:18 +0100 Subject: [PATCH 01/19] Fix UpdateCollectionCommand to set RevisionDate using TimeProvider and update corresponding tests. Adjust tests to verify correct RevisionDate assignment during collection updates. --- .../UpdateCollectionCommand.cs | 6 ++- .../UpdateCollectionCommandTests.cs | 39 +++++++++++++------ .../CollectionRepositoryReplaceTests.cs | 3 ++ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs index b84e76a18cdb..6be1053c0e24 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/UpdateCollectionCommand.cs @@ -16,15 +16,18 @@ public class UpdateCollectionCommand : IUpdateCollectionCommand private readonly IEventService _eventService; private readonly IOrganizationRepository _organizationRepository; private readonly ICollectionRepository _collectionRepository; + private readonly TimeProvider _timeProvider; public UpdateCollectionCommand( IEventService eventService, IOrganizationRepository organizationRepository, - ICollectionRepository collectionRepository) + ICollectionRepository collectionRepository, + TimeProvider timeProvider) { _eventService = eventService; _organizationRepository = organizationRepository; _collectionRepository = collectionRepository; + _timeProvider = timeProvider; } public async Task UpdateAsync(Collection collection, IEnumerable groups = null, @@ -75,6 +78,7 @@ public async Task UpdateAsync(Collection collection, IEnumerable existingUsers, - SutProvider sutProvider) + [CollectionAccessSelectionCustomize(true)] IEnumerable existingUsers) { + var sutProvider = SetupSutProvider(); + collection.RevisionDate = DateTime.UtcNow.AddYears(-1); + organization.AllowAdminAccessToAllCollectionItems = false; var creationDate = collection.CreationDate; sutProvider.GetDependency() @@ -35,7 +40,6 @@ public async Task UpdateAsync_WithoutGroupsAndUsers_ReplacesCollection( .Returns(new Tuple( collection, new CollectionAccessDetails { Groups = [], Users = existingUsers })); - var utcNow = DateTime.UtcNow; await sutProvider.Sut.UpdateAsync(collection, null, null); @@ -49,22 +53,23 @@ await sutProvider.GetDependency() .Received(1) .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); - Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal(_expectedRevisionDate, collection.RevisionDate); } [Theory, BitAutoData] public async Task UpdateAsync_WithGroupsAndUsers_ReplacesCollectionWithGroupsAndUsers( Organization organization, Collection collection, [CollectionAccessSelectionCustomize(true)] IEnumerable groups, - IEnumerable users, - SutProvider sutProvider) + IEnumerable users) { + var sutProvider = SetupSutProvider(); + collection.RevisionDate = DateTime.UtcNow.AddYears(-1); + var creationDate = collection.CreationDate; organization.UseGroups = true; sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); - var utcNow = DateTime.UtcNow; await sutProvider.Sut.UpdateAsync(collection, groups, users); @@ -78,22 +83,23 @@ await sutProvider.GetDependency() .Received(1) .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); - Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal(_expectedRevisionDate, collection.RevisionDate); } [Theory, BitAutoData] public async Task UpdateAsync_WithOrganizationUseGroupDisabled_ReplacesCollectionWithoutGroups( Organization organization, Collection collection, [CollectionAccessSelectionCustomize] IEnumerable groups, - [CollectionAccessSelectionCustomize(true)] IEnumerable users, - SutProvider sutProvider) + [CollectionAccessSelectionCustomize(true)] IEnumerable users) { + var sutProvider = SetupSutProvider(); + collection.RevisionDate = DateTime.UtcNow.AddYears(-1); + var creationDate = collection.CreationDate; organization.UseGroups = false; sutProvider.GetDependency() .GetByIdAsync(organization.Id) .Returns(organization); - var utcNow = DateTime.UtcNow; await sutProvider.Sut.UpdateAsync(collection, groups, users); @@ -107,7 +113,7 @@ await sutProvider.GetDependency() .Received(1) .LogCollectionEventAsync(collection, EventType.Collection_Updated); Assert.Equal(collection.CreationDate, creationDate); - Assert.True(collection.RevisionDate - utcNow < TimeSpan.FromSeconds(1)); + Assert.Equal(_expectedRevisionDate, collection.RevisionDate); } [Theory, BitAutoData] @@ -316,4 +322,13 @@ await sutProvider.GetDependency() .Received(1) .ReplaceAsync(collection, null, null); } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedRevisionDate); + return sutProvider; + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs index de4fd53a6895..0857505e6f23 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs @@ -50,7 +50,9 @@ await collectionRepository.CreateAsync(collection, ); // Act + var originalRevisionDate = collection.RevisionDate; collection.Name = "Updated Collection Name"; + collection.RevisionDate = DateTime.UtcNow; await collectionRepository.ReplaceAsync(collection, [ @@ -74,6 +76,7 @@ await collectionRepository.ReplaceAsync(collection, Assert.NotNull(actualCollection); Assert.Equal("Updated Collection Name", actualCollection.Name); + Assert.True(actualCollection.RevisionDate > originalRevisionDate); var groups = actualAccess.Groups.ToArray(); Assert.Equal(2, groups.Length); From a330cd6c3a0f990a49511a5810e066f3de1a46f6 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 3 Apr 2026 15:31:30 +0100 Subject: [PATCH 02/19] Enhance BulkAddCollectionAccessCommand to include revision date in access updates. Update ICollectionRepository and its implementations to accept revision date parameter. Modify stored procedure to update collection revision dates accordingly. Add tests to verify correct behavior of access creation and revision date updates. --- .../BulkAddCollectionAccessCommand.cs | 12 ++++-- .../Repositories/ICollectionRepository.cs | 12 +++++- .../Repositories/CollectionRepository.cs | 12 +++++- .../Repositories/CollectionRepository.cs | 13 +++++- ...Collection_CreateOrUpdateAccessForMany.sql | 12 +++++- .../BulkAddCollectionAccessCommandTests.cs | 20 ++++++++-- .../CollectionRepositoryTests.cs | 40 +++++++++++++++++++ 7 files changed, 110 insertions(+), 11 deletions(-) diff --git a/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs b/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs index 929c236ef2e6..68f0ad75cdcf 100644 --- a/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs +++ b/src/Core/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommand.cs @@ -15,17 +15,20 @@ public class BulkAddCollectionAccessCommand : IBulkAddCollectionAccessCommand private readonly IOrganizationUserRepository _organizationUserRepository; private readonly IGroupRepository _groupRepository; private readonly IEventService _eventService; + private readonly TimeProvider _timeProvider; public BulkAddCollectionAccessCommand( ICollectionRepository collectionRepository, IOrganizationUserRepository organizationUserRepository, IGroupRepository groupRepository, - IEventService eventService) + IEventService eventService, + TimeProvider timeProvider) { _collectionRepository = collectionRepository; _organizationUserRepository = organizationUserRepository; _groupRepository = groupRepository; _eventService = eventService; + _timeProvider = timeProvider; } public async Task AddAccessAsync(ICollection collections, @@ -34,15 +37,18 @@ public async Task AddAccessAsync(ICollection collections, { await ValidateRequestAsync(collections, users, groups); + var revisionDate = _timeProvider.GetUtcNow().UtcDateTime; + await _collectionRepository.CreateOrUpdateAccessForManyAsync( collections.First().OrganizationId, collections.Select(c => c.Id), users, - groups + groups, + revisionDate ); await _eventService.LogCollectionEventsAsync(collections.Select(c => - (c, EventType.Collection_Updated, (DateTime?)DateTime.UtcNow))); + (c, EventType.Collection_Updated, (DateTime?)revisionDate))); } private async Task ValidateRequestAsync(ICollection collections, ICollection usersAccess, ICollection groupsAccess) diff --git a/src/Core/Repositories/ICollectionRepository.cs b/src/Core/Repositories/ICollectionRepository.cs index 2db809e3de32..4f34c7a3187c 100644 --- a/src/Core/Repositories/ICollectionRepository.cs +++ b/src/Core/Repositories/ICollectionRepository.cs @@ -60,8 +60,18 @@ public interface ICollectionRepository : IRepository Task UpdateUsersAsync(Guid id, IEnumerable users); Task> GetManyUsersByIdAsync(Guid id); Task DeleteManyAsync(IEnumerable collectionIds); + + /// + /// Creates or updates the access for many collections. + /// + /// The Organization ID. + /// The Collection IDs to create or update access for. + /// The users to grant access to. + /// The groups to grant access to. + /// The revision date to use for the collections. Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, - IEnumerable users, IEnumerable groups); + IEnumerable users, IEnumerable groups, + DateTime revisionDate); /// /// Creates default user collections for the specified organization users. diff --git a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs index 2c733956c03e..e0b2657258a8 100644 --- a/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.Dapper/Repositories/CollectionRepository.cs @@ -301,7 +301,8 @@ await connection.ExecuteAsync("[dbo].[Collection_DeleteByIds]", } public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, - IEnumerable users, IEnumerable groups) + IEnumerable users, IEnumerable groups, + DateTime revisionDate) { using (var connection = new SqlConnection(ConnectionString)) { @@ -310,7 +311,14 @@ public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumera var results = await connection.ExecuteAsync( $"[{Schema}].[Collection_CreateOrUpdateAccessForMany]", - new { OrganizationId = organizationId, CollectionIds = collectionIds.ToGuidIdArrayTVP(), Users = usersArray, Groups = groupsArray }, + new + { + OrganizationId = organizationId, + CollectionIds = collectionIds.ToGuidIdArrayTVP(), + Users = usersArray, + Groups = groupsArray, + RevisionDate = revisionDate + }, commandType: CommandType.StoredProcedure); } } diff --git a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs index 67441fb3f53c..277d9edc3379 100644 --- a/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs +++ b/src/Infrastructure.EntityFramework/Repositories/CollectionRepository.cs @@ -630,7 +630,8 @@ public async Task DeleteManyAsync(IEnumerable collectionIds) } public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumerable collectionIds, - IEnumerable users, IEnumerable groups) + IEnumerable users, IEnumerable groups, + DateTime revisionDate) { using (var scope = ServiceScopeFactory.CreateScope()) { @@ -715,6 +716,16 @@ public async Task CreateOrUpdateAccessForManyAsync(Guid organizationId, IEnumera } // Need to save the new collection users/groups before running the bump revision code await dbContext.SaveChangesAsync(); + + // Bump the revision date on all affected collections + var collections = await dbContext.Collections + .Where(c => collectionIdsList.Contains(c.Id)) + .ToListAsync(); + foreach (var c in collections) + { + c.RevisionDate = revisionDate; + } + await dbContext.UserBumpAccountRevisionDateByCollectionIdsAsync(collectionIdsList, organizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql b/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql index 6a41971aa00a..ae061887ce44 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql @@ -2,7 +2,8 @@ CREATE PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany] @OrganizationId UNIQUEIDENTIFIER, @CollectionIds AS [dbo].[GuidIdArray] READONLY, @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, - @Users AS [dbo].[CollectionAccessSelectionType] READONLY + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -109,5 +110,14 @@ BEGIN [Source].[Manage] ); + IF @RevisionDate IS NOT NULL + BEGIN + -- Bump the revision date on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + INNER JOIN @CollectionIds CI ON C.[Id] = CI.[Id] + END + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId END diff --git a/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs b/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs index 713edeefbfa5..59b214d20e4a 100644 --- a/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs +++ b/test/Core.Test/OrganizationFeatures/OrganizationCollections/BulkAddCollectionAccessCommandTests.cs @@ -10,6 +10,7 @@ using Bit.Core.Test.Vault.AutoFixture; using Bit.Test.Common.AutoFixture; using Bit.Test.Common.AutoFixture.Attributes; +using Microsoft.Extensions.Time.Testing; using NSubstitute; using Xunit; @@ -18,8 +19,10 @@ namespace Bit.Core.Test.OrganizationFeatures.OrganizationCollections; [SutProviderCustomize] public class BulkAddCollectionAccessCommandTests { + private static readonly DateTime _expectedRevisionDate = DateTime.UtcNow.AddYears(1); + [Theory, BitAutoData, CollectionCustomization] - public async Task AddAccessAsync_Success(SutProvider sutProvider, + public async Task AddAccessAsync_Success( Organization org, ICollection collections, ICollection organizationUsers, @@ -27,6 +30,7 @@ public async Task AddAccessAsync_Success(SutProvider collectionUsers, IEnumerable collectionGroups) { + var sutProvider = SetupSutProvider(); SetCollectionsToSharedType(collections); sutProvider.GetDependency() @@ -59,7 +63,8 @@ await sutProvider.GetDependency().Received().CreateOrUpda org.Id, Arg.Is>(ids => ids.SequenceEqual(collections.Select(c => c.Id))), userAccessSelections, - groupAccessSelections); + groupAccessSelections, + _expectedRevisionDate); await sutProvider.GetDependency().Received().LogCollectionEventsAsync( Arg.Is>( @@ -279,7 +284,7 @@ public async Task AddAccessAsync_WithDefaultUserCollectionType_ThrowsBadRequest( Assert.Contains("You cannot add access to collections with the type as DefaultUserCollection.", exception.Message); - await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateOrUpdateAccessForManyAsync(default, default, default, default); + await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().CreateOrUpdateAccessForManyAsync(default, default, default, default, default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().LogCollectionEventsAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyAsync(default); await sutProvider.GetDependency().DidNotReceiveWithAnyArgs().GetManyByManyIds(default); @@ -313,4 +318,13 @@ private static ICollection ToAccessSelection(IEnumera ReadOnly = cg.ReadOnly }).ToList(); } + + private static SutProvider SetupSutProvider() + { + var sutProvider = new SutProvider() + .WithFakeTimeProvider() + .Create(); + sutProvider.GetDependency().SetUtcNow(_expectedRevisionDate); + return sutProvider; + } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs index 0f8feb4a6ab6..90143fc96c4f 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs @@ -608,4 +608,44 @@ public async Task GetManySharedCollectionsByOrganizationIdAsync_Success( Assert.Contains(collections, c => c.Name == "Collection 3"); Assert.DoesNotContain(collections, c => c.Name == "My Items"); } + + [DatabaseTheory, DatabaseData] + public async Task CreateOrUpdateAccessForManyAsync_CreatesAccessAndBumpsRevisionDate( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + IGroupRepository groupRepository, + ICollectionRepository collectionRepository, + IUserRepository userRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var user = await userRepository.CreateTestUserAsync(); + var orgUser = await organizationUserRepository.CreateTestOrganizationUserAsync(organization, user); + var group = await groupRepository.CreateTestGroupAsync(organization); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + + var originalRevisionDate = collection.RevisionDate; + var revisionDate = DateTime.UtcNow.AddMinutes(10); + + await collectionRepository.CreateOrUpdateAccessForManyAsync( + organization.Id, + [collection.Id], + [new CollectionAccessSelection { Id = orgUser.Id, Manage = true, HidePasswords = false, ReadOnly = false }], + [new CollectionAccessSelection { Id = group.Id, Manage = false, HidePasswords = true, ReadOnly = true }], + revisionDate + ); + + var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.True(actualCollection.RevisionDate > originalRevisionDate); + Assert.Equal(revisionDate, actualCollection.RevisionDate, TimeSpan.FromSeconds(1)); + + var userAccess = Assert.Single(actualAccess.Users); + Assert.Equal(orgUser.Id, userAccess.Id); + Assert.True(userAccess.Manage); + + var groupAccess = Assert.Single(actualAccess.Groups); + Assert.Equal(group.Id, groupAccess.Id); + Assert.True(groupAccess.ReadOnly); + Assert.True(groupAccess.HidePasswords); + } } From 59a90c67537f1d25550581ad0a833334c08dffa5 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 3 Apr 2026 15:32:56 +0100 Subject: [PATCH 03/19] Update GroupRepository and stored procedures to bump RevisionDate for affected collections during group creation and updates. Enhance integration tests to verify that collection revision dates are correctly updated when groups are created or modified. --- .../Repositories/GroupRepository.cs | 18 +++++ .../Group_CreateWithCollections.sql | 7 ++ .../Group_UpdateWithCollections.sql | 16 +++- .../Repositories/GroupRepositoryTests.cs | 77 ++++++++++++++++++- 4 files changed, 116 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs index 3b6ea749fa8a..4baf86704550 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs @@ -38,6 +38,11 @@ from c in dbContext.Collections Manage = y.Manage, }); await dbContext.CollectionGroups.AddRangeAsync(collectionGroups); + // Bump RevisionDate on all affected collections + foreach (var c in availableCollections.Where(a => filteredCollections.Any(fc => fc.Id == a.Id))) + { + c.RevisionDate = grp.RevisionDate; + } await dbContext.SaveChangesAsync(); } } @@ -227,6 +232,19 @@ public async Task ReplaceAsync(AdminConsoleEntities.Group group, IEnumerable !requestedCollectionIds.Contains(cg.CollectionId))); + // Bump the revision date on all affected collections + var allAffectedCollectionIds = existingCollectionGroups.Select(cg => cg.CollectionId) + .Union(requestedCollections.Select(rc => rc.Id)) + .Distinct() + .ToList(); + var affectedCollections = await dbContext.Collections + .Where(c => allAffectedCollectionIds.Contains(c.Id)) + .ToListAsync(); + foreach (var c in affectedCollections) + { + c.RevisionDate = group.RevisionDate; + } + await dbContext.UserBumpAccountRevisionDateByOrganizationIdAsync(group.OrganizationId); await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql index 0d1db68a8730..0ded052e03a0 100644 --- a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql @@ -39,5 +39,12 @@ BEGIN WHERE [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId END diff --git a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql index cabc2dd3b229..5cdbdd431287 100644 --- a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[Group_UpdateWithCollections] +CREATE PROCEDURE [dbo].[Group_UpdateWithCollections] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(100), @@ -12,6 +12,20 @@ BEGIN EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND ( + C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments + OR C.[Id] IN ( + SELECT CG.[CollectionId] + FROM [dbo].[CollectionGroup] CG + WHERE CG.[GroupId] = @Id -- Existing assignments (includes ones being removed) + ) + ) + ;WITH [AvailableCollectionsCTE] AS( SELECT Id diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs index e2c2cbfa0269..1093f4546cb3 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs @@ -1,4 +1,6 @@ -using Bit.Core.AdminConsole.Repositories; +using Bit.Core.AdminConsole.Entities; +using Bit.Core.AdminConsole.Repositories; +using Bit.Core.Models.Data; using Bit.Core.Repositories; using Xunit; @@ -6,6 +8,79 @@ namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories; public class GroupRepositoryTests { + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_WithCollections_CreatesGroupAccessAndBumpsCollectionRevisionDate( + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository) + { + var org = await organizationRepository.CreateTestOrganizationAsync(); + var collection1 = await collectionRepository.CreateTestCollectionAsync(org); + var collection2 = await collectionRepository.CreateTestCollectionAsync(org); + var originalRevisionDate1 = collection1.RevisionDate; + var originalRevisionDate2 = collection2.RevisionDate; + + var group = new Group { OrganizationId = org.Id, Name = "New Group" }; + await groupRepository.CreateAsync(group, [ + new CollectionAccessSelection { Id = collection1.Id, Manage = true, HidePasswords = false, ReadOnly = false }, + new CollectionAccessSelection { Id = collection2.Id, Manage = false, HidePasswords = true, ReadOnly = true }, + ]); + + var (actualGroup, actualCollections) = await groupRepository.GetByIdWithCollectionsAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal("New Group", actualGroup.Name); + Assert.Equal(2, actualCollections.Count); + Assert.Single(actualCollections, c => c.Id == collection1.Id && c.Manage && !c.HidePasswords && !c.ReadOnly); + Assert.Single(actualCollections, c => c.Id == collection2.Id && !c.Manage && c.HidePasswords && c.ReadOnly); + + var (actualCollection1, _) = await collectionRepository.GetByIdWithAccessAsync(collection1.Id); + var (actualCollection2, _) = await collectionRepository.GetByIdWithAccessAsync(collection2.Id); + Assert.NotNull(actualCollection1); + Assert.NotNull(actualCollection2); + Assert.True(actualCollection1.RevisionDate > originalRevisionDate1); + Assert.True(actualCollection2.RevisionDate > originalRevisionDate2); + } + + [DatabaseTheory, DatabaseData] + public async Task ReplaceAsync_WithCollections_UpdatesGroupAndBumpsCollectionRevisionDate( + IGroupRepository groupRepository, + IOrganizationRepository organizationRepository, + ICollectionRepository collectionRepository) + { + // Arrange + var org = await organizationRepository.CreateTestOrganizationAsync(); + var group = await groupRepository.CreateTestGroupAsync(org); + var collection1 = await collectionRepository.CreateTestCollectionAsync(org); + var collection2 = await collectionRepository.CreateTestCollectionAsync(org); + + var originalRevisionDate1 = collection1.RevisionDate; + var originalRevisionDate2 = collection2.RevisionDate; + + // Act + group.Name = "Updated Group Name"; + group.RevisionDate = DateTime.UtcNow; + await groupRepository.ReplaceAsync(group, [ + new CollectionAccessSelection { Id = collection1.Id, Manage = true, HidePasswords = false, ReadOnly = false }, + new CollectionAccessSelection { Id = collection2.Id, Manage = false, HidePasswords = true, ReadOnly = true }, + ]); + + // Assert + var (actualGroup, actualCollections) = await groupRepository.GetByIdWithCollectionsAsync(group.Id); + Assert.NotNull(actualGroup); + Assert.Equal("Updated Group Name", actualGroup.Name); + + Assert.Equal(2, actualCollections.Count); + Assert.Single(actualCollections, c => c.Id == collection1.Id && c.Manage && !c.HidePasswords && !c.ReadOnly); + Assert.Single(actualCollections, c => c.Id == collection2.Id && !c.Manage && c.HidePasswords && c.ReadOnly); + + var (actualCollection1, _) = await collectionRepository.GetByIdWithAccessAsync(collection1.Id); + var (actualCollection2, _) = await collectionRepository.GetByIdWithAccessAsync(collection2.Id); + Assert.NotNull(actualCollection1); + Assert.NotNull(actualCollection2); + Assert.True(actualCollection1.RevisionDate > originalRevisionDate1); + Assert.True(actualCollection2.RevisionDate > originalRevisionDate2); + } + [DatabaseTheory, DatabaseData] public async Task AddGroupUsersByIdAsync_CreatesGroupUsers( IGroupRepository groupRepository, From 4ee23ddfe4c7b48a847ae50d4e88ab7dfc71e3d2 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 3 Apr 2026 15:33:55 +0100 Subject: [PATCH 04/19] Implement revision date updates for affected collections in OrganizationUserRepository and related stored procedures. Add integration tests to ensure revision dates are correctly bumped during organization user creation and updates. --- .../OrganizationUserRepository.cs | 37 +++++++ ...onUser_CreateManyWithCollectionsGroups.sql | 10 ++ ...OrganizationUser_CreateWithCollections.sql | 7 ++ ...OrganizationUser_UpdateWithCollections.sql | 17 +++- .../OrganizationUserCreateTests.cs | 98 +++++++++++++++++++ .../OrganizationUserReplaceTests.cs | 12 +++ 6 files changed, 180 insertions(+), 1 deletion(-) create mode 100644 test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index afc3de9e487f..3b0d847e999d 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -46,6 +46,11 @@ from c in dbContext.Collections Manage = y.Manage }); await dbContext.CollectionUsers.AddRangeAsync(collectionUsers); + // Bump RevisionDate on all affected collections + foreach (var c in availableCollections.Where(a => filteredCollections.Any(fc => fc.Id == a.Id))) + { + c.RevisionDate = organizationUser.RevisionDate; + } await dbContext.SaveChangesAsync(); } @@ -651,6 +656,20 @@ join c in dbContext.Collections on cu.CollectionId equals c.Id // Remove all existing ones that are no longer requested var requestedCollectionIds = requestedCollections.Select(c => c.Id).ToList(); dbContext.CollectionUsers.RemoveRange(existingCollectionUsers.Where(cu => !requestedCollectionIds.Contains(cu.CollectionId))); + + // Bump the revision date on all affected collections + var allAffectedCollectionIds = existingCollectionUsers.Select(cu => cu.CollectionId) + .Union(requestedCollections.Select(rc => rc.Id)) + .Distinct() + .ToList(); + var affectedCollections = await dbContext.Collections + .Where(c => allAffectedCollectionIds.Contains(c.Id)) + .ToListAsync(); + foreach (var c in affectedCollections) + { + c.RevisionDate = obj.RevisionDate; + } + await dbContext.SaveChangesAsync(); } } @@ -942,6 +961,24 @@ public async Task CreateManyAsync(IEnumerable organizati OrganizationUserId = user.OrganizationUser.Id })); + // Bump RevisionDate on all affected collections + var affectedCollectionIds = organizationUserCollection + .SelectMany(x => x.Collections) + .Select(c => c.Id) + .Distinct() + .ToList(); + if (affectedCollectionIds.Count > 0) + { + var affectedCollections = await dbContext.Collections + .Where(c => affectedCollectionIds.Contains(c.Id)) + .ToListAsync(); + var now = DateTime.UtcNow; + foreach (var c in affectedCollections) + { + c.RevisionDate = now; + } + } + await dbContext.SaveChangesAsync(); } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql index 78ff2933f6a5..c1f95fdbbda7 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -92,6 +92,16 @@ BEGIN [HidePasswords] BIT '$.HidePasswords', [Manage] BIT '$.Manage' ) OUC + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = GETUTCDATE() + FROM [dbo].[Collection] C + WHERE C.[Id] IN ( + SELECT OUC.[CollectionId] + FROM OPENJSON(@collectionData) + WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC + ) END go diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql index a2ec1e9d3385..3c3a3c5545c5 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql @@ -45,4 +45,11 @@ BEGIN @Collections WHERE [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index e030958c3e80..061c8ac38466 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] +CREATE PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, @@ -18,6 +18,21 @@ BEGIN SET NOCOUNT ON EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND ( + C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments + OR C.[Id] IN ( + SELECT CU.[CollectionId] + FROM [dbo].[CollectionUser] CU + WHERE CU.[OrganizationUserId] = @Id -- Existing assignments (includes ones being removed) + ) + ) + -- Update UPDATE [Target] diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs new file mode 100644 index 000000000000..61d4165ac8af --- /dev/null +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs @@ -0,0 +1,98 @@ +using Bit.Core.AdminConsole.OrganizationFeatures.OrganizationUsers.InviteUsers.Models; +using Bit.Core.Entities; +using Bit.Core.Enums; +using Bit.Core.Models.Data; +using Bit.Core.Repositories; +using Bit.Core.Utilities; +using Xunit; + +namespace Bit.Infrastructure.IntegrationTest.AdminConsole.Repositories.OrganizationUserRepository; + +public class OrganizationUserCreateTests +{ + [DatabaseTheory, DatabaseData] + public async Task CreateAsync_WithCollections_CreatesAccessAndBumpsCollectionRevisionDate( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var originalCollectionRevisionDate = collection.RevisionDate; + + var orgUser = new OrganizationUser + { + OrganizationId = organization.Id, + UserId = null, + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + }; + + await organizationUserRepository.CreateAsync(orgUser, [ + new CollectionAccessSelection { Id = collection.Id, Manage = true, HidePasswords = false, ReadOnly = false } + ]); + + await AssertOrgUserAndCollectionRevisionDate( + organizationUserRepository, collectionRepository, + orgUser, collection.Id, originalCollectionRevisionDate); + } + + [DatabaseTheory, DatabaseData] + public async Task CreateManyAsync_WithCollections_CreatesAccessAndBumpsCollectionRevisionDate( + IOrganizationRepository organizationRepository, + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository) + { + var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var originalCollectionRevisionDate = collection.RevisionDate; + + var orgUser = new OrganizationUser + { + Id = CoreHelpers.GenerateComb(), + OrganizationId = organization.Id, + UserId = null, + Email = $"invite-{Guid.NewGuid()}@example.com", + Status = OrganizationUserStatusType.Invited, + Type = OrganizationUserType.User, + CreationDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow, + }; + + await organizationUserRepository.CreateManyAsync([ + new CreateOrganizationUser + { + OrganizationUser = orgUser, + Collections = [new CollectionAccessSelection { Id = collection.Id, Manage = true }], + Groups = [], + } + ]); + + await AssertOrgUserAndCollectionRevisionDate( + organizationUserRepository, collectionRepository, + orgUser, collection.Id, originalCollectionRevisionDate); + } + + private static async Task AssertOrgUserAndCollectionRevisionDate( + IOrganizationUserRepository organizationUserRepository, + ICollectionRepository collectionRepository, + OrganizationUser expectedOrgUser, + Guid collectionId, + DateTime originalCollectionRevisionDate) + { + var (actualOrgUser, actualCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(expectedOrgUser.Id); + Assert.NotNull(actualOrgUser); + Assert.Equal(expectedOrgUser.OrganizationId, actualOrgUser.OrganizationId); + Assert.Equal(expectedOrgUser.Email, actualOrgUser.Email); + Assert.Equal(expectedOrgUser.Status, actualOrgUser.Status); + Assert.Equal(expectedOrgUser.Type, actualOrgUser.Type); + + var collectionAccess = Assert.Single(actualCollections); + Assert.Equal(collectionId, collectionAccess.Id); + Assert.True(collectionAccess.Manage); + + var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collectionId); + Assert.NotNull(actualCollection); + Assert.True(actualCollection.RevisionDate > originalCollectionRevisionDate); + } +} diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs index 0f6393fec0f6..e51055e91895 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs @@ -25,6 +25,7 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsInvited_Success( orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var originalCollectionRevisionDate = collection.RevisionDate; await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -39,6 +40,11 @@ await organizationUserRepository.ReplaceAsync(orgUser, [ var collectionAccess = Assert.Single(actualCollections); Assert.Equal(collection.Id, collectionAccess.Id); Assert.True(collectionAccess.Manage); + + // Collection revision date should be bumped + var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.True(actualCollection.RevisionDate > originalCollectionRevisionDate); } /// @@ -62,6 +68,7 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var originalCollectionRevisionDate = collection.RevisionDate; await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -81,5 +88,10 @@ await organizationUserRepository.ReplaceAsync(orgUser, [ var actualUser = await userRepository.GetByIdAsync(user.Id); Assert.NotNull(actualUser); Assert.True(actualUser.AccountRevisionDate.CompareTo(user.AccountRevisionDate) > 0); + + // Collection revision date should be bumped + var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); + Assert.NotNull(actualCollection); + Assert.True(actualCollection.RevisionDate > originalCollectionRevisionDate); } } From 0a7c65fcf3fd998252f708e553e34c5cd44cc1ab Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 3 Apr 2026 15:34:45 +0100 Subject: [PATCH 05/19] Update database migration script --- ...llectionBumpRevisionDateOnAccessChange.sql | 528 ++++++++++++++++++ 1 file changed, 528 insertions(+) create mode 100644 util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql diff --git a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql new file mode 100644 index 000000000000..059dbc523402 --- /dev/null +++ b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -0,0 +1,528 @@ +-- Bump Collection.RevisionDate when collection access is modified via: +-- 1. Bulk collection access (BulkAddCollectionAccessCommand) +-- 2. Organization user update (UpdateOrganizationUserCommand) +-- 3. Group update (UpdateGroupCommand) + +CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany] + @OrganizationId UNIQUEIDENTIFIER, + @CollectionIds AS [dbo].[GuidIdArray] READONLY, + @Groups AS [dbo].[CollectionAccessSelectionType] READONLY, + @Users AS [dbo].[CollectionAccessSelectionType] READONLY, + @RevisionDate DATETIME2(7) = NULL +AS +BEGIN + SET NOCOUNT ON + + -- Groups + ;WITH [NewCollectionGroups] AS ( + SELECT + cId.[Id] AS [CollectionId], + cg.[Id] AS [GroupId], + cg.[ReadOnly], + cg.[HidePasswords], + cg.[Manage] + FROM + @Groups AS cg + CROSS JOIN -- Create a CollectionGroup record for every CollectionId + @CollectionIds cId + INNER JOIN + [dbo].[Group] g ON cg.[Id] = g.[Id] + WHERE + g.[OrganizationId] = @OrganizationId + ) + MERGE + [dbo].[CollectionGroup] as [Target] + USING + [NewCollectionGroups] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[GroupId] = [Source].[GroupId] + -- Update the target if any values are different from the source + WHEN MATCHED AND EXISTS( + SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage] + EXCEPT + SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage] + ) THEN UPDATE SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + WHEN NOT MATCHED BY TARGET + THEN INSERT + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + VALUES + ( + [Source].[CollectionId], + [Source].[GroupId], + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + ); + + -- Users + ;WITH [NewCollectionUsers] AS ( + SELECT + cId.[Id] AS [CollectionId], + cu.[Id] AS [OrganizationUserId], + cu.[ReadOnly], + cu.[HidePasswords], + cu.[Manage] + FROM + @Users AS cu + CROSS JOIN -- Create a CollectionUser record for every CollectionId + @CollectionIds cId + INNER JOIN + [dbo].[OrganizationUser] u ON cu.[Id] = u.[Id] + WHERE + u.[OrganizationId] = @OrganizationId + ) + MERGE + [dbo].[CollectionUser] as [Target] + USING + [NewCollectionUsers] AS [Source] + ON + [Target].[CollectionId] = [Source].[CollectionId] + AND [Target].[OrganizationUserId] = [Source].[OrganizationUserId] + -- Update the target if any values are different from the source + WHEN MATCHED AND EXISTS( + SELECT [Source].[ReadOnly], [Source].[HidePasswords], [Source].[Manage] + EXCEPT + SELECT [Target].[ReadOnly], [Target].[HidePasswords], [Target].[Manage] + ) THEN UPDATE SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + WHEN NOT MATCHED BY TARGET + THEN INSERT + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + VALUES + ( + [Source].[CollectionId], + [Source].[OrganizationUserId], + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + ); + + IF @RevisionDate IS NOT NULL + BEGIN + -- Bump the revision date on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + INNER JOIN @CollectionIds CI ON C.[Id] = CI.[Id] + END + + EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Email NVARCHAR(256), + @Key VARCHAR(MAX), + @Status SMALLINT, + @Type TINYINT, + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Permissions NVARCHAR(MAX), + @ResetPasswordKey VARCHAR(MAX), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY, + @AccessSecretsManager BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND ( + C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments + OR C.[Id] IN ( + SELECT CU.[CollectionId] + FROM [dbo].[CollectionUser] CU + WHERE CU.[OrganizationUserId] = @Id -- Existing assignments (includes ones being removed) + ) + ) + + -- Update + UPDATE + [Target] + SET + [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + FROM + [dbo].[CollectionUser] AS [Target] + INNER JOIN + @Collections AS [Source] ON [Source].[Id] = [Target].[CollectionId] + WHERE + [Target].[OrganizationUserId] = @Id + AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + OR [Target].[Manage] != [Source].[Manage] + ) + + -- Insert + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Source].[Id], + @Id, + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + FROM + @Collections AS [Source] + INNER JOIN + [dbo].[Collection] C ON C.[Id] = [Source].[Id] AND C.[OrganizationId] = @OrganizationId + WHERE + NOT EXISTS ( + SELECT + 1 + FROM + [dbo].[CollectionUser] + WHERE + [CollectionId] = [Source].[Id] + AND [OrganizationUserId] = @Id + ) + + -- Delete + DELETE + CU + FROM + [dbo].[CollectionUser] CU + INNER JOIN + [dbo].[Collection] C ON C.[Id] = CU.[CollectionId] + WHERE + CU.[OrganizationUserId] = @Id + AND C.[Type] != 1 -- Don't delete default collections + AND NOT EXISTS ( + SELECT + 1 + FROM + @Collections + WHERE + [Id] = CU.[CollectionId] + ) +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Group_UpdateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(100), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND ( + C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments + OR C.[Id] IN ( + SELECT CG.[CollectionId] + FROM [dbo].[CollectionGroup] CG + WHERE CG.[GroupId] = @Id -- Existing assignments (includes ones being removed) + ) + ) + + ;WITH [AvailableCollectionsCTE] AS( + SELECT + Id + FROM + [dbo].[Collection] + WHERE + OrganizationId = @OrganizationId + ) + MERGE + [dbo].[CollectionGroup] AS [Target] + USING + @Collections AS [Source] + ON + [Target].[CollectionId] = [Source].[Id] + AND [Target].[GroupId] = @Id + WHEN NOT MATCHED BY TARGET + AND [Source].[Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) THEN + INSERT + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + VALUES + ( + [Source].[Id], + @Id, + [Source].[ReadOnly], + [Source].[HidePasswords], + [Source].[Manage] + ) + WHEN MATCHED AND ( + [Target].[ReadOnly] != [Source].[ReadOnly] + OR [Target].[HidePasswords] != [Source].[HidePasswords] + OR [Target].[Manage] != [Source].[Manage] + ) THEN + UPDATE SET [Target].[ReadOnly] = [Source].[ReadOnly], + [Target].[HidePasswords] = [Source].[HidePasswords], + [Target].[Manage] = [Source].[Manage] + WHEN NOT MATCHED BY SOURCE + AND [Target].[GroupId] = @Id THEN + DELETE + ; + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @UserId UNIQUEIDENTIFIER, + @Email NVARCHAR(256), + @Key VARCHAR(MAX), + @Status SMALLINT, + @Type TINYINT, + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Permissions NVARCHAR(MAX), + @ResetPasswordKey VARCHAR(MAX), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY, + @AccessSecretsManager BIT = 0 +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[OrganizationUser_Create] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager + + ;WITH [AvailableCollectionsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Collection] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Id], + @Id, + [ReadOnly], + [HidePasswords], + [Manage] + FROM + @Collections + WHERE + [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[Group_CreateWithCollections] + @Id UNIQUEIDENTIFIER, + @OrganizationId UNIQUEIDENTIFIER, + @Name NVARCHAR(100), + @ExternalId NVARCHAR(300), + @CreationDate DATETIME2(7), + @RevisionDate DATETIME2(7), + @Collections AS [dbo].[CollectionAccessSelectionType] READONLY +AS +BEGIN + SET NOCOUNT ON + + EXEC [dbo].[Group_Create] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate + + ;WITH [AvailableCollectionsCTE] AS( + SELECT + [Id] + FROM + [dbo].[Collection] + WHERE + [OrganizationId] = @OrganizationId + ) + INSERT INTO [dbo].[CollectionGroup] + ( + [CollectionId], + [GroupId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [Id], + @Id, + [ReadOnly], + [HidePasswords], + [Manage] + FROM + @Collections + WHERE + [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments + + EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId +END +GO + +CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] + @organizationUserData NVARCHAR(MAX), + @collectionData NVARCHAR(MAX), + @groupData NVARCHAR(MAX) +AS +BEGIN + SET NOCOUNT ON + + INSERT INTO [dbo].[OrganizationUser] + ( + [Id], + [OrganizationId], + [UserId], + [Email], + [Key], + [Status], + [Type], + [ExternalId], + [CreationDate], + [RevisionDate], + [Permissions], + [ResetPasswordKey], + [AccessSecretsManager] + ) + SELECT + OUI.[Id], + OUI.[OrganizationId], + OUI.[UserId], + OUI.[Email], + OUI.[Key], + OUI.[Status], + OUI.[Type], + OUI.[ExternalId], + OUI.[CreationDate], + OUI.[RevisionDate], + OUI.[Permissions], + OUI.[ResetPasswordKey], + OUI.[AccessSecretsManager] + FROM + OPENJSON(@organizationUserData) + WITH ( + [Id] UNIQUEIDENTIFIER '$.Id', + [OrganizationId] UNIQUEIDENTIFIER '$.OrganizationId', + [UserId] UNIQUEIDENTIFIER '$.UserId', + [Email] NVARCHAR(256) '$.Email', + [Key] VARCHAR(MAX) '$.Key', + [Status] SMALLINT '$.Status', + [Type] TINYINT '$.Type', + [ExternalId] NVARCHAR(300) '$.ExternalId', + [CreationDate] DATETIME2(7) '$.CreationDate', + [RevisionDate] DATETIME2(7) '$.RevisionDate', + [Permissions] NVARCHAR (MAX) '$.Permissions', + [ResetPasswordKey] VARCHAR (MAX) '$.ResetPasswordKey', + [AccessSecretsManager] BIT '$.AccessSecretsManager' + ) OUI + + INSERT INTO [dbo].[GroupUser] + ( + [OrganizationUserId], + [GroupId] + ) + SELECT + OUG.OrganizationUserId, + OUG.GroupId + FROM + OPENJSON(@groupData) + WITH( + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [GroupId] UNIQUEIDENTIFIER '$.GroupId' + ) OUG + + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + OUC.[CollectionId], + OUC.[OrganizationUserId], + OUC.[ReadOnly], + OUC.[HidePasswords], + OUC.[Manage] + FROM + OPENJSON(@collectionData) + WITH( + [CollectionId] UNIQUEIDENTIFIER '$.CollectionId', + [OrganizationUserId] UNIQUEIDENTIFIER '$.OrganizationUserId', + [ReadOnly] BIT '$.ReadOnly', + [HidePasswords] BIT '$.HidePasswords', + [Manage] BIT '$.Manage' + ) OUC + + -- Bump RevisionDate on all affected collections + UPDATE C + SET C.[RevisionDate] = GETUTCDATE() + FROM [dbo].[Collection] C + WHERE C.[Id] IN ( + SELECT OUC.[CollectionId] + FROM OPENJSON(@collectionData) + WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC + ) +END +GO From e878ae2c62e6c0c07a6028b85acb4ebc2b97289f Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 3 Apr 2026 15:41:21 +0100 Subject: [PATCH 06/19] Update migration script summary --- ...04-02_00_CollectionBumpRevisionDateOnAccessChange.sql | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql index 059dbc523402..6f64c9664b08 100644 --- a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql +++ b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -1,7 +1,10 @@ -- Bump Collection.RevisionDate when collection access is modified via: --- 1. Bulk collection access (BulkAddCollectionAccessCommand) --- 2. Organization user update (UpdateOrganizationUserCommand) --- 3. Group update (UpdateGroupCommand) +-- 1. Bulk collection access (Collection_CreateOrUpdateAccessForMany) +-- 2. Organization user create (OrganizationUser_CreateWithCollections) +-- 3. Organization user update (OrganizationUser_UpdateWithCollections) +-- 4. Bulk organization user create (OrganizationUser_CreateManyWithCollectionsAndGroups) +-- 5. Group create (Group_CreateWithCollections) +-- 6. Group update (Group_UpdateWithCollections) CREATE OR ALTER PROCEDURE [dbo].[Collection_CreateOrUpdateAccessForMany] @OrganizationId UNIQUEIDENTIFIER, From 48d9e7c71a9191aacc6876f6db2a16a448fa5084 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Fri, 3 Apr 2026 15:57:54 +0100 Subject: [PATCH 07/19] Refactor OrganizationUserReplaceTests to create collection first --- .../OrganizationUserReplaceTests.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs index e51055e91895..a95a6a21f532 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs @@ -18,14 +18,14 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsInvited_Success( ICollectionRepository collectionRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var originalCollectionRevisionDate = collection.RevisionDate; var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); // Act: update the user, including collection access so we test this overloaded method orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; - var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalCollectionRevisionDate = collection.RevisionDate; await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -59,6 +59,8 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( ICollectionRepository collectionRepository) { var organization = await organizationRepository.CreateTestOrganizationAsync(); + var collection = await collectionRepository.CreateTestCollectionAsync(organization); + var originalCollectionRevisionDate = collection.RevisionDate; var user = await userRepository.CreateTestUserAsync(); // OrganizationUser is linked with the User in the Confirmed status @@ -67,8 +69,6 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( // Act: update the user, including collection access so we test this overloaded method orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; - var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalCollectionRevisionDate = collection.RevisionDate; await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } From 2aafad269b41582f59dea1c9a34b8c73da9e20d5 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 11:13:40 +0100 Subject: [PATCH 08/19] Refactor stored procedures to use Common Table Expressions (CTEs) for updating RevisionDate of affected collections. This change improves readability and maintainability by consolidating the logic for identifying affected collections in Group_UpdateWithCollections and OrganizationUser_UpdateWithCollections procedures. --- .../Group_UpdateWithCollections.sql | 16 +++++----- ...OrganizationUser_UpdateWithCollections.sql | 16 +++++----- ...llectionBumpRevisionDateOnAccessChange.sql | 32 +++++++++---------- 3 files changed, 32 insertions(+), 32 deletions(-) diff --git a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql index 5cdbdd431287..e4a0be85bd78 100644 --- a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql @@ -13,18 +13,18 @@ BEGIN EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT [Id] FROM @Collections + UNION + SELECT CG.[CollectionId] + FROM [dbo].[CollectionGroup] CG + WHERE CG.[GroupId] = @Id + ) UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C WHERE C.[OrganizationId] = @OrganizationId - AND ( - C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments - OR C.[Id] IN ( - SELECT CG.[CollectionId] - FROM [dbo].[CollectionGroup] CG - WHERE CG.[GroupId] = @Id -- Existing assignments (includes ones being removed) - ) - ) + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) ;WITH [AvailableCollectionsCTE] AS( SELECT diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index 061c8ac38466..0c5ad2e855c9 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -20,18 +20,18 @@ BEGIN EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT [Id] FROM @Collections + UNION + SELECT CU.[CollectionId] + FROM [dbo].[CollectionUser] CU + WHERE CU.[OrganizationUserId] = @Id + ) UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C WHERE C.[OrganizationId] = @OrganizationId - AND ( - C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments - OR C.[Id] IN ( - SELECT CU.[CollectionId] - FROM [dbo].[CollectionUser] CU - WHERE CU.[OrganizationUserId] = @Id -- Existing assignments (includes ones being removed) - ) - ) + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) -- Update UPDATE diff --git a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql index 6f64c9664b08..768497994ab9 100644 --- a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql +++ b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -153,18 +153,18 @@ BEGIN EXEC [dbo].[OrganizationUser_Update] @Id, @OrganizationId, @UserId, @Email, @Key, @Status, @Type, @ExternalId, @CreationDate, @RevisionDate, @Permissions, @ResetPasswordKey, @AccessSecretsManager -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT [Id] FROM @Collections + UNION + SELECT CU.[CollectionId] + FROM [dbo].[CollectionUser] CU + WHERE CU.[OrganizationUserId] = @Id + ) UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C WHERE C.[OrganizationId] = @OrganizationId - AND ( - C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments - OR C.[Id] IN ( - SELECT CU.[CollectionId] - FROM [dbo].[CollectionUser] CU - WHERE CU.[OrganizationUserId] = @Id -- Existing assignments (includes ones being removed) - ) - ) + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) -- Update UPDATE @@ -251,18 +251,18 @@ BEGIN EXEC [dbo].[Group_Update] @Id, @OrganizationId, @Name, @ExternalId, @CreationDate, @RevisionDate -- Bump RevisionDate on all affected collections + ;WITH [AffectedCollectionsCTE] AS ( + SELECT [Id] FROM @Collections + UNION + SELECT CG.[CollectionId] + FROM [dbo].[CollectionGroup] CG + WHERE CG.[GroupId] = @Id + ) UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C WHERE C.[OrganizationId] = @OrganizationId - AND ( - C.[Id] IN (SELECT [Id] FROM @Collections) -- New/updated assignments - OR C.[Id] IN ( - SELECT CG.[CollectionId] - FROM [dbo].[CollectionGroup] CG - WHERE CG.[GroupId] = @Id -- Existing assignments (includes ones being removed) - ) - ) + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) ;WITH [AvailableCollectionsCTE] AS( SELECT From ab54a707e83f36f3153343aafc9e5cc863c9c000 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 11:20:57 +0100 Subject: [PATCH 09/19] Enhance OrganizationUser_CreateManyWithCollectionsAndGroups stored procedure to accept RevisionDate parameter for updating affected collections. Update OrganizationUserRepository to utilize the provided RevisionDate when available, ensuring accurate revision date management during organization user operations. --- .../OrganizationUserRepository.cs | 3 ++- ...onUser_CreateManyWithCollectionsGroups.sql | 22 +++++++++++-------- ...llectionBumpRevisionDateOnAccessChange.sql | 22 +++++++++++-------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 7dbe4b18ff8e..4c2d2437c70f 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -672,7 +672,8 @@ await connection.ExecuteAsync( { GroupId = group, OrganizationUserId = user.OrganizationUser.Id - })) + })), + RevisionDate = organizationUsersList.First().OrganizationUser.RevisionDate }, commandType: CommandType.StoredProcedure); } diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql index c1f95fdbbda7..65e1866a5a58 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -1,7 +1,8 @@ CREATE PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] @organizationUserData NVARCHAR(MAX), @collectionData NVARCHAR(MAX), - @groupData NVARCHAR(MAX) + @groupData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -94,14 +95,17 @@ BEGIN ) OUC -- Bump RevisionDate on all affected collections - UPDATE C - SET C.[RevisionDate] = GETUTCDATE() - FROM [dbo].[Collection] C - WHERE C.[Id] IN ( - SELECT OUC.[CollectionId] - FROM OPENJSON(@collectionData) - WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC - ) + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[Id] IN ( + SELECT OUC.[CollectionId] + FROM OPENJSON(@collectionData) + WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC + ) + END END go diff --git a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql index 768497994ab9..d962227dea00 100644 --- a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql +++ b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -426,7 +426,8 @@ GO CREATE OR ALTER PROCEDURE [dbo].[OrganizationUser_CreateManyWithCollectionsAndGroups] @organizationUserData NVARCHAR(MAX), @collectionData NVARCHAR(MAX), - @groupData NVARCHAR(MAX) + @groupData NVARCHAR(MAX), + @RevisionDate DATETIME2(7) = NULL AS BEGIN SET NOCOUNT ON @@ -519,13 +520,16 @@ BEGIN ) OUC -- Bump RevisionDate on all affected collections - UPDATE C - SET C.[RevisionDate] = GETUTCDATE() - FROM [dbo].[Collection] C - WHERE C.[Id] IN ( - SELECT OUC.[CollectionId] - FROM OPENJSON(@collectionData) - WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC - ) + IF @RevisionDate IS NOT NULL + BEGIN + UPDATE C + SET C.[RevisionDate] = @RevisionDate + FROM [dbo].[Collection] C + WHERE C.[Id] IN ( + SELECT OUC.[CollectionId] + FROM OPENJSON(@collectionData) + WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC + ) + END END GO From 4d80849d57ad18174f4736b2a3201f914ac3e244 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 11:25:29 +0100 Subject: [PATCH 10/19] Refactor OrganizationUser_CreateManyWithCollectionsGroups and migration script to utilize temporary table for CollectionUser data insertion. This change improves performance and maintains consistency in updating RevisionDate for affected collections. --- ...onUser_CreateManyWithCollectionsGroups.sql | 31 +++++++++++-------- ...llectionBumpRevisionDateOnAccessChange.sql | 31 +++++++++++-------- 2 files changed, 36 insertions(+), 26 deletions(-) diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql index 65e1866a5a58..2a9fab4f7ecb 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -70,20 +70,13 @@ BEGIN [GroupId] UNIQUEIDENTIFIER '$.GroupId' ) OUG - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords], - [Manage] - ) SELECT OUC.[CollectionId], OUC.[OrganizationUserId], OUC.[ReadOnly], OUC.[HidePasswords], OUC.[Manage] + INTO #CollectionUserData FROM OPENJSON(@collectionData) WITH( @@ -94,17 +87,29 @@ BEGIN [Manage] BIT '$.Manage' ) OUC + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + FROM #CollectionUserData + -- Bump RevisionDate on all affected collections IF @RevisionDate IS NOT NULL BEGIN UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C - WHERE C.[Id] IN ( - SELECT OUC.[CollectionId] - FROM OPENJSON(@collectionData) - WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC - ) + WHERE C.[Id] IN (SELECT [CollectionId] FROM #CollectionUserData) END END go diff --git a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql index d962227dea00..5a56e2f50c2c 100644 --- a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql +++ b/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -495,20 +495,13 @@ BEGIN [GroupId] UNIQUEIDENTIFIER '$.GroupId' ) OUG - INSERT INTO [dbo].[CollectionUser] - ( - [CollectionId], - [OrganizationUserId], - [ReadOnly], - [HidePasswords], - [Manage] - ) SELECT OUC.[CollectionId], OUC.[OrganizationUserId], OUC.[ReadOnly], OUC.[HidePasswords], OUC.[Manage] + INTO #CollectionUserData FROM OPENJSON(@collectionData) WITH( @@ -519,17 +512,29 @@ BEGIN [Manage] BIT '$.Manage' ) OUC + INSERT INTO [dbo].[CollectionUser] + ( + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + ) + SELECT + [CollectionId], + [OrganizationUserId], + [ReadOnly], + [HidePasswords], + [Manage] + FROM #CollectionUserData + -- Bump RevisionDate on all affected collections IF @RevisionDate IS NOT NULL BEGIN UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C - WHERE C.[Id] IN ( - SELECT OUC.[CollectionId] - FROM OPENJSON(@collectionData) - WITH ([CollectionId] UNIQUEIDENTIFIER '$.CollectionId') OUC - ) + WHERE C.[Id] IN (SELECT [CollectionId] FROM #CollectionUserData) END END GO From 7206101044058d376c8c932a5bb25533dcb1950d Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 11:49:45 +0100 Subject: [PATCH 11/19] Refactor OrganizationUserRepository to consistently use RevisionDate from created OrganizationUsers when updating affected collections. This change enhances the accuracy of revision date management across the repository. --- .../AdminConsole/Repositories/OrganizationUserRepository.cs | 1 + .../AdminConsole/Repositories/OrganizationUserRepository.cs | 5 +++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index 4c2d2437c70f..a9cb4efeb3e4 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -673,6 +673,7 @@ await connection.ExecuteAsync( GroupId = group, OrganizationUserId = user.OrganizationUser.Id })), + // Use the same RevisionDate as the created OrganizationUsers RevisionDate = organizationUsersList.First().OrganizationUser.RevisionDate }, commandType: CommandType.StoredProcedure); diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 3b0d847e999d..08f4ac7177f0 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -972,10 +972,11 @@ public async Task CreateManyAsync(IEnumerable organizati var affectedCollections = await dbContext.Collections .Where(c => affectedCollectionIds.Contains(c.Id)) .ToListAsync(); - var now = DateTime.UtcNow; + // Use the same RevisionDate as the created OrganizationUsers + var revisionDate = organizationUserCollection.First().OrganizationUser.RevisionDate; foreach (var c in affectedCollections) { - c.RevisionDate = now; + c.RevisionDate = revisionDate; } } From 1f9ae7b72ce6bc23b25f074d7e604c196370d2ea Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 12:32:17 +0100 Subject: [PATCH 12/19] Refactor tests to ensure consistent handling of RevisionDate across Group and Collection repositories. Update assertions to compare RevisionDate directly, improving accuracy in revision date management during tests. --- .../CollectionRepositoryReplaceTests.cs | 3 +-- .../CollectionRepositoryTests.cs | 4 +--- .../Repositories/GroupRepositoryTests.cs | 17 ++++++----------- .../OrganizationUserCreateTests.cs | 13 ++++++------- .../OrganizationUserReplaceTests.cs | 12 ++++++------ 5 files changed, 20 insertions(+), 29 deletions(-) diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs index 0857505e6f23..2f9931077ffb 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryReplaceTests.cs @@ -50,7 +50,6 @@ await collectionRepository.CreateAsync(collection, ); // Act - var originalRevisionDate = collection.RevisionDate; collection.Name = "Updated Collection Name"; collection.RevisionDate = DateTime.UtcNow; @@ -76,7 +75,7 @@ await collectionRepository.ReplaceAsync(collection, Assert.NotNull(actualCollection); Assert.Equal("Updated Collection Name", actualCollection.Name); - Assert.True(actualCollection.RevisionDate > originalRevisionDate); + Assert.Equal(collection.RevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); var groups = actualAccess.Groups.ToArray(); Assert.Equal(2, groups.Length); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs index 90143fc96c4f..da5d7e3361ba 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/CollectionRepository/CollectionRepositoryTests.cs @@ -623,7 +623,6 @@ public async Task CreateOrUpdateAccessForManyAsync_CreatesAccessAndBumpsRevision var group = await groupRepository.CreateTestGroupAsync(organization); var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalRevisionDate = collection.RevisionDate; var revisionDate = DateTime.UtcNow.AddMinutes(10); await collectionRepository.CreateOrUpdateAccessForManyAsync( @@ -636,8 +635,7 @@ await collectionRepository.CreateOrUpdateAccessForManyAsync( var (actualCollection, actualAccess) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); Assert.NotNull(actualCollection); - Assert.True(actualCollection.RevisionDate > originalRevisionDate); - Assert.Equal(revisionDate, actualCollection.RevisionDate, TimeSpan.FromSeconds(1)); + Assert.Equal(revisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); var userAccess = Assert.Single(actualAccess.Users); Assert.Equal(orgUser.Id, userAccess.Id); diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs index 1093f4546cb3..5d936565c7ef 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/GroupRepositoryTests.cs @@ -17,10 +17,8 @@ public async Task CreateAsync_WithCollections_CreatesGroupAccessAndBumpsCollecti var org = await organizationRepository.CreateTestOrganizationAsync(); var collection1 = await collectionRepository.CreateTestCollectionAsync(org); var collection2 = await collectionRepository.CreateTestCollectionAsync(org); - var originalRevisionDate1 = collection1.RevisionDate; - var originalRevisionDate2 = collection2.RevisionDate; - var group = new Group { OrganizationId = org.Id, Name = "New Group" }; + var group = new Group { OrganizationId = org.Id, Name = "New Group", RevisionDate = DateTime.UtcNow.AddMinutes(10) }; await groupRepository.CreateAsync(group, [ new CollectionAccessSelection { Id = collection1.Id, Manage = true, HidePasswords = false, ReadOnly = false }, new CollectionAccessSelection { Id = collection2.Id, Manage = false, HidePasswords = true, ReadOnly = true }, @@ -37,8 +35,8 @@ await groupRepository.CreateAsync(group, [ var (actualCollection2, _) = await collectionRepository.GetByIdWithAccessAsync(collection2.Id); Assert.NotNull(actualCollection1); Assert.NotNull(actualCollection2); - Assert.True(actualCollection1.RevisionDate > originalRevisionDate1); - Assert.True(actualCollection2.RevisionDate > originalRevisionDate2); + Assert.Equal(group.RevisionDate, actualCollection1.RevisionDate, TimeSpan.FromMilliseconds(10)); + Assert.Equal(group.RevisionDate, actualCollection2.RevisionDate, TimeSpan.FromMilliseconds(10)); } [DatabaseTheory, DatabaseData] @@ -53,12 +51,9 @@ public async Task ReplaceAsync_WithCollections_UpdatesGroupAndBumpsCollectionRev var collection1 = await collectionRepository.CreateTestCollectionAsync(org); var collection2 = await collectionRepository.CreateTestCollectionAsync(org); - var originalRevisionDate1 = collection1.RevisionDate; - var originalRevisionDate2 = collection2.RevisionDate; - // Act group.Name = "Updated Group Name"; - group.RevisionDate = DateTime.UtcNow; + group.RevisionDate = DateTime.UtcNow.AddMinutes(10); await groupRepository.ReplaceAsync(group, [ new CollectionAccessSelection { Id = collection1.Id, Manage = true, HidePasswords = false, ReadOnly = false }, new CollectionAccessSelection { Id = collection2.Id, Manage = false, HidePasswords = true, ReadOnly = true }, @@ -77,8 +72,8 @@ await groupRepository.ReplaceAsync(group, [ var (actualCollection2, _) = await collectionRepository.GetByIdWithAccessAsync(collection2.Id); Assert.NotNull(actualCollection1); Assert.NotNull(actualCollection2); - Assert.True(actualCollection1.RevisionDate > originalRevisionDate1); - Assert.True(actualCollection2.RevisionDate > originalRevisionDate2); + Assert.Equal(group.RevisionDate, actualCollection1.RevisionDate, TimeSpan.FromMilliseconds(10)); + Assert.Equal(group.RevisionDate, actualCollection2.RevisionDate, TimeSpan.FromMilliseconds(10)); } [DatabaseTheory, DatabaseData] diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs index 61d4165ac8af..6408dc65aaa8 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserCreateTests.cs @@ -18,7 +18,6 @@ public async Task CreateAsync_WithCollections_CreatesAccessAndBumpsCollectionRev { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalCollectionRevisionDate = collection.RevisionDate; var orgUser = new OrganizationUser { @@ -26,6 +25,7 @@ public async Task CreateAsync_WithCollections_CreatesAccessAndBumpsCollectionRev UserId = null, Status = OrganizationUserStatusType.Invited, Type = OrganizationUserType.User, + RevisionDate = DateTime.UtcNow.AddMinutes(10), }; await organizationUserRepository.CreateAsync(orgUser, [ @@ -34,7 +34,7 @@ await organizationUserRepository.CreateAsync(orgUser, [ await AssertOrgUserAndCollectionRevisionDate( organizationUserRepository, collectionRepository, - orgUser, collection.Id, originalCollectionRevisionDate); + orgUser, collection.Id, orgUser.RevisionDate); } [DatabaseTheory, DatabaseData] @@ -45,7 +45,6 @@ public async Task CreateManyAsync_WithCollections_CreatesAccessAndBumpsCollectio { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalCollectionRevisionDate = collection.RevisionDate; var orgUser = new OrganizationUser { @@ -56,7 +55,7 @@ public async Task CreateManyAsync_WithCollections_CreatesAccessAndBumpsCollectio Status = OrganizationUserStatusType.Invited, Type = OrganizationUserType.User, CreationDate = DateTime.UtcNow, - RevisionDate = DateTime.UtcNow, + RevisionDate = DateTime.UtcNow.AddMinutes(10), }; await organizationUserRepository.CreateManyAsync([ @@ -70,7 +69,7 @@ await organizationUserRepository.CreateManyAsync([ await AssertOrgUserAndCollectionRevisionDate( organizationUserRepository, collectionRepository, - orgUser, collection.Id, originalCollectionRevisionDate); + orgUser, collection.Id, orgUser.RevisionDate); } private static async Task AssertOrgUserAndCollectionRevisionDate( @@ -78,7 +77,7 @@ private static async Task AssertOrgUserAndCollectionRevisionDate( ICollectionRepository collectionRepository, OrganizationUser expectedOrgUser, Guid collectionId, - DateTime originalCollectionRevisionDate) + DateTime expectedCollectionRevisionDate) { var (actualOrgUser, actualCollections) = await organizationUserRepository.GetByIdWithCollectionsAsync(expectedOrgUser.Id); Assert.NotNull(actualOrgUser); @@ -93,6 +92,6 @@ private static async Task AssertOrgUserAndCollectionRevisionDate( var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collectionId); Assert.NotNull(actualCollection); - Assert.True(actualCollection.RevisionDate > originalCollectionRevisionDate); + Assert.Equal(expectedCollectionRevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); } } diff --git a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs index a95a6a21f532..9bfcb2f60f6d 100644 --- a/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs +++ b/test/Infrastructure.IntegrationTest/AdminConsole/Repositories/OrganizationUserRepository/OrganizationUserReplaceTests.cs @@ -19,13 +19,13 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsInvited_Success( { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalCollectionRevisionDate = collection.RevisionDate; var orgUser = await organizationUserRepository.CreateTestOrganizationUserInviteAsync(organization); // Act: update the user, including collection access so we test this overloaded method orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; + orgUser.RevisionDate = DateTime.UtcNow.AddMinutes(10); await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -41,10 +41,10 @@ await organizationUserRepository.ReplaceAsync(orgUser, [ Assert.Equal(collection.Id, collectionAccess.Id); Assert.True(collectionAccess.Manage); - // Collection revision date should be bumped + // Collection revision date should match the orgUser's RevisionDate var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); Assert.NotNull(actualCollection); - Assert.True(actualCollection.RevisionDate > originalCollectionRevisionDate); + Assert.Equal(orgUser.RevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); } /// @@ -60,7 +60,6 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( { var organization = await organizationRepository.CreateTestOrganizationAsync(); var collection = await collectionRepository.CreateTestCollectionAsync(organization); - var originalCollectionRevisionDate = collection.RevisionDate; var user = await userRepository.CreateTestUserAsync(); // OrganizationUser is linked with the User in the Confirmed status @@ -69,6 +68,7 @@ public async Task ReplaceAsync_WithCollectionAccess_WhenUserIsConfirmed_Success( // Act: update the user, including collection access so we test this overloaded method orgUser.Type = OrganizationUserType.Admin; orgUser.AccessSecretsManager = true; + orgUser.RevisionDate = DateTime.UtcNow.AddMinutes(10); await organizationUserRepository.ReplaceAsync(orgUser, [ new CollectionAccessSelection { Id = collection.Id, Manage = true } @@ -89,9 +89,9 @@ await organizationUserRepository.ReplaceAsync(orgUser, [ Assert.NotNull(actualUser); Assert.True(actualUser.AccountRevisionDate.CompareTo(user.AccountRevisionDate) > 0); - // Collection revision date should be bumped + // Collection revision date should match the orgUser's RevisionDate var (actualCollection, _) = await collectionRepository.GetByIdWithAccessAsync(collection.Id); Assert.NotNull(actualCollection); - Assert.True(actualCollection.RevisionDate > originalCollectionRevisionDate); + Assert.Equal(orgUser.RevisionDate, actualCollection.RevisionDate, TimeSpan.FromMilliseconds(10)); } } From 0e81b49f53a7fc86cbddf8bd8215dc99f472359a Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 12:37:53 +0100 Subject: [PATCH 13/19] Restore BOM in Group_UpdateWithCollections and OrganizationUser_UpdateWithCollections --- src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql | 2 +- .../OrganizationUser_UpdateWithCollections.sql | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql index e4a0be85bd78..24fb3a8032c4 100644 --- a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[Group_UpdateWithCollections] +CREATE PROCEDURE [dbo].[Group_UpdateWithCollections] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @Name NVARCHAR(100), diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index 0c5ad2e855c9..dc8b358d2c5c 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -1,4 +1,4 @@ -CREATE PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] +CREATE PROCEDURE [dbo].[OrganizationUser_UpdateWithCollections] @Id UNIQUEIDENTIFIER, @OrganizationId UNIQUEIDENTIFIER, @UserId UNIQUEIDENTIFIER, From 1bd19cba82112103c789e39ac1f2ee3240cf3234 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 12:38:21 +0100 Subject: [PATCH 14/19] Refactor GroupRepository and OrganizationUserRepository to improve handling of RevisionDate. Updated collection filtering logic to use HashSet for efficiency and ensured that affected collections are filtered by OrganizationId, enhancing accuracy in revision date management. --- .../AdminConsole/Repositories/GroupRepository.cs | 6 ++++-- .../Repositories/OrganizationUserRepository.cs | 10 +++++++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs index 4baf86704550..da256815d667 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/GroupRepository.cs @@ -39,7 +39,8 @@ from c in dbContext.Collections }); await dbContext.CollectionGroups.AddRangeAsync(collectionGroups); // Bump RevisionDate on all affected collections - foreach (var c in availableCollections.Where(a => filteredCollections.Any(fc => fc.Id == a.Id))) + var filteredCollectionIds = filteredCollections.Select(fc => fc.Id).ToHashSet(); + foreach (var c in availableCollections.Where(a => filteredCollectionIds.Contains(a.Id))) { c.RevisionDate = grp.RevisionDate; } @@ -238,7 +239,8 @@ public async Task ReplaceAsync(AdminConsoleEntities.Group group, IEnumerable allAffectedCollectionIds.Contains(c.Id)) + .Where(c => c.OrganizationId == group.OrganizationId + && allAffectedCollectionIds.Contains(c.Id)) .ToListAsync(); foreach (var c in affectedCollections) { diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 08f4ac7177f0..1c3db7480a67 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -47,7 +47,8 @@ from c in dbContext.Collections }); await dbContext.CollectionUsers.AddRangeAsync(collectionUsers); // Bump RevisionDate on all affected collections - foreach (var c in availableCollections.Where(a => filteredCollections.Any(fc => fc.Id == a.Id))) + var filteredCollectionIds = filteredCollections.Select(fc => fc.Id).ToHashSet(); + foreach (var c in availableCollections.Where(a => filteredCollectionIds.Contains(a.Id))) { c.RevisionDate = organizationUser.RevisionDate; } @@ -663,7 +664,8 @@ join c in dbContext.Collections on cu.CollectionId equals c.Id .Distinct() .ToList(); var affectedCollections = await dbContext.Collections - .Where(c => allAffectedCollectionIds.Contains(c.Id)) + .Where(c => c.OrganizationId == obj.OrganizationId + && allAffectedCollectionIds.Contains(c.Id)) .ToListAsync(); foreach (var c in affectedCollections) { @@ -969,8 +971,10 @@ public async Task CreateManyAsync(IEnumerable organizati .ToList(); if (affectedCollectionIds.Count > 0) { + var organizationId = organizationUserCollection.First().OrganizationUser.OrganizationId; var affectedCollections = await dbContext.Collections - .Where(c => affectedCollectionIds.Contains(c.Id)) + .Where(c => c.OrganizationId == organizationId + && affectedCollectionIds.Contains(c.Id)) .ToListAsync(); // Use the same RevisionDate as the created OrganizationUsers var revisionDate = organizationUserCollection.First().OrganizationUser.RevisionDate; From dabb6722058de94b929f02c13643b8fe0c00cfcb Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 12:56:54 +0100 Subject: [PATCH 15/19] Bump migration script date --- ...=> 2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename util/Migrator/DbScripts/{2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql => 2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql} (100%) diff --git a/util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql similarity index 100% rename from util/Migrator/DbScripts/2026-04-02_00_CollectionBumpRevisionDateOnAccessChange.sql rename to util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql From 4948c155e37e0479cd29796a10a316c59de96bb3 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 14:45:33 +0100 Subject: [PATCH 16/19] Remove internal set from RevisionDate on Group and OrganizationUser The Dapper repositories use a System.Text.Json serialize/deserialize round-trip to build *WithCollections objects. System.Text.Json silently skips properties with non-public setters, so RevisionDate was reverting to DateTime.UtcNow instead of preserving the value set in C#. --- src/Core/AdminConsole/Entities/Group.cs | 2 +- src/Core/AdminConsole/Entities/OrganizationUser.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Core/AdminConsole/Entities/Group.cs b/src/Core/AdminConsole/Entities/Group.cs index 4355ea9705f7..90b11ce00a34 100644 --- a/src/Core/AdminConsole/Entities/Group.cs +++ b/src/Core/AdminConsole/Entities/Group.cs @@ -16,7 +16,7 @@ public class Group : ITableObject, IExternal [MaxLength(300)] public string? ExternalId { get; set; } public DateTime CreationDate { get; internal set; } = DateTime.UtcNow; - public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; public void SetNewId() { diff --git a/src/Core/AdminConsole/Entities/OrganizationUser.cs b/src/Core/AdminConsole/Entities/OrganizationUser.cs index 3c99974ca5a3..80086e49d9dc 100644 --- a/src/Core/AdminConsole/Entities/OrganizationUser.cs +++ b/src/Core/AdminConsole/Entities/OrganizationUser.cs @@ -65,7 +65,7 @@ public class OrganizationUser : ITableObject, IExternal, IOrganizationUser /// /// The last date the OrganizationUser entry was updated. /// - public DateTime RevisionDate { get; internal set; } = DateTime.UtcNow; + public DateTime RevisionDate { get; set; } = DateTime.UtcNow; /// /// A json blob representing the of the OrganizationUser if they /// are a Custom user role (i.e. the is Custom). MAY be NULL if they are not From 7ae69c92c946be5fbdeed3a8ed5c11baaf8b36db Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Mon, 6 Apr 2026 15:32:24 +0100 Subject: [PATCH 17/19] Refactor OrganizationUser_CreateManyWithCollectionsGroups and migration script to improve the logic for updating RevisionDate. The update now uses INNER JOINs to ensure accurate filtering of collections based on OrganizationId and CollectionUser data, enhancing the precision of revision date management. --- .../OrganizationUser_CreateManyWithCollectionsGroups.sql | 6 +++++- ...26-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql index 2a9fab4f7ecb..dc368010bdcd 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -109,7 +109,11 @@ BEGIN UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C - WHERE C.[Id] IN (SELECT [CollectionId] FROM #CollectionUserData) + INNER JOIN [dbo].[OrganizationUser] OU + ON OU.[OrganizationId] = C.[OrganizationId] + INNER JOIN #CollectionUserData CUD + ON CUD.[CollectionId] = C.[Id] + AND CUD.[OrganizationUserId] = OU.[Id] END END go diff --git a/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql index 5a56e2f50c2c..3c5a5675bb41 100644 --- a/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql +++ b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -534,7 +534,11 @@ BEGIN UPDATE C SET C.[RevisionDate] = @RevisionDate FROM [dbo].[Collection] C - WHERE C.[Id] IN (SELECT [CollectionId] FROM #CollectionUserData) + INNER JOIN [dbo].[OrganizationUser] OU + ON OU.[OrganizationId] = C.[OrganizationId] + INNER JOIN #CollectionUserData CUD + ON CUD.[CollectionId] = C.[Id] + AND CUD.[OrganizationUserId] = OU.[Id] END END GO From c3cf999fef86ff995b36d17f170a06d455439fd9 Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Tue, 7 Apr 2026 11:17:56 +0100 Subject: [PATCH 18/19] Fix sprocs styling --- ...Collection_CreateOrUpdateAccessForMany.sql | 12 +- .../Group_CreateWithCollections.sql | 14 ++- .../Group_UpdateWithCollections.sql | 30 +++-- ...onUser_CreateManyWithCollectionsGroups.sql | 16 +-- ...OrganizationUser_CreateWithCollections.sql | 14 ++- ...OrganizationUser_UpdateWithCollections.sql | 30 +++-- ...llectionBumpRevisionDateOnAccessChange.sql | 116 ++++++++++++------ 7 files changed, 152 insertions(+), 80 deletions(-) diff --git a/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql b/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql index ae061887ce44..6ca78fc22f0f 100644 --- a/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql +++ b/src/Sql/dbo/Stored Procedures/Collection_CreateOrUpdateAccessForMany.sql @@ -113,10 +113,14 @@ BEGIN IF @RevisionDate IS NOT NULL BEGIN -- Bump the revision date on all affected collections - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - INNER JOIN @CollectionIds CI ON C.[Id] = CI.[Id] + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @CollectionIds CI ON C.[Id] = CI.[Id] END EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId diff --git a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql index 0ded052e03a0..b4766b406583 100644 --- a/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_CreateWithCollections.sql @@ -40,11 +40,15 @@ BEGIN [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) -- Bump RevisionDate on all affected collections - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId END diff --git a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql index 24fb3a8032c4..70de3efa5e99 100644 --- a/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/Group_UpdateWithCollections.sql @@ -14,17 +14,29 @@ BEGIN -- Bump RevisionDate on all affected collections ;WITH [AffectedCollectionsCTE] AS ( - SELECT [Id] FROM @Collections + SELECT + [Id] + FROM + @Collections + UNION - SELECT CG.[CollectionId] - FROM [dbo].[CollectionGroup] CG - WHERE CG.[GroupId] = @Id + + SELECT + CG.[CollectionId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[GroupId] = @Id ) - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) ;WITH [AvailableCollectionsCTE] AS( SELECT diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql index dc368010bdcd..6603538f1850 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateManyWithCollectionsGroups.sql @@ -106,14 +106,14 @@ BEGIN -- Bump RevisionDate on all affected collections IF @RevisionDate IS NOT NULL BEGIN - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - INNER JOIN [dbo].[OrganizationUser] OU - ON OU.[OrganizationId] = C.[OrganizationId] - INNER JOIN #CollectionUserData CUD - ON CUD.[CollectionId] = C.[Id] - AND CUD.[OrganizationUserId] = OU.[Id] + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + #CollectionUserData CUD ON CUD.[CollectionId] = C.[Id] END END go diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql index 3c3a3c5545c5..0555db7cf8e7 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_CreateWithCollections.sql @@ -47,9 +47,13 @@ BEGIN [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) -- Bump RevisionDate on all affected collections - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) END diff --git a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql index dc8b358d2c5c..f0a77b7a2680 100644 --- a/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql +++ b/src/Sql/dbo/Stored Procedures/OrganizationUser_UpdateWithCollections.sql @@ -21,17 +21,29 @@ BEGIN -- Bump RevisionDate on all affected collections ;WITH [AffectedCollectionsCTE] AS ( - SELECT [Id] FROM @Collections + SELECT + [Id] + FROM + @Collections + UNION - SELECT CU.[CollectionId] - FROM [dbo].[CollectionUser] CU - WHERE CU.[OrganizationUserId] = @Id + + SELECT + CU.[CollectionId] + FROM + [dbo].[CollectionUser] CU + WHERE + CU.[OrganizationUserId] = @Id ) - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) -- Update UPDATE diff --git a/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql index 3c5a5675bb41..c5b996d72a67 100644 --- a/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql +++ b/util/Migrator/DbScripts/2026-04-06_00_CollectionBumpRevisionDateOnAccessChange.sql @@ -121,10 +121,14 @@ BEGIN IF @RevisionDate IS NOT NULL BEGIN -- Bump the revision date on all affected collections - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - INNER JOIN @CollectionIds CI ON C.[Id] = CI.[Id] + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + @CollectionIds CI ON C.[Id] = CI.[Id] END EXEC [dbo].[User_BumpAccountRevisionDateByCollectionIds] @CollectionIds, @OrganizationId @@ -154,17 +158,29 @@ BEGIN -- Bump RevisionDate on all affected collections ;WITH [AffectedCollectionsCTE] AS ( - SELECT [Id] FROM @Collections + SELECT + [Id] + FROM + @Collections + UNION - SELECT CU.[CollectionId] - FROM [dbo].[CollectionUser] CU - WHERE CU.[OrganizationUserId] = @Id + + SELECT + CU.[CollectionId] + FROM + [dbo].[CollectionUser] CU + WHERE + CU.[OrganizationUserId] = @Id ) - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) -- Update UPDATE @@ -252,17 +268,29 @@ BEGIN -- Bump RevisionDate on all affected collections ;WITH [AffectedCollectionsCTE] AS ( - SELECT [Id] FROM @Collections + SELECT + [Id] + FROM + @Collections + UNION - SELECT CG.[CollectionId] - FROM [dbo].[CollectionGroup] CG - WHERE CG.[GroupId] = @Id + + SELECT + CG.[CollectionId] + FROM + [dbo].[CollectionGroup] CG + WHERE + CG.[GroupId] = @Id ) - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM [AffectedCollectionsCTE]) ;WITH [AvailableCollectionsCTE] AS( SELECT @@ -363,11 +391,15 @@ BEGIN [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) -- Bump RevisionDate on all affected collections - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) END GO @@ -413,11 +445,15 @@ BEGIN [Id] IN (SELECT [Id] FROM [AvailableCollectionsCTE]) -- Bump RevisionDate on all affected collections - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - WHERE C.[OrganizationId] = @OrganizationId - AND C.[Id] IN (SELECT [Id] FROM @Collections) -- New assignments + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + WHERE + C.[OrganizationId] = @OrganizationId + AND C.[Id] IN (SELECT [Id] FROM @Collections) EXEC [dbo].[User_BumpAccountRevisionDateByOrganizationId] @OrganizationId END @@ -531,14 +567,14 @@ BEGIN -- Bump RevisionDate on all affected collections IF @RevisionDate IS NOT NULL BEGIN - UPDATE C - SET C.[RevisionDate] = @RevisionDate - FROM [dbo].[Collection] C - INNER JOIN [dbo].[OrganizationUser] OU - ON OU.[OrganizationId] = C.[OrganizationId] - INNER JOIN #CollectionUserData CUD - ON CUD.[CollectionId] = C.[Id] - AND CUD.[OrganizationUserId] = OU.[Id] + UPDATE + C + SET + C.[RevisionDate] = @RevisionDate + FROM + [dbo].[Collection] C + INNER JOIN + #CollectionUserData CUD ON CUD.[CollectionId] = C.[Id] END END GO From 7de27a9d8a93f662521249befc57a92a0a041e6f Mon Sep 17 00:00:00 2001 From: Rui Tome Date: Tue, 7 Apr 2026 11:23:14 +0100 Subject: [PATCH 19/19] Added early return to OrganizationUserRepository.CreateManyAsync if the supplied parameter is empty --- .../AdminConsole/Repositories/OrganizationUserRepository.cs | 4 ++++ .../AdminConsole/Repositories/OrganizationUserRepository.cs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs index a9cb4efeb3e4..b7de0568a62a 100644 --- a/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.Dapper/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -652,6 +652,10 @@ public async Task CreateManyAsync(IEnumerable organizati await using var connection = new SqlConnection(_marsConnectionString); var organizationUsersList = organizationUserCollection.ToList(); + if (organizationUsersList.Count == 0) + { + return; + } await connection.ExecuteAsync( $"[{Schema}].[OrganizationUser_CreateManyWithCollectionsAndGroups]", diff --git a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs index 1c3db7480a67..a441d519d064 100644 --- a/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs +++ b/src/Infrastructure.EntityFramework/AdminConsole/Repositories/OrganizationUserRepository.cs @@ -944,6 +944,11 @@ on ou.UserId equals u.Id public async Task CreateManyAsync(IEnumerable organizationUserCollection) { + if (!organizationUserCollection.Any()) + { + return; + } + using var scope = ServiceScopeFactory.CreateScope(); await using var dbContext = GetDatabaseContext(scope);