Phase 7A Hard Delete

This commit is contained in:
Nick Beckley 2026-06-12 11:46:31 +01:00
parent ea3177c2aa
commit 36ae1f95df
5 changed files with 576 additions and 2 deletions

View File

@ -21,6 +21,8 @@ public interface IProjectRepository
Task ArchiveForUserAsync(int projectId, int userId);
Task<bool> ArchiveForOwnerAsync(int projectId, int userId);
Task<bool> RestoreForOwnerAsync(int projectId, int userId);
Task<IReadOnlyList<string>> 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<IReadOnlyList<string>> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<string>(
"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

View File

@ -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; }

View File

@ -19,6 +19,7 @@ public interface IProjectService
Task<int> SaveAsync(ProjectEditViewModel model);
Task<bool> ArchiveAsync(int projectId);
Task<bool> RestoreAsync(int projectId);
Task<ProjectHardDeleteResult> HardDeleteAsync(int projectId);
}
public interface IProjectCollaborationService
@ -309,7 +310,13 @@ public interface IAcceptanceService
Task<Phase1AcceptanceViewModel> 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<ProjectService> logger) : IProjectService
{
public async Task<ProjectIndexViewModel> ListAsync()
{
@ -418,6 +425,158 @@ public sealed class ProjectService(IProjectRepository projects, IBookRepository
return restored;
}
public async Task<ProjectHardDeleteResult> 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<string> filePaths)
{
var deleted = 0;
var missing = 0;
var failed = 0;
var deletedDirectories = new HashSet<string>(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(

View File

@ -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

View File

@ -80,7 +80,13 @@ else
var archivedDateLabel = project.ArchivedDate?.ToLocalTime().ToString("yyyy-MM-dd");
<article class="plain-card">
<h3>@project.ProjectName</h3>
<p class="eyebrow">Archived@if (!string.IsNullOrWhiteSpace(archivedDateLabel)) { <span> / @archivedDateLabel</span> }</p>
<p class="eyebrow">
Archived
@if (!string.IsNullOrWhiteSpace(archivedDateLabel))
{
<span> / @archivedDateLabel</span>
}
</p>
<p>@(string.IsNullOrWhiteSpace(project.Description) ? "No description yet." : project.Description)</p>
<div class="button-row">
<a class="btn btn-outline-secondary btn-sm" asp-controller="Exports" asp-action="ProjectBackup" asp-route-projectId="@project.ProjectID">Download backup</a>