-- Merge duplicate locations and story assets without deleting their archived source records. -- Keep the selected target details; move canonical references and preserve known names. CREATE OR ALTER PROCEDURE dbo.Location_Merge @SourceID int, @TargetID int, @UserID int AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; IF @SourceID IS NULL OR @TargetID IS NULL OR @SourceID = @TargetID THROW 51000, 'Choose two different locations to merge.', 1; BEGIN TRY BEGIN TRANSACTION; DECLARE @ProjectID int, @SourceName nvarchar(200), @TargetName nvarchar(200), @Now datetime2 = SYSUTCDATETIME(); SELECT @ProjectID = ProjectID FROM dbo.Locations WHERE LocationID = @SourceID; -- Serialize merges in a project before locking individual entities. DECLARE @LockedProjectID int; SELECT @LockedProjectID = ProjectID FROM dbo.Projects WITH (UPDLOCK, HOLDLOCK) WHERE ProjectID = @ProjectID; IF @UserID IS NULL OR NOT EXISTS (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1) THROW 51000, 'You do not have access to this project.', 1; SELECT @SourceName = LocationName FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) WHERE LocationID = @SourceID AND IsArchived = 0; SELECT @TargetName = LocationName FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) WHERE LocationID = @TargetID AND ProjectID = @ProjectID AND IsArchived = 0; IF @SourceName IS NULL OR @TargetName IS NULL THROW 51000, 'Both locations must be active and belong to the same project.', 1; -- Merging an ancestor into its descendant would create a hierarchy cycle. DECLARE @AncestorID int = @TargetID; DECLARE @Visited TABLE (LocationID int PRIMARY KEY); WHILE @AncestorID IS NOT NULL BEGIN IF @AncestorID = @SourceID OR EXISTS (SELECT 1 FROM @Visited WHERE LocationID = @AncestorID) THROW 51000, 'Cannot merge a location into one of its descendants. Choose another location or change its parent first.', 1; INSERT @Visited VALUES (@AncestorID); SELECT @AncestorID = ParentLocationID FROM dbo.Locations WITH (UPDLOCK, HOLDLOCK) WHERE LocationID = @AncestorID; END; INSERT dbo.LocationAliases (LocationID, Alias, SortOrder) SELECT @TargetID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.LocationAliases WHERE LocationID = @TargetID), 0) + ROW_NUMBER() OVER (ORDER BY AliasValue) * 10 FROM (SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue UNION SELECT LTRIM(RTRIM(Alias)) FROM dbo.LocationAliases WHERE LocationID = @SourceID) names WHERE NULLIF(AliasValue, N'') IS NOT NULL AND AliasValue <> @TargetName AND NOT EXISTS (SELECT 1 FROM dbo.LocationAliases existing WHERE existing.LocationID = @TargetID AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue)); DELETE sourceRows FROM dbo.WordCompanionSceneLocations sourceRows WHERE sourceRows.LocationID = @SourceID AND EXISTS (SELECT 1 FROM dbo.WordCompanionSceneLocations targetRows WHERE targetRows.LocationID = @TargetID AND targetRows.SceneID = sourceRows.SceneID); DELETE sourceRows FROM dbo.SceneLocationSuggestions sourceRows WHERE sourceRows.LocationID = @SourceID AND sourceRows.Status = N'Pending' AND EXISTS (SELECT 1 FROM dbo.SceneLocationSuggestions targetRows WHERE targetRows.LocationID = @TargetID AND targetRows.SceneID = sourceRows.SceneID AND targetRows.Status = sourceRows.Status); UPDATE dbo.Locations SET ParentLocationID = @TargetID WHERE ParentLocationID = @SourceID; UPDATE dbo.Scenes SET PrimaryLocationID = @TargetID WHERE PrimaryLocationID = @SourceID; UPDATE dbo.SceneCharacters SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.SceneCharacters SET EntryLocationID = @TargetID WHERE EntryLocationID = @SourceID; UPDATE dbo.SceneCharacters SET ExitLocationID = @TargetID WHERE ExitLocationID = @SourceID; UPDATE dbo.StoryAssets SET CurrentLocationID = @TargetID WHERE CurrentLocationID = @SourceID; UPDATE dbo.SceneAssetLocations SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.SceneFloorPlanOccupancy SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.WordCompanionSceneLocations SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.SceneLocationSuggestions SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.FloorPlans SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.FloorPlanFloors SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.FloorPlanBlocks SET LocationID = @TargetID WHERE LocationID = @SourceID; UPDATE dbo.FloorPlanTransitions SET FromLocationID = @TargetID WHERE FromLocationID = @SourceID; UPDATE dbo.FloorPlanTransitions SET ToLocationID = @TargetID WHERE ToLocationID = @SourceID; UPDATE dbo.CoreImportBasicLocationCandidates SET ExistingLocationID = @TargetID WHERE ExistingLocationID = @SourceID; UPDATE dbo.CoreImportBasicLocationCandidates SET ParentLocationID = @TargetID WHERE ParentLocationID = @SourceID; UPDATE dbo.CoreImportBasicLocationCandidates SET ResultLocationID = @TargetID WHERE ResultLocationID = @SourceID; UPDATE dbo.CoreImportBasicLocationCandidates SET SameAsLocationID = @TargetID WHERE SameAsLocationID = @SourceID; -- Keep self-links archived with their original endpoints, as in character merging. UPDATE dbo.LocationRelationships SET IsArchived = 1, UpdatedDate = @Now WHERE (FromLocationID = @SourceID AND ToLocationID = @TargetID) OR (FromLocationID = @TargetID AND ToLocationID = @SourceID); UPDATE dbo.LocationRelationships SET FromLocationID = CASE WHEN FromLocationID = @SourceID THEN @TargetID ELSE FromLocationID END, ToLocationID = CASE WHEN ToLocationID = @SourceID THEN @TargetID ELSE ToLocationID END, UpdatedDate = @Now WHERE IsArchived = 0 AND (FromLocationID = @SourceID OR ToLocationID = @SourceID); -- Collapse identical active links while retaining links with different evidence/settings. UPDATE duplicate SET IsArchived = 1, UpdatedDate = @Now FROM dbo.LocationRelationships duplicate WHERE duplicate.IsArchived = 0 AND (duplicate.FromLocationID = @TargetID OR duplicate.ToLocationID = @TargetID) AND EXISTS (SELECT 1 FROM dbo.LocationRelationships kept WHERE kept.IsArchived = 0 AND kept.LocationRelationshipID < duplicate.LocationRelationshipID AND kept.FromLocationID = duplicate.FromLocationID AND kept.ToLocationID = duplicate.ToLocationID AND (kept.LocationRelationshipTypeID = duplicate.LocationRelationshipTypeID OR (kept.LocationRelationshipTypeID IS NULL AND duplicate.LocationRelationshipTypeID IS NULL)) AND (kept.IsBidirectional = duplicate.IsBidirectional OR (kept.IsBidirectional IS NULL AND duplicate.IsBidirectional IS NULL)) AND (kept.Strength = duplicate.Strength OR (kept.Strength IS NULL AND duplicate.Strength IS NULL)) AND (kept.CanHearNormalSpeech = duplicate.CanHearNormalSpeech OR (kept.CanHearNormalSpeech IS NULL AND duplicate.CanHearNormalSpeech IS NULL)) AND (kept.CanHearLoudNoise = duplicate.CanHearLoudNoise OR (kept.CanHearLoudNoise IS NULL AND duplicate.CanHearLoudNoise IS NULL)) AND (kept.CanSeeMovement = duplicate.CanSeeMovement OR (kept.CanSeeMovement IS NULL AND duplicate.CanSeeMovement IS NULL)) AND (kept.CanSeeClearly = duplicate.CanSeeClearly OR (kept.CanSeeClearly IS NULL AND duplicate.CanSeeClearly IS NULL)) AND (kept.CanTravelDirectly = duplicate.CanTravelDirectly OR (kept.CanTravelDirectly IS NULL AND duplicate.CanTravelDirectly IS NULL)) AND (kept.Notes = duplicate.Notes OR (kept.Notes IS NULL AND duplicate.Notes IS NULL))); UPDATE dbo.Locations SET IsArchived = 1, ArchivedDate = @Now, ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (LocationID ', @TargetID, N').'), UpdatedDate = @Now, ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE LocationID = @SourceID; COMMIT TRANSACTION; SELECT @ProjectID AS ProjectID, @TargetID AS TargetID, @SourceName AS SourceName, @TargetName AS TargetName; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH; END; GO CREATE OR ALTER PROCEDURE dbo.StoryAsset_Merge @SourceID int, @TargetID int, @UserID int AS BEGIN SET NOCOUNT ON; SET XACT_ABORT ON; IF @SourceID IS NULL OR @TargetID IS NULL OR @SourceID = @TargetID THROW 51000, 'Choose two different assets to merge.', 1; BEGIN TRY BEGIN TRANSACTION; DECLARE @ProjectID int, @SourceName nvarchar(200), @TargetName nvarchar(200), @Now datetime2 = SYSUTCDATETIME(); SELECT @ProjectID = ProjectID FROM dbo.StoryAssets WHERE StoryAssetID = @SourceID; -- Serialize merges in a project before locking individual entities. DECLARE @LockedProjectID int; SELECT @LockedProjectID = ProjectID FROM dbo.Projects WITH (UPDLOCK, HOLDLOCK) WHERE ProjectID = @ProjectID; IF @UserID IS NULL OR NOT EXISTS (SELECT 1 FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID AND UserID = @UserID AND IsActive = 1) THROW 51000, 'You do not have access to this project.', 1; SELECT @SourceName = AssetName FROM dbo.StoryAssets WITH (UPDLOCK, HOLDLOCK) WHERE StoryAssetID = @SourceID AND IsArchived = 0; SELECT @TargetName = AssetName FROM dbo.StoryAssets WITH (UPDLOCK, HOLDLOCK) WHERE StoryAssetID = @TargetID AND ProjectID = @ProjectID AND IsArchived = 0; IF @SourceName IS NULL OR @TargetName IS NULL THROW 51000, 'Both assets must be active and belong to the same project.', 1; INSERT dbo.AssetAliases (StoryAssetID, Alias, SortOrder) SELECT @TargetID, AliasValue, ISNULL((SELECT MAX(SortOrder) FROM dbo.AssetAliases WHERE StoryAssetID = @TargetID), 0) + ROW_NUMBER() OVER (ORDER BY AliasValue) * 10 FROM (SELECT LTRIM(RTRIM(@SourceName)) AS AliasValue UNION SELECT LTRIM(RTRIM(Alias)) FROM dbo.AssetAliases WHERE StoryAssetID = @SourceID) names WHERE NULLIF(AliasValue, N'') IS NOT NULL AND AliasValue <> @TargetName AND NOT EXISTS (SELECT 1 FROM dbo.AssetAliases existing WHERE existing.StoryAssetID = @TargetID AND UPPER(LTRIM(RTRIM(existing.Alias))) = UPPER(AliasValue)); DELETE sourceRows FROM dbo.WordCompanionSceneAssets sourceRows WHERE sourceRows.StoryAssetID = @SourceID AND EXISTS (SELECT 1 FROM dbo.WordCompanionSceneAssets targetRows WHERE targetRows.StoryAssetID = @TargetID AND targetRows.SceneID = sourceRows.SceneID); DELETE sourceRows FROM dbo.SceneAssetSuggestions sourceRows WHERE sourceRows.AssetID = @SourceID AND sourceRows.Status = N'Pending' AND EXISTS (SELECT 1 FROM dbo.SceneAssetSuggestions targetRows WHERE targetRows.AssetID = @TargetID AND targetRows.SceneID = sourceRows.SceneID AND targetRows.Status = sourceRows.Status); DELETE sourceRows FROM dbo.SceneFloorPlanOccupancy sourceRows WHERE sourceRows.StoryAssetID = @SourceID AND EXISTS (SELECT 1 FROM dbo.SceneFloorPlanOccupancy targetRows WHERE targetRows.StoryAssetID = @TargetID AND targetRows.SceneID = sourceRows.SceneID); DELETE sourceRows FROM dbo.ContinuityWarningAcknowledgements sourceRows WHERE sourceRows.AssetID = @SourceID AND EXISTS (SELECT 1 FROM dbo.ContinuityWarningAcknowledgements targetRows WHERE targetRows.AssetID = @TargetID AND targetRows.ProjectID = sourceRows.ProjectID AND targetRows.WarningType = sourceRows.WarningType AND targetRows.SceneIDKey = sourceRows.SceneIDKey AND targetRows.CharacterIDKey = sourceRows.CharacterIDKey); UPDATE dbo.AssetEvents SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; UPDATE dbo.AssetCustodyEvents SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; UPDATE dbo.CharacterKnowledge SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; UPDATE dbo.SceneAssetLocations SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; UPDATE dbo.SceneFloorPlanOccupancy SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; UPDATE dbo.WordCompanionSceneAssets SET StoryAssetID = @TargetID WHERE StoryAssetID = @SourceID; UPDATE dbo.SceneAssetSuggestions SET AssetID = @TargetID WHERE AssetID = @SourceID; UPDATE dbo.ContinuityWarningAcknowledgements SET AssetID = @TargetID WHERE AssetID = @SourceID; -- Keep self-links archived with their original endpoints, as in character merging. UPDATE dbo.AssetDependencies SET IsArchived = 1, UpdatedDate = @Now WHERE (SourceAssetID = @SourceID AND TargetAssetID = @TargetID) OR (SourceAssetID = @TargetID AND TargetAssetID = @SourceID); UPDATE dbo.AssetDependencies SET SourceAssetID = CASE WHEN SourceAssetID = @SourceID THEN @TargetID ELSE SourceAssetID END, TargetAssetID = CASE WHEN TargetAssetID = @SourceID THEN @TargetID ELSE TargetAssetID END, UpdatedDate = @Now WHERE IsArchived = 0 AND (SourceAssetID = @SourceID OR TargetAssetID = @SourceID); -- Collapse identical active links while retaining links with different evidence/settings. UPDATE duplicate SET IsArchived = 1, UpdatedDate = @Now FROM dbo.AssetDependencies duplicate WHERE duplicate.IsArchived = 0 AND (duplicate.SourceAssetID = @TargetID OR duplicate.TargetAssetID = @TargetID) AND EXISTS (SELECT 1 FROM dbo.AssetDependencies kept WHERE kept.IsArchived = 0 AND kept.AssetDependencyID < duplicate.AssetDependencyID AND kept.SourceAssetID = duplicate.SourceAssetID AND kept.TargetAssetID = duplicate.TargetAssetID AND (kept.AssetDependencyTypeID = duplicate.AssetDependencyTypeID OR (kept.AssetDependencyTypeID IS NULL AND duplicate.AssetDependencyTypeID IS NULL)) AND (kept.SourceRequiredStateID = duplicate.SourceRequiredStateID OR (kept.SourceRequiredStateID IS NULL AND duplicate.SourceRequiredStateID IS NULL)) AND (kept.TargetBlockedStateID = duplicate.TargetBlockedStateID OR (kept.TargetBlockedStateID IS NULL AND duplicate.TargetBlockedStateID IS NULL)) AND (kept.Description = duplicate.Description OR (kept.Description IS NULL AND duplicate.Description IS NULL))); UPDATE dbo.StoryAssets SET IsArchived = 1, ArchivedDate = @Now, ArchivedReason = CONCAT(N'Merged into ', @TargetName, N' (StoryAssetID ', @TargetID, N').'), UpdatedDate = @Now, ModifiedByUserID = @UserID, ModifiedDateUTC = @Now WHERE StoryAssetID = @SourceID; COMMIT TRANSACTION; SELECT @ProjectID AS ProjectID, @TargetID AS TargetID, @SourceName AS SourceName, @TargetName AS TargetName; END TRY BEGIN CATCH IF XACT_STATE() <> 0 ROLLBACK TRANSACTION; THROW; END CATCH; END; GO