using System.Security.Cryptography; using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; namespace JobTrackerApi.Services; public sealed record AccountDeletionRequestResult(Guid RequestId, string Status, string Stage); public sealed class AccountDeletionService( JobTrackerContext db, AccountOwnedFileInventory fileInventory, AccountDeletionTombstoneStore tombstones, IAiSidecarCachePurger aiSidecarCache, IConfiguration configuration, IMemoryCache memoryCache, TimeProvider timeProvider, ILogger logger) { private const int MaxAttempts = 20; public bool CanAcceptRequests => configuration.GetValue("AccountLifecycle:DeletionEnabled", false); public async Task RequestAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken) { if (!CanAcceptRequests) return null; var request = await RequestCoreAsync(ownerUserId, requestedByUserId, cancellationToken); return new AccountDeletionRequestResult(request.Id, request.Status, request.Stage); } public async Task StageRestoredAccountsAsync(CancellationToken cancellationToken) { var ownerKeys = (await tombstones.ReadAsync(cancellationToken)).Select(item => item.OwnerKey).ToHashSet(StringComparer.Ordinal); if (ownerKeys.Count == 0) return 0; var users = await db.Users.AsNoTracking().Select(item => item.Id).ToListAsync(cancellationToken); var restored = users.Where(item => ownerKeys.Contains(AppPaths.GetOwnerStorageKey(item))).ToList(); foreach (var ownerUserId in restored) { await RequestCoreAsync(ownerUserId, "tombstone-replay", cancellationToken); } return restored.Count; } public async Task ProcessPendingAsync(CancellationToken cancellationToken) { var requestIds = await db.AccountDeletionRequests.AsNoTracking() .Where(item => item.Status != AccountDeletionRequestStatuses.Completed && item.AttemptCount < MaxAttempts) .OrderBy(item => item.Id) .Select(item => item.Id) .ToListAsync(cancellationToken); var completed = 0; foreach (var requestId in requestIds) { if (await ProcessAsync(requestId, cancellationToken)) completed++; } return completed; } public async Task ProcessAsync(Guid requestId, CancellationToken cancellationToken) { // Requests are normally processed in a fresh background scope. Clearing here also makes // direct retries safe when the same scoped service accepted the request: ExecuteDelete // must not leave a previously tracked ApplicationUser pending for a later SaveChanges. db.ChangeTracker.Clear(); var request = await db.AccountDeletionRequests.Include(item => item.Files).FirstOrDefaultAsync(item => item.Id == requestId, cancellationToken); if (request is null) return false; if (request.Status == AccountDeletionRequestStatuses.Completed) return true; request.Status = AccountDeletionRequestStatuses.Processing; request.AttemptCount++; request.StartedAtUtc ??= timeProvider.GetUtcNow(); request.LastErrorCategory = null; request.LastErrorMessage = null; await db.SaveChangesAsync(cancellationToken); try { if (request.Stage == AccountDeletionStages.Requested) await PrepareFilesAsync(request, cancellationToken); if (request.Stage == AccountDeletionStages.QuarantiningFiles) await QuarantineFilesAsync(request, cancellationToken); if (request.Stage == AccountDeletionStages.DeletingDatabase) await DeleteDatabaseRowsAsync(request, cancellationToken); if (request.Stage == AccountDeletionStages.PurgingFiles) await PurgeFilesAsync(request, cancellationToken); if (request.Stage == AccountDeletionStages.RecordingTombstone) await CompleteAsync(request, cancellationToken); return request.Status == AccountDeletionRequestStatuses.Completed; } catch (Exception ex) { request.Status = AccountDeletionRequestStatuses.RetryRequired; request.LastErrorCategory = Classify(ex); request.LastErrorMessage = Sanitize(ex.Message); try { await db.SaveChangesAsync(cancellationToken); } catch (Exception saveError) { logger.LogError(saveError, "Could not persist account deletion failure for {RequestId}", request.Id); } logger.LogWarning(ex, "Account deletion request {RequestId} stopped at {Stage}; it remains retryable", request.Id, request.Stage); return false; } } private async Task RequestCoreAsync(string ownerUserId, string requestedByUserId, CancellationToken cancellationToken) { var existing = await db.AccountDeletionRequests .Where(item => item.OwnerUserId == ownerUserId && item.Status != AccountDeletionRequestStatuses.Completed) .OrderBy(item => item.Id) .FirstOrDefaultAsync(cancellationToken); if (existing is not null) return existing; var user = await db.Users.FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken) ?? throw new InvalidOperationException("The account no longer exists."); var now = timeProvider.GetUtcNow(); var request = new AccountDeletionRequest { Id = Guid.NewGuid(), OwnerUserId = ownerUserId, OwnerKey = AppPaths.GetOwnerStorageKey(ownerUserId), RequestedByUserId = requestedByUserId, Status = AccountDeletionRequestStatuses.Pending, Stage = AccountDeletionStages.Requested, RequestedAtUtc = now, }; user.DeletionStatus = AccountDeletionStatuses.Pending; user.DeletionRequestedAtUtc = now; user.SecurityStamp = Guid.NewGuid().ToString(); foreach (var variant in await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId && item.IsPublic).ToListAsync(cancellationToken)) variant.IsPublic = false; foreach (var session in await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId && item.RevokedAtUtc == null).ToListAsync(cancellationToken)) session.RevokedAtUtc = now; db.TrustedDevices.RemoveRange(await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == ownerUserId).ToListAsync(cancellationToken)); var operations = await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == ownerUserId && item.Status != OperationStatuses.Succeeded && item.Status != OperationStatuses.Failed && item.Status != OperationStatuses.Cancelled).ToListAsync(cancellationToken); foreach (var operation in operations) { if (operation.Status == OperationStatuses.Running) operation.CancellationRequestedAtUtc = now.UtcDateTime; else { operation.Status = OperationStatuses.Cancelled; operation.CompletedAtUtc = now.UtcDateTime; operation.LeaseToken = null; operation.LeaseExpiresAtUtc = null; } } db.AccountDeletionRequests.Add(request); await db.SaveChangesAsync(cancellationToken); return request; } private async Task PrepareFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken) { var inventory = await fileInventory.BuildAsync(request.OwnerUserId, cancellationToken, includeAccountExports: true); if (inventory.Warnings.Any(item => item.StartsWith("Excluded unsafe", StringComparison.Ordinal))) throw new InvalidOperationException("One or more owned file paths failed the managed-root safety check."); request.WarningJson = JsonSerializer.Serialize(inventory.Warnings); foreach (var ownedFile in inventory.Files.Where(item => item.SourcePath is not null)) { var sourcePath = ownedFile.SourcePath!; if (request.Files.Any(item => string.Equals(item.OriginalPath, sourcePath, PathComparison))) continue; await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); var hash = Convert.ToHexString(await SHA256.HashDataAsync(source, cancellationToken)).ToLowerInvariant(); request.Files.Add(new AccountDeletionFile { AccountDeletionRequestId = request.Id, Category = ownedFile.Category, OriginalPath = sourcePath, QuarantinePath = sourcePath + $".{request.Id:N}.account-deleting", Status = "planned", ByteSize = source.Length, Sha256 = hash, }); } request.FileCount = request.Files.Count; request.Stage = AccountDeletionStages.QuarantiningFiles; await db.SaveChangesAsync(cancellationToken); } private async Task QuarantineFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken) { try { foreach (var file in request.Files) { cancellationToken.ThrowIfCancellationRequested(); if (File.Exists(file.QuarantinePath)) { file.Status = "quarantined"; continue; } if (!File.Exists(file.OriginalPath)) { file.Status = "missing"; AppendWarning(request, $"Owned {file.Category} file disappeared before quarantine."); continue; } File.Move(file.OriginalPath, file.QuarantinePath, overwrite: false); file.Status = "quarantined"; } request.Stage = AccountDeletionStages.DeletingDatabase; await db.SaveChangesAsync(cancellationToken); } catch { RestoreQuarantinedFiles(request); await db.SaveChangesAsync(cancellationToken); throw; } } private async Task DeleteDatabaseRowsAsync(AccountDeletionRequest request, CancellationToken cancellationToken) { foreach (var file in request.Files) { if (File.Exists(file.OriginalPath) || (file.Status != "missing" && !File.Exists(file.QuarantinePath))) throw new InvalidOperationException("Owned files are not fully quarantined; database deletion was not started."); } var transaction = db.Database.IsRelational() ? await db.Database.BeginTransactionAsync(cancellationToken) : null; try { var owner = request.OwnerUserId; var applicationIds = await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken); var variantIds = await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken); var deleted = 0; deleted += await db.EmailDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.EmailSendAttempts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.ApplicationChecklistItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CoverLetterVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.InterviewPrepItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.InterviewPrepNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.AiWorkspaceNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.AiInteractions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.TailoredCvDrafts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.Correspondences.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken); deleted += await db.JobEvents.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken); deleted += await db.Attachments.IgnoreQueryFilters().Where(item => applicationIds.Contains(item.JobApplicationId)).ExecuteDeleteAsync(cancellationToken); deleted += await db.GmailReviewDecisions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CvVariantVersions.IgnoreQueryFilters().Where(item => variantIds.Contains(item.CvVariantId)).ExecuteDeleteAsync(cancellationToken); deleted += await db.CvVariants.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerExperiences.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerEducations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerSkills.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerProjects.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.CvUploadArtifacts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); if (await db.GmailConnections.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner, cancellationToken)) AppendWarning(request, "Google consent was not revoked remotely; local Gmail credentials were deleted."); deleted += await db.GmailConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.MicrosoftGraphConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.ImapConnections.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserRuleSettings.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserNotifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.Jobs.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.Companies.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.TwoFactorRecoveryCodes.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.TrustedDevices.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserSessions.IgnoreQueryFilters().Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserClaims.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserLogins.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserTokens.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.UserRoles.Where(item => item.UserId == owner).ExecuteDeleteAsync(cancellationToken); deleted += await db.Users.Where(item => item.Id == owner).ExecuteDeleteAsync(cancellationToken); request.DatabaseRowCount = deleted; request.Stage = AccountDeletionStages.PurgingFiles; request.Status = AccountDeletionRequestStatuses.Processing; await db.SaveChangesAsync(cancellationToken); if (transaction is not null) await transaction.CommitAsync(cancellationToken); } catch { if (transaction is not null) { try { await transaction.RollbackAsync(CancellationToken.None); } catch (Exception rollbackError) { logger.LogError(rollbackError, "Account deletion transaction rollback outcome is uncertain for {RequestId}", request.Id); } } // A commit can succeed at the database and still lose the acknowledgement. Restore // quarantined files only when the owner row proves the database deletion rolled back. // When the outcome cannot be read, leave files quarantined and safely replay deletion. try { if (await db.Users.AsNoTracking().AnyAsync(item => item.Id == request.OwnerUserId, CancellationToken.None)) { RestoreQuarantinedFiles(request); request.Stage = AccountDeletionStages.DeletingDatabase; } } catch (Exception verificationError) { logger.LogError(verificationError, "Could not verify database deletion outcome for {RequestId}; files remain quarantined", request.Id); request.Stage = AccountDeletionStages.DeletingDatabase; } throw; } finally { if (transaction is not null) await transaction.DisposeAsync(); } } private async Task PurgeFilesAsync(AccountDeletionRequest request, CancellationToken cancellationToken) { foreach (var file in request.Files) { cancellationToken.ThrowIfCancellationRequested(); if (File.Exists(file.QuarantinePath)) File.Delete(file.QuarantinePath); file.Status = "purged"; } if (memoryCache is MemoryCache cache) cache.Compact(1.0); // The sidecar cache is content-keyed rather than owner-keyed, so deletion clears it // globally. Keep this inside the durable stage: an unavailable sidecar leaves the request // retryable and prevents a false completion/tombstone acknowledgement. await aiSidecarCache.PurgeAsync(cancellationToken); request.Stage = AccountDeletionStages.RecordingTombstone; await db.SaveChangesAsync(cancellationToken); } private async Task CompleteAsync(AccountDeletionRequest request, CancellationToken cancellationToken) { var completedAt = timeProvider.GetUtcNow(); await tombstones.AppendAsync(request.Id, request.OwnerKey, completedAt, cancellationToken); request.Stage = AccountDeletionStages.Completed; request.Status = AccountDeletionRequestStatuses.Completed; request.CompletedAtUtc = completedAt; await db.SaveChangesAsync(cancellationToken); } private static void RestoreQuarantinedFiles(AccountDeletionRequest request) { foreach (var file in request.Files.Where(item => File.Exists(item.QuarantinePath) && !File.Exists(item.OriginalPath))) { try { File.Move(file.QuarantinePath, file.OriginalPath, overwrite: false); file.Status = "planned"; } catch { } } } private static void AppendWarning(AccountDeletionRequest request, string warning) { var warnings = string.IsNullOrWhiteSpace(request.WarningJson) ? new List() : JsonSerializer.Deserialize>(request.WarningJson) ?? new List(); if (!warnings.Contains(warning, StringComparer.Ordinal)) warnings.Add(warning); request.WarningJson = JsonSerializer.Serialize(warnings); } private static string Classify(Exception exception) => exception switch { IOException => "file_io", UnauthorizedAccessException => "file_access", DbUpdateException => "database", OperationCanceledException => "cancelled", _ => "unexpected", }; private static string Sanitize(string value) { var message = value.Replace('\r', ' ').Replace('\n', ' ').Trim(); return message.Length > 500 ? message[..500] : message; } private static StringComparison PathComparison => OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; } public sealed class AccountDeletionHostedService( IServiceScopeFactory scopes, IStartupReadiness startupReadiness, ILogger logger) : BackgroundService { protected override async Task ExecuteAsync(CancellationToken stoppingToken) { await startupReadiness.WaitUntilReadyAsync(stoppingToken); while (!stoppingToken.IsCancellationRequested) { try { await using var scope = scopes.CreateAsyncScope(); await scope.ServiceProvider.GetRequiredService().ProcessPendingAsync(stoppingToken); } catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { } catch (Exception ex) { logger.LogError(ex, "Account deletion reconciliation failed; durable requests remain retryable"); } await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } } }