From 36ae1f95dfcc660c22503bc084aff083b699b958 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 12 Jun 2026 11:46:31 +0100 Subject: [PATCH] Phase 7A Hard Delete --- PlotLine/Data/Repositories.cs | 21 ++ PlotLine/Models/CoreModels.cs | 41 +++ PlotLine/Services/CoreServices.cs | 161 ++++++++++- PlotLine/Sql/054_ProjectHardDeletion.sql | 347 +++++++++++++++++++++++ PlotLine/Views/Projects/Index.cshtml | 8 +- 5 files changed, 576 insertions(+), 2 deletions(-) create mode 100644 PlotLine/Sql/054_ProjectHardDeletion.sql diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 3b535a8..5e44a67 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -21,6 +21,8 @@ public interface IProjectRepository Task ArchiveForUserAsync(int projectId, int userId); Task ArchiveForOwnerAsync(int projectId, int userId); Task RestoreForOwnerAsync(int projectId, int userId); + Task> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId); + Task HardDeleteForOwnerAsync(int projectId, int userId); } public interface IProjectCollaborationRepository @@ -1290,6 +1292,25 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) : commandType: CommandType.StoredProcedure); return rowsAffected > 0; } + + public async Task> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.ProjectHardDelete_ListFilePathsForOwner", + new { ProjectID = projectId, UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.Where(path => !string.IsNullOrWhiteSpace(path)).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + } + + public async Task HardDeleteForOwnerAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.ProjectHardDelete_ForOwner", + new { ProjectID = projectId, UserID = userId }, + commandType: CommandType.StoredProcedure); + } } public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connectionFactory) : IProjectCollaborationRepository diff --git a/PlotLine/Models/CoreModels.cs b/PlotLine/Models/CoreModels.cs index e067ca1..2903e84 100644 --- a/PlotLine/Models/CoreModels.cs +++ b/PlotLine/Models/CoreModels.cs @@ -67,6 +67,47 @@ public sealed class ProjectActivity : "System"; } +public sealed class ProjectHardDeleteResult +{ + public bool Succeeded { get; init; } + public bool NotFound { get; init; } + public bool NotArchived { get; init; } + public bool FileCleanupHadFailures { get; init; } + public int DeletedFileCount { get; init; } + public int MissingFileCount { get; init; } + public int FailedFileCount { get; init; } + public string Message { get; init; } = string.Empty; + + public static ProjectHardDeleteResult Success(int deletedFileCount, int missingFileCount, int failedFileCount) => new() + { + Succeeded = true, + FileCleanupHadFailures = failedFileCount > 0, + DeletedFileCount = deletedFileCount, + MissingFileCount = missingFileCount, + FailedFileCount = failedFileCount, + Message = failedFileCount > 0 + ? "Project permanently deleted, but some files could not be removed. Check the application logs." + : "Project permanently deleted." + }; + + public static ProjectHardDeleteResult Inaccessible() => new() + { + NotFound = true, + Message = "Project not found." + }; + + public static ProjectHardDeleteResult ActiveProject() => new() + { + NotArchived = true, + Message = "Only archived projects can be permanently deleted." + }; + + public static ProjectHardDeleteResult Failure(string message) => new() + { + Message = message + }; +} + public sealed class UserFile { public long UserFileID { get; set; } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index cab0524..2a25005 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -19,6 +19,7 @@ public interface IProjectService Task SaveAsync(ProjectEditViewModel model); Task ArchiveAsync(int projectId); Task RestoreAsync(int projectId); + Task HardDeleteAsync(int projectId); } public interface IProjectCollaborationService @@ -309,7 +310,13 @@ public interface IAcceptanceService Task GetPhase1ChecklistAsync(int? projectId); } -public sealed class ProjectService(IProjectRepository projects, IBookRepository books, IProjectActivityService activity, ICurrentUserService currentUser) : IProjectService +public sealed class ProjectService( + IProjectRepository projects, + IBookRepository books, + IProjectActivityService activity, + ICurrentUserService currentUser, + IWebHostEnvironment environment, + ILogger logger) : IProjectService { public async Task ListAsync() { @@ -418,6 +425,158 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository return restored; } + + public async Task HardDeleteAsync(int projectId) + { + if (!currentUser.UserId.HasValue) + { + logger.LogWarning("Hard project delete requested without an authenticated user for project {ProjectID}.", projectId); + return ProjectHardDeleteResult.Inaccessible(); + } + + var userId = currentUser.UserId.Value; + var project = await projects.GetForUserAsync(projectId, userId); + if (project is null || !project.IsOwner) + { + logger.LogWarning("Hard project delete denied for project {ProjectID} and user {UserID}: project not found or user is not owner.", projectId, userId); + return ProjectHardDeleteResult.Inaccessible(); + } + + if (!project.IsArchived) + { + logger.LogWarning("Hard project delete refused for active project {ProjectID} and user {UserID}.", projectId, userId); + return ProjectHardDeleteResult.ActiveProject(); + } + + var filePaths = await projects.ListHardDeleteFilePathsForOwnerAsync(projectId, userId); + try + { + await projects.HardDeleteForOwnerAsync(projectId, userId); + logger.LogInformation("Database hard delete completed for archived project {ProjectID} owned by user {UserID}. {FilePathCount} file paths queued for cleanup.", + projectId, + userId, + filePaths.Count); + } + catch (Exception 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. No files were removed."); + } + + var cleanup = DeleteProjectFiles(projectId, filePaths); + if (cleanup.Failed > 0) + { + logger.LogWarning("Project {ProjectID} was hard deleted from the database but file cleanup had {FailedFileCount} failures, {DeletedFileCount} deleted files, and {MissingFileCount} missing files.", + projectId, + cleanup.Failed, + cleanup.Deleted, + cleanup.Missing); + } + else + { + logger.LogInformation("File cleanup completed for hard-deleted project {ProjectID}: {DeletedFileCount} deleted files, {MissingFileCount} missing files.", + projectId, + cleanup.Deleted, + cleanup.Missing); + } + + return ProjectHardDeleteResult.Success(cleanup.Deleted, cleanup.Missing, cleanup.Failed); + } + + private (int Deleted, int Missing, int Failed) DeleteProjectFiles(int projectId, IReadOnlyList filePaths) + { + var deleted = 0; + var missing = 0; + var failed = 0; + var deletedDirectories = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var filePath in filePaths) + { + var absolutePath = ResolveProjectUploadPath(filePath); + if (absolutePath is null) + { + logger.LogWarning("Hard delete skipped a non-project upload path for project {ProjectID}.", projectId); + continue; + } + + if (!File.Exists(absolutePath)) + { + missing++; + logger.LogInformation("Hard delete file cleanup found a missing file for project {ProjectID}.", projectId); + continue; + } + + try + { + File.Delete(absolutePath); + deleted++; + logger.LogInformation("Hard delete removed a project file for project {ProjectID}.", projectId); + + var directory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(directory)) + { + deletedDirectories.Add(directory); + } + } + catch (Exception ex) + { + failed++; + logger.LogWarning(ex, "Hard delete could not remove a project file for project {ProjectID}.", projectId); + } + } + + foreach (var directory in deletedDirectories.OrderByDescending(x => x.Length)) + { + TryDeleteEmptyUploadDirectory(projectId, directory); + } + + return (deleted, missing, failed); + } + + private string? ResolveProjectUploadPath(string storagePath) + { + if (string.IsNullOrWhiteSpace(storagePath) + || storagePath.StartsWith("http://", StringComparison.OrdinalIgnoreCase) + || storagePath.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var normalised = storagePath.Replace('\\', '/').TrimStart('/'); + if (!normalised.StartsWith("uploads/scenes/", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); + var absolutePath = Path.GetFullPath(Path.Combine(environment.WebRootPath, normalised.Replace('/', Path.DirectorySeparatorChar))); + return absolutePath.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) ? absolutePath : null; + } + + private void TryDeleteEmptyUploadDirectory(int projectId, string directory) + { + var uploadsRoot = Path.GetFullPath(Path.Combine(environment.WebRootPath, "uploads")); + var fullDirectory = Path.GetFullPath(directory); + if (!fullDirectory.StartsWith(uploadsRoot, StringComparison.OrdinalIgnoreCase) || string.Equals(fullDirectory, uploadsRoot, StringComparison.OrdinalIgnoreCase)) + { + return; + } + + try + { + if (Directory.Exists(fullDirectory) && !Directory.EnumerateFileSystemEntries(fullDirectory).Any()) + { + Directory.Delete(fullDirectory); + logger.LogInformation("Hard delete removed an empty upload directory for project {ProjectID}.", projectId); + } + } + catch (Exception ex) + { + logger.LogWarning(ex, "Hard delete could not remove an empty upload directory for project {ProjectID}.", projectId); + } + } } public sealed class ProjectCollaborationService( diff --git a/PlotLine/Sql/054_ProjectHardDeletion.sql b/PlotLine/Sql/054_ProjectHardDeletion.sql new file mode 100644 index 0000000..8903712 --- /dev/null +++ b/PlotLine/Sql/054_ProjectHardDeletion.sql @@ -0,0 +1,347 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectHardDelete_ListFilePathsForOwner + @ProjectID int, + @UserID int +AS +BEGIN + 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 + AND p.IsArchived = 1 + ) + RETURN; + + SELECT DISTINCT StoragePath + FROM dbo.UserFile + WHERE ProjectID = @ProjectID + AND NULLIF(LTRIM(RTRIM(StoragePath)), N'') IS NOT NULL + + UNION + + SELECT DISTINCT sa.FilePath + FROM dbo.SceneAttachments sa + INNER JOIN dbo.Scenes s ON s.SceneID = sa.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID + AND NULLIF(LTRIM(RTRIM(sa.FilePath)), N'') IS NOT NULL; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.ProjectHardDelete_ForOwner + @ProjectID int, + @UserID int +AS +BEGIN + SET NOCOUNT ON; + SET XACT_ABORT 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 EXISTS (SELECT 1 FROM dbo.Projects WHERE ProjectID = @ProjectID AND IsArchived = 0) + THROW 52002, 'Only archived projects can be permanently deleted.', 1; + + BEGIN TRY + BEGIN TRANSACTION; + + DELETE FROM dbo.ProjectActivity WHERE ProjectID = @ProjectID; + DELETE prh + FROM dbo.ProjectRestoreHistory prh + LEFT JOIN dbo.ProjectRestorePoints sourcePoint ON sourcePoint.ProjectRestorePointID = prh.SourceRestorePointID + LEFT JOIN dbo.ProjectRestorePoints safetyPoint ON safetyPoint.ProjectRestorePointID = prh.SafetyRestorePointID + WHERE prh.ProjectID = @ProjectID + OR sourcePoint.ProjectID = @ProjectID + OR safetyPoint.ProjectID = @ProjectID; + + DELETE FROM dbo.ProjectRestorePoints WHERE ProjectID = @ProjectID; + DELETE FROM dbo.ProjectInvitation WHERE ProjectID = @ProjectID; + + DELETE uf + FROM dbo.UserFile uf + WHERE uf.ProjectID = @ProjectID + OR EXISTS + ( + SELECT 1 + FROM dbo.SceneAttachments sa + INNER JOIN dbo.Scenes s ON s.SceneID = sa.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID + AND sa.FilePath = uf.StoragePath + ); + + DELETE sw + FROM dbo.ScenarioWarnings sw + INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sw.ScenarioID + WHERE sc.ProjectID = @ProjectID; + + DELETE svr + FROM dbo.ScenarioValidationRuns svr + INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = svr.ScenarioID + WHERE sc.ProjectID = @ProjectID; + + DELETE sso + FROM dbo.ScenarioSceneOrder sso + INNER JOIN dbo.Scenarios sc ON sc.ScenarioID = sso.ScenarioID + WHERE sc.ProjectID = @ProjectID; + + DELETE FROM dbo.Scenarios WHERE ProjectID = @ProjectID; + + DELETE FROM dbo.ContinuityWarnings WHERE ProjectID = @ProjectID; + DELETE FROM dbo.ValidationRuns WHERE ProjectID = @ProjectID; + + DELETE sa + FROM dbo.SceneAttachments sa + INNER JOIN dbo.Scenes s ON s.SceneID = sa.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE sci + FROM dbo.SceneChecklistItems sci + INNER JOIN dbo.Scenes s ON s.SceneID = sci.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE sw + FROM dbo.SceneWorkflow sw + INNER JOIN dbo.Scenes s ON s.SceneID = sw.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE sn + FROM dbo.SceneNotes sn + INNER JOIN dbo.Scenes s ON s.SceneID = sn.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE cg + FROM dbo.ChapterGoals cg + INNER JOIN dbo.Chapters c ON c.ChapterID = cg.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE sd + FROM dbo.SceneDependencies sd + LEFT JOIN dbo.Scenes ss ON ss.SceneID = sd.SourceSceneID + LEFT JOIN dbo.Chapters sc ON sc.ChapterID = ss.ChapterID + LEFT JOIN dbo.Books sb ON sb.BookID = sc.BookID + LEFT JOIN dbo.Scenes ts ON ts.SceneID = sd.TargetSceneID + LEFT JOIN dbo.Chapters tc ON tc.ChapterID = ts.ChapterID + LEFT JOIN dbo.Books tb ON tb.BookID = tc.BookID + WHERE sd.ProjectID = @ProjectID + OR sb.ProjectID = @ProjectID + OR tb.ProjectID = @ProjectID; + + DELETE re + FROM dbo.RelationshipEvents re + INNER JOIN dbo.CharacterRelationships cr ON cr.CharacterRelationshipID = re.CharacterRelationshipID + WHERE cr.ProjectID = @ProjectID; + + DELETE FROM dbo.CharacterRelationships WHERE ProjectID = @ProjectID; + + DELETE ck + FROM dbo.CharacterKnowledge ck + LEFT JOIN dbo.Characters ch ON ch.CharacterID = ck.CharacterID + LEFT JOIN dbo.Scenes s ON s.SceneID = ck.SceneID + LEFT JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + LEFT JOIN dbo.Books b ON b.BookID = c.BookID + LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ck.StoryAssetID + LEFT JOIN dbo.PlotThreads pt ON pt.PlotThreadID = ck.PlotThreadID + LEFT JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID + WHERE ch.ProjectID = @ProjectID + OR b.ProjectID = @ProjectID + OR sa.ProjectID = @ProjectID + OR pl.ProjectID = @ProjectID; + + DELETE cae + FROM dbo.CharacterAttributeEvents cae + INNER JOIN dbo.Characters ch ON ch.CharacterID = cae.CharacterID + WHERE ch.ProjectID = @ProjectID; + + DELETE sc + FROM dbo.SceneCharacters sc + LEFT JOIN dbo.Characters ch ON ch.CharacterID = sc.CharacterID + LEFT JOIN dbo.Scenes s ON s.SceneID = sc.SceneID + LEFT JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + LEFT JOIN dbo.Books b ON b.BookID = c.BookID + WHERE ch.ProjectID = @ProjectID + OR b.ProjectID = @ProjectID; + + DELETE acec + FROM dbo.AssetCustodyEventCharacters acec + INNER JOIN dbo.AssetCustodyEvents ace ON ace.AssetCustodyEventID = acec.AssetCustodyEventID + INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID + WHERE sa.ProjectID = @ProjectID; + + DELETE ace + FROM dbo.AssetCustodyEvents ace + INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ace.StoryAssetID + WHERE sa.ProjectID = @ProjectID; + + DELETE ad + FROM dbo.AssetDependencies ad + INNER JOIN dbo.StoryAssets sourceAsset ON sourceAsset.StoryAssetID = ad.SourceAssetID + INNER JOIN dbo.StoryAssets targetAsset ON targetAsset.StoryAssetID = ad.TargetAssetID + WHERE sourceAsset.ProjectID = @ProjectID + OR targetAsset.ProjectID = @ProjectID; + + DELETE ae + FROM dbo.AssetEvents ae + INNER JOIN dbo.StoryAssets sa ON sa.StoryAssetID = ae.StoryAssetID + WHERE sa.ProjectID = @ProjectID; + + DELETE sal + FROM dbo.SceneAssetLocations sal + LEFT JOIN dbo.StoryAssets sa ON sa.StoryAssetID = sal.StoryAssetID + LEFT JOIN dbo.Scenes s ON s.SceneID = sal.SceneID + LEFT JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + LEFT JOIN dbo.Books b ON b.BookID = c.BookID + WHERE sa.ProjectID = @ProjectID + OR b.ProjectID = @ProjectID; + + DELETE petpl + FROM dbo.PlotEventTargetPlotLines petpl + LEFT JOIN dbo.ThreadEvents te ON te.ThreadEventID = petpl.PlotEventID + LEFT JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID + LEFT JOIN dbo.PlotLines eventPlotLine ON eventPlotLine.PlotLineID = pt.PlotLineID + LEFT JOIN dbo.PlotLines targetPlotLine ON targetPlotLine.PlotLineID = petpl.PlotLineID + WHERE eventPlotLine.ProjectID = @ProjectID + OR targetPlotLine.ProjectID = @ProjectID; + + DELETE te + FROM dbo.ThreadEvents te + LEFT JOIN dbo.PlotThreads pt ON pt.PlotThreadID = te.PlotThreadID + LEFT JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID + LEFT JOIN dbo.PlotLines target ON target.PlotLineID = te.TargetPlotLineID + WHERE pl.ProjectID = @ProjectID + OR target.ProjectID = @ProjectID; + + DELETE pt + FROM dbo.PlotThreads pt + INNER JOIN dbo.PlotLines pl ON pl.PlotLineID = pt.PlotLineID + WHERE pl.ProjectID = @ProjectID; + + UPDATE dbo.PlotLines + SET ParentPlotLineID = NULL, + EmergesFromPlotLineID = NULL + WHERE ProjectID = @ProjectID; + + DELETE FROM dbo.PlotLines WHERE ProjectID = @ProjectID; + + DELETE FROM dbo.TimelineViewPresets WHERE ProjectID = @ProjectID; + DELETE FROM dbo.ProjectTimelineMetricSettings WHERE ProjectID = @ProjectID; + DELETE FROM dbo.ProjectTimelineSettings WHERE ProjectID = @ProjectID; + + DELETE smv + FROM dbo.SceneMetricValues smv + INNER JOIN dbo.Scenes s ON s.SceneID = smv.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE sp + FROM dbo.ScenePurposes sp + INNER JOIN dbo.Scenes s ON s.SceneID = sp.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE so + FROM dbo.SceneOutcomes so + INNER JOIN dbo.Scenes s ON s.SceneID = so.SceneID + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + UPDATE dbo.Characters + SET AgeReferenceSceneID = NULL + WHERE ProjectID = @ProjectID; + + UPDATE s + SET PrimaryLocationID = NULL + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + UPDATE dbo.StoryAssets + SET CurrentLocationID = NULL + WHERE ProjectID = @ProjectID; + + DELETE FROM dbo.StoryAssets WHERE ProjectID = @ProjectID; + + UPDATE dbo.Locations + SET ParentLocationID = NULL + WHERE ProjectID = @ProjectID; + + DELETE lr + FROM dbo.LocationRelationships lr + INNER JOIN dbo.Locations fromLocation ON fromLocation.LocationID = lr.FromLocationID + INNER JOIN dbo.Locations toLocation ON toLocation.LocationID = lr.ToLocationID + WHERE fromLocation.ProjectID = @ProjectID + OR toLocation.ProjectID = @ProjectID; + + DELETE FROM dbo.Locations WHERE ProjectID = @ProjectID; + + DELETE s + FROM dbo.Scenes s + INNER JOIN dbo.Chapters c ON c.ChapterID = s.ChapterID + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE c + FROM dbo.Chapters c + INNER JOIN dbo.Books b ON b.BookID = c.BookID + WHERE b.ProjectID = @ProjectID; + + DELETE FROM dbo.Books WHERE ProjectID = @ProjectID; + DELETE FROM dbo.Characters WHERE ProjectID = @ProjectID; + + DELETE gpmm + FROM dbo.GenreMetricPresetMetrics gpmm + INNER JOIN dbo.SceneMetricTypes mt ON mt.MetricTypeID = gpmm.MetricTypeID + WHERE mt.ProjectID = @ProjectID; + + DELETE FROM dbo.SceneMetricTypes WHERE ProjectID = @ProjectID; + DELETE FROM dbo.CharacterAttributeTypes WHERE ProjectID = @ProjectID; + DELETE FROM dbo.AssetStates WHERE ProjectID = @ProjectID; + + DELETE FROM dbo.ProjectUserAccess WHERE ProjectID = @ProjectID; + DELETE FROM dbo.Projects WHERE ProjectID = @ProjectID; + + COMMIT TRANSACTION; + END TRY + BEGIN CATCH + IF @@TRANCOUNT > 0 + ROLLBACK TRANSACTION; + + THROW; + END CATCH +END; +GO diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index 547539b..da75806 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -80,7 +80,13 @@ else var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");

@project.ProjectName

-

Archived@if (!string.IsNullOrWhiteSpace(archivedDateLabel)) { / @archivedDateLabel }

+

+ Archived + @if (!string.IsNullOrWhiteSpace(archivedDateLabel)) + { + / @archivedDateLabel + } +

@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)