Phase 7D User Delete

This commit is contained in:
Nick Beckley 2026-06-12 20:11:52 +01:00
parent c88dde3693
commit 6dee173c5c
9 changed files with 526 additions and 95 deletions

View File

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

View File

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

View File

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

View File

@ -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>();

View 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);
}
}

View File

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

View File

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

View 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);
}
}
}

View 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