diff --git a/PlotLine/Data/AuthRepositories.cs b/PlotLine/Data/AuthRepositories.cs index 986f00d..4a56a0e 100644 --- a/PlotLine/Data/AuthRepositories.cs +++ b/PlotLine/Data/AuthRepositories.cs @@ -16,6 +16,8 @@ public interface IUserRepository Task ChangePasswordAsync(int userId, string passwordHash); Task EnableTwoFactorAsync(int userId); Task DisableTwoFactorAsync(int userId); + Task> ListHardDeleteFilePathsAsync(int userId); + Task 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> ListHardDeleteFilePathsAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.UserHardDelete_ListFilePaths", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task HardDeleteAccountDataAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.UserHardDelete_ForAccountDeletion", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + } } public sealed class AuthenticationRepository(ISqlConnectionFactory connectionFactory) : IAuthenticationRepository diff --git a/PlotLine/Data/Repositories.cs b/PlotLine/Data/Repositories.cs index 5e44a67..a54cd16 100644 --- a/PlotLine/Data/Repositories.cs +++ b/PlotLine/Data/Repositories.cs @@ -10,6 +10,7 @@ public interface IProjectRepository Task> ListForUserAsync(int userId); Task> ListActiveForUserAsync(int userId); Task> ListArchivedForUserAsync(int userId); + Task> ListOwnedForAccountDeletionAsync(int userId); Task GetAsync(int projectId); Task GetForUserAsync(int projectId, int userId); Task GetArchivedForOwnerAsync(int projectId, int userId); @@ -23,6 +24,8 @@ public interface IProjectRepository Task RestoreForOwnerAsync(int projectId, int userId); Task> ListHardDeleteFilePathsForOwnerAsync(int projectId, int userId); Task HardDeleteForOwnerAsync(int projectId, int userId); + Task> 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> ListOwnedForAccountDeletionAsync(int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.Project_ListOwnedForAccountDeletion", + new { UserID = userId }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + public async Task 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> ListHardDeleteFilePathsForAccountDeletionAsync(int projectId, int userId) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "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 diff --git a/PlotLine/Models/AuthResultModels.cs b/PlotLine/Models/AuthResultModels.cs index 88263ed..365cdd6 100644 --- a/PlotLine/Models/AuthResultModels.cs +++ b/PlotLine/Models/AuthResultModels.cs @@ -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 + }; +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 5148abc..20bbd46 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -109,6 +109,7 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -142,6 +143,7 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/PlotLine/Services/AccountDeletionService.cs b/PlotLine/Services/AccountDeletionService.cs new file mode 100644 index 0000000..8ceccc8 --- /dev/null +++ b/PlotLine/Services/AccountDeletionService.cs @@ -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 logger) : IAccountDeletionService +{ + public async Task 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 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 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); + } +} diff --git a/PlotLine/Services/AuthServiceInterfaces.cs b/PlotLine/Services/AuthServiceInterfaces.cs index a92ec28..5e9c830 100644 --- a/PlotLine/Services/AuthServiceInterfaces.cs +++ b/PlotLine/Services/AuthServiceInterfaces.cs @@ -22,6 +22,11 @@ public interface IAuthService Task> GenerateRecoveryCodesAsync(int userId); } +public interface IAccountDeletionService +{ + Task HardDeleteAccountAsync(int userId); +} + public interface IPasswordService { string HashPassword(string password); diff --git a/PlotLine/Services/CoreServices.cs b/PlotLine/Services/CoreServices.cs index ab06b9d..ddfd14c 100644 --- a/PlotLine/Services/CoreServices.cs +++ b/PlotLine/Services/CoreServices.cs @@ -316,7 +316,7 @@ public sealed class ProjectService( IBookRepository books, IProjectActivityService activity, ICurrentUserService currentUser, - IWebHostEnvironment environment, + IHardDeleteFileCleanupService fileCleanup, ILogger logger) : IProjectService { public async Task 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 filePaths) - { - var deleted = 0; - var missing = 0; - var failed = 0; - var deletedDirectories = new HashSet(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( diff --git a/PlotLine/Services/HardDeleteFileCleanupService.cs b/PlotLine/Services/HardDeleteFileCleanupService.cs new file mode 100644 index 0000000..24996e9 --- /dev/null +++ b/PlotLine/Services/HardDeleteFileCleanupService.cs @@ -0,0 +1,107 @@ +namespace PlotLine.Services; + +public interface IHardDeleteFileCleanupService +{ + HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList storagePaths); +} + +public sealed record HardDeleteFileCleanupResult(int Deleted, int Missing, int Failed); + +public sealed class HardDeleteFileCleanupService( + IWebHostEnvironment environment, + ILogger logger) : IHardDeleteFileCleanupService +{ + public HardDeleteFileCleanupResult DeleteFiles(string operationName, int ownerId, IReadOnlyList storagePaths) + { + var deleted = 0; + var missing = 0; + var failed = 0; + var deletedDirectories = new HashSet(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); + } + } +} diff --git a/PlotLine/Sql/055_AccountHardDeletion.sql b/PlotLine/Sql/055_AccountHardDeletion.sql new file mode 100644 index 0000000..23029a5 --- /dev/null +++ b/PlotLine/Sql/055_AccountHardDeletion.sql @@ -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