Fix admin reset and project hard delete

This commit is contained in:
Nick Beckley 2026-08-24 21:19:59 +00:00
parent c2a6dbd849
commit 3b2b25eeab
6 changed files with 279 additions and 34 deletions

View File

@ -104,6 +104,9 @@ var tests = new (string Name, Action Test)[]
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates),
("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers),
("Admin Story Intelligence reset returns to diagnostics with messages", AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages),
("Project hard delete clears Story Intelligence dependencies in transaction", ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction),
("Permanent project delete keeps archive and name safety gates", PermanentProjectDeleteKeepsArchiveAndNameSafetyGates),
("Word manuscript cleanup classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
@ -117,6 +120,48 @@ foreach (var test in tests)
Console.WriteLine($"PASS {test.Name}");
}
static void AdminStoryIntelligenceResetReturnsToDiagnosticsWithMessages()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/AdminController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/StoryIntelligenceDevelopmentResetService.cs"));
Assert(controller.Contains("try", StringComparison.Ordinal)
&& controller.Contains("catch (Exception ex)", StringComparison.Ordinal), "Reset action should convert failures into a diagnostics-page error.");
Assert(controller.Contains("TempData[result.Success ? \"AdminMessage\" : \"AdminError\"]", StringComparison.Ordinal), "Reset action should show a clear success or error message.");
Assert(controller.Contains("return RedirectToAction(nameof(StoryIntelligenceDiagnostics))", StringComparison.Ordinal), "Reset action should return to the stable diagnostics route.");
Assert(service.Contains("IProjectRepository projects", StringComparison.Ordinal), "Reset should delegate project deletion to the shared hard-delete repository path.");
Assert(!service.Contains("DELETE FROM dbo.StoryIntelligenceRuns", StringComparison.Ordinal), "Reset should not delete run parents before hard-delete dependency cleanup.");
}
static void ProjectHardDeleteClearsStoryIntelligenceDependenciesInTransaction()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var repository = File.ReadAllText(Path.Combine(root, "Data/Repositories.cs"));
Assert(repository.Contains("using var transaction = connection.BeginTransaction()", StringComparison.Ordinal), "Project hard delete should be wrapped by a repository transaction.");
Assert(repository.Contains("ProjectHardDeleteStoryIntelligenceDependencyCleanupSql", StringComparison.Ordinal), "Project hard delete should run scoped Story Intelligence dependency cleanup before the stored procedure.");
Assert(repository.Contains("AllowActiveProject = false", StringComparison.Ordinal), "Archived-project permanent delete must refuse active projects before cleanup.");
Assert(repository.Contains("THROW 52002, 'Only archived projects can be permanently deleted.'", StringComparison.Ordinal), "Hard delete cleanup should enforce the archive gate before deleting child rows.");
Assert(repository.Contains("CharacterIntelligenceEvidenceEvents", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence evidence rows.");
Assert(repository.Contains("CharacterIntelligenceProfiles", StringComparison.Ordinal), "Hard delete cleanup should remove character intelligence profile rows.");
Assert(repository.Contains("StoryMemoryProcessedSceneResults", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory scene-result rows.");
Assert(repository.Contains("StoryMemoryImportPreferences", StringComparison.Ordinal), "Hard delete cleanup should remove durable story memory import rows.");
Assert(repository.Contains("WHERE ProjectID = @ProjectID", StringComparison.Ordinal), "Hard delete cleanup must be scoped by project id.");
}
static void PermanentProjectDeleteKeepsArchiveAndNameSafetyGates()
{
var root = Path.Combine(AppContext.BaseDirectory, "../../../../PlotLine");
var controller = File.ReadAllText(Path.Combine(root, "Controllers/ProjectsController.cs"));
var service = File.ReadAllText(Path.Combine(root, "Services/CoreServices.cs"));
Assert(controller.Contains("GetArchivedOwnerProjectAsync(projectId)", StringComparison.Ordinal), "Permanent delete should require an archived owner project.");
Assert(controller.Contains("string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal)", StringComparison.Ordinal), "Permanent delete should require an exact project-name confirmation.");
Assert(service.Contains("return ProjectHardDeleteResult.ActiveProject()", StringComparison.Ordinal), "Permanent delete should reject non-archived projects.");
Assert(service.Contains("Blocking database dependency", StringComparison.Ordinal), "Permanent delete failures should report the blocking database dependency.");
}
static void RepairsTrailingDecimalConfidence()
{
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");

View File

@ -22,7 +22,8 @@ public sealed class AdminController(
IStoryIntelligenceFullChapterTestService fullChapterTest,
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
INameIntelligenceService nameIntelligence,
IIllustrationLibraryService illustrationLibrary) : Controller
IIllustrationLibraryService illustrationLibrary,
ILogger<AdminController> logger) : Controller
{
[HttpGet("")]
[HttpGet("index")]
@ -198,8 +199,27 @@ public sealed class AdminController(
return Forbid();
}
logger.LogWarning("Story Intelligence development reset requested by admin user {UserId}.", userId);
try
{
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
logger.LogWarning(
"Story Intelligence development reset finished for admin user {UserId}. Success={Success} DeletedProjects={DeletedProjects} RemainingProjects={RemainingProjects} RemainingImports={RemainingImports} RemainingRuns={RemainingRuns}.",
userId,
result.Success,
result.DeletedProjects,
result.RemainingProjects,
result.RemainingImportSessions,
result.RemainingRuns);
}
catch (Exception ex)
{
logger.LogError(ex, "Story Intelligence development reset failed for admin user {UserId}.", userId);
TempData["AdminError"] = "Story Intelligence reset failed. No files were removed; check the application logs for the blocking dependency.";
}
return RedirectToAction(nameof(StoryIntelligenceDiagnostics));
}

View File

@ -6,7 +6,7 @@ using PlotLine.ViewModels;
namespace PlotLine.Controllers;
[Authorize]
public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity) : Controller
public sealed class ProjectsController(IProjectService projects, IProjectActivityService activity, ILogger<ProjectsController> logger) : Controller
{
public async Task<IActionResult> Index() => View(await projects.ListAsync());
@ -92,15 +92,19 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
[ValidateAntiForgeryToken]
public async Task<IActionResult> HardDeleteArchivedProject(int projectId, string? confirmationName)
{
logger.LogWarning("Permanent project delete requested for archived project {ProjectID}.", projectId);
var project = await projects.GetArchivedOwnerProjectAsync(projectId);
if (project is null)
{
logger.LogWarning("Permanent project delete rejected for project {ProjectID}: project is not archived or current user is not owner.", projectId);
TempData["ArchiveError"] = "Only an archived project owner can permanently delete a project.";
return RedirectToAction(nameof(Index));
}
if (!string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal))
{
logger.LogWarning("Permanent project delete rejected for archived project {ProjectID}: confirmation name did not match.", projectId);
TempData["ArchiveError"] = "Type the project name exactly to permanently delete it.";
return RedirectToAction(nameof(Index));
}
@ -108,10 +112,12 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
var result = await projects.HardDeleteAsync(projectId);
if (result.Succeeded)
{
logger.LogWarning("Permanent project delete completed for archived project {ProjectID}.", projectId);
TempData["ArchiveMessage"] = "Project permanently deleted.";
}
else
{
logger.LogWarning("Permanent project delete failed for archived project {ProjectID}: {Message}", projectId, result.Message);
TempData["ArchiveError"] = result.NotArchived
? "Only archived projects can be permanently deleted."
: result.Message;

View File

@ -2098,11 +2098,162 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
public async Task HardDeleteForOwnerAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
ProjectHardDeleteStoryIntelligenceDependencyCleanupSql,
new { ProjectID = projectId, UserID = userId, AllowActiveProject = false },
transaction,
commandTimeout: 120);
await connection.ExecuteAsync(
"dbo.ProjectHardDelete_ForOwner",
new { ProjectID = projectId, UserID = userId },
transaction,
commandType: CommandType.StoredProcedure);
transaction.Commit();
}
catch
{
TryRollback(transaction);
throw;
}
}
private const string ProjectHardDeleteStoryIntelligenceDependencyCleanupSql = """
SET NOCOUNT ON;
IF NOT EXISTS
(
SELECT 1
FROM dbo.Projects p
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
WHERE p.ProjectID = @ProjectID
AND pua.UserID = @UserID
AND pua.AccessRole = N'Owner'
AND pua.IsActive = 1
)
THROW 52001, 'Project was not found for this owner.', 1;
IF @AllowActiveProject = 0 AND EXISTS (SELECT 1 FROM dbo.Projects WHERE ProjectID = @ProjectID AND IsArchived = 0)
THROW 52002, 'Only archived projects can be permanently deleted.', 1;
DECLARE @BookIDs TABLE (BookID int NOT NULL PRIMARY KEY);
DECLARE @ChapterIDs TABLE (ChapterID int NOT NULL PRIMARY KEY);
DECLARE @SceneIDs TABLE (SceneID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceRunIDs TABLE (StoryIntelligenceRunID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceChapterResultIDs TABLE (ChapterResultID int NOT NULL PRIMARY KEY);
DECLARE @StoryIntelligenceSceneResultIDs TABLE (SceneResultID int NOT NULL PRIMARY KEY);
DECLARE @ImportSessionIDs TABLE (ImportSessionID int NOT NULL PRIMARY KEY);
DECLARE @CharacterIntelligenceProfileIDs TABLE (CharacterIntelligenceProfileID int NOT NULL PRIMARY KEY);
INSERT @BookIDs (BookID)
SELECT BookID FROM dbo.Books WHERE ProjectID = @ProjectID;
INSERT @ChapterIDs (ChapterID)
SELECT c.ChapterID
FROM dbo.Chapters c
INNER JOIN @BookIDs b ON b.BookID = c.BookID;
INSERT @SceneIDs (SceneID)
SELECT s.SceneID
FROM dbo.Scenes s
INNER JOIN @ChapterIDs c ON c.ChapterID = s.ChapterID;
INSERT @ImportSessionIDs (ImportSessionID)
SELECT StoryIntelligenceBookPipelineID
FROM dbo.StoryIntelligenceBookPipelines pipeline
WHERE pipeline.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = pipeline.BookID);
INSERT @StoryIntelligenceRunIDs (StoryIntelligenceRunID)
SELECT DISTINCT r.StoryIntelligenceRunID
FROM dbo.StoryIntelligenceRuns r
WHERE r.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = r.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = r.ChapterID)
OR EXISTS (SELECT 1 FROM @SceneIDs s INNER JOIN dbo.Scenes scene ON scene.SceneID = s.SceneID WHERE scene.ImportRunID = r.StoryIntelligenceRunID);
INSERT @StoryIntelligenceChapterResultIDs (ChapterResultID)
SELECT DISTINCT cr.ChapterResultID
FROM dbo.StoryIntelligenceChapterResults cr
WHERE cr.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @StoryIntelligenceRunIDs r WHERE r.StoryIntelligenceRunID = cr.StoryIntelligenceRunID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = cr.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = cr.ChapterID);
INSERT @StoryIntelligenceSceneResultIDs (SceneResultID)
SELECT DISTINCT sr.SceneResultID
FROM dbo.StoryIntelligenceSceneResults sr
WHERE sr.ProjectID = @ProjectID
OR EXISTS (SELECT 1 FROM @StoryIntelligenceRunIDs r WHERE r.StoryIntelligenceRunID = sr.StoryIntelligenceRunID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceChapterResultIDs cr WHERE cr.ChapterResultID = sr.ChapterResultID)
OR EXISTS (SELECT 1 FROM @BookIDs b WHERE b.BookID = sr.BookID)
OR EXISTS (SELECT 1 FROM @ChapterIDs c WHERE c.ChapterID = sr.ChapterID)
OR EXISTS (SELECT 1 FROM @SceneIDs s WHERE s.SceneID = sr.SceneID);
INSERT @CharacterIntelligenceProfileIDs (CharacterIntelligenceProfileID)
SELECT DISTINCT profile.CharacterIntelligenceProfileID
FROM dbo.CharacterIntelligenceProfiles profile
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs sessionIDs WHERE sessionIDs.ImportSessionID = profile.ImportSessionID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = profile.LastUpdatedSceneResultID);
DELETE evidence
FROM dbo.CharacterIntelligenceEvidenceEvents evidence
WHERE EXISTS (SELECT 1 FROM @CharacterIntelligenceProfileIDs profileIDs WHERE profileIDs.CharacterIntelligenceProfileID = evidence.CharacterIntelligenceProfileID)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = evidence.SceneResultID);
DELETE profileRows
FROM dbo.CharacterIntelligenceProfiles profileRows
WHERE EXISTS (SELECT 1 FROM @CharacterIntelligenceProfileIDs profileIDs WHERE profileIDs.CharacterIntelligenceProfileID = profileRows.CharacterIntelligenceProfileID);
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)
OR EXISTS (SELECT 1 FROM @StoryIntelligenceSceneResultIDs resultIDs WHERE resultIDs.SceneResultID = StoryMemoryProcessedSceneResults.SceneResultID);
DELETE FROM dbo.StoryMemoryImportPreferences
WHERE EXISTS (SELECT 1 FROM @ImportSessionIDs ids WHERE ids.ImportSessionID = StoryMemoryImportPreferences.ImportSessionID);
""";
public async Task<IReadOnlyList<string>> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId)
{
@ -2117,10 +2268,42 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
public async Task HardDeleteForAccountDeletionAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
connection.Open();
using var transaction = connection.BeginTransaction();
try
{
await connection.ExecuteAsync(
ProjectHardDeleteStoryIntelligenceDependencyCleanupSql,
new { ProjectID = projectId, UserID = userId, AllowActiveProject = true },
transaction,
commandTimeout: 120);
await connection.ExecuteAsync(
"dbo.ProjectHardDelete_ForAccountDeletion",
new { ProjectID = projectId, UserID = userId },
transaction,
commandType: CommandType.StoredProcedure);
transaction.Commit();
}
catch
{
TryRollback(transaction);
throw;
}
}
private static void TryRollback(IDbTransaction transaction)
{
try
{
transaction.Rollback();
}
catch
{
// The stored procedure may have already rolled back the ambient transaction.
}
}
}

View File

@ -3,6 +3,7 @@ using System.Net;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
using PlotLine.Data;
using PlotLine.Models;
@ -917,6 +918,13 @@ public sealed class ProjectService(
userId,
filePaths.Count);
}
catch (SqlException ex)
{
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
projectId,
userId);
return ProjectHardDeleteResult.Failure($"Project could not be permanently deleted. Blocking database dependency: {ex.Errors[0].Message}");
}
catch (Exception ex)
{
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",

View File

@ -10,23 +10,21 @@ public interface IStoryIntelligenceDevelopmentResetService
public sealed class StoryIntelligenceDevelopmentResetService(
ISqlConnectionFactory connectionFactory,
IProjectRepository projects,
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
{
public async Task<StoryIntelligenceDevelopmentResetResult> ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default)
{
logger.LogWarning("Admin Story Intelligence reset started for user {UserId}.", userId);
using var connection = connectionFactory.CreateConnection();
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(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);
logger.LogWarning("Admin Story Intelligence reset hard-deleting archived project {ProjectID} for user {UserId}.", projectId, userId);
await projects.HardDeleteForOwnerAsync(projectId, userId);
}
var verificationCommand = new CommandDefinition(VerifyResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
@ -154,21 +152,6 @@ END;
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()),