using Dapper; using PlotLine.Data; namespace PlotLine.Services; public interface IStoryIntelligenceDevelopmentResetService { Task ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default); } public sealed class StoryIntelligenceDevelopmentResetService( ISqlConnectionFactory connectionFactory, ILogger logger) : IStoryIntelligenceDevelopmentResetService { public async Task ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default) { using var connection = connectionFactory.CreateConnection(); var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120); var summary = await connection.QuerySingleAsync(command); foreach (var projectId in summary.ProjectIdList) { var deleteCommand = new CommandDefinition( "dbo.ProjectHardDelete_ForOwner", new { ProjectID = projectId, UserID = userId }, commandType: System.Data.CommandType.StoredProcedure, cancellationToken: cancellationToken, commandTimeout: 120); await connection.ExecuteAsync(deleteCommand); } var verificationCommand = new CommandDefinition(VerifyResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120); var verification = await connection.QuerySingleAsync(verificationCommand); var result = new StoryIntelligenceDevelopmentResetResult( summary.ProjectIdList.Count, summary.ImportSessionCount, summary.RunCount, verification.RemainingProjects, verification.RemainingImports, verification.RemainingRuns, verification.PreservedIllustrationItems); logger.LogWarning( "Admin Story Intelligence reset completed for user {UserId}. DeletedProjects={DeletedProjects} DeletedImports={DeletedImports} DeletedRuns={DeletedRuns} PreservedIllustrationItems={PreservedIllustrationItems}.", userId, result.DeletedProjects, result.DeletedImportSessions, result.DeletedRuns, result.PreservedIllustrationItems); return result; } private const string PrepareResetSql = """ SET NOCOUNT ON; DECLARE @ProjectIDs TABLE (ProjectID int NOT NULL PRIMARY KEY); DECLARE @BookIDs TABLE (BookID int NOT NULL PRIMARY KEY); DECLARE @ImportSessionIDs TABLE (ImportSessionID int NOT NULL PRIMARY KEY); DECLARE @RunIDs TABLE (StoryIntelligenceRunID int NOT NULL PRIMARY KEY); INSERT @ProjectIDs (ProjectID) SELECT DISTINCT p.ProjectID FROM dbo.Projects p INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID WHERE pua.UserID = @UserID AND pua.AccessRole = N'Owner' AND pua.IsActive = 1; INSERT @BookIDs (BookID) SELECT b.BookID FROM dbo.Books b INNER JOIN @ProjectIDs p ON p.ProjectID = b.ProjectID; INSERT @ImportSessionIDs (ImportSessionID) SELECT StoryIntelligenceBookPipelineID FROM dbo.StoryIntelligenceBookPipelines pipeline WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = pipeline.ProjectID) OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = pipeline.BookID); INSERT @RunIDs (StoryIntelligenceRunID) SELECT StoryIntelligenceRunID FROM dbo.StoryIntelligenceRuns run WHERE run.UserID = @UserID AND ( run.ProjectID IS NULL OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = run.ProjectID) OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = run.BookID) ); DECLARE @ImportSessionCount int = (SELECT COUNT(*) FROM @ImportSessionIDs); DECLARE @RunCount int = (SELECT COUNT(*) FROM @RunIDs); UPDATE dbo.StoryIntelligenceRuns SET Status = CASE WHEN Status IN (N'Pending', N'Running') THEN N'Cancelled' ELSE Status END, CurrentStage = N'Cancelled before admin reset', CurrentMessage = N'Admin reset invalidated this run while preserving generated illustrations.', CancellationRequestedUtc = COALESCE(CancellationRequestedUtc, SYSUTCDATETIME()), CancelledUtc = COALESCE(CancelledUtc, SYSUTCDATETIME()), CompletedUtc = COALESCE(CompletedUtc, SYSUTCDATETIME()), UpdatedUtc = SYSUTCDATETIME() WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID) AND Status IN (N'Pending', N'Running'); DELETE FROM dbo.StoryMemoryIllustrationAssignments WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationAssignments.ImportSessionID); DELETE FROM dbo.StoryMemoryIllustrationDemands WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryIllustrationDemands.ImportSessionID); DELETE FROM dbo.StoryMemoryRelationships WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryRelationships.ImportSessionID); DELETE FROM dbo.StoryMemoryCharacterAttributes WHERE EXISTS ( SELECT 1 FROM dbo.StoryMemoryCharacters c INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAttributes.StoryMemoryCharacterID ); DELETE FROM dbo.StoryMemoryCharacterAliases WHERE EXISTS ( SELECT 1 FROM dbo.StoryMemoryCharacters c INNER JOIN @ImportSessionIDs ids ON ids.ImportSessionID = c.ImportSessionID WHERE c.StoryMemoryCharacterID = StoryMemoryCharacterAliases.StoryMemoryCharacterID ); DELETE FROM dbo.StoryMemoryAssets WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryAssets.ImportSessionID); DELETE FROM dbo.StoryMemoryLocations WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryLocations.ImportSessionID); DELETE FROM dbo.StoryMemoryCharacters WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryCharacters.ImportSessionID); DELETE FROM dbo.StoryMemoryProcessedSceneResults WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryProcessedSceneResults.ImportSessionID); DELETE FROM dbo.StoryMemoryImportPreferences WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID); DELETE FROM dbo.StoryIntelligenceCharacterIllustrationAssignments WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceCharacterIllustrationAssignments.ImportSessionID) OR EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceCharacterIllustrationAssignments.ProjectID); DELETE FROM dbo.StoryIntelligenceIllustrationDemands WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID); DELETE FROM dbo.StoryIntelligenceBookPipelines WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryIntelligenceBookPipelines.StoryIntelligenceBookPipelineID); DELETE FROM dbo.StoryIntelligenceSceneResults WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceSceneResults.StoryIntelligenceRunID); DELETE FROM dbo.StoryIntelligenceChapterResults WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceChapterResults.StoryIntelligenceRunID); DELETE FROM dbo.StoryIntelligenceImportCommits WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceImportCommits.StoryIntelligenceRunID); DELETE FROM dbo.StoryIntelligenceRuns WHERE EXISTS (SELECT 1 FROM @RunIDs ids WHERE ids.StoryIntelligenceRunID = StoryIntelligenceRuns.StoryIntelligenceRunID); UPDATE dbo.Projects SET IsArchived = 1, ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()), ArchivedReason = N'Admin Story Intelligence reset preserving generated illustrations', UpdatedDate = SYSUTCDATETIME() WHERE EXISTS (SELECT 1 FROM @ProjectIDs ids WHERE ids.ProjectID = Projects.ProjectID); SELECT ProjectIds = COALESCE(STRING_AGG(CONVERT(varchar(20), ProjectID), ','), ''), ImportSessionCount = @ImportSessionCount, RunCount = @RunCount FROM @ProjectIDs; """; private const string VerifyResetSql = """ SELECT RemainingProjects = ( SELECT COUNT(*) FROM dbo.Projects p INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID WHERE pua.UserID = @UserID AND pua.AccessRole = N'Owner' AND pua.IsActive = 1 ), RemainingImports = ( SELECT COUNT(*) FROM dbo.StoryIntelligenceBookPipelines pipeline INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = pipeline.ProjectID WHERE pua.UserID = @UserID AND pua.AccessRole = N'Owner' AND pua.IsActive = 1 ), RemainingRuns = ( SELECT COUNT(*) FROM dbo.StoryIntelligenceRuns WHERE UserID = @UserID ), PreservedIllustrationItems = ( SELECT COUNT(*) FROM dbo.IllustrationLibraryItems WHERE Category IN (N'Character', N'Location', N'Asset') ); """; private sealed class PreparedResetSummary { public string ProjectIds { get; init; } = string.Empty; public int ImportSessionCount { get; init; } public int RunCount { get; init; } public IReadOnlyList ProjectIdList => SplitProjectIds(ProjectIds); } private sealed class ResetVerification { public int RemainingProjects { get; init; } public int RemainingImports { get; init; } public int RemainingRuns { get; init; } public int PreservedIllustrationItems { get; init; } } private static IReadOnlyList SplitProjectIds(string projectIds) => string.IsNullOrWhiteSpace(projectIds) ? [] : projectIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) .Select(value => int.TryParse(value, out var id) ? id : 0) .Where(id => id > 0) .ToArray(); } public sealed record StoryIntelligenceDevelopmentResetResult( int DeletedProjects, int DeletedImportSessions, int DeletedRuns, int RemainingProjects, int RemainingImportSessions, int RemainingRuns, int PreservedIllustrationItems) { public bool Success => RemainingProjects == 0 && RemainingImportSessions == 0 && RemainingRuns == 0; public string Message => Success ? $"Reset complete. Deleted {DeletedProjects:N0} project(s), {DeletedImportSessions:N0} import(s) and {DeletedRuns:N0} run(s). Preserved {PreservedIllustrationItems:N0} generated image record(s)." : $"Reset finished with remaining data: {RemainingProjects:N0} project(s), {RemainingImportSessions:N0} import(s), {RemainingRuns:N0} run(s). Preserved {PreservedIllustrationItems:N0} generated image record(s)."; }