From 88f8894e87b39912880c62de4eb63627752e567a Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Fri, 12 Jun 2026 19:45:22 +0100 Subject: [PATCH] Phase 7B Hard Delete UI --- PlotLine/Controllers/ProjectsController.cs | 39 +++++++++++ PlotLine/Services/CoreServices.cs | 32 ++++++--- PlotLine/Services/ProjectAccessFilter.cs | 11 +++ PlotLine/Views/Projects/Index.cshtml | 79 ++++++++++++++++++++++ 4 files changed, 153 insertions(+), 8 deletions(-) diff --git a/PlotLine/Controllers/ProjectsController.cs b/PlotLine/Controllers/ProjectsController.cs index 8137bf5..09d7161 100644 --- a/PlotLine/Controllers/ProjectsController.cs +++ b/PlotLine/Controllers/ProjectsController.cs @@ -80,4 +80,43 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit TempData["ArchiveMessage"] = "Project restored."; return RedirectToAction(nameof(Index)); } + + [HttpGet] + public IActionResult HardDeleteArchivedProject() + { + TempData["ArchiveError"] = "The project could not be deleted. Please try again."; + return RedirectToAction(nameof(Index)); + } + + [HttpPost] + [ValidateAntiForgeryToken] + public async Task HardDeleteArchivedProject(int projectId, string? confirmationName) + { + var project = await projects.GetArchivedOwnerProjectAsync(projectId); + if (project is null) + { + TempData["ArchiveError"] = "The project could not be deleted. Please try again."; + return RedirectToAction(nameof(Index)); + } + + if (!string.Equals(confirmationName?.Trim(), project.ProjectName, StringComparison.Ordinal)) + { + TempData["ArchiveError"] = "The project could not be deleted. Please try again."; + return RedirectToAction(nameof(Index)); + } + + var result = await projects.HardDeleteAsync(projectId); + if (result.Succeeded) + { + TempData["ArchiveMessage"] = "Project permanently deleted."; + } + else + { + TempData["ArchiveError"] = result.NotArchived + ? "Only archived projects can be permanently deleted." + : "The project could not be deleted. Please try again."; + } + + return RedirectToAction(nameof(Index)); + } } diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index 2a25005..ab06b9d 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -13,6 +13,7 @@ public interface IProjectService { Task ListAsync(); Task GetDetailAsync(int projectId); + Task GetArchivedOwnerProjectAsync(int projectId); Task GetCreateAsync(); Task GetEditAsync(int projectId); Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model); @@ -347,6 +348,13 @@ public sealed class ProjectService( }; } + public Task GetArchivedOwnerProjectAsync(int projectId) + { + return currentUser.UserId.HasValue + ? projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value) + : Task.FromResult(null); + } + public async Task GetCreateAsync() { var model = new ProjectEditViewModel(); @@ -435,22 +443,30 @@ public sealed class ProjectService( } var userId = currentUser.UserId.Value; - var project = await projects.GetForUserAsync(projectId, userId); - if (project is null || !project.IsOwner) + var activeProject = await projects.GetForUserAsync(projectId, userId); + if (activeProject is not null) { - 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 (!activeProject.IsOwner) + { + logger.LogWarning("Hard project delete denied for project {ProjectID} and user {UserID}: 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); + var project = await projects.GetArchivedForOwnerAsync(projectId, userId); + if (project is null) + { + logger.LogWarning("Hard project delete denied for archived project {ProjectID} and user {UserID}: project not found or user is not owner.", projectId, userId); + return ProjectHardDeleteResult.Inaccessible(); + } + + IReadOnlyList filePaths; try { + filePaths = await projects.ListHardDeleteFilePathsForOwnerAsync(projectId, userId); 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, diff --git a/PlotLine/Services/ProjectAccessFilter.cs b/PlotLine/Services/ProjectAccessFilter.cs index 20ba295..ca37b9d 100644 --- a/PlotLine/Services/ProjectAccessFilter.cs +++ b/PlotLine/Services/ProjectAccessFilter.cs @@ -18,6 +18,11 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs "ManageAccount" }; + private static readonly Dictionary> SkippedActions = new(StringComparer.OrdinalIgnoreCase) + { + ["Projects"] = new(StringComparer.OrdinalIgnoreCase) { "Restore", "HardDeleteArchivedProject" } + }; + private static readonly Dictionary ModelPropertyEntities = new(StringComparer.OrdinalIgnoreCase) { ["ProjectID"] = "Project", @@ -150,6 +155,12 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs return; } + if (SkippedActions.TryGetValue(controllerName, out var skippedActions) && skippedActions.Contains(descriptor.ActionName)) + { + await next(); + return; + } + var requirements = CollectRequirements(controllerName, descriptor.ActionName, context.ActionArguments); foreach (var requirement in requirements) { diff --git a/PlotLine/Views/Projects/Index.cshtml b/PlotLine/Views/Projects/Index.cshtml index da75806..fa9c320 100644 --- a/PlotLine/Views/Projects/Index.cshtml +++ b/PlotLine/Views/Projects/Index.cshtml @@ -94,10 +94,89 @@ else + @if (project.IsOwner) + { + + } } } + + +} + +@section Scripts { + }