Phase 7D User Delete
This commit is contained in:
parent
c88dde3693
commit
6dee173c5c
@ -16,6 +16,8 @@ public interface IUserRepository
|
||||
Task<AppUser?> ChangePasswordAsync(int userId, string passwordHash);
|
||||
Task<AppUser?> EnableTwoFactorAsync(int userId);
|
||||
Task<AppUser?> DisableTwoFactorAsync(int userId);
|
||||
Task<IReadOnlyList<string>> ListHardDeleteFilePathsAsync(int userId);
|
||||
Task<bool> HardDeleteAccountDataAsync(int userId);
|
||||
}
|
||||
|
||||
public interface IAuthenticationRepository
|
||||
@ -125,6 +127,25 @@ public sealed class UserRepository(ISqlConnectionFactory connectionFactory) : IU
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> ListHardDeleteFilePathsAsync(int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<string>(
|
||||
"dbo.UserHardDelete_ListFilePaths",
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<bool> HardDeleteAccountDataAsync(int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
return await connection.QuerySingleAsync<bool>(
|
||||
"dbo.UserHardDelete_ForAccountDeletion",
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class AuthenticationRepository(ISqlConnectionFactory connectionFactory) : IAuthenticationRepository
|
||||
|
||||
@ -10,6 +10,7 @@ public interface IProjectRepository
|
||||
Task<IReadOnlyList<Project>> ListForUserAsync(int userId);
|
||||
Task<IReadOnlyList<Project>> ListActiveForUserAsync(int userId);
|
||||
Task<IReadOnlyList<Project>> ListArchivedForUserAsync(int userId);
|
||||
Task<IReadOnlyList<Project>> ListOwnedForAccountDeletionAsync(int userId);
|
||||
Task<Project?> GetAsync(int projectId);
|
||||
Task<Project?> GetForUserAsync(int projectId, int userId);
|
||||
Task<Project?> GetArchivedForOwnerAsync(int projectId, int userId);
|
||||
@ -23,6 +24,8 @@ public interface IProjectRepository
|
||||
Task<bool> RestoreForOwnerAsync(int projectId, int userId);
|
||||
Task<IReadOnlyList<string>> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId);
|
||||
Task HardDeleteForOwnerAsync(int projectId, int userId);
|
||||
Task<IReadOnlyList<string>> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId);
|
||||
Task HardDeleteForAccountDeletionAsync(int projectId, int userId);
|
||||
}
|
||||
|
||||
public interface IProjectCollaborationRepository
|
||||
@ -1219,6 +1222,16 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<Project>> ListOwnedForAccountDeletionAsync(int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<Project>(
|
||||
"dbo.Project_ListOwnedForAccountDeletion",
|
||||
new { UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task<Project?> GetAsync(int projectId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
@ -1311,6 +1324,25 @@ public sealed class ProjectRepository(ISqlConnectionFactory connectionFactory) :
|
||||
new { ProjectID = projectId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<string>> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
var rows = await connection.QueryAsync<string>(
|
||||
"dbo.ProjectHardDelete_ListFilePathsForAccountDeletion",
|
||||
new { ProjectID = projectId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
return rows.ToList();
|
||||
}
|
||||
|
||||
public async Task HardDeleteForAccountDeletionAsync(int projectId, int userId)
|
||||
{
|
||||
using var connection = connectionFactory.CreateConnection();
|
||||
await connection.ExecuteAsync(
|
||||
"dbo.ProjectHardDelete_ForAccountDeletion",
|
||||
new { ProjectID = projectId, UserID = userId },
|
||||
commandType: CommandType.StoredProcedure);
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class ProjectCollaborationRepository(ISqlConnectionFactory connectionFactory) : IProjectCollaborationRepository
|
||||
|
||||
@ -31,3 +31,44 @@ public sealed class TwoFactorSetupResult : AuthenticationResult
|
||||
|
||||
public new static TwoFactorSetupResult Failed(string errorMessage) => new() { ErrorMessage = errorMessage };
|
||||
}
|
||||
|
||||
public sealed class AccountDeletionResult
|
||||
{
|
||||
public bool Succeeded { get; init; }
|
||||
public bool NotFound { get; init; }
|
||||
public bool BlockedByPaidSubscription { get; init; }
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public int DeletedProjectCount { get; init; }
|
||||
public int DeletedFileCount { get; init; }
|
||||
public int MissingFileCount { get; init; }
|
||||
public int FailedFileCount { get; init; }
|
||||
|
||||
public static AccountDeletionResult Success(int deletedProjectCount, int deletedFileCount, int missingFileCount, int failedFileCount) => new()
|
||||
{
|
||||
Succeeded = true,
|
||||
Message = failedFileCount > 0
|
||||
? "Your account was deleted, but some uploaded files could not be removed automatically."
|
||||
: "Your account was deleted.",
|
||||
DeletedProjectCount = deletedProjectCount,
|
||||
DeletedFileCount = deletedFileCount,
|
||||
MissingFileCount = missingFileCount,
|
||||
FailedFileCount = failedFileCount
|
||||
};
|
||||
|
||||
public static AccountDeletionResult MissingUser() => new()
|
||||
{
|
||||
NotFound = true,
|
||||
Message = "The account could not be found."
|
||||
};
|
||||
|
||||
public static AccountDeletionResult PaidSubscriptionBlocked() => new()
|
||||
{
|
||||
BlockedByPaidSubscription = true,
|
||||
Message = "This account cannot be deleted while a paid subscription or billing issue is active."
|
||||
};
|
||||
|
||||
public static AccountDeletionResult Failure(string message) => new()
|
||||
{
|
||||
Message = message
|
||||
};
|
||||
}
|
||||
|
||||
@ -109,6 +109,7 @@ public class Program
|
||||
builder.Services.AddScoped<IProjectActivityRepository, ProjectActivityRepository>();
|
||||
builder.Services.AddScoped<ISubscriptionRepository, SubscriptionRepository>();
|
||||
builder.Services.AddScoped<IProjectService, ProjectService>();
|
||||
builder.Services.AddScoped<IHardDeleteFileCleanupService, HardDeleteFileCleanupService>();
|
||||
builder.Services.AddScoped<IProjectCollaborationService, ProjectCollaborationService>();
|
||||
builder.Services.AddScoped<IProjectActivityService, ProjectActivityService>();
|
||||
builder.Services.AddScoped<IBookService, BookService>();
|
||||
@ -142,6 +143,7 @@ public class Program
|
||||
builder.Services.AddSingleton<IHelpService, HelpService>();
|
||||
builder.Services.AddSingleton<IHelpMarkdownRenderer, HelpMarkdownRenderer>();
|
||||
builder.Services.AddScoped<IAuthService, AuthService>();
|
||||
builder.Services.AddScoped<IAccountDeletionService, AccountDeletionService>();
|
||||
builder.Services.AddScoped<IPasswordService, PasswordService>();
|
||||
builder.Services.AddScoped<ITwoFactorService, TwoFactorService>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
|
||||
113
PlotLine/Services/AccountDeletionService.cs
Normal file
113
PlotLine/Services/AccountDeletionService.cs
Normal file
@ -0,0 +1,113 @@
|
||||
using PlotLine.Data;
|
||||
using PlotLine.Models;
|
||||
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public sealed class AccountDeletionService(
|
||||
IUserRepository users,
|
||||
IProjectRepository projects,
|
||||
ISubscriptionService subscriptions,
|
||||
IHardDeleteFileCleanupService fileCleanup,
|
||||
ILogger<AccountDeletionService> logger) : IAccountDeletionService
|
||||
{
|
||||
public async Task<AccountDeletionResult> HardDeleteAccountAsync(int userId)
|
||||
{
|
||||
var user = await users.GetByIdAsync(userId);
|
||||
if (user is null)
|
||||
{
|
||||
logger.LogWarning("Account hard delete requested for missing user {UserID}.", userId);
|
||||
return AccountDeletionResult.MissingUser();
|
||||
}
|
||||
|
||||
if (await subscriptions.HasPaidBillingRiskAsync(userId))
|
||||
{
|
||||
logger.LogWarning("Account hard delete blocked for user {UserID}: paid billing risk exists.", userId);
|
||||
return AccountDeletionResult.PaidSubscriptionBlocked();
|
||||
}
|
||||
|
||||
var deletedProjects = 0;
|
||||
var deletedFiles = 0;
|
||||
var missingFiles = 0;
|
||||
var failedFiles = 0;
|
||||
|
||||
var ownedProjects = await projects.ListOwnedForAccountDeletionAsync(userId);
|
||||
logger.LogInformation("Account hard delete started for user {UserID} with {OwnedProjectCount} owned projects.", userId, ownedProjects.Count);
|
||||
|
||||
foreach (var project in ownedProjects)
|
||||
{
|
||||
IReadOnlyList<string> projectFilePaths;
|
||||
try
|
||||
{
|
||||
projectFilePaths = await projects.ListHardDeleteFilePathsForAccountDeletionAsync(project.ProjectID, userId);
|
||||
await projects.HardDeleteForAccountDeletionAsync(project.ProjectID, userId);
|
||||
deletedProjects++;
|
||||
logger.LogInformation("Account hard delete removed project {ProjectID} for user {UserID}. {FilePathCount} file paths queued for cleanup.",
|
||||
project.ProjectID,
|
||||
userId,
|
||||
projectFilePaths.Count);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Account hard delete failed while deleting project {ProjectID} for user {UserID}. Account deletion stopped.",
|
||||
project.ProjectID,
|
||||
userId);
|
||||
return AccountDeletionResult.Failure("The account could not be deleted. No further account data was removed.");
|
||||
}
|
||||
|
||||
var projectCleanup = fileCleanup.DeleteFiles("account project hard delete", project.ProjectID, projectFilePaths);
|
||||
deletedFiles += projectCleanup.Deleted;
|
||||
missingFiles += projectCleanup.Missing;
|
||||
failedFiles += projectCleanup.Failed;
|
||||
}
|
||||
|
||||
IReadOnlyList<string> userFilePaths;
|
||||
try
|
||||
{
|
||||
userFilePaths = await users.ListHardDeleteFilePathsAsync(userId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Account hard delete could not list remaining user-level files for user {UserID}. Account deletion stopped.", userId);
|
||||
return AccountDeletionResult.Failure("The account could not be deleted. No further account data was removed.");
|
||||
}
|
||||
|
||||
var userCleanup = fileCleanup.DeleteFiles("account user file hard delete", userId, userFilePaths);
|
||||
deletedFiles += userCleanup.Deleted;
|
||||
missingFiles += userCleanup.Missing;
|
||||
failedFiles += userCleanup.Failed;
|
||||
|
||||
try
|
||||
{
|
||||
var deleted = await users.HardDeleteAccountDataAsync(userId);
|
||||
if (!deleted)
|
||||
{
|
||||
logger.LogError("Account hard delete failed to remove the AppUser row for user {UserID}.", userId);
|
||||
return AccountDeletionResult.Failure("The account could not be deleted.");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Account hard delete failed while removing remaining user data for user {UserID}.", userId);
|
||||
return AccountDeletionResult.Failure("The account could not be deleted.");
|
||||
}
|
||||
|
||||
if (failedFiles > 0)
|
||||
{
|
||||
logger.LogWarning("Account hard delete completed for user {UserID}, but file cleanup had {FailedFileCount} failures, {DeletedFileCount} deleted files, and {MissingFileCount} missing files.",
|
||||
userId,
|
||||
failedFiles,
|
||||
deletedFiles,
|
||||
missingFiles);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger.LogInformation("Account hard delete completed for user {UserID}: {DeletedProjectCount} projects, {DeletedFileCount} files deleted, {MissingFileCount} files missing.",
|
||||
userId,
|
||||
deletedProjects,
|
||||
deletedFiles,
|
||||
missingFiles);
|
||||
}
|
||||
|
||||
return AccountDeletionResult.Success(deletedProjects, deletedFiles, missingFiles, failedFiles);
|
||||
}
|
||||
}
|
||||
@ -22,6 +22,11 @@ public interface IAuthService
|
||||
Task<IReadOnlyList<string>> GenerateRecoveryCodesAsync(int userId);
|
||||
}
|
||||
|
||||
public interface IAccountDeletionService
|
||||
{
|
||||
Task<AccountDeletionResult> HardDeleteAccountAsync(int userId);
|
||||
}
|
||||
|
||||
public interface IPasswordService
|
||||
{
|
||||
string HashPassword(string password);
|
||||
|
||||
@ -316,7 +316,7 @@ public sealed class ProjectService(
|
||||
IBookRepository books,
|
||||
IProjectActivityService activity,
|
||||
ICurrentUserService currentUser,
|
||||
IWebHostEnvironment environment,
|
||||
IHardDeleteFileCleanupService fileCleanup,
|
||||
ILogger<ProjectService> logger) : IProjectService
|
||||
{
|
||||
public async Task<ProjectIndexViewModel> ListAsync()
|
||||
@ -481,7 +481,7 @@ public sealed class ProjectService(
|
||||
return ProjectHardDeleteResult.Failure("Project could not be permanently deleted. No files were removed.");
|
||||
}
|
||||
|
||||
var cleanup = DeleteProjectFiles(projectId, filePaths);
|
||||
var cleanup = fileCleanup.DeleteFiles("project hard delete", 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.",
|
||||
@ -500,99 +500,6 @@ public sealed class ProjectService(
|
||||
|
||||
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(
|
||||
|
||||
107
PlotLine/Services/HardDeleteFileCleanupService.cs
Normal file
107
PlotLine/Services/HardDeleteFileCleanupService.cs
Normal file
@ -0,0 +1,107 @@
|
||||
namespace PlotLine.Services;
|
||||
|
||||
public interface IHardDeleteFileCleanupService
|
||||
{
|
||||
HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList<string> storagePaths);
|
||||
}
|
||||
|
||||
public sealed record HardDeleteFileCleanupResult(int Deleted, int Missing, int Failed);
|
||||
|
||||
public sealed class HardDeleteFileCleanupService(
|
||||
IWebHostEnvironment environment,
|
||||
ILogger<HardDeleteFileCleanupService> logger) : IHardDeleteFileCleanupService
|
||||
{
|
||||
public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList<string> storagePaths)
|
||||
{
|
||||
var deleted = 0;
|
||||
var missing = 0;
|
||||
var failed = 0;
|
||||
var deletedDirectories = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var storagePath in storagePaths)
|
||||
{
|
||||
var absolutePath = ResolveUploadPath(storagePath);
|
||||
if (absolutePath is null)
|
||||
{
|
||||
logger.LogWarning("Hard delete skipped a non-local upload path during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!File.Exists(absolutePath))
|
||||
{
|
||||
missing++;
|
||||
logger.LogInformation("Hard delete file cleanup found a missing file during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
File.Delete(absolutePath);
|
||||
deleted++;
|
||||
logger.LogInformation("Hard delete removed an uploaded file during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||
|
||||
var directory = Path.GetDirectoryName(absolutePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
deletedDirectories.Add(directory);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failed++;
|
||||
logger.LogWarning(ex, "Hard delete could not remove an uploaded file during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var directory in deletedDirectories.OrderByDescending(x => x.Length))
|
||||
{
|
||||
TryDeleteEmptyUploadDirectory(operationName, ownerId, directory);
|
||||
}
|
||||
|
||||
return new HardDeleteFileCleanupResult(deleted, missing, failed);
|
||||
}
|
||||
|
||||
private string? ResolveUploadPath(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/", 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(string operationName, int ownerId, 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 during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogWarning(ex, "Hard delete could not remove an empty upload directory during {OperationName} for owner {OwnerID}.", operationName, ownerId);
|
||||
}
|
||||
}
|
||||
}
|
||||
203
PlotLine/Sql/055_AccountHardDeletion.sql
Normal file
203
PlotLine/Sql/055_AccountHardDeletion.sql
Normal file
@ -0,0 +1,203 @@
|
||||
SET ANSI_NULLS ON;
|
||||
GO
|
||||
SET QUOTED_IDENTIFIER ON;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.Project_ListOwnedForAccountDeletion
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
SELECT p.ProjectID, p.ProjectName, p.Description, p.CreatedDate, p.UpdatedDate, p.IsArchived, p.ArchivedDate, p.ArchivedReason,
|
||||
pua.AccessRole
|
||||
FROM dbo.Projects p
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||
WHERE pua.UserID = @UserID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1
|
||||
ORDER BY p.ProjectID;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.ProjectHardDelete_ListFilePathsForAccountDeletion
|
||||
@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
|
||||
)
|
||||
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_ForAccountDeletion
|
||||
@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 52101, 'Project was not found for this account owner.', 1;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
UPDATE dbo.Projects
|
||||
SET IsArchived = 1,
|
||||
ArchivedDate = COALESCE(ArchivedDate, SYSUTCDATETIME()),
|
||||
ArchivedReason = COALESCE(ArchivedReason, N'Account deletion')
|
||||
WHERE ProjectID = @ProjectID;
|
||||
|
||||
EXEC dbo.ProjectHardDelete_ForOwner @ProjectID = @ProjectID, @UserID = @UserID;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @@TRANCOUNT > 0
|
||||
ROLLBACK TRANSACTION;
|
||||
|
||||
THROW;
|
||||
END CATCH
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.UserHardDelete_ListFilePaths
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM dbo.AppUser WHERE UserID = @UserID)
|
||||
RETURN;
|
||||
|
||||
SELECT DISTINCT StoragePath
|
||||
FROM dbo.UserFile
|
||||
WHERE UserID = @UserID
|
||||
AND NULLIF(LTRIM(RTRIM(StoragePath)), N'') IS NOT NULL;
|
||||
END;
|
||||
GO
|
||||
|
||||
CREATE OR ALTER PROCEDURE dbo.UserHardDelete_ForAccountDeletion
|
||||
@UserID int
|
||||
AS
|
||||
BEGIN
|
||||
SET NOCOUNT ON;
|
||||
SET XACT_ABORT ON;
|
||||
|
||||
DECLARE @Email nvarchar(256);
|
||||
|
||||
SELECT @Email = Email
|
||||
FROM dbo.AppUser
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
IF @Email IS NULL
|
||||
BEGIN
|
||||
SELECT CAST(0 AS bit);
|
||||
RETURN;
|
||||
END;
|
||||
|
||||
IF EXISTS
|
||||
(
|
||||
SELECT 1
|
||||
FROM dbo.Projects p
|
||||
INNER JOIN dbo.ProjectUserAccess pua ON pua.ProjectID = p.ProjectID
|
||||
WHERE pua.UserID = @UserID
|
||||
AND pua.AccessRole = N'Owner'
|
||||
AND pua.IsActive = 1
|
||||
)
|
||||
THROW 52102, 'All owned projects must be deleted before deleting the account.', 1;
|
||||
|
||||
BEGIN TRY
|
||||
BEGIN TRANSACTION;
|
||||
|
||||
DELETE FROM dbo.EmailQueue
|
||||
WHERE ToEmail = @Email;
|
||||
|
||||
DELETE FROM dbo.ProjectInvitation
|
||||
WHERE InvitedByUserID = @UserID
|
||||
OR AcceptedByUserID = @UserID
|
||||
OR EmailAddress = @Email;
|
||||
|
||||
DELETE FROM dbo.ProjectActivity
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
UPDATE dbo.ProjectUserAccess
|
||||
SET InvitedByUserID = NULL
|
||||
WHERE InvitedByUserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.ProjectUserAccess
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.UserFile
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.UserSubscription
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.UserTwoFactor
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.UserEmailVerificationToken
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.UserPasswordResetToken
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DELETE FROM dbo.UserLoginAudit
|
||||
WHERE UserID = @UserID
|
||||
OR EmailAttempted = @Email;
|
||||
|
||||
DELETE FROM dbo.AppUser
|
||||
WHERE UserID = @UserID;
|
||||
|
||||
DECLARE @Deleted bit = CASE WHEN @@ROWCOUNT = 1 THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END;
|
||||
|
||||
COMMIT TRANSACTION;
|
||||
|
||||
SELECT @Deleted;
|
||||
END TRY
|
||||
BEGIN CATCH
|
||||
IF @@TRANCOUNT > 0
|
||||
ROLLBACK TRANSACTION;
|
||||
|
||||
THROW;
|
||||
END CATCH
|
||||
END;
|
||||
GO
|
||||
Loading…
x
Reference in New Issue
Block a user