@@ -2185,7 +2185,10 @@ private int LocationCleanup()
21852185
21862186 /// <summary>
21872187 /// Delete group membership duplicates if they are not allowed by web.config and return the
2188- /// number of records deleted.
2188+ /// number of records deleted. Before deleting, re-map any GroupMemberAssignment rows that
2189+ /// reference a non-oldest duplicate to reference the oldest GroupMember for that person/group/role.
2190+ /// If the remap would violate the unique index on (GroupMemberId, GroupId, LocationId, ScheduleId),
2191+ /// delete the newer conflicting assignment instead.
21892192 /// </summary>
21902193 /// <returns>The number of records deleted</returns>
21912194 private int GroupMembershipCleanup ( )
@@ -2203,30 +2206,116 @@ private int GroupMembershipCleanup()
22032206
22042207 var groupMemberService = new GroupMemberService ( rockContext ) ;
22052208 var groupMemberHistoricalService = new GroupMemberHistoricalService ( rockContext ) ;
2209+ var groupMemberAssignmentService = new GroupMemberAssignmentService ( rockContext ) ;
22062210
2207- var duplicateQuery = groupMemberService . Queryable ( )
2208-
2209- // Duplicates are the same person, group, and role occurring more than once
2211+ // Build a per-duplicate mapping: (Duplicate GroupMemberId) -> (Oldest GroupMemberId)
2212+ // We need this so we can remap any GroupMemberAssignemnts to the surviving GroupMember record.
2213+ // NOTE: ThenBy(Id) gives deterministic ordering when CreatedDateTime ties.
2214+ var remapDict = groupMemberService . Queryable ( )
22102215 . GroupBy ( m => new { m . PersonId , m . GroupId , m . GroupRoleId } )
2211-
2212- // Filter out sets with only one occurrence because those are not duplicates
22132216 . Where ( g => g . Count ( ) > 1 )
2217+ . SelectMany ( g =>
2218+ g . OrderBy ( gm => gm . CreatedDateTime ?? DateTime . MinValue )
2219+ . ThenBy ( gm => gm . Id )
2220+ . Skip ( 1 )
2221+ . Select ( dup => new
2222+ {
2223+ DuplicateId = dup . Id ,
2224+ OldestId = g . OrderBy ( gm => gm . CreatedDateTime ?? DateTime . MinValue )
2225+ . ThenBy ( gm => gm . Id )
2226+ . Select ( gm => gm . Id )
2227+ . FirstOrDefault ( )
2228+ } ) )
2229+ . ToDictionary ( x => x . DuplicateId , x => x . OldestId ) ;
2230+
2231+ if ( ! remapDict . Any ( ) )
2232+ {
2233+ return 0 ;
2234+ }
2235+
2236+ var duplicateGroupMemberIds = remapDict . Keys . ToList ( ) ;
2237+ var oldestIds = remapDict . Values . Distinct ( ) . ToList ( ) ;
2238+
2239+ // Step 1: Re-map GroupMemberAssignments, handling unique index collisions
2240+ // Unique index columns: GroupMemberId, GroupId, LocationId, ScheduleId
2241+ // If target exists, delete the newer assignment instead of remapping.
2242+
2243+ Func < int , int , int ? , int ? , string > key = ( groupMemberId , groupId , locationId , scheduleId ) =>
2244+ $ "{ groupMemberId } |{ groupId } |{ locationId ? . ToString ( ) ?? "NULL" } |{ scheduleId ? . ToString ( ) ?? "NULL" } ";
2245+
2246+ // Load source assignments (ones pointing at duplicates).
2247+ var sourceAssignments = groupMemberAssignmentService . Queryable ( )
2248+ . Where ( a => duplicateGroupMemberIds . Contains ( a . GroupMemberId ) )
2249+ . ToList ( ) ;
2250+
2251+ // Load existing assignments for oldest ids (to detect collisions).
2252+ var existingTargetKeys = new HashSet < string > (
2253+ groupMemberAssignmentService . Queryable ( )
2254+ . Where ( a => oldestIds . Contains ( a . GroupMemberId ) )
2255+ . Select ( a => new
2256+ {
2257+ a . GroupMemberId ,
2258+ a . GroupId ,
2259+ a . LocationId ,
2260+ a . ScheduleId
2261+ } )
2262+ . ToList ( )
2263+ . Select ( x => key ( x . GroupMemberId , x . GroupId , x . LocationId , x . ScheduleId ) )
2264+ ) ;
2265+
2266+ // Reserve keys for assignments that already exist, after they are moved (prevents collisions within the batch).
2267+ var duplicateAssignmentIdsToDelete = new List < int > ( ) ;
2268+
2269+ foreach ( var assignment in sourceAssignments )
2270+ {
2271+ int oldestGroupMemberId ;
2272+ if ( ! remapDict . TryGetValue ( assignment . GroupMemberId , out oldestGroupMemberId ) || oldestGroupMemberId <= 0 )
2273+ {
2274+ continue ;
2275+ }
2276+
2277+ if ( assignment . GroupMemberId == oldestGroupMemberId )
2278+ {
2279+ continue ;
2280+ }
2281+
2282+ var targetKey = key ( oldestGroupMemberId , assignment . GroupId , assignment . LocationId , assignment . ScheduleId ) ;
2283+
2284+ if ( existingTargetKeys . Contains ( targetKey ) )
2285+ {
2286+ duplicateAssignmentIdsToDelete . Add ( assignment . Id ) ;
2287+ continue ;
2288+ }
22142289
2215- // Leave the oldest membership and delete the others
2216- . SelectMany ( g => g . OrderBy ( gm => gm . CreatedDateTime ) . Skip ( 1 ) ) ;
2290+ // Point this assignment to the oldest, surviving group member id
2291+ assignment . GroupMemberId = oldestGroupMemberId ;
2292+ existingTargetKeys . Add ( targetKey ) ;
2293+ }
2294+
2295+ // Delete any assignments that were going to be duplicates.
2296+ if ( duplicateAssignmentIdsToDelete . Any ( ) )
2297+ {
2298+ var assignmentsToDeleteQuery = groupMemberAssignmentService . Queryable ( )
2299+ . Where ( a => duplicateAssignmentIdsToDelete . Contains ( a . Id ) ) ;
22172300
2218- // Get the IDs to delete the history
2219- var groupMemberIds = duplicateQuery . Select ( d => d . Id ) ;
2301+ groupMemberAssignmentService . DeleteRange ( assignmentsToDeleteQuery ) ;
2302+ }
2303+
2304+ // Step 2: Delete history rows for duplicates.
22202305 var historyQuery = groupMemberHistoricalService . Queryable ( )
2221- . Where ( gmh => groupMemberIds . Contains ( gmh . GroupMemberId ) ) ;
2306+ . Where ( gmh => duplicateGroupMemberIds . Contains ( gmh . GroupMemberId ) ) ;
2307+
2308+ // Step 3: Delete duplicate GroupMember rows (non-oldest).
2309+ var duplicatesQuery = groupMemberService . Queryable ( )
2310+ . Where ( gm => duplicateGroupMemberIds . Contains ( gm . Id ) ) ;
22222311
2223- // Delete the history and duplicate memberships
22242312 groupMemberHistoricalService . DeleteRange ( historyQuery ) ;
2225- groupMemberService . DeleteRange ( duplicateQuery ) ;
2313+ groupMemberService . DeleteRange ( duplicatesQuery ) ;
2314+
22262315 rockContext . SaveChanges ( ) ;
22272316
22282317 // Return the count of memberships deleted
2229- return groupMemberIds . Count ( ) ;
2318+ return duplicateGroupMemberIds . Count ;
22302319 }
22312320
22322321 private int DeleteDuplicatePreviousFamilyLocations ( )
0 commit comments