From 842e793f6908d1988c53f9b2d98014e82afa7724 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 15 Aug 2026 19:03:54 +0200 Subject: [PATCH] feat(account): add deletion lifecycle --- JobTrackerApi.Tests/AccountDataExportTests.cs | 3 +- JobTrackerApi.Tests/AccountDeletionTests.cs | 284 ++ .../AuthSessionRevocationTests.cs | 14 + .../SessionsControllerTests.cs | 3 + JobTrackerApi.Tests/UsersControllerTests.cs | 14 + .../Controllers/AccountLifecycleController.cs | 108 + JobTrackerApi/Controllers/AuthController.cs | 2 + .../Controllers/TwoFactorController.cs | 2 +- JobTrackerApi/Controllers/UsersController.cs | 19 +- JobTrackerApi/Data/JobTrackerContext.cs | 28 + ...27_AddAccountDeletionLifecycle.Designer.cs | 2842 +++++++++++++++++ ...60815164027_AddAccountDeletionLifecycle.cs | 156 + .../JobTrackerContextModelSnapshot.cs | 136 + JobTrackerApi/Models/AccountDeletion.cs | 59 + JobTrackerApi/Models/ApplicationUser.cs | 2 + JobTrackerApi/Program.cs | 10 + .../Services/AccountDataExportService.cs | 124 +- .../Services/AccountDeletionService.cs | 391 +++ .../Services/AccountDeletionTombstoneStore.cs | 64 + .../Services/AccountOwnedFileInventory.cs | 139 + JobTrackerApi/Services/AppPaths.cs | 10 + .../Services/LocalSessionValidator.cs | 5 +- JobTrackerApi/appsettings.json | 3 + .../src/account-data-export.test.tsx | 36 +- job-tracker-ui/src/admin-users-page.test.tsx | 12 + job-tracker-ui/src/components/BackupCard.tsx | 61 +- job-tracker-ui/src/i18n/translations.ts | 18 + job-tracker-ui/src/views/AdminUsersPage.tsx | 2 +- 28 files changed, 4418 insertions(+), 129 deletions(-) create mode 100644 JobTrackerApi.Tests/AccountDeletionTests.cs create mode 100644 JobTrackerApi/Controllers/AccountLifecycleController.cs create mode 100644 JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.Designer.cs create mode 100644 JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.cs create mode 100644 JobTrackerApi/Models/AccountDeletion.cs create mode 100644 JobTrackerApi/Services/AccountDeletionService.cs create mode 100644 JobTrackerApi/Services/AccountDeletionTombstoneStore.cs create mode 100644 JobTrackerApi/Services/AccountOwnedFileInventory.cs diff --git a/JobTrackerApi.Tests/AccountDataExportTests.cs b/JobTrackerApi.Tests/AccountDataExportTests.cs index 9276825..4cab5ad 100644 --- a/JobTrackerApi.Tests/AccountDataExportTests.cs +++ b/JobTrackerApi.Tests/AccountDataExportTests.cs @@ -199,7 +199,8 @@ public sealed class AccountDataExportTests .Options; var db = new JobTrackerContext(options, currentUser.Object); db.Database.EnsureCreated(); - return (db, paths, new AccountDataExportService(db, paths, new AttachmentStorage(paths), TimeProvider.System)); + var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths)); + return (db, paths, new AccountDataExportService(db, paths, inventory, TimeProvider.System)); } private static string ReadText(ZipArchiveEntry entry) diff --git a/JobTrackerApi.Tests/AccountDeletionTests.cs b/JobTrackerApi.Tests/AccountDeletionTests.cs new file mode 100644 index 0000000..71fd1c0 --- /dev/null +++ b/JobTrackerApi.Tests/AccountDeletionTests.cs @@ -0,0 +1,284 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class AccountDeletionTests +{ + [Fact] + public async Task Disabled_lifecycle_never_accepts_a_deletion_request() + { + await InFixtureAsync(enabled: false, async fixture => + { + fixture.Db.Users.Add(User("owner")); + await fixture.Db.SaveChangesAsync(); + + Assert.Null(await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None)); + Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync()).DeletionStatus); + Assert.Empty(await fixture.Db.AccountDeletionRequests.ToListAsync()); + }); + } + + [Fact] + public async Task Request_is_idempotent_and_immediately_locks_down_the_account() + { + await InFixtureAsync(enabled: true, async fixture => + { + var now = DateTimeOffset.UtcNow; + fixture.Db.Users.AddRange(User("owner"), User("other")); + fixture.Db.CvVariants.Add(new CvVariant + { + OwnerUserId = "owner", PublicSlug = "public-owner", Name = "Owner CV", IsPublic = true, + CreatedAtUtc = now, UpdatedAtUtc = now, + }); + fixture.Db.UserSessions.Add(new UserSession + { + Id = "session", UserId = "owner", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1), + }); + fixture.Db.TrustedDevices.Add(new TrustedDevice + { + UserId = "owner", TokenHash = "hash", CreatedAtUtc = now, LastSeenAtUtc = now, ExpiresAtUtc = now.AddDays(1), + }); + var queued = Operation("owner", OperationStatuses.Queued); + var running = Operation("owner", OperationStatuses.Running); + fixture.Db.UserOperations.AddRange(queued, running); + await fixture.Db.SaveChangesAsync(); + + var first = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None); + var second = await fixture.Service.RequestAsync("owner", "owner", CancellationToken.None); + + Assert.NotNull(first); + Assert.Equal(first!.RequestId, second!.RequestId); + Assert.Single(await fixture.Db.AccountDeletionRequests.ToListAsync()); + var user = await fixture.Db.Users.SingleAsync(item => item.Id == "owner"); + Assert.Equal(AccountDeletionStatuses.Pending, user.DeletionStatus); + Assert.NotNull(user.DeletionRequestedAtUtc); + Assert.False((await fixture.Db.CvVariants.IgnoreQueryFilters().SingleAsync()).IsPublic); + Assert.NotNull((await fixture.Db.UserSessions.IgnoreQueryFilters().SingleAsync()).RevokedAtUtc); + Assert.Empty(await fixture.Db.TrustedDevices.IgnoreQueryFilters().ToListAsync()); + Assert.Equal(OperationStatuses.Cancelled, (await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == queued.Id)).Status); + Assert.NotNull((await fixture.Db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == running.Id)).CancellationRequestedAtUtc); + Assert.Equal(AccountDeletionStatuses.Active, (await fixture.Db.Users.SingleAsync(item => item.Id == "other")).DeletionStatus); + }); + } + + [Fact] + public async Task Completed_deletion_is_owner_isolated_purges_files_and_replays_after_restore() + { + await InFixtureAsync(enabled: true, async fixture => + { + var owner = User("owner"); + var other = User("other"); + fixture.Db.Users.AddRange(owner, other); + var ownerCompany = new Company { OwnerUserId = owner.Id, Name = "Owner company" }; + var otherCompany = new Company { OwnerUserId = other.Id, Name = "Other company" }; + fixture.Db.Companies.AddRange(ownerCompany, otherCompany); + await fixture.Db.SaveChangesAsync(); + var ownerApplication = new JobApplication { OwnerUserId = owner.Id, CompanyId = ownerCompany.Id, JobTitle = "Owner role" }; + var otherApplication = new JobApplication { OwnerUserId = other.Id, CompanyId = otherCompany.Id, JobTitle = "Other role" }; + fixture.Db.JobApplications.AddRange(ownerApplication, otherApplication); + await fixture.Db.SaveChangesAsync(); + + var attachmentPath = Path.Combine(fixture.Paths.AttachmentsRoot, ownerApplication.Id.ToString(), "owner.txt"); + Directory.CreateDirectory(Path.GetDirectoryName(attachmentPath)!); + await File.WriteAllTextAsync(attachmentPath, "owner attachment"); + fixture.Db.Attachments.Add(new Attachment + { + JobApplicationId = ownerApplication.Id, FileName = "owner.txt", FilePath = attachmentPath, + FileType = "text/plain", FileSize = new FileInfo(attachmentPath).Length, + }); + var artifactRoot = Path.Combine(fixture.Paths.CvArtifactsRoot, AppPaths.GetOwnerStorageKey(owner.Id)); + Directory.CreateDirectory(artifactRoot); + var artifactPath = Path.Combine(artifactRoot, "owner-cv.txt"); + await File.WriteAllTextAsync(artifactPath, "owner cv"); + fixture.Db.CvUploadArtifacts.Add(new CvUploadArtifact + { + OwnerUserId = owner.Id, OriginalFileName = "owner-cv.txt", StoredFileName = "owner-cv.txt", + MimeType = "text/plain", ByteSize = new FileInfo(artifactPath).Length, Sha256 = "synthetic", StoragePath = artifactPath, + }); + var otherOperation = Operation(other.Id, OperationStatuses.Queued); + fixture.Db.UserOperations.Add(otherOperation); + await fixture.Db.SaveChangesAsync(); + var generatedRoot = Path.Combine(fixture.Paths.GetOwnerCvExportsRoot(owner.Id), "20260815"); + Directory.CreateDirectory(generatedRoot); + var generatedPath = Path.Combine(generatedRoot, "owner.pdf"); + await File.WriteAllTextAsync(generatedPath, "owner pdf"); + var accountExportRoot = fixture.Paths.GetOwnerAccountExportsRoot(owner.Id); + Directory.CreateDirectory(accountExportRoot); + var accountExportPath = Path.Combine(accountExportRoot, "previous.zip"); + await File.WriteAllTextAsync(accountExportPath, "previous account export"); + + var accepted = await fixture.Service.RequestAsync(owner.Id, owner.Id, CancellationToken.None); + Assert.NotNull(accepted); + Assert.True(await fixture.Service.ProcessAsync(accepted!.RequestId, CancellationToken.None)); + + Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id)); + Assert.True(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == other.Id)); + Assert.False(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id)); + Assert.True(await fixture.Db.Companies.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id)); + Assert.False(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == owner.Id)); + Assert.True(await fixture.Db.JobApplications.IgnoreQueryFilters().AnyAsync(item => item.OwnerUserId == other.Id)); + Assert.True(await fixture.Db.UserOperations.IgnoreQueryFilters().AnyAsync(item => item.Id == otherOperation.Id)); + Assert.False(File.Exists(attachmentPath)); + Assert.False(File.Exists(artifactPath)); + Assert.False(File.Exists(generatedPath)); + Assert.False(File.Exists(accountExportPath)); + var request = await fixture.Db.AccountDeletionRequests.Include(item => item.Files).SingleAsync(item => item.Id == accepted.RequestId); + Assert.Equal(AccountDeletionRequestStatuses.Completed, request.Status); + Assert.Equal(AccountDeletionStages.Completed, request.Stage); + Assert.NotEmpty(request.Files); + Assert.All(request.Files, item => Assert.Equal("purged", item.Status)); + Assert.Single(await fixture.Tombstones.ReadAsync(CancellationToken.None)); + Assert.True(await fixture.Service.ProcessAsync(accepted.RequestId, CancellationToken.None)); + + fixture.Db.Users.Add(User(owner.Id)); + await fixture.Db.SaveChangesAsync(); + Assert.Equal(1, await fixture.Service.StageRestoredAccountsAsync(CancellationToken.None)); + fixture.Db.ChangeTracker.Clear(); + Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.AsNoTracking().SingleAsync(item => item.Id == owner.Id)).DeletionStatus); + Assert.Equal(1, await fixture.Service.ProcessPendingAsync(CancellationToken.None)); + Assert.False(await fixture.Db.Users.AsNoTracking().AnyAsync(item => item.Id == owner.Id)); + Assert.Equal(2, (await fixture.Tombstones.ReadAsync(CancellationToken.None)).Count); + }); + } + + [Fact] + public async Task Quarantine_failure_restores_files_and_never_starts_database_deletion() + { + await InFixtureAsync(enabled: true, async fixture => + { + fixture.Db.Users.Add(User("owner")); + var source = Path.Combine(fixture.Paths.AttachmentsRoot, "source.txt"); + await File.WriteAllTextAsync(source, "must survive"); + var invalidTarget = source + ".quarantine"; + Directory.CreateDirectory(invalidTarget); + var request = new AccountDeletionRequest + { + Id = Guid.NewGuid(), OwnerUserId = "owner", OwnerKey = AppPaths.GetOwnerStorageKey("owner"), + RequestedByUserId = "owner", Status = AccountDeletionRequestStatuses.Pending, + Stage = AccountDeletionStages.QuarantiningFiles, RequestedAtUtc = DateTimeOffset.UtcNow, + Files = + [ + new AccountDeletionFile + { + Category = "attachment", OriginalPath = source, QuarantinePath = invalidTarget, + Status = "planned", ByteSize = new FileInfo(source).Length, Sha256 = "synthetic", + }, + ], + }; + fixture.Db.AccountDeletionRequests.Add(request); + await fixture.Db.SaveChangesAsync(); + + Assert.False(await fixture.Service.ProcessAsync(request.Id, CancellationToken.None)); + fixture.Db.ChangeTracker.Clear(); + var failed = await fixture.Db.AccountDeletionRequests.SingleAsync(item => item.Id == request.Id); + Assert.Equal(AccountDeletionRequestStatuses.RetryRequired, failed.Status); + Assert.Equal(AccountDeletionStages.QuarantiningFiles, failed.Stage); + Assert.True(File.Exists(source)); + Assert.True(await fixture.Db.Users.AnyAsync(item => item.Id == "owner")); + }); + } + + [Fact] + public async Task Self_service_requires_exact_phrase_and_recent_sign_in() + { + await InFixtureAsync(enabled: true, async fixture => + { + var now = DateTimeOffset.UtcNow; + fixture.Db.Users.Add(User("owner")); + fixture.Db.UserSessions.Add(new UserSession + { + Id = "session", UserId = "owner", CreatedAtUtc = now.AddMinutes(-16), + LastSeenAtUtc = now, ExpiresAtUtc = now.AddHours(1), + }); + await fixture.Db.SaveChangesAsync(); + var controller = Controller(fixture, "owner", "session"); + + Assert.IsType(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE wrong@example.test"), CancellationToken.None)); + var stale = Assert.IsType(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None)); + Assert.Equal(StatusCodes.Status403Forbidden, stale.StatusCode); + + var session = await fixture.Db.UserSessions.SingleAsync(); + session.CreatedAtUtc = DateTimeOffset.UtcNow; + await fixture.Db.SaveChangesAsync(); + var accepted = Assert.IsType(await controller.Delete(new AccountLifecycleController.DeleteAccountRequest("DELETE owner@example.test"), CancellationToken.None)); + Assert.Equal(StatusCodes.Status202Accepted, accepted.StatusCode); + Assert.Equal(AccountDeletionStatuses.Pending, (await fixture.Db.Users.SingleAsync()).DeletionStatus); + }); + } + + private static ApplicationUser User(string id) => new() + { + Id = id, UserName = $"{id}@example.test", NormalizedUserName = $"{id.ToUpperInvariant()}@EXAMPLE.TEST", + Email = $"{id}@example.test", NormalizedEmail = $"{id.ToUpperInvariant()}@EXAMPLE.TEST", EmailConfirmed = true, + }; + + private static UserOperation Operation(string owner, string status) => new() + { + Id = Guid.NewGuid(), OwnerUserId = owner, TaskType = "synthetic", IdempotencyKey = Guid.NewGuid().ToString("N"), + Status = status, CreatedAtUtc = DateTime.UtcNow, AvailableAtUtc = DateTime.UtcNow, + }; + + private static AccountLifecycleController Controller(Fixture fixture, string userId, string sessionId) + { + var controller = new AccountLifecycleController(fixture.Db, fixture.Service, TimeProvider.System) + { + ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }, + }; + controller.ControllerContext.HttpContext.User = new ClaimsPrincipal(new ClaimsIdentity( + [ + new Claim(ClaimTypes.NameIdentifier, userId), + new Claim("sid", sessionId), + ], "local")); + return controller; + } + + private static async Task InFixtureAsync(bool enabled, Func test) + { + var root = Path.Combine(Path.GetTempPath(), $"jobtracker-account-deletion-{Guid.NewGuid():N}"); + Directory.CreateDirectory(root); + try + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary + { + ["Data:Root"] = root, + ["AccountLifecycle:DeletionEnabled"] = enabled.ToString(), + }).Build(); + var environment = new Mock(); + environment.SetupGet(item => item.ContentRootPath).Returns(root); + var paths = new AppPaths(configuration, environment.Object); + var currentUser = new Mock(); + currentUser.SetupGet(item => item.UserId).Returns("owner"); + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={Path.Combine(root, "deletion-tests.db")}") + .Options; + await using var db = new JobTrackerContext(options, currentUser.Object); + await db.Database.EnsureCreatedAsync(); + using var cache = new MemoryCache(new MemoryCacheOptions()); + var inventory = new AccountOwnedFileInventory(db, paths, new AttachmentStorage(paths)); + var tombstones = new AccountDeletionTombstoneStore(paths); + var service = new AccountDeletionService(db, inventory, tombstones, configuration, cache, TimeProvider.System, NullLogger.Instance); + await test(new Fixture(db, paths, tombstones, service)); + } + finally + { + SqliteConnection.ClearAllPools(); + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + private sealed record Fixture(JobTrackerContext Db, AppPaths Paths, AccountDeletionTombstoneStore Tombstones, AccountDeletionService Service); +} diff --git a/JobTrackerApi.Tests/AuthSessionRevocationTests.cs b/JobTrackerApi.Tests/AuthSessionRevocationTests.cs index 5302e4c..d58cf32 100644 --- a/JobTrackerApi.Tests/AuthSessionRevocationTests.cs +++ b/JobTrackerApi.Tests/AuthSessionRevocationTests.cs @@ -21,6 +21,9 @@ public sealed class AuthSessionRevocationTests public async Task Logout_revokes_the_exact_session_and_a_copied_principal_stops_working() { using var db = TestHostFactory.CreateInMemoryDb(null); + db.Users.AddRange( + new ApplicationUser { Id = "user-1", UserName = "one@example.test", Email = "one@example.test" }, + new ApplicationUser { Id = "user-2", UserName = "two@example.test", Email = "two@example.test" }); db.UserSessions.Add(Session("sid-current", "user-1")); db.UserSessions.Add(Session("sid-other", "user-2")); await db.SaveChangesAsync(); @@ -130,6 +133,17 @@ public sealed class AuthSessionRevocationTests Assert.True(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-pending"), DateTimeOffset.UtcNow)); } + [Fact] + public async Task Session_validator_rejects_an_account_pending_deletion() + { + using var db = TestHostFactory.CreateInMemoryDb(null); + db.Users.Add(new ApplicationUser { Id = "user-1", Email = "pending-delete@example.test", UserName = "pending-delete@example.test", DeletionStatus = AccountDeletionStatuses.Pending }); + db.UserSessions.Add(Session("sid-delete", "user-1")); + await db.SaveChangesAsync(); + + Assert.False(await LocalSessionValidator.IsValidAsync(db, Principal("user-1", "sid-delete"), DateTimeOffset.UtcNow)); + } + [Fact] public async Task Password_security_stamp_change_invalidates_a_pending_two_factor_challenge() { diff --git a/JobTrackerApi.Tests/SessionsControllerTests.cs b/JobTrackerApi.Tests/SessionsControllerTests.cs index ee4bb93..2c2e19c 100644 --- a/JobTrackerApi.Tests/SessionsControllerTests.cs +++ b/JobTrackerApi.Tests/SessionsControllerTests.cs @@ -161,6 +161,7 @@ public sealed class SessionsControllerTests public async Task List_returns_only_the_callers_own_active_sessions() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.Users.AddRange(new ApplicationUser { Id = "user-1" }, new ApplicationUser { Id = "user-2" }); db.UserSessions.Add(NewSession("sid-mine", "user-1")); db.UserSessions.Add(NewSession("sid-other-user", "user-2")); db.UserSessions.Add(NewSession("sid-mine-expired", "user-1", expiresAtUtc: DateTimeOffset.UtcNow.AddHours(-1))); @@ -181,6 +182,7 @@ public sealed class SessionsControllerTests public async Task Revoke_enforces_ownership_and_blocks_a_subsequent_request_using_that_sessions_token() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.Users.AddRange(new ApplicationUser { Id = "user-1" }, new ApplicationUser { Id = "user-2" }); db.UserSessions.Add(NewSession("sid-mine", "user-1")); db.UserSessions.Add(NewSession("sid-not-mine", "user-2")); await db.SaveChangesAsync(); @@ -203,6 +205,7 @@ public sealed class SessionsControllerTests public async Task RevokeOthers_revokes_every_other_session_but_leaves_the_current_one_usable() { using var db = TestHostFactory.CreateInMemoryDb("user-1"); + db.Users.AddRange(new ApplicationUser { Id = "user-1" }, new ApplicationUser { Id = "user-2" }); db.UserSessions.Add(NewSession("sid-current", "user-1")); db.UserSessions.Add(NewSession("sid-other-device", "user-1")); db.UserSessions.Add(NewSession("sid-not-mine", "user-2")); diff --git a/JobTrackerApi.Tests/UsersControllerTests.cs b/JobTrackerApi.Tests/UsersControllerTests.cs index ce67c43..95ced4d 100644 --- a/JobTrackerApi.Tests/UsersControllerTests.cs +++ b/JobTrackerApi.Tests/UsersControllerTests.cs @@ -67,6 +67,20 @@ public sealed class UsersControllerTests users.Verify(x => x.DeleteAsync(It.IsAny()), Times.Never); } + [Fact] + public async Task Delete_never_falls_back_to_identity_only_removal() + { + var member = User("user-1"); + var users = TestHostFactory.CreateUserManager(member); + users.Setup(x => x.IsInRoleAsync(member, "Admin")).ReturnsAsync(false); + var controller = CreateController(users, "admin-1"); + + var result = Assert.IsType(await controller.Delete(member.Id, CancellationToken.None)); + + Assert.Equal(StatusCodes.Status503ServiceUnavailable, result.StatusCode); + users.Verify(x => x.DeleteAsync(It.IsAny()), Times.Never); + } + [Fact] public async Task List_marks_the_current_user_and_last_admin_safety_state() { diff --git a/JobTrackerApi/Controllers/AccountLifecycleController.cs b/JobTrackerApi/Controllers/AccountLifecycleController.cs new file mode 100644 index 0000000..10e88a3 --- /dev/null +++ b/JobTrackerApi/Controllers/AccountLifecycleController.cs @@ -0,0 +1,108 @@ +using System.Security.Claims; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/account-lifecycle")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class AccountLifecycleController( + JobTrackerContext db, + AccountDeletionService deletions, + TimeProvider timeProvider) : ControllerBase +{ + public sealed record DeleteAccountRequest(string Confirmation); + + [HttpGet("status")] + public async Task Status(CancellationToken cancellationToken) + { + var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User); + if (ownerUserId is null) return Unauthorized(); + var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken); + if (user is null) return NotFound(); + var request = await db.AccountDeletionRequests.AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderByDescending(item => item.Id).FirstOrDefaultAsync(cancellationToken); + return Ok(new + { + deletionEnabled = deletions.CanAcceptRequests, + deletionStatus = user.DeletionStatus, + requiredConfirmation = $"DELETE {user.Email}", + request = request is null ? null : ToDto(request), + }); + } + + [HttpPost("delete")] + [EnableRateLimiting("account-data")] + public async Task Delete([FromBody] DeleteAccountRequest request, CancellationToken cancellationToken) + { + if (!deletions.CanAcceptRequests) + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved."); + var ownerUserId = LocalAuthIdentity.GetRequiredUserId(User); + if (ownerUserId is null) return Unauthorized(); + var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken); + if (user is null) return NotFound(); + if (!string.Equals(request.Confirmation?.Trim(), $"DELETE {user.Email}", StringComparison.Ordinal)) + return BadRequest("Type the exact confirmation shown before deleting the account."); + if (!await IsRecentlyAuthenticated(ownerUserId, cancellationToken)) + return StatusCode(StatusCodes.Status403Forbidden, new ProblemDetails { Title = "Recent sign-in required", Detail = "Sign in again before deleting the account." }); + if (await IsFinalAdministrator(ownerUserId, cancellationToken)) + return Conflict(new ProblemDetails { Title = "Last administrator protected", Detail = "Assign the Admin role to another user before deleting the final administrator." }); + + var result = await deletions.RequestAsync(ownerUserId, ownerUserId, cancellationToken); + if (result is null) return StatusCode(StatusCodes.Status503ServiceUnavailable); + ExpireCookies(); + return Accepted(new { requestId = result.RequestId, status = result.Status, stage = result.Stage }); + } + + [HttpGet("admin/requests/{id:guid}")] + [Authorize(Roles = "Admin")] + public async Task AdminStatus(Guid id, CancellationToken cancellationToken) + { + var request = await db.AccountDeletionRequests.AsNoTracking().FirstOrDefaultAsync(item => item.Id == id, cancellationToken); + return request is null ? NotFound() : Ok(ToDto(request)); + } + + private async Task IsRecentlyAuthenticated(string ownerUserId, CancellationToken cancellationToken) + { + var sid = User.FindFirstValue("sid"); + if (string.IsNullOrWhiteSpace(sid)) return false; + var session = await db.UserSessions.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(item => item.Id == sid && item.UserId == ownerUserId, cancellationToken); + return session is { RevokedAtUtc: null } && session.CreatedAtUtc >= timeProvider.GetUtcNow().AddMinutes(-15); + } + + private async Task IsFinalAdministrator(string ownerUserId, CancellationToken cancellationToken) + { + var adminRoleId = await db.Roles.Where(item => item.NormalizedName == "ADMIN").Select(item => item.Id).FirstOrDefaultAsync(cancellationToken); + if (adminRoleId is null) return false; + var isAdmin = await db.UserRoles.AnyAsync(item => item.UserId == ownerUserId && item.RoleId == adminRoleId, cancellationToken); + return isAdmin && await db.UserRoles.CountAsync(item => item.RoleId == adminRoleId, cancellationToken) <= 1; + } + + private void ExpireCookies() + { + Response.Cookies.Delete(AuthSessionOptions.SessionCookieName, AuthSessionOptions.BuildExpiredCookie(Request.IsHttps)); + Response.Cookies.Delete(AuthSessionOptions.CsrfCookieName, AuthSessionOptions.BuildExpiredReadableCookie(Request.IsHttps)); + TrustedDeviceService.ClearCookie(Response, Request.IsHttps); + } + + private static object ToDto(AccountDeletionRequest request) => new + { + request.Id, + request.Status, + request.Stage, + request.AttemptCount, + request.DatabaseRowCount, + request.FileCount, + request.WarningJson, + request.LastErrorCategory, + request.LastErrorMessage, + request.RequestedAtUtc, + request.StartedAtUtc, + request.CompletedAtUtc, + }; +} diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index f56e47b..0a6a155 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -1087,6 +1087,8 @@ public sealed class AuthController : ControllerBase // decorative: skipping straight to AppSessionIssuer here would defeat the whole feature. private async Task CompleteSignInAsync(ApplicationUser user, bool rememberMe, string provider, CancellationToken cancellationToken) { + if (user.DeletionStatus != AccountDeletionStatuses.Active) + return StatusCode(StatusCodes.Status403Forbidden, new { error = "account_deletion_pending" }); // "Trust this device" cookie check happens BEFORE the 2FA gate: if it matches a // non-expired row for this exact user, skip straight to a real session, same as if 2FA // weren't required at all. Falls through to the normal gate for any other outcome diff --git a/JobTrackerApi/Controllers/TwoFactorController.cs b/JobTrackerApi/Controllers/TwoFactorController.cs index 4b6c525..aad9613 100644 --- a/JobTrackerApi/Controllers/TwoFactorController.cs +++ b/JobTrackerApi/Controllers/TwoFactorController.cs @@ -196,7 +196,7 @@ public sealed class TwoFactorController : ControllerBase if (session is null) return Unauthorized(); var user = await _users.FindByIdAsync(session.UserId); - if (user is null || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted)) + if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active || !user.TwoFactorEnabled || string.IsNullOrWhiteSpace(user.TotpSecretEncrypted)) { return Unauthorized(); } diff --git a/JobTrackerApi/Controllers/UsersController.cs b/JobTrackerApi/Controllers/UsersController.cs index e829605..7561752 100644 --- a/JobTrackerApi/Controllers/UsersController.cs +++ b/JobTrackerApi/Controllers/UsersController.cs @@ -19,13 +19,15 @@ public sealed class UsersController : ControllerBase private readonly IAppEmailSender _email; private readonly ExternalOrigin _externalOrigin; private readonly ILogger _logger; - public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg, ILogger logger, ExternalOrigin? externalOrigin = null) + private readonly AccountDeletionService? _deletions; + public UsersController(UserManager users, RoleManager roles, IAppEmailSender email, IConfiguration cfg, ILogger logger, ExternalOrigin? externalOrigin = null, AccountDeletionService? deletions = null) { _users = users; _roles = roles; _email = email; _externalOrigin = externalOrigin ?? ExternalOrigin.FromConfiguration(cfg); _logger = logger; + _deletions = deletions; } public sealed record UserDto( @@ -163,11 +165,16 @@ public sealed class UsersController : ControllerBase }); } - var res = await _users.DeleteAsync(u); - if (!res.Succeeded) - return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); - - return NoContent(); + if (_deletions is null || !_deletions.CanAcceptRequests) + return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable", detail: "Account deletion remains disabled until retention and restore safeguards are approved."); + if (!string.Equals(Request.Headers["X-Confirm-Account-Deletion"].ToString(), u.Email, StringComparison.OrdinalIgnoreCase)) + return BadRequest("Confirm the exact account email before deletion."); + var requestedBy = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); + if (string.IsNullOrWhiteSpace(requestedBy)) return Unauthorized(); + var request = await _deletions.RequestAsync(u.Id, requestedBy, cancellationToken); + return request is null + ? Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Account deletion unavailable") + : Accepted(new { requestId = request.RequestId, status = request.Status, stage = request.Stage }); } [HttpPost("{id}/send-password-reset")] diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index 3603ab0..1c1529b 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -63,6 +63,8 @@ namespace JobTrackerApi.Data public DbSet UserNotifications => Set(); public DbSet EmailSendAttempts => Set(); public DbSet EmailDrafts => Set(); + public DbSet AccountDeletionRequests => Set(); + public DbSet AccountDeletionFiles => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -80,6 +82,32 @@ namespace JobTrackerApi.Data .Property(x => x.MicrosoftObjectId) .HasMaxLength(36); + modelBuilder.Entity() + .Property(x => x.DeletionStatus) + .HasMaxLength(32) + .HasDefaultValue(AccountDeletionStatuses.Active); + + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.OwnerKey).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.RequestedByUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.Stage).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.LastErrorCategory).HasMaxLength(64); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.Status }); + modelBuilder.Entity() + .HasIndex(x => new { x.Status, x.RequestedAtUtc }); + modelBuilder.Entity().Property(x => x.Category).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.Status).HasMaxLength(32); + modelBuilder.Entity().Property(x => x.Sha256).HasMaxLength(64); + modelBuilder.Entity() + .HasIndex(x => new { x.AccountDeletionRequestId, x.Status }); + modelBuilder.Entity() + .HasOne(x => x.Request) + .WithMany(x => x.Files) + .HasForeignKey(x => x.AccountDeletionRequestId) + .OnDelete(DeleteBehavior.Cascade); + // Both supported databases allow multiple NULL values in a unique index, so legacy // rows remain unassigned while each proven tenant/object pair has exactly one owner. modelBuilder.Entity() diff --git a/JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.Designer.cs b/JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.Designer.cs new file mode 100644 index 0000000..4fff4e6 --- /dev/null +++ b/JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.Designer.cs @@ -0,0 +1,2842 @@ +// +using System; +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + [DbContext(typeof(JobTrackerContext))] + [Migration("20260815164027_AddAccountDeletionLifecycle")] + partial class AddAccountDeletionLifecycle + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountDeletionRequestId") + .HasColumnType("TEXT"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("QuarantinePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountDeletionRequestId", "Status"); + + b.ToTable("AccountDeletionFiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DatabaseRowCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("LastErrorCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("OwnerKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("WarningJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Status"); + + b.HasIndex("Status", "RequestedAtUtc"); + + b.ToTable("AccountDeletionRequests"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("EstimatedTokenCount") + .HasColumnType("INTEGER"); + + b.Property("InputCharacterCount") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Mode") + .HasColumnType("TEXT"); + + b.Property("Module") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OutputCharacterCount") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Module", "CreatedAtUtc"); + + b.ToTable("AiInteractions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NoteType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ResultJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "NoteType") + .IsUnique(); + + b.ToTable("AiWorkspaceNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AutoSignal") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CompletedAt") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("IsAutoCompleted") + .HasColumnType("INTEGER"); + + b.Property("IsSystemGenerated") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Section") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SystemKey") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId", "SystemKey") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("ApplicationChecklistItems"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationUser", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("AiEnabled") + .HasColumnType("INTEGER"); + + b.Property("AvatarImageDataUrl") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CurrentCvExtractionRunId") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CurrentCvUploadArtifactId") + .HasColumnType("INTEGER"); + + b.Property("DeletionRequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeletionStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("active"); + + b.Property("DisplayName") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ExternalAiProcessingAllowed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("GoogleEmail") + .HasColumnType("TEXT"); + + b.Property("GoogleLinkedAt") + .HasColumnType("TEXT"); + + b.Property("GoogleSubject") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("MicrosoftEmail") + .HasColumnType("TEXT"); + + b.Property("MicrosoftLinkedAt") + .HasColumnType("TEXT"); + + b.Property("MicrosoftObjectId") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("MicrosoftSubject") + .HasColumnType("TEXT"); + + b.Property("MicrosoftTenantId") + .HasMaxLength(36) + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PendingEmail") + .HasMaxLength(320) + .HasColumnType("TEXT"); + + b.Property("PendingEmailRequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("ProfileCvStructureJson") + .HasColumnType("TEXT"); + + b.Property("ProfileCvText") + .HasColumnType("TEXT"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("StripeCustomerId") + .HasColumnType("TEXT"); + + b.Property("StripeLastEventCreatedUtc") + .HasColumnType("TEXT"); + + b.Property("StripeSubscriptionId") + .HasColumnType("TEXT"); + + b.Property("StripeSubscriptionStatus") + .HasColumnType("TEXT"); + + b.Property("TotpEnabledAtUtc") + .HasColumnType("TEXT"); + + b.Property("TotpPendingSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TotpSecretEncrypted") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("MicrosoftTenantId", "MicrosoftObjectId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("FileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FilePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FileSize") + .HasColumnType("INTEGER"); + + b.Property("FileType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Purpose") + .HasColumnType("TEXT"); + + b.Property("UploadDate") + .HasColumnType("TEXT"); + + b.Property("UseForAi") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("DateNormalized") + .HasColumnType("TEXT"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Issuer") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerCertifications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("DetailsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("Institution") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Qualification") + .HasColumnType("TEXT"); + + b.Property("QualificationLevel") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerEducations"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Company") + .HasColumnType("TEXT"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("IsCurrent") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.Property("Title") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerExperiences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Level") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerLanguages"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LongTailJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId") + .IsUnique(); + + b.ToTable("CareerProfiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfileJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "Version"); + + b.ToTable("CareerProfileVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BulletsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("End") + .HasColumnType("TEXT"); + + b.Property("EndDate") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("LinksJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("TEXT"); + + b.Property("SkillsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Start") + .HasColumnType("TEXT"); + + b.Property("StartDate") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerProjects"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CareerProfileId") + .HasColumnType("INTEGER"); + + b.Property("Category") + .HasColumnType("TEXT"); + + b.Property("ItemKey") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Proficiency") + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CareerProfileId"); + + b.HasIndex("OwnerUserId", "CareerProfileId", "SortOrder"); + + b.ToTable("CareerSkills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("LastContactedAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NextContactAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("PipelineStage") + .HasColumnType("TEXT"); + + b.Property("RecruiterEmail") + .HasColumnType("TEXT"); + + b.Property("RecruiterLinkedIn") + .HasColumnType("TEXT"); + + b.Property("RecruiterName") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Companies"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentMetadataJson") + .HasColumnType("TEXT"); + + b.Property("Channel") + .HasColumnType("TEXT"); + + b.Property("Content") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Date") + .HasColumnType("TEXT"); + + b.Property("Direction") + .HasColumnType("TEXT"); + + b.Property("ExternalFrom") + .HasColumnType("TEXT"); + + b.Property("ExternalLabelsJson") + .HasColumnType("TEXT"); + + b.Property("ExternalMessageId") + .HasColumnType("TEXT"); + + b.Property("ExternalThreadId") + .HasColumnType("TEXT"); + + b.Property("ExternalTo") + .HasColumnType("TEXT"); + + b.Property("From") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Provider") + .HasColumnType("TEXT"); + + b.Property("Subject") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("Correspondences"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CoverLetterVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AiAction") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Text") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "Version"); + + b.ToTable("CoverLetterVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ArtifactId") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("ErrorMessage") + .HasColumnType("TEXT"); + + b.Property("LlmPromptVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("NormalizedText") + .HasColumnType("TEXT"); + + b.Property("NormalizerVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParserVersion") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RawExtractedText") + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StructuredProfileJson") + .HasColumnType("TEXT"); + + b.Property("Trigger") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("ArtifactId"); + + b.HasIndex("OwnerUserId", "StartedAtUtc"); + + b.ToTable("CvExtractionRuns"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvUploadArtifact", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("MimeType") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OriginalFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoragePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("StoredFileName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UploadedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "UploadedAtUtc"); + + b.ToTable("CvUploadArtifacts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPublic") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PublicSlug") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("PublicSlug") + .IsUnique(); + + b.HasIndex("OwnerUserId", "UpdatedAtUtc"); + + b.ToTable("CvVariants"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CvVariantId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SettingsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Source") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Version") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CvVariantId", "Version"); + + b.ToTable("CvVariantVersions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.EmailDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("BodyText") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ClientRequestId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Revision") + .HasColumnType("INTEGER"); + + b.Property("Subject") + .IsRequired() + .HasMaxLength(998) + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("To") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "UpdatedAtUtc"); + + b.ToTable("EmailDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ClientRequestId") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("FailureCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PayloadHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProviderMessageId") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "ClientRequestId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "CreatedAtUtc"); + + b.ToTable("EmailSendAttempts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GmailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "GmailAddress") + .IsUnique(); + + b.ToTable("GmailConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.GmailReviewDecision", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Decision") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ThreadId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UpdatedAt") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("GmailReviewDecisions"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ImapConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedPassword") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("UseSsl") + .HasColumnType("INTEGER"); + + b.Property("Username") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("ImapConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("Content") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("IsPrepared") + .HasColumnType("INTEGER"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("SortOrder") + .HasColumnType("INTEGER"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(16) + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("TEXT"); + + b.Property("UpdatedAtUtc") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId", "SortOrder"); + + b.ToTable("InterviewPrepItems"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AttachmentContextSignature") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("GeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LikelyQuestionsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("TalkingPointsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("WeakSpotsJson") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("InterviewPrepNotes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CountryCode") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Source") + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CompanyId") + .HasColumnType("INTEGER"); + + b.Property("CoverLetterText") + .HasColumnType("TEXT"); + + b.Property("DateApplied") + .HasColumnType("TEXT"); + + b.Property("Deadline") + .HasColumnType("TEXT"); + + b.Property("DeletedAt") + .HasColumnType("TEXT"); + + b.Property("Description") + .HasColumnType("TEXT"); + + b.Property("DescriptionLanguage") + .HasColumnType("TEXT"); + + b.Property("FeedbackRequestedAt") + .HasColumnType("TEXT"); + + b.Property("FollowUpAt") + .HasColumnType("TEXT"); + + b.Property("HasCoverLetter") + .HasColumnType("INTEGER"); + + b.Property("HasOtherAttachment") + .HasColumnType("INTEGER"); + + b.Property("HasPortfolio") + .HasColumnType("INTEGER"); + + b.Property("HasResume") + .HasColumnType("INTEGER"); + + b.Property("IsDeleted") + .HasColumnType("INTEGER"); + + b.Property("JobId") + .HasColumnType("INTEGER"); + + b.Property("JobTitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("JobUrl") + .HasColumnType("TEXT"); + + b.Property("LastReminderEmailSentAt") + .HasColumnType("TEXT"); + + b.Property("Location") + .HasColumnType("TEXT"); + + b.Property("NextAction") + .HasColumnType("TEXT"); + + b.Property("Notes") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("RecruiterMessageDraft") + .HasColumnType("TEXT"); + + b.Property("ResponseDate") + .HasColumnType("TEXT"); + + b.Property("ResponseReceived") + .HasColumnType("INTEGER"); + + b.Property("Salary") + .HasColumnType("TEXT"); + + b.Property("SalaryCurrency") + .HasColumnType("TEXT"); + + b.Property("SalaryMax") + .HasColumnType("TEXT"); + + b.Property("SalaryMin") + .HasColumnType("TEXT"); + + b.Property("SalaryPeriod") + .HasColumnType("TEXT"); + + b.Property("SavedAt") + .HasColumnType("TEXT"); + + b.Property("ShortSummary") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Tags") + .HasColumnType("TEXT"); + + b.Property("TailoredCvText") + .HasColumnType("TEXT"); + + b.Property("TailoredCvUpdatedAt") + .HasColumnType("TEXT"); + + b.Property("TranslatedDescription") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("JobId"); + + b.HasIndex("OwnerUserId"); + + b.HasIndex("OwnerUserId", "FollowUpAt"); + + b.HasIndex("OwnerUserId", "IsDeleted"); + + b.HasIndex("OwnerUserId", "IsDeleted", "Status"); + + b.ToTable("JobApplications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("At") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("NewValue") + .HasColumnType("TEXT"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("OldValue") + .HasColumnType("TEXT"); + + b.Property("Type") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId"); + + b.ToTable("JobEvents"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.MicrosoftGraphConnection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessTokenExpiresAt") + .HasColumnType("TEXT"); + + b.Property("ConnectedAt") + .HasColumnType("TEXT"); + + b.Property("EncryptedAccessToken") + .HasColumnType("TEXT"); + + b.Property("EncryptedRefreshToken") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("LastSyncAttemptedAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncError") + .HasColumnType("TEXT"); + + b.Property("LastSyncMode") + .HasColumnType("TEXT"); + + b.Property("LastSyncSource") + .HasColumnType("TEXT"); + + b.Property("LastSyncStatus") + .HasColumnType("TEXT"); + + b.Property("LastSyncSucceededAt") + .HasColumnType("TEXT"); + + b.Property("LastSyncedAt") + .HasColumnType("TEXT"); + + b.Property("MailAddress") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Scope") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("MicrosoftGraphConnections"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.RuleSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("RuleSettings"); + + b.HasData( + new + { + Id = 1, + AppliedFollowUpDays = 14, + AppliedGhostDays = 30, + FeedbackFollowUpDays = 7, + FeedbackGhostDays = 14, + OfferFollowUpDays = 7, + OfferGhostDays = 14 + }); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SystemEmailSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("From") + .HasColumnType("TEXT"); + + b.Property("FromName") + .HasColumnType("TEXT"); + + b.Property("SmtpEnableSsl") + .HasColumnType("INTEGER"); + + b.Property("SmtpHost") + .HasColumnType("TEXT"); + + b.Property("SmtpPassword") + .HasColumnType("TEXT"); + + b.Property("SmtpPort") + .HasColumnType("INTEGER"); + + b.Property("SmtpTimeoutMs") + .HasColumnType("INTEGER"); + + b.Property("SmtpUser") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("SystemEmailSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CanonicalProfileVersion") + .HasColumnType("INTEGER"); + + b.Property("CustomSectionsJson") + .HasColumnType("TEXT"); + + b.Property("EducationJson") + .HasColumnType("TEXT"); + + b.Property("ExperienceJson") + .HasColumnType("TEXT"); + + b.Property("GenerationContextHash") + .HasColumnType("TEXT"); + + b.Property("Headline") + .HasColumnType("TEXT"); + + b.Property("JobApplicationId") + .HasColumnType("INTEGER"); + + b.Property("LastEditedAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastGeneratedAtUtc") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RenderOptionsJson") + .HasColumnType("TEXT"); + + b.Property("SelectedSkillsJson") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("SummaryJson") + .HasColumnType("TEXT"); + + b.Property("TemplateId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("JobApplicationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "JobApplicationId") + .IsUnique(); + + b.ToTable("TailoredCvDrafts"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TrustedDevice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("TokenHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash"); + + b.HasIndex("UserId"); + + b.ToTable("TrustedDevices"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TwoFactorRecoveryCode", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("CodeHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UsedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "UsedAtUtc"); + + b.ToTable("TwoFactorRecoveryCodes"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserNotification", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DismissedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Kind") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LinkPath") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("Message") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("OperationId") + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("ReadAtUtc") + .HasColumnType("TEXT"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(160) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OperationId") + .IsUnique(); + + b.HasIndex("OwnerUserId", "DismissedAtUtc", "ReadAtUtc", "CreatedAtUtc"); + + b.ToTable("UserNotifications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserOperation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("AvailableAtUtc") + .HasColumnType("TEXT"); + + b.Property("CancellationRequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeadlineAtUtc") + .HasColumnType("TEXT"); + + b.Property("EntitlementDecision") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("FailureCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("FailureMessage") + .HasMaxLength(512) + .HasColumnType("TEXT"); + + b.Property("IdempotencyKey") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("LastHeartbeatAtUtc") + .HasColumnType("TEXT"); + + b.Property("LeaseExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LeaseToken") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("MaxAttempts") + .HasColumnType("INTEGER"); + + b.Property("Model") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("PrivacyPolicy") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("ProgressPercent") + .HasColumnType("INTEGER"); + + b.Property("ProgressStage") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Provider") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("ResultReference") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("SubjectId") + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.Property("SubjectType") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("TaskType") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "TaskType", "IdempotencyKey") + .IsUnique(); + + b.HasIndex("Status", "AvailableAtUtc", "Priority"); + + b.ToTable("UserOperations"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserRuleSettings", b => + { + b.Property("OwnerUserId") + .HasColumnType("TEXT"); + + b.Property("AppliedFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("AppliedGhostDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("FeedbackGhostDays") + .HasColumnType("INTEGER"); + + b.Property("OfferFollowUpDays") + .HasColumnType("INTEGER"); + + b.Property("OfferGhostDays") + .HasColumnType("INTEGER"); + + b.HasKey("OwnerUserId"); + + b.ToTable("UserRuleSettings"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserSession", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeviceLabel") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("TEXT"); + + b.Property("LastSeenAtUtc") + .HasColumnType("TEXT"); + + b.Property("RevokedAtUtc") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("TEXT"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b => + { + b.HasOne("JobTrackerApi.Models.AccountDeletionRequest", "Request") + .WithMany("Files") + .HasForeignKey("AccountDeletionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Request"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AiWorkspaceNote", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.ApplicationChecklistItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Attachment", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Attachments") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerCertification", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Certifications") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerEducation", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Education") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerExperience", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Experiences") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerLanguage", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Languages") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfileVersion", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany() + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProject", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Projects") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerSkill", b => + { + b.HasOne("JobTrackerApi.Models.CareerProfile", "CareerProfile") + .WithMany("Skills") + .HasForeignKey("CareerProfileId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CareerProfile"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Messages") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CoverLetterVersion", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => + { + b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") + .WithMany() + .HasForeignKey("ArtifactId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Artifact"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariant", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CvVariantVersion", b => + { + b.HasOne("JobTrackerApi.Models.CvVariant", "CvVariant") + .WithMany() + .HasForeignKey("CvVariantId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CvVariant"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.EmailDraft", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.EmailSendAttempt", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepItem", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.InterviewPrepNote", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany() + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.HasOne("JobTrackerApi.Models.Company", "Company") + .WithMany("Jobs") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.Job", "Job") + .WithMany("Applications") + .HasForeignKey("JobId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Company"); + + b.Navigation("Job"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobEvent", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany("Events") + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.TailoredCvDraft", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithOne("TailoredCvDraft") + .HasForeignKey("JobTrackerApi.Models.TailoredCvDraft", "JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.UserNotification", b => + { + b.HasOne("JobTrackerApi.Models.UserOperation", "Operation") + .WithOne() + .HasForeignKey("JobTrackerApi.Models.UserNotification", "OperationId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Operation"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("JobTrackerApi.Models.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b => + { + b.Navigation("Files"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => + { + b.Navigation("Certifications"); + + b.Navigation("Education"); + + b.Navigation("Experiences"); + + b.Navigation("Languages"); + + b.Navigation("Projects"); + + b.Navigation("Skills"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Company", b => + { + b.Navigation("Jobs"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.Job", b => + { + b.Navigation("Applications"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.JobApplication", b => + { + b.Navigation("Attachments"); + + b.Navigation("Events"); + + b.Navigation("Messages"); + + b.Navigation("TailoredCvDraft"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.cs b/JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.cs new file mode 100644 index 0000000..56750b3 --- /dev/null +++ b/JobTrackerApi/Migrations/20260815164027_AddAccountDeletionLifecycle.cs @@ -0,0 +1,156 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations +{ + /// + public partial class AddAccountDeletionLifecycle : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + if (ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase)) + { + migrationBuilder.Sql(""" + ALTER TABLE `AspNetUsers` + ADD COLUMN `DeletionRequestedAtUtc` datetime(6) NULL, + ADD COLUMN `DeletionStatus` varchar(32) NOT NULL DEFAULT 'active'; + + CREATE TABLE `AccountDeletionRequests` ( + `Id` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `OwnerUserId` varchar(255) NOT NULL, + `OwnerKey` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `RequestedByUserId` varchar(255) NOT NULL, + `Status` varchar(32) NOT NULL, + `Stage` varchar(32) NOT NULL, + `AttemptCount` int NOT NULL, + `DatabaseRowCount` int NOT NULL, + `FileCount` int NOT NULL, + `WarningJson` longtext NULL, + `LastErrorCategory` varchar(64) NULL, + `LastErrorMessage` varchar(1024) NULL, + `RequestedAtUtc` datetime(6) NOT NULL, + `StartedAtUtc` datetime(6) NULL, + `CompletedAtUtc` datetime(6) NULL, + CONSTRAINT `PK_AccountDeletionRequests` PRIMARY KEY (`Id`) + ) CHARACTER SET=utf8mb4; + + CREATE TABLE `AccountDeletionFiles` ( + `Id` bigint NOT NULL AUTO_INCREMENT, + `AccountDeletionRequestId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + `Category` varchar(64) NOT NULL, + `OriginalPath` longtext NOT NULL, + `QuarantinePath` longtext NOT NULL, + `Status` varchar(32) NOT NULL, + `ByteSize` bigint NOT NULL, + `Sha256` char(64) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL, + CONSTRAINT `PK_AccountDeletionFiles` PRIMARY KEY (`Id`), + CONSTRAINT `FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId` + FOREIGN KEY (`AccountDeletionRequestId`) REFERENCES `AccountDeletionRequests` (`Id`) ON DELETE CASCADE + ) CHARACTER SET=utf8mb4; + """); + } + else + { + migrationBuilder.AddColumn( + name: "DeletionRequestedAtUtc", + table: "AspNetUsers", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "DeletionStatus", + table: "AspNetUsers", + type: "TEXT", + maxLength: 32, + nullable: false, + defaultValue: "active"); + + migrationBuilder.CreateTable( + name: "AccountDeletionRequests", + columns: table => new + { + Id = table.Column(type: "TEXT", nullable: false), + OwnerUserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + OwnerKey = table.Column(type: "TEXT", maxLength: 64, nullable: false), + RequestedByUserId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + Stage = table.Column(type: "TEXT", maxLength: 32, nullable: false), + AttemptCount = table.Column(type: "INTEGER", nullable: false), + DatabaseRowCount = table.Column(type: "INTEGER", nullable: false), + FileCount = table.Column(type: "INTEGER", nullable: false), + WarningJson = table.Column(type: "TEXT", nullable: true), + LastErrorCategory = table.Column(type: "TEXT", maxLength: 64, nullable: true), + LastErrorMessage = table.Column(type: "TEXT", nullable: true), + RequestedAtUtc = table.Column(type: "TEXT", nullable: false), + StartedAtUtc = table.Column(type: "TEXT", nullable: true), + CompletedAtUtc = table.Column(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountDeletionRequests", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AccountDeletionFiles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AccountDeletionRequestId = table.Column(type: "TEXT", nullable: false), + Category = table.Column(type: "TEXT", maxLength: 64, nullable: false), + OriginalPath = table.Column(type: "TEXT", nullable: false), + QuarantinePath = table.Column(type: "TEXT", nullable: false), + Status = table.Column(type: "TEXT", maxLength: 32, nullable: false), + ByteSize = table.Column(type: "INTEGER", nullable: false), + Sha256 = table.Column(type: "TEXT", maxLength: 64, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AccountDeletionFiles", x => x.Id); + table.ForeignKey( + name: "FK_AccountDeletionFiles_AccountDeletionRequests_AccountDeletionRequestId", + column: x => x.AccountDeletionRequestId, + principalTable: "AccountDeletionRequests", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + } + + migrationBuilder.CreateIndex( + name: "IX_AccountDeletionFiles_AccountDeletionRequestId_Status", + table: "AccountDeletionFiles", + columns: new[] { "AccountDeletionRequestId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_AccountDeletionRequests_OwnerUserId_Status", + table: "AccountDeletionRequests", + columns: new[] { "OwnerUserId", "Status" }); + + migrationBuilder.CreateIndex( + name: "IX_AccountDeletionRequests_Status_RequestedAtUtc", + table: "AccountDeletionRequests", + columns: new[] { "Status", "RequestedAtUtc" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AccountDeletionFiles"); + + migrationBuilder.DropTable( + name: "AccountDeletionRequests"); + + migrationBuilder.DropColumn( + name: "DeletionRequestedAtUtc", + table: "AspNetUsers"); + + migrationBuilder.DropColumn( + name: "DeletionStatus", + table: "AspNetUsers"); + } + } +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index ab03c54..28b170a 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -17,6 +17,116 @@ namespace JobTrackerApi.Migrations #pragma warning disable 612, 618 modelBuilder.HasAnnotation("ProductVersion", "9.0.14"); + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountDeletionRequestId") + .HasColumnType("TEXT"); + + b.Property("ByteSize") + .HasColumnType("INTEGER"); + + b.Property("Category") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OriginalPath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("QuarantinePath") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Sha256") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("AccountDeletionRequestId", "Status"); + + b.ToTable("AccountDeletionFiles"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AttemptCount") + .HasColumnType("INTEGER"); + + b.Property("CompletedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DatabaseRowCount") + .HasColumnType("INTEGER"); + + b.Property("FileCount") + .HasColumnType("INTEGER"); + + b.Property("LastErrorCategory") + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("LastErrorMessage") + .HasColumnType("TEXT"); + + b.Property("OwnerKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("OwnerUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("RequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("RequestedByUserId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Stage") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("StartedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property("WarningJson") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId", "Status"); + + b.HasIndex("Status", "RequestedAtUtc"); + + b.ToTable("AccountDeletionRequests"); + }); + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => { b.Property("Id") @@ -209,6 +319,16 @@ namespace JobTrackerApi.Migrations b.Property("CurrentCvUploadArtifactId") .HasColumnType("INTEGER"); + b.Property("DeletionRequestedAtUtc") + .HasColumnType("TEXT"); + + b.Property("DeletionStatus") + .IsRequired() + .ValueGeneratedOnAdd() + .HasMaxLength(32) + .HasColumnType("TEXT") + .HasDefaultValue("active"); + b.Property("DisplayName") .HasColumnType("TEXT"); @@ -2332,6 +2452,17 @@ namespace JobTrackerApi.Migrations b.ToTable("AspNetUserTokens", (string)null); }); + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionFile", b => + { + b.HasOne("JobTrackerApi.Models.AccountDeletionRequest", "Request") + .WithMany("Files") + .HasForeignKey("AccountDeletionRequestId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Request"); + }); + modelBuilder.Entity("JobTrackerApi.Models.AiInteraction", b => { b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") @@ -2662,6 +2793,11 @@ namespace JobTrackerApi.Migrations .IsRequired(); }); + modelBuilder.Entity("JobTrackerApi.Models.AccountDeletionRequest", b => + { + b.Navigation("Files"); + }); + modelBuilder.Entity("JobTrackerApi.Models.CareerProfile", b => { b.Navigation("Certifications"); diff --git a/JobTrackerApi/Models/AccountDeletion.cs b/JobTrackerApi/Models/AccountDeletion.cs new file mode 100644 index 0000000..66439eb --- /dev/null +++ b/JobTrackerApi/Models/AccountDeletion.cs @@ -0,0 +1,59 @@ +namespace JobTrackerApi.Models; + +public static class AccountDeletionStatuses +{ + public const string Active = "active"; + public const string Pending = "pending"; + public const string Completed = "completed"; +} + +public static class AccountDeletionRequestStatuses +{ + public const string Pending = "pending"; + public const string Processing = "processing"; + public const string RetryRequired = "retry_required"; + public const string Completed = "completed"; +} + +public static class AccountDeletionStages +{ + public const string Requested = "requested"; + public const string QuarantiningFiles = "quarantining_files"; + public const string DeletingDatabase = "deleting_database"; + public const string PurgingFiles = "purging_files"; + public const string RecordingTombstone = "recording_tombstone"; + public const string Completed = "completed"; +} + +public sealed class AccountDeletionRequest +{ + public Guid Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public string OwnerKey { get; set; } = string.Empty; + public string RequestedByUserId { get; set; } = string.Empty; + public string Status { get; set; } = AccountDeletionRequestStatuses.Pending; + public string Stage { get; set; } = AccountDeletionStages.Requested; + public int AttemptCount { get; set; } + public int DatabaseRowCount { get; set; } + public int FileCount { get; set; } + public string? WarningJson { get; set; } + public string? LastErrorCategory { get; set; } + public string? LastErrorMessage { get; set; } + public DateTimeOffset RequestedAtUtc { get; set; } + public DateTimeOffset? StartedAtUtc { get; set; } + public DateTimeOffset? CompletedAtUtc { get; set; } + public List Files { get; set; } = new(); +} + +public sealed class AccountDeletionFile +{ + public long Id { get; set; } + public Guid AccountDeletionRequestId { get; set; } + public AccountDeletionRequest Request { get; set; } = null!; + public string Category { get; set; } = string.Empty; + public string OriginalPath { get; set; } = string.Empty; + public string QuarantinePath { get; set; } = string.Empty; + public string Status { get; set; } = "planned"; + public long ByteSize { get; set; } + public string Sha256 { get; set; } = string.Empty; +} diff --git a/JobTrackerApi/Models/ApplicationUser.cs b/JobTrackerApi/Models/ApplicationUser.cs index 7fd1218..72f1c94 100644 --- a/JobTrackerApi/Models/ApplicationUser.cs +++ b/JobTrackerApi/Models/ApplicationUser.cs @@ -32,4 +32,6 @@ public sealed class ApplicationUser : IdentityUser public DateTime? StripeLastEventCreatedUtc { get; set; } public bool AiEnabled { get; set; } = true; public bool ExternalAiProcessingAllowed { get; set; } + public string DeletionStatus { get; set; } = AccountDeletionStatuses.Active; + public DateTimeOffset? DeletionRequestedAtUtc { get; set; } } diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 1024ac9..112cbfe 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -46,7 +46,10 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); @@ -170,6 +173,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); +builder.Services.AddHostedService(); builder.Services.AddHttpClient("jobimport") .ConfigurePrimaryHttpMessageHandler(() => new HttpClientHandler @@ -552,6 +556,12 @@ app.Use(async (ctx, next) => await app.InitializeJobTrackerAsync(); +await using (var deletionReplayScope = app.Services.CreateAsyncScope()) +{ + var staged = await deletionReplayScope.ServiceProvider.GetRequiredService().StageRestoredAccountsAsync(CancellationToken.None); + if (staged > 0) app.Logger.LogWarning("Staged {Count} restored deleted accounts for mandatory tombstone replay.", staged); +} + await using (var attachmentScope = app.Services.CreateAsyncScope()) { var result = await attachmentScope.ServiceProvider.GetRequiredService() diff --git a/JobTrackerApi/Services/AccountDataExportService.cs b/JobTrackerApi/Services/AccountDataExportService.cs index 680c28e..f613711 100644 --- a/JobTrackerApi/Services/AccountDataExportService.cs +++ b/JobTrackerApi/Services/AccountDataExportService.cs @@ -14,7 +14,7 @@ public sealed record AccountDataExportArtifact(string StoragePath, string Downlo public sealed class AccountDataExportService( JobTrackerContext db, AppPaths paths, - IAttachmentStorage attachmentStorage, + AccountOwnedFileInventory fileInventory, TimeProvider timeProvider) { private const string SchemaVersion = "jobtracker.user-export.v1"; @@ -32,8 +32,7 @@ public sealed class AccountDataExportService( var user = await db.Users.AsNoTracking().FirstOrDefaultAsync(item => item.Id == ownerUserId, cancellationToken) ?? throw new InvalidOperationException("The account no longer exists."); var generatedAt = timeProvider.GetUtcNow(); - var ownerKey = AppPaths.GetOwnerStorageKey(ownerUserId); - var outputRoot = Path.Combine(paths.DataRoot, "AccountExports", ownerKey); + var outputRoot = paths.GetOwnerAccountExportsRoot(ownerUserId); Directory.CreateDirectory(outputRoot); var outputPath = Path.Combine(outputRoot, $"{Guid.NewGuid():N}.zip"); var warnings = new List(); @@ -255,18 +254,15 @@ public sealed class AccountDataExportService( TrustedDevices = trustedDevices, }, sessions.Count + trustedDevices.Count + recoveryCodeCount); - foreach (var attachment in attachments) + var ownedFiles = await fileInventory.BuildAsync(ownerUserId, cancellationToken); + warnings.AddRange(ownedFiles.Warnings); + foreach (var ownedFile in ownedFiles.Files) { - await AddOwnedFileAsync(archive, entries, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath, cancellationToken); + if (ownedFile.InlineBytes is not null) + await AddBytesAsync(archive, entries, ownedFile.ExportPath, ownedFile.InlineBytes, ownedFile.Category, 1, cancellationToken); + else + await AddFileAsync(archive, entries, ownedFile.SourcePath!, ownedFile.ExportPath, ownedFile.Category, cancellationToken); } - foreach (var artifact in artifacts) - { - await AddOwnedFileAsync(archive, entries, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", path => IsManagedPath(paths.CvArtifactsRoot, path), cancellationToken); - } - - await AddAvatarAsync(archive, entries, warnings, paths, ownerUserId, user.AvatarImageDataUrl, cancellationToken); - await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv", cancellationToken); - await AddDirectoryAsync(archive, entries, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export", cancellationToken); const string readme = """ Jobjakt readable account export @@ -317,77 +313,6 @@ public sealed class AccountDataExportService( } } - private static async Task AddOwnedFileAsync( - ZipArchive archive, - ICollection entries, - ICollection warnings, - string sourcePath, - string entryName, - string category, - Func isManaged, - CancellationToken cancellationToken) - { - if (!isManaged(sourcePath)) - { - warnings.Add($"Excluded unsafe {category} path for {entryName}."); - return; - } - if (!File.Exists(sourcePath)) - { - warnings.Add($"Owned {category} file was unavailable: {entryName}."); - return; - } - await AddFileAsync(archive, entries, sourcePath, entryName, category, cancellationToken); - } - - private static async Task AddDirectoryAsync(ZipArchive archive, ICollection entries, ICollection warnings, string root, string entryRoot, string category, CancellationToken cancellationToken) - { - if (!Directory.Exists(root)) return; - foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions - { - RecurseSubdirectories = true, - IgnoreInaccessible = true, - AttributesToSkip = FileAttributes.ReparsePoint, - })) - { - var relative = Path.GetRelativePath(root, path); - if (relative.StartsWith("..", StringComparison.Ordinal)) - { - warnings.Add($"Excluded unsafe {category} path."); - continue; - } - var entryName = $"{entryRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}"; - await AddFileAsync(archive, entries, path, entryName, category, cancellationToken); - } - } - - private static async Task AddAvatarAsync(ZipArchive archive, ICollection entries, ICollection warnings, AppPaths paths, string ownerUserId, string? storedAvatar, CancellationToken cancellationToken) - { - if (string.IsNullOrWhiteSpace(storedAvatar)) return; - if (storedAvatar.StartsWith("file:", StringComparison.Ordinal)) - { - var path = storedAvatar[5..]; - var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId)); - await AddOwnedFileAsync(archive, entries, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate), cancellationToken); - return; - } - if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) - { - var comma = storedAvatar.IndexOf(','); - if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase)) - { - try - { - var bytes = Convert.FromBase64String(storedAvatar[(comma + 1)..]); - await AddBytesAsync(archive, entries, "files/avatar/avatar", bytes, "avatar", 1, cancellationToken); - return; - } - catch (FormatException) { } - } - } - warnings.Add("The profile avatar was stored in an unsupported format and could not be included."); - } - private static async Task AddFileAsync(ZipArchive archive, ICollection entries, string sourcePath, string entryName, string category, CancellationToken cancellationToken) { await using var source = new FileStream(sourcePath, FileMode.Open, FileAccess.Read, FileShare.Read, 64 * 1024, FileOptions.Asynchronous | FileOptions.SequentialScan); @@ -407,36 +332,5 @@ public sealed class AccountDataExportService( entries.Add(new ManifestEntry(entry.FullName, bytes.LongLength, Convert.ToHexString(SHA256.HashData(bytes)).ToLowerInvariant(), category, itemCount)); } - private static bool IsManagedPath(string root, string path) - { - if (string.IsNullOrWhiteSpace(path)) return false; - try - { - var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); - var fullPath = Path.GetFullPath(path); - if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false; - var current = Path.GetDirectoryName(fullPath); - while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison)) - { - if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false; - current = Path.GetDirectoryName(current); - } - return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0; - } - catch - { - return false; - } - } - - private static string SafeSegment(string? value) - { - var candidate = Path.GetFileName(value ?? string.Empty).Trim(); - foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_'); - if (candidate.Length > 120) candidate = candidate[..120]; - return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate; - } - private sealed record ManifestEntry(string Path, long Bytes, string Sha256, string Category, int ItemCount); } diff --git a/JobTrackerApi/Services/AccountDeletionService.cs b/JobTrackerApi/Services/AccountDeletionService.cs new file mode 100644 index 0000000..7cd8f21 --- /dev/null +++ b/JobTrackerApi/Services/AccountDeletionService.cs @@ -0,0 +1,391 @@ +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, + 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); + AppendWarning(request, "The local AI sidecar cache is content-keyed and ages out under its configured TTL; production deletion remains disabled until cache purge/restart is rehearsed."); + 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); + } + } +} diff --git a/JobTrackerApi/Services/AccountDeletionTombstoneStore.cs b/JobTrackerApi/Services/AccountDeletionTombstoneStore.cs new file mode 100644 index 0000000..414af1e --- /dev/null +++ b/JobTrackerApi/Services/AccountDeletionTombstoneStore.cs @@ -0,0 +1,64 @@ +using System.Text.Json; + +namespace JobTrackerApi.Services; + +public sealed record AccountDeletionTombstone(string SchemaVersion, Guid RequestId, string OwnerKey, DateTimeOffset CompletedAtUtc); + +public sealed class AccountDeletionTombstoneStore(AppPaths paths) +{ + private const string SchemaVersion = "jobtracker.account-deletion-tombstone.v1"; + private readonly SemaphoreSlim _gate = new(1, 1); + private string LedgerPath => Path.Combine(paths.AccountDeletionTombstonesRoot, "tombstones.jsonl"); + + public async Task AppendAsync(Guid requestId, string ownerKey, DateTimeOffset completedAtUtc, CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try + { + var existing = await ReadUnsafeAsync(cancellationToken); + if (existing.Any(item => item.RequestId == requestId)) return; + Directory.CreateDirectory(paths.AccountDeletionTombstonesRoot); + var line = JsonSerializer.Serialize(new AccountDeletionTombstone(SchemaVersion, requestId, ownerKey, completedAtUtc)); + await File.AppendAllTextAsync(LedgerPath, line + Environment.NewLine, cancellationToken); + } + finally + { + _gate.Release(); + } + } + + public async Task> ReadAsync(CancellationToken cancellationToken) + { + await _gate.WaitAsync(cancellationToken); + try { return await ReadUnsafeAsync(cancellationToken); } + finally { _gate.Release(); } + } + + private async Task> ReadUnsafeAsync(CancellationToken cancellationToken) + { + if (!File.Exists(LedgerPath)) return Array.Empty(); + var result = new List(); + foreach (var line in await File.ReadAllLinesAsync(LedgerPath, cancellationToken)) + { + if (string.IsNullOrWhiteSpace(line)) continue; + try + { + var item = JsonSerializer.Deserialize(line); + if (item is null + || item.SchemaVersion != SchemaVersion + || item.RequestId == Guid.Empty + || item.OwnerKey.Length != 64 + || item.OwnerKey.Any(character => !Uri.IsHexDigit(character))) + throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record."); + result.Add(item); + } + catch (JsonException) + { + // A partial/corrupt line is never ignored by replay callers: expose a sentinel so + // startup fails closed instead of declaring the tombstone set complete. + throw new InvalidOperationException("The account-deletion tombstone ledger contains an invalid record."); + } + } + return result; + } +} diff --git a/JobTrackerApi/Services/AccountOwnedFileInventory.cs b/JobTrackerApi/Services/AccountOwnedFileInventory.cs new file mode 100644 index 0000000..831285a --- /dev/null +++ b/JobTrackerApi/Services/AccountOwnedFileInventory.cs @@ -0,0 +1,139 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public sealed record AccountOwnedFile(string Category, string ExportPath, string? SourcePath, byte[]? InlineBytes); +public sealed record AccountOwnedFileInventoryResult(IReadOnlyList Files, IReadOnlyList Warnings); + +public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths paths, IAttachmentStorage attachmentStorage) +{ + public async Task BuildAsync(string ownerUserId, CancellationToken cancellationToken, bool includeAccountExports = false) + { + ArgumentException.ThrowIfNullOrWhiteSpace(ownerUserId); + var files = new List(); + var warnings = new List(); + var applicationIds = await db.JobApplications.IgnoreQueryFilters().AsNoTracking() + .Where(item => item.OwnerUserId == ownerUserId) + .Select(item => item.Id) + .ToListAsync(cancellationToken); + var attachments = await db.Attachments.IgnoreQueryFilters().AsNoTracking() + .Where(item => applicationIds.Contains(item.JobApplicationId)) + .OrderBy(item => item.Id) + .ToListAsync(cancellationToken); + foreach (var attachment in attachments) + { + AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath); + } + + var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking() + .Where(item => item.OwnerUserId == ownerUserId) + .OrderBy(item => item.Id) + .ToListAsync(cancellationToken); + foreach (var artifact in artifacts) + { + AddPath(files, warnings, artifact.StoragePath, $"files/cv-artifacts/{artifact.Id}/{SafeSegment(artifact.OriginalFileName)}", "cv-artifact", candidate => IsManagedPath(paths.CvArtifactsRoot, candidate)); + } + + var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken); + AddAvatar(files, warnings, ownerUserId, avatar); + AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv"); + AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export"); + if (includeAccountExports) + AddDirectory(files, warnings, paths.GetOwnerAccountExportsRoot(ownerUserId), "files/account-exports", "account-export"); + return new AccountOwnedFileInventoryResult(files, warnings); + } + + private void AddAvatar(ICollection files, ICollection warnings, string ownerUserId, string? storedAvatar) + { + if (string.IsNullOrWhiteSpace(storedAvatar)) return; + if (storedAvatar.StartsWith("file:", StringComparison.Ordinal)) + { + var path = storedAvatar[5..]; + var root = Path.Combine(paths.DataRoot, "Avatars", AppPaths.GetOwnerStorageKey(ownerUserId)); + AddPath(files, warnings, path, $"files/avatar/{SafeSegment(Path.GetFileName(path))}", "avatar", candidate => IsManagedPath(root, candidate)); + return; + } + if (storedAvatar.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + { + var comma = storedAvatar.IndexOf(','); + if (comma > 0 && storedAvatar[..comma].Contains(";base64", StringComparison.OrdinalIgnoreCase)) + { + try + { + files.Add(new AccountOwnedFile("avatar", "files/avatar/avatar", null, Convert.FromBase64String(storedAvatar[(comma + 1)..]))); + return; + } + catch (FormatException) { } + } + } + warnings.Add("The profile avatar was stored in an unsupported format and could not be included."); + } + + private static void AddPath(ICollection files, ICollection warnings, string sourcePath, string exportPath, string category, Func isManaged) + { + if (!isManaged(sourcePath)) + { + warnings.Add($"Excluded unsafe {category} path for {exportPath}."); + return; + } + if (!File.Exists(sourcePath)) + { + warnings.Add($"Owned {category} file was unavailable: {exportPath}."); + return; + } + files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(sourcePath), null)); + } + + private static void AddDirectory(ICollection files, ICollection warnings, string root, string exportRoot, string category) + { + if (!Directory.Exists(root)) return; + foreach (var path in Directory.EnumerateFiles(root, "*", new EnumerationOptions + { + RecurseSubdirectories = true, + IgnoreInaccessible = true, + AttributesToSkip = FileAttributes.ReparsePoint, + })) + { + var relative = Path.GetRelativePath(root, path); + if (relative.StartsWith("..", StringComparison.Ordinal) || !IsManagedPath(root, path)) + { + warnings.Add($"Excluded unsafe {category} path."); + continue; + } + var exportPath = $"{exportRoot}/{string.Join('/', relative.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar).Select(SafeSegment))}"; + files.Add(new AccountOwnedFile(category, exportPath, Path.GetFullPath(path), null)); + } + } + + public static bool IsManagedPath(string root, string path) + { + if (string.IsNullOrWhiteSpace(path)) return false; + try + { + var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var fullRoot = Path.GetFullPath(root).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); + var fullPath = Path.GetFullPath(path); + if (!fullPath.StartsWith(fullRoot + Path.DirectorySeparatorChar, comparison)) return false; + var current = Path.GetDirectoryName(fullPath); + while (!string.IsNullOrWhiteSpace(current) && !string.Equals(current, fullRoot, comparison)) + { + if (Directory.Exists(current) && (File.GetAttributes(current) & FileAttributes.ReparsePoint) != 0) return false; + current = Path.GetDirectoryName(current); + } + return !File.Exists(fullPath) || (File.GetAttributes(fullPath) & FileAttributes.ReparsePoint) == 0; + } + catch + { + return false; + } + } + + private static string SafeSegment(string? value) + { + var candidate = Path.GetFileName(value ?? string.Empty).Trim(); + foreach (var invalid in Path.GetInvalidFileNameChars()) candidate = candidate.Replace(invalid, '_'); + if (candidate.Length > 120) candidate = candidate[..120]; + return string.IsNullOrWhiteSpace(candidate) || candidate is "." or ".." ? "file" : candidate; + } +} diff --git a/JobTrackerApi/Services/AppPaths.cs b/JobTrackerApi/Services/AppPaths.cs index 9b219c9..2c5463a 100644 --- a/JobTrackerApi/Services/AppPaths.cs +++ b/JobTrackerApi/Services/AppPaths.cs @@ -12,6 +12,7 @@ namespace JobTrackerApi.Services public string CvArtifactsRoot { get; } public string CvExportsRoot { get; } public string CvBenchmarksRoot { get; } + public string AccountDeletionTombstonesRoot { get; } public AppPaths(IConfiguration cfg, IHostEnvironment env) { @@ -49,6 +50,12 @@ namespace JobTrackerApi.Services Directory.CreateDirectory(cvBenchmarksRoot); CvBenchmarksRoot = cvBenchmarksRoot; + + var tombstonesRoot = (cfg["AccountLifecycle:TombstonesRoot"] ?? "").Trim(); + if (string.IsNullOrWhiteSpace(tombstonesRoot)) tombstonesRoot = Path.Combine(DataRoot, "DeletionTombstones"); + if (!Path.IsPathRooted(tombstonesRoot)) tombstonesRoot = Path.Combine(env.ContentRootPath, tombstonesRoot); + Directory.CreateDirectory(tombstonesRoot); + AccountDeletionTombstonesRoot = tombstonesRoot; } public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName); @@ -71,6 +78,9 @@ namespace JobTrackerApi.Services public string GetOwnerDailyExportsRoot(string? configuredFolder, string ownerUserId) => Path.Combine(GetExportsRoot(configuredFolder), GetOwnerStorageKey(ownerUserId)); + + public string GetOwnerAccountExportsRoot(string ownerUserId) => + Path.Combine(DataRoot, "AccountExports", GetOwnerStorageKey(ownerUserId)); } } diff --git a/JobTrackerApi/Services/LocalSessionValidator.cs b/JobTrackerApi/Services/LocalSessionValidator.cs index 795037f..97550b9 100644 --- a/JobTrackerApi/Services/LocalSessionValidator.cs +++ b/JobTrackerApi/Services/LocalSessionValidator.cs @@ -1,5 +1,6 @@ using System.Security.Claims; using JobTrackerApi.Data; +using JobTrackerApi.Models; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; @@ -19,7 +20,9 @@ public static class LocalSessionValidator var session = await db.UserSessions.IgnoreQueryFilters() .FirstOrDefaultAsync(x => x.Id == sid && x.UserId == userId, cancellationToken); if (session is null || session.RevokedAtUtc is not null || session.ExpiresAtUtc <= now) return false; - if (requireConfirmedEmail && !await db.Users.IgnoreQueryFilters().AnyAsync(x => x.Id == userId && x.EmailConfirmed, cancellationToken)) return false; + var user = await db.Users.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + if (user is null || user.DeletionStatus != AccountDeletionStatuses.Active) return false; + if (requireConfirmedEmail && !user.EmailConfirmed) return false; if (now - session.LastSeenAtUtc > TimeSpan.FromMinutes(5)) { diff --git a/JobTrackerApi/appsettings.json b/JobTrackerApi/appsettings.json index 54409f7..23d7bff 100644 --- a/JobTrackerApi/appsettings.json +++ b/JobTrackerApi/appsettings.json @@ -22,6 +22,9 @@ "ExternalProvider": "ollama", "RoutingMode": "local_first" }, + "AccountLifecycle": { + "DeletionEnabled": false + }, "AiQueue": { "WorkerConcurrency": 1, "GlobalCapacity": 100, diff --git a/job-tracker-ui/src/account-data-export.test.tsx b/job-tracker-ui/src/account-data-export.test.tsx index 609b1da..9abadaa 100644 --- a/job-tracker-ui/src/account-data-export.test.tsx +++ b/job-tracker-ui/src/account-data-export.test.tsx @@ -1,11 +1,12 @@ import React from 'react'; import '@testing-library/jest-dom'; -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import BackupCard from './components/BackupCard'; import { I18nProvider } from './i18n/I18nProvider'; import { ToastProvider } from './toast'; import { api } from './api'; +import { PromptProvider } from './prompt'; jest.mock('./api', () => ({ api: { @@ -22,7 +23,9 @@ jest.mock('./api', () => ({ const mockedApi = api as jest.Mocked; beforeEach(() => { + mockedApi.get.mockReset(); mockedApi.post.mockReset(); + mockedApi.get.mockResolvedValue({ data: { deletionEnabled: false, deletionStatus: 'active', requiredConfirmation: 'DELETE owner@example.test', request: null } } as any); Object.defineProperty(URL, 'createObjectURL', { configurable: true, value: jest.fn(() => 'blob:account-export') }); Object.defineProperty(URL, 'revokeObjectURL', { configurable: true, value: jest.fn() }); jest.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined); @@ -35,7 +38,7 @@ test('downloads the readable account ZIP from the protected endpoint', async () data: new Blob(['zip']), headers: { 'content-disposition': 'attachment; filename="jobjakt-account-export.zip"' }, } as any); - render(); + render(); fireEvent.click(screen.getByRole('button', { name: /download readable account export/i })); @@ -46,9 +49,36 @@ test('downloads the readable account ZIP from the protected endpoint', async () test('explains the recent-sign-in requirement without weakening it', async () => { mockedApi.post.mockRejectedValue({ response: { status: 403, data: { detail: 'Sign in again before downloading a complete account export.' } } }); - render(); + render(); expect(screen.getByText(/requires a sign-in from the last 15 minutes/i)).toBeInTheDocument(); fireEvent.click(screen.getByRole('button', { name: /download readable account export/i })); expect(await screen.findByText(/sign in again before downloading/i)).toBeInTheDocument(); }); + +test('keeps account deletion hidden behind the server flag', async () => { + render(); + + expect(await screen.findByText(/account deletion is not enabled yet/i)).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /delete my account/i })).not.toBeInTheDocument(); +}); + +test('requires the exact server-provided deletion phrase', async () => { + mockedApi.get.mockResolvedValue({ data: { deletionEnabled: true, deletionStatus: 'active', requiredConfirmation: 'DELETE owner@example.test', request: null } } as any); + mockedApi.post.mockRejectedValue({ response: { status: 503, data: { detail: 'Safeguard rehearsal is incomplete.' } } }); + render(); + + fireEvent.click(await screen.findByRole('button', { name: /delete my account/i })); + let dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'DELETE somebody-else@example.test' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /delete my account/i })); + expect(await screen.findByText(/confirmation did not match/i)).toBeInTheDocument(); + expect(mockedApi.post).not.toHaveBeenCalledWith('/account-lifecycle/delete', expect.anything()); + await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument()); + + fireEvent.click(screen.getByRole('button', { name: /delete my account/i })); + dialog = await screen.findByRole('dialog'); + fireEvent.change(within(dialog).getByRole('textbox'), { target: { value: 'DELETE owner@example.test' } }); + fireEvent.click(within(dialog).getByRole('button', { name: /delete my account/i })); + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith('/account-lifecycle/delete', { confirmation: 'DELETE owner@example.test' })); +}); diff --git a/job-tracker-ui/src/admin-users-page.test.tsx b/job-tracker-ui/src/admin-users-page.test.tsx index 3228c45..629a0a8 100644 --- a/job-tracker-ui/src/admin-users-page.test.tsx +++ b/job-tracker-ui/src/admin-users-page.test.tsx @@ -21,6 +21,7 @@ const mockedApi = api as jest.Mocked; function renderPage(users: unknown[]) { mockedApi.get.mockResolvedValue({ data: users } as any); mockedApi.put.mockResolvedValue({ data: null } as any); + mockedApi.delete.mockResolvedValue({ data: null } as any); render( @@ -82,3 +83,14 @@ test("disables demotion and deletion for the final administrator", async () => { expect(await screen.findByRole("button", { name: "Remove admin" })).toBeDisabled(); expect(screen.getByRole("button", { name: "Delete" })).toBeDisabled(); }); + +test("sends the exact account email after confirming deletion", async () => { + renderPage([{ id: "other", email: "other@example.com", userName: "other", roles: [], emailConfirmed: true, isCurrentUser: false, canRemoveAdmin: true }]); + + fireEvent.click(await screen.findByRole("button", { name: "Delete" })); + fireEvent.click(within(await screen.findByRole("dialog")).getByRole("button", { name: "Delete" })); + + await waitFor(() => expect(mockedApi.delete).toHaveBeenCalledWith("/users/other", { + headers: { "X-Confirm-Account-Deletion": "other@example.com" }, + })); +}); diff --git a/job-tracker-ui/src/components/BackupCard.tsx b/job-tracker-ui/src/components/BackupCard.tsx index 30a7780..f363a29 100644 --- a/job-tracker-ui/src/components/BackupCard.tsx +++ b/job-tracker-ui/src/components/BackupCard.tsx @@ -1,15 +1,35 @@ -import React, { useState } from "react"; +import React, { useEffect, useState } from "react"; import { Alert, Box, Button, Divider, Paper, Typography } from "@mui/material"; import { api, getApiErrorMessage } from "../api"; +import { clearAuthClientState } from "../auth"; import { useToast } from "../toast"; import { useI18n } from "../i18n/I18nProvider"; +import { usePrompt } from "../prompt"; + +type AccountLifecycleStatus = { + deletionEnabled: boolean; + deletionStatus: string; + requiredConfirmation: string; + request?: { status: string; stage: string } | null; +}; export default function BackupCard() { const { toast } = useToast(); const { t } = useI18n(); + const { prompt } = usePrompt(); const [downloading, setDownloading] = useState(false); const [exportingAccount, setExportingAccount] = useState(false); + const [deletingAccount, setDeletingAccount] = useState(false); + const [lifecycle, setLifecycle] = useState(null); + + useEffect(() => { + let active = true; + api.get("/account-lifecycle/status") + .then((response) => { if (active) setLifecycle(response.data); }) + .catch(() => { if (active) setLifecycle(null); }); + return () => { active = false; }; + }, []); const downloadBlob = (blob: Blob, contentDisposition: string | undefined, fallbackName: string) => { const url = URL.createObjectURL(blob); @@ -49,6 +69,31 @@ export default function BackupCard() { } }; + const deleteAccount = async () => { + if (!lifecycle?.deletionEnabled || lifecycle.deletionStatus !== "active") return; + const confirmation = await prompt({ + title: t("accountDeleteTitle"), + message: t("accountDeletePrompt", { confirmation: lifecycle.requiredConfirmation }), + confirmLabel: t("accountDeleteButton"), + cancelLabel: t("cancel"), + }); + if (confirmation === null) return; + if (confirmation.trim() !== lifecycle.requiredConfirmation) { + toast(t("accountDeleteConfirmationMismatch"), "error"); + return; + } + setDeletingAccount(true); + try { + const response = await api.post("/account-lifecycle/delete", { confirmation }); + setLifecycle((current) => current ? { ...current, deletionStatus: "pending", request: response.data } : current); + clearAuthClientState(); + window.location.assign("/login"); + } catch (error: any) { + toast(getApiErrorMessage(error, t("accountDeleteFailed")), "error"); + setDeletingAccount(false); + } + }; + return ( @@ -73,6 +118,20 @@ export default function BackupCard() { {downloading ? t("backupPreparing") : t("backupDownload")} + + {t("accountDeleteTitle")} + {lifecycle?.deletionStatus === "pending" ? ( + {t("accountDeletePending", { stage: lifecycle.request?.stage ?? "pending" })} + ) : lifecycle?.deletionEnabled ? ( + + {t("accountDeleteWarning")} + + + ) : ( + {t("accountDeleteDisabled")} + )} ); } diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 63805ae..d1a308b 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -821,6 +821,15 @@ export const translations = { backupDownload: "Download encrypted backup", backupDownloaded: "Backup downloaded.", backupFailed: "Backup failed.", + accountDeleteTitle: "Delete account", + accountDeleteWarning: "This permanently removes your account, job history, career data, documents, sessions, and connected-account credentials. Export your data first. You will be signed out immediately.", + accountDeletePrompt: "This cannot be undone. Type {confirmation} exactly to continue.", + accountDeleteButton: "Delete my account", + accountDeleteStarting: "Starting deletion...", + accountDeleteConfirmationMismatch: "The confirmation did not match. Your account was not changed.", + accountDeleteFailed: "Account deletion could not be started.", + accountDeletePending: "Account deletion is in progress ({stage}). Access has already been disabled.", + accountDeleteDisabled: "Account deletion is not enabled yet. The deletion workflow remains safely off until retention, restore, and production cache-purge safeguards are approved.", authStatusTitle: "Authentication", authStatusNotSignedIn: "Not signed in.", authStatusRoles: "Roles: {roles}", @@ -1981,6 +1990,15 @@ export const translations = { backupDownload: "Last ned kryptert sikkerhetskopi", backupDownloaded: "Sikkerhetskopi lastet ned.", backupFailed: "Sikkerhetskopiering mislyktes.", + accountDeleteTitle: "Slett konto", + accountDeleteWarning: "Dette fjerner kontoen, jobbhistorikken, karrieredata, dokumenter, økter og tilkoblede kontolegitimasjoner permanent. Eksporter dataene dine først. Du logges ut umiddelbart.", + accountDeletePrompt: "Dette kan ikke angres. Skriv {confirmation} nøyaktig for Ã¥ fortsette.", + accountDeleteButton: "Slett kontoen min", + accountDeleteStarting: "Starter sletting...", + accountDeleteConfirmationMismatch: "Bekreftelsen stemte ikke. Kontoen ble ikke endret.", + accountDeleteFailed: "Kontosletting kunne ikke startes.", + accountDeletePending: "Kontosletting pÃ¥gÃ¥r ({stage}). Tilgangen er allerede deaktivert.", + accountDeleteDisabled: "Kontosletting er ikke aktivert ennÃ¥. Arbeidsflyten forblir trygt avslÃ¥tt til vern for oppbevaring, gjenoppretting og tømming av produksjonsbuffer er godkjent.", authStatusTitle: "Autentisering", authStatusNotSignedIn: "Ikke logget inn.", authStatusRoles: "Roller: {roles}", diff --git a/job-tracker-ui/src/views/AdminUsersPage.tsx b/job-tracker-ui/src/views/AdminUsersPage.tsx index 9c8f78d..55598fa 100644 --- a/job-tracker-ui/src/views/AdminUsersPage.tsx +++ b/job-tracker-ui/src/views/AdminUsersPage.tsx @@ -114,7 +114,7 @@ export default function AdminUsersPage() { : t("adminUsersDeleteConfirmNamed", { name }); if (!(await confirmAction(message, { title: t("adminUsersDeleteConfirmTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return; try { - await api.delete(`/users/${u.id}`); + await api.delete(`/users/${u.id}`, { headers: { "X-Confirm-Account-Deletion": u.email || u.userName || "" } }); toast(t("adminUsersDeleted"), "info"); await load(); } catch (e) {