Fix admin reset and project hard delete
This commit is contained in:
parent
c2a6dbd849
commit
3b2b25eeab
@ -104,6 +104,9 @@ var tests = new (string Name, Action Test)[]
|
|||||||
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
|
("Word Companion writing screens receive resolved context identifiers", WordCompanionWritingScreensReceiveResolvedContextIdentifiers),
|
||||||
("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates),
|
("Writing screens show diagnostic empty states", WritingScreensShowDiagnosticEmptyStates),
|
||||||
("Word Companion three-screen flow logs connection identifiers", WordCompanionThreeScreenFlowLogsConnectionIdentifiers),
|
("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 classifies PlotDirector markers by tag only", WordManuscriptCleanupClassifiesPlotDirectorMarkersByTagOnly),
|
||||||
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
|
("Word manuscript cleanup preserves content controls content", WordManuscriptCleanupPreservesContentControlContent),
|
||||||
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
|
("Word manuscript cleanup UI separates unlink from clean unlink", WordManuscriptCleanupUiSeparatesUnlinkFromCleanUnlink),
|
||||||
@ -117,6 +120,48 @@ foreach (var test in tests)
|
|||||||
Console.WriteLine($"PASS {test.Name}");
|
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()
|
static void RepairsTrailingDecimalConfidence()
|
||||||
{
|
{
|
||||||
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");
|
var result = StoryIntelligenceJsonRepair.Repair("""{"observations":[{"confidence":0. }]}""");
|
||||||
|
|||||||
@ -22,7 +22,8 @@ public sealed class AdminController(
|
|||||||
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
IStoryIntelligenceFullChapterTestService fullChapterTest,
|
||||||
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
|
IStoryIntelligenceImportCommitService storyIntelligenceImportCommit,
|
||||||
INameIntelligenceService nameIntelligence,
|
INameIntelligenceService nameIntelligence,
|
||||||
IIllustrationLibraryService illustrationLibrary) : Controller
|
IIllustrationLibraryService illustrationLibrary,
|
||||||
|
ILogger<AdminController> logger) : Controller
|
||||||
{
|
{
|
||||||
[HttpGet("")]
|
[HttpGet("")]
|
||||||
[HttpGet("index")]
|
[HttpGet("index")]
|
||||||
@ -198,8 +199,27 @@ public sealed class AdminController(
|
|||||||
return Forbid();
|
return Forbid();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.LogWarning("Story Intelligence development reset requested by admin user {UserId}.", userId);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
|
var result = await storyIntelligenceDevelopmentReset.ResetOwnedProjectsAndImportsAsync(userId, cancellationToken);
|
||||||
TempData[result.Success ? "AdminMessage" : "AdminError"] = result.Message;
|
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));
|
return RedirectToAction(nameof(StoryIntelligenceDiagnostics));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,7 @@ using PlotLine.ViewModels;
|
|||||||
namespace PlotLine.Controllers;
|
namespace PlotLine.Controllers;
|
||||||
|
|
||||||
[Authorize]
|
[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());
|
public async Task<IActionResult> Index() => View(await projects.ListAsync());
|
||||||
|
|
||||||
@ -92,15 +92,19 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
|
|||||||
[ValidateAntiForgeryToken]
|
[ValidateAntiForgeryToken]
|
||||||
public async Task<IActionResult> HardDeleteArchivedProject(int projectId, string? confirmationName)
|
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);
|
var project = await projects.GetArchivedOwnerProjectAsync(projectId);
|
||||||
if (project is null)
|
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.";
|
TempData["ArchiveError"] = "Only an archived project owner can permanently delete a project.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal))
|
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.";
|
TempData["ArchiveError"] = "Type the project name exactly to permanently delete it.";
|
||||||
return RedirectToAction(nameof(Index));
|
return RedirectToAction(nameof(Index));
|
||||||
}
|
}
|
||||||
@ -108,10 +112,12 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
|
|||||||
var result = await projects.HardDeleteAsync(projectId);
|
var result = await projects.HardDeleteAsync(projectId);
|
||||||
if (result.Succeeded)
|
if (result.Succeeded)
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("Permanent project delete completed for archived project {ProjectID}.", projectId);
|
||||||
TempData["ArchiveMessage"] = "Project permanently deleted.";
|
TempData["ArchiveMessage"] = "Project permanently deleted.";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
logger.LogWarning("Permanent project delete failed for archived project {ProjectID}: {Message}", projectId, result.Message);
|
||||||
TempData["ArchiveError"] = result.NotArchived
|
TempData["ArchiveError"] = result.NotArchived
|
||||||
? "Only archived projects can be permanently deleted."
|
? "Only archived projects can be permanently deleted."
|
||||||
: result.Message;
|
: result.Message;
|
||||||
|
|||||||
@ -2098,11 +2098,162 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
|
|||||||
public async Task HardDeleteForOwnerAsync(int projectId, int userId)
|
public async Task HardDeleteForOwnerAsync(int projectId, int userId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
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(
|
await connection.ExecuteAsync(
|
||||||
"dbo.ProjectHardDelete_ForOwner",
|
"dbo.ProjectHardDelete_ForOwner",
|
||||||
new { ProjectID = projectId, UserID = userId },
|
new { ProjectID = projectId, UserID = userId },
|
||||||
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
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)
|
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)
|
public async Task HardDeleteForAccountDeletionAsync(int projectId, int userId)
|
||||||
{
|
{
|
||||||
using var connection = connectionFactory.CreateConnection();
|
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(
|
await connection.ExecuteAsync(
|
||||||
"dbo.ProjectHardDelete_ForAccountDeletion",
|
"dbo.ProjectHardDelete_ForAccountDeletion",
|
||||||
new { ProjectID = projectId, UserID = userId },
|
new { ProjectID = projectId, UserID = userId },
|
||||||
|
transaction,
|
||||||
commandType: CommandType.StoredProcedure);
|
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.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,6 +3,7 @@ using System.Net;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc.Rendering;
|
using Microsoft.AspNetCore.Mvc.Rendering;
|
||||||
|
using Microsoft.Data.SqlClient;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using PlotLine.Data;
|
using PlotLine.Data;
|
||||||
using PlotLine.Models;
|
using PlotLine.Models;
|
||||||
@ -917,6 +918,13 @@ public sealed class ProjectService(
|
|||||||
userId,
|
userId,
|
||||||
filePaths.Count);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
|
logger.LogError(ex, "Database hard delete failed for archived project {ProjectID} owned by user {UserID}. File cleanup was not attempted.",
|
||||||
|
|||||||
@ -10,23 +10,21 @@ public interface IStoryIntelligenceDevelopmentResetService
|
|||||||
|
|
||||||
public sealed class StoryIntelligenceDevelopmentResetService(
|
public sealed class StoryIntelligenceDevelopmentResetService(
|
||||||
ISqlConnectionFactory connectionFactory,
|
ISqlConnectionFactory connectionFactory,
|
||||||
|
IProjectRepository projects,
|
||||||
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
|
ILogger<StoryIntelligenceDevelopmentResetService> logger) : IStoryIntelligenceDevelopmentResetService
|
||||||
{
|
{
|
||||||
public async Task<StoryIntelligenceDevelopmentResetResult> ResetOwnedProjectsAndImportsAsync(int userId, CancellationToken cancellationToken = default)
|
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();
|
using var connection = connectionFactory.CreateConnection();
|
||||||
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
|
var command = new CommandDefinition(PrepareResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
|
||||||
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(command);
|
var summary = await connection.QuerySingleAsync<PreparedResetSummary>(command);
|
||||||
|
|
||||||
foreach (var projectId in summary.ProjectIdList)
|
foreach (var projectId in summary.ProjectIdList)
|
||||||
{
|
{
|
||||||
var deleteCommand = new CommandDefinition(
|
logger.LogWarning("Admin Story Intelligence reset hard-deleting archived project {ProjectID} for user {UserId}.", projectId, userId);
|
||||||
"dbo.ProjectHardDelete_ForOwner",
|
await projects.HardDeleteForOwnerAsync(projectId, userId);
|
||||||
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 verificationCommand = new CommandDefinition(VerifyResetSql, new { UserID = userId }, cancellationToken: cancellationToken, commandTimeout: 120);
|
||||||
@ -154,21 +152,6 @@ END;
|
|||||||
DELETE FROM dbo.StoryIntelligenceIllustrationDemands
|
DELETE FROM dbo.StoryIntelligenceIllustrationDemands
|
||||||
WHERE EXISTS (SELECT 1 FROM @ProjectIDs p WHERE p.ProjectID = StoryIntelligenceIllustrationDemands.ProjectID);
|
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
|
UPDATE dbo.Projects
|
||||||
SET IsArchived = 1,
|
SET IsArchived = 1,
|
||||||
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user