Phase 7B Hard Delete UI
This commit is contained in:
parent
36ae1f95df
commit
88f8894e87
@ -80,4 +80,43 @@ public sealed class ProjectsController(IProjectService projects, IProjectActivit
|
|||||||
TempData["ArchiveMessage"] = "Project restored.";
|
TempData["ArchiveMessage"] = "Project restored.";
|
||||||
return RedirectToAction(nameof(Index));
|
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<IActionResult> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -13,6 +13,7 @@ public interface IProjectService
|
|||||||
{
|
{
|
||||||
Task<ProjectIndexViewModel> ListAsync();
|
Task<ProjectIndexViewModel> ListAsync();
|
||||||
Task<ProjectDetailViewModel?> GetDetailAsync(int projectId);
|
Task<ProjectDetailViewModel?> GetDetailAsync(int projectId);
|
||||||
|
Task<Project?> GetArchivedOwnerProjectAsync(int projectId);
|
||||||
Task<ProjectEditViewModel> GetCreateAsync();
|
Task<ProjectEditViewModel> GetCreateAsync();
|
||||||
Task<ProjectEditViewModel?> GetEditAsync(int projectId);
|
Task<ProjectEditViewModel?> GetEditAsync(int projectId);
|
||||||
Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model);
|
Task PopulateGenrePresetOptionsAsync(ProjectEditViewModel model);
|
||||||
@ -347,6 +348,13 @@ public sealed class ProjectService(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Task<Project?> GetArchivedOwnerProjectAsync(int projectId)
|
||||||
|
{
|
||||||
|
return currentUser.UserId.HasValue
|
||||||
|
? projects.GetArchivedForOwnerAsync(projectId, currentUser.UserId.Value)
|
||||||
|
: Task.FromResult<Project?>(null);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<ProjectEditViewModel> GetCreateAsync()
|
public async Task<ProjectEditViewModel> GetCreateAsync()
|
||||||
{
|
{
|
||||||
var model = new ProjectEditViewModel();
|
var model = new ProjectEditViewModel();
|
||||||
@ -435,22 +443,30 @@ public sealed class ProjectService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
var userId = currentUser.UserId.Value;
|
var userId = currentUser.UserId.Value;
|
||||||
var project = await projects.GetForUserAsync(projectId, userId);
|
var activeProject = await projects.GetForUserAsync(projectId, userId);
|
||||||
if (project is null || !project.IsOwner)
|
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);
|
if (!activeProject.IsOwner)
|
||||||
|
{
|
||||||
|
logger.LogWarning("Hard project delete denied for project {ProjectID} and user {UserID}: user is not owner.", projectId, userId);
|
||||||
return ProjectHardDeleteResult.Inaccessible();
|
return ProjectHardDeleteResult.Inaccessible();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!project.IsArchived)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Hard project delete refused for active project {ProjectID} and user {UserID}.", projectId, userId);
|
logger.LogWarning("Hard project delete refused for active project {ProjectID} and user {UserID}.", projectId, userId);
|
||||||
return ProjectHardDeleteResult.ActiveProject();
|
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<string> filePaths;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
filePaths = await projects.ListHardDeleteFilePathsForOwnerAsync(projectId, userId);
|
||||||
await projects.HardDeleteForOwnerAsync(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.",
|
logger.LogInformation("Database hard delete completed for archived project {ProjectID} owned by user {UserID}. {FilePathCount} file paths queued for cleanup.",
|
||||||
projectId,
|
projectId,
|
||||||
|
|||||||
@ -18,6 +18,11 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
|||||||
"ManageAccount"
|
"ManageAccount"
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static readonly Dictionary<string, HashSet<string>> SkippedActions = new(StringComparer.OrdinalIgnoreCase)
|
||||||
|
{
|
||||||
|
["Projects"] = new(StringComparer.OrdinalIgnoreCase) { "Restore", "HardDeleteArchivedProject" }
|
||||||
|
};
|
||||||
|
|
||||||
private static readonly Dictionary<string, string> ModelPropertyEntities = new(StringComparer.OrdinalIgnoreCase)
|
private static readonly Dictionary<string, string> ModelPropertyEntities = new(StringComparer.OrdinalIgnoreCase)
|
||||||
{
|
{
|
||||||
["ProjectID"] = "Project",
|
["ProjectID"] = "Project",
|
||||||
@ -150,6 +155,12 @@ public sealed class ProjectAccessFilter(IProjectAccessService access, ICurrentUs
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (SkippedActions.TryGetValue(controllerName, out var skippedActions) && skippedActions.Contains(descriptor.ActionName))
|
||||||
|
{
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var requirements = CollectRequirements(controllerName, descriptor.ActionName, context.ActionArguments);
|
var requirements = CollectRequirements(controllerName, descriptor.ActionName, context.ActionArguments);
|
||||||
foreach (var requirement in requirements)
|
foreach (var requirement in requirements)
|
||||||
{
|
{
|
||||||
|
|||||||
@ -94,10 +94,89 @@ else
|
|||||||
<input type="hidden" name="id" value="@project.ProjectID" />
|
<input type="hidden" name="id" value="@project.ProjectID" />
|
||||||
<button class="btn btn-outline-primary btn-sm" type="submit">Restore</button>
|
<button class="btn btn-outline-primary btn-sm" type="submit">Restore</button>
|
||||||
</form>
|
</form>
|
||||||
|
@if (project.IsOwner)
|
||||||
|
{
|
||||||
|
<button class="btn btn-outline-danger btn-sm"
|
||||||
|
type="button"
|
||||||
|
data-hard-delete-project
|
||||||
|
data-project-id="@project.ProjectID"
|
||||||
|
data-project-name="@project.ProjectName">
|
||||||
|
Delete Permanently
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<div class="modal fade" id="hardDeleteProjectModal" tabindex="-1" aria-labelledby="hardDeleteProjectModalTitle" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-dialog-centered">
|
||||||
|
<div class="modal-content plotline-confirm-modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title fs-5" id="hardDeleteProjectModalTitle">Permanently delete this project?</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Cancel"></button>
|
||||||
|
</div>
|
||||||
|
<form asp-controller="Projects" asp-action="HardDeleteArchivedProject" method="post" asp-antiforgery="true" data-confirm-skip="true" data-hard-delete-project-form>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>This will permanently delete <strong data-hard-delete-project-name></strong> and all associated data, including books, chapters, scenes, characters, locations, assets, plot threads, notes, metrics, attachments and uploaded files.</p>
|
||||||
|
<p class="mb-3">This action cannot be undone.</p>
|
||||||
|
<input type="hidden" name="projectId" data-hard-delete-project-id />
|
||||||
|
<label class="form-label" for="hardDeleteProjectConfirmation">Type the project name to continue</label>
|
||||||
|
<input class="form-control"
|
||||||
|
id="hardDeleteProjectConfirmation"
|
||||||
|
name="confirmationName"
|
||||||
|
autocomplete="off"
|
||||||
|
data-hard-delete-project-confirmation />
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="submit" class="btn btn-danger" disabled data-hard-delete-project-submit>Delete Permanently</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@section Scripts {
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
const modalElement = document.getElementById("hardDeleteProjectModal");
|
||||||
|
if (!modalElement || !window.bootstrap) return;
|
||||||
|
|
||||||
|
const modal = new bootstrap.Modal(modalElement);
|
||||||
|
const projectNameLabel = modalElement.querySelector("[data-hard-delete-project-name]");
|
||||||
|
const projectIdInput = modalElement.querySelector("[data-hard-delete-project-id]");
|
||||||
|
const confirmationInput = modalElement.querySelector("[data-hard-delete-project-confirmation]");
|
||||||
|
const submitButton = modalElement.querySelector("[data-hard-delete-project-submit]");
|
||||||
|
let selectedProjectName = "";
|
||||||
|
|
||||||
|
const updateSubmitState = () => {
|
||||||
|
if (!submitButton || !confirmationInput) return;
|
||||||
|
submitButton.disabled = confirmationInput.value.trim() !== selectedProjectName;
|
||||||
|
};
|
||||||
|
|
||||||
|
document.querySelectorAll("[data-hard-delete-project]").forEach((button) => {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
selectedProjectName = button.dataset.projectName || "";
|
||||||
|
if (projectNameLabel) projectNameLabel.textContent = selectedProjectName;
|
||||||
|
if (projectIdInput) projectIdInput.value = button.dataset.projectId || "";
|
||||||
|
if (confirmationInput) confirmationInput.value = "";
|
||||||
|
updateSubmitState();
|
||||||
|
modal.show();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
confirmationInput?.addEventListener("input", updateSubmitState);
|
||||||
|
modalElement.addEventListener("shown.bs.modal", () => confirmationInput?.focus());
|
||||||
|
modalElement.addEventListener("hidden.bs.modal", () => {
|
||||||
|
selectedProjectName = "";
|
||||||
|
if (projectIdInput) projectIdInput.value = "";
|
||||||
|
if (confirmationInput) confirmationInput.value = "";
|
||||||
|
updateSubmitState();
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user