From 5337ba3a5e02f091685038369fd553c537322a77 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Mon, 31 Aug 2026 16:54:41 +0200 Subject: [PATCH] feat(applications): preserve submitted packages --- .../AttachmentConsistencyTests.cs | 2 + .../BackgroundWorkerTenantTests.cs | 4 +- JobTrackerApi.Tests/MigrationChainTests.cs | 6 + .../SubmittedApplicationPackageTests.cs | 156 ++++++++++++++++ .../ApplicationAssetsController.cs | 49 ++++- JobTrackerApi/Data/JobTrackerContext.cs | 29 +++ ..._AdoptSubmittedApplicationPackageSchema.cs | 74 ++++++++ .../JobTrackerContextModelSnapshot.cs | 65 +++++++ .../Models/SubmittedApplicationPackage.cs | 37 ++++ JobTrackerApi/Program.cs | 1 + .../Services/AccountDataExportService.cs | 14 +- .../Services/AccountDeletionService.cs | 3 + .../Services/AccountOwnedFileInventory.cs | 11 ++ JobTrackerApi/Services/AttachmentStorage.cs | 44 +++++ .../Services/StartupSchemaOwnership.cs | 2 + .../SubmittedApplicationPackageService.cs | 175 ++++++++++++++++++ docs/architecture/application-workspace.md | 22 ++- docs/work-programmes/master-progress.md | 7 +- .../src/application-assets.test.tsx | 16 ++ job-tracker-ui/src/applicationWorkspace.ts | 37 ++++ .../src/components/ApplicationAssets.tsx | 128 ++++++++++++- job-tracker-ui/src/i18n/translations.ts | 32 ++++ 22 files changed, 903 insertions(+), 11 deletions(-) create mode 100644 JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs create mode 100644 JobTrackerApi/Migrations/20260830135000_AdoptSubmittedApplicationPackageSchema.cs create mode 100644 JobTrackerApi/Models/SubmittedApplicationPackage.cs create mode 100644 JobTrackerApi/Services/SubmittedApplicationPackageService.cs diff --git a/JobTrackerApi.Tests/AttachmentConsistencyTests.cs b/JobTrackerApi.Tests/AttachmentConsistencyTests.cs index 76e6c58..2ec8c25 100644 --- a/JobTrackerApi.Tests/AttachmentConsistencyTests.cs +++ b/JobTrackerApi.Tests/AttachmentConsistencyTests.cs @@ -275,10 +275,12 @@ public sealed class AttachmentConsistencyTests public bool FailPromote { get; init; } public bool FailDeletePurge { get; init; } public string CreateFinalPath(int jobId, string storedFileName) => inner.CreateFinalPath(jobId, storedFileName); + public string CreatePackagePath(int jobId, int packageVersion, string storedFileName) => inner.CreatePackagePath(jobId, packageVersion, storedFileName); public string StagePath(string finalPath) => inner.StagePath(finalPath); public string DeletePath(string finalPath) => inner.DeletePath(finalPath); public bool IsManagedPath(string path) => inner.IsManagedPath(path); public Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) => inner.StageAsync(file, stagePath, cancellationToken); + public Task SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken) => inner.SnapshotAsync(sourcePath, destinationPath, cancellationToken); public void Promote(string stagePath, string finalPath) { if (FailPromote) throw new IOException("Synthetic promotion failure."); diff --git a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs index 672671d..2cfb8ef 100644 --- a/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs +++ b/JobTrackerApi.Tests/BackgroundWorkerTenantTests.cs @@ -428,8 +428,8 @@ public sealed class BackgroundWorkerTenantTests db.Companies.AddRange(companies); await db.SaveChangesAsync(); db.JobApplications.AddRange( - new JobApplication { OwnerUserId = "user-1", CompanyId = companies[0].Id, JobTitle = "One", Status = "Applied", DateApplied = appliedAt ?? DateTime.Now.AddDays(-30), Description = "description-user-1" }, - new JobApplication { OwnerUserId = "user-2", CompanyId = companies[1].Id, JobTitle = "Two", Status = "Applied", DateApplied = appliedAt ?? DateTime.Now.AddDays(-30), Description = "description-user-2" }); + new JobApplication { OwnerUserId = "user-1", CompanyId = companies[0].Id, JobTitle = "One", Status = "Applied", DateApplied = appliedAt ?? FixedNow.DateTime.AddDays(-30), Description = "description-user-1" }, + new JobApplication { OwnerUserId = "user-2", CompanyId = companies[1].Id, JobTitle = "Two", Status = "Applied", DateApplied = appliedAt ?? FixedNow.DateTime.AddDays(-30), Description = "description-user-2" }); await db.SaveChangesAsync(); } diff --git a/JobTrackerApi.Tests/MigrationChainTests.cs b/JobTrackerApi.Tests/MigrationChainTests.cs index 98ba3d0..2d5a14a 100644 --- a/JobTrackerApi.Tests/MigrationChainTests.cs +++ b/JobTrackerApi.Tests/MigrationChainTests.cs @@ -30,6 +30,10 @@ public sealed class MigrationChainTests SELECT COUNT(*) FROM sqlite_master WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords'); """)); + Assert.Equal(2, await ScalarAsync(connection, """ + SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' AND name IN ('SubmittedApplicationPackages', 'SubmittedPackageAttachments'); + """)); Assert.Equal(10, await ScalarAsync(connection, """ SELECT COUNT(*) FROM pragma_table_info('JobApplications') WHERE name IN ('OwnerUserId', 'ShortSummary', 'TailoredCvText', 'TailoredCvUpdatedAt', @@ -117,6 +121,8 @@ public sealed class MigrationChainTests Assert.Contains("CREATE TABLE IF NOT EXISTS `CvVariantVersions`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CoverLetterVersions`", script, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS `SubmittedApplicationPackages`", script, StringComparison.Ordinal); + Assert.Contains("CREATE TABLE IF NOT EXISTS `SubmittedPackageAttachments`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `InterviewPrepItems`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfiles`", script, StringComparison.Ordinal); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfileVersions`", script, StringComparison.Ordinal); diff --git a/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs b/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs new file mode 100644 index 0000000..f3a6d19 --- /dev/null +++ b/JobTrackerApi.Tests/SubmittedApplicationPackageTests.cs @@ -0,0 +1,156 @@ +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class SubmittedApplicationPackageTests +{ + [Fact] + public async Task Storage_snapshot_copies_exact_bytes_and_calculates_sha256() + { + var root = Path.Combine(Path.GetTempPath(), $"jobtracker-package-{Guid.NewGuid():N}"); + try + { + var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary { ["Data:Root"] = root }).Build(); + var environment = new Mock(); + environment.SetupGet(x => x.ContentRootPath).Returns(root); + var storage = new AttachmentStorage(new AppPaths(config, environment.Object)); + var source = storage.CreateFinalPath(7, "source.txt"); + await File.WriteAllTextAsync(source, "exact submitted bytes"); + var destination = storage.CreatePackagePath(7, 1, "source.txt"); + + var result = await storage.SnapshotAsync(source, destination, default); + + Assert.Equal("exact submitted bytes", await File.ReadAllTextAsync(destination)); + Assert.Equal("32283fd0838392ef9d8c707f521096966432939d5d83624bbfce150cde3ce26c", result.Sha256); + } + finally + { + if (Directory.Exists(root)) Directory.Delete(root, recursive: true); + } + } + + [Fact] + public async Task Capture_freezes_documents_and_attachment_hashes() + { + var temp = Path.GetTempFileName(); + await File.WriteAllTextAsync(temp, "exact submitted bytes"); + try + { + var (db, service, storage) = New("owner-1"); + await using var _ = db; + var job = await SeedAsync(db, "owner-1", temp); + + var result = await service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada Example", null), default); + + Assert.NotNull(result); + Assert.Equal(1, result.Version); + Assert.True(result.HasCv); + Assert.True(result.HasCoverLetter); + Assert.Single(result.Attachments); + Assert.Equal(new string('a', 64), result.Attachments[0].Sha256); + var stored = await db.SubmittedApplicationPackages.Include(x => x.Attachments).SingleAsync(); + Assert.Contains("Frozen CV", stored.RenderedCvHtml); + Assert.Contains("Original profile", stored.CareerProfileJson); + Assert.Contains("Application answer", stored.ApplicationMaterialJson); + storage.Verify(x => x.SnapshotAsync(temp, It.IsAny(), It.IsAny()), Times.Once); + } + finally + { + File.Delete(temp); + } + } + + [Fact] + public async Task Capture_is_tenant_scoped() + { + var (db, service, _) = New("owner-1"); + await using var dispose = db; + var job = await SeedAsync(db, "owner-1", null); + + Assert.Null(await service.CaptureAsync("owner-2", job.Id, new CvRenderPerson("Other", null), default)); + Assert.Empty(db.SubmittedApplicationPackages); + } + + [Fact] + public async Task Missing_attachment_aborts_the_whole_snapshot() + { + var (db, service, storage) = New("owner-1"); + await using var dispose = db; + var job = await SeedAsync(db, "owner-1", "missing-file.pdf"); + storage.Setup(x => x.IsManagedPath(It.IsAny())).Returns(true); + + await Assert.ThrowsAsync(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default)); + Assert.Empty(db.SubmittedApplicationPackages); + } + + private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock Storage) New(string userId) + { + var currentUser = new Mock(); + currentUser.SetupGet(x => x.UserId).Returns(userId); + var options = new DbContextOptionsBuilder().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options; + var db = new JobTrackerContext(options, currentUser.Object); + + var variants = new Mock(); + variants.Setup(x => x.RenderAsync(userId, It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "Frozen CV")); + + var storage = new Mock(); + storage.Setup(x => x.IsManagedPath(It.IsAny())).Returns(true); + storage.Setup(x => x.CreatePackagePath(It.IsAny(), It.IsAny(), It.IsAny())).Returns("snapshot.pdf"); + storage.Setup(x => x.SnapshotAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .ReturnsAsync(new StoredSnapshotFile("snapshot.pdf", 21, new string('a', 64))); + + return (db, new SubmittedApplicationPackageService(db, variants.Object, storage.Object), storage); + } + + private static async Task SeedAsync(JobTrackerContext db, string owner, string? attachmentPath) + { + var company = new Company { OwnerUserId = owner, Name = "Example AS" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + var job = new JobApplication + { + OwnerUserId = owner, + CompanyId = company.Id, + JobTitle = "Engineer", + Status = "Applied", + CoverLetterText = "Exact cover letter", + Notes = "Application answer", + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + db.CareerProfiles.Add(new CareerProfile { OwnerUserId = owner, ProfileJson = "{\"summary\":\"Original profile\"}" }); + db.CvVariants.Add(new CvVariant + { + OwnerUserId = owner, + JobApplicationId = job.Id, + Name = "Backend CV", + PublicSlug = Guid.NewGuid().ToString("N"), + Version = 4, + SettingsJson = CvVariantSettingsJson.Serialize(new CvVariantSettings { ThemeId = "nordic" }), + CreatedAtUtc = DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow, + }); + if (attachmentPath is not null) + { + db.Attachments.Add(new Attachment + { + JobApplicationId = job.Id, + FileName = "evidence.pdf", + FilePath = attachmentPath, + FileType = "application/pdf", + FileSize = 21, + Purpose = "portfolio", + }); + } + await db.SaveChangesAsync(); + return job; + } +} diff --git a/JobTrackerApi/Controllers/ApplicationAssetsController.cs b/JobTrackerApi/Controllers/ApplicationAssetsController.cs index 323c5b6..fc74f3b 100644 --- a/JobTrackerApi/Controllers/ApplicationAssetsController.cs +++ b/JobTrackerApi/Controllers/ApplicationAssetsController.cs @@ -23,11 +23,13 @@ public sealed class ApplicationAssetsController : ControllerBase private readonly UserManager _users; private readonly IApplicationAssetsService _assets; + private readonly ISubmittedApplicationPackageService _packages; - public ApplicationAssetsController(UserManager users, IApplicationAssetsService assets) + public ApplicationAssetsController(UserManager users, IApplicationAssetsService assets, ISubmittedApplicationPackageService packages) { _users = users; _assets = assets; + _packages = packages; } [HttpGet("cv")] @@ -85,5 +87,50 @@ public sealed class ApplicationAssetsController : ControllerBase return result is null ? NotFound() : Ok(result); } + [HttpGet("submitted-packages")] + public async Task>> ListSubmittedPackages(int jobId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _packages.ListAsync(userId, jobId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpPost("submitted-packages")] + public async Task> CaptureSubmittedPackage(int jobId, CancellationToken ct) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + var result = await _packages.CaptureAsync(user.Id, jobId, Person(user), ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("submitted-packages/{packageId:int}")] + public async Task> GetSubmittedPackage(int jobId, int packageId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var result = await _packages.GetAsync(userId, jobId, packageId, ct); + return result is null ? NotFound() : Ok(result); + } + + [HttpGet("submitted-packages/{packageId:int}/attachments/{attachmentId:int}")] + public async Task DownloadSubmittedAttachment(int jobId, int packageId, int attachmentId, CancellationToken ct) + { + var userId = await CurrentUserIdAsync(); + if (userId is null) return Unauthorized(); + var file = await _packages.GetAttachmentAsync(userId, jobId, packageId, attachmentId, ct); + return file is null ? NotFound() : PhysicalFile(file.Path, file.ContentType, file.FileName); + } + private async Task CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; + + private static CvRenderPerson Person(ApplicationUser user) + { + var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim(); + if (string.IsNullOrWhiteSpace(name)) name = user.Email?.Trim(); + return new CvRenderPerson(string.IsNullOrWhiteSpace(name) ? "Your Name" : name, AvatarStorage.Resolve(user.AvatarImageDataUrl)); + } } diff --git a/JobTrackerApi/Data/JobTrackerContext.cs b/JobTrackerApi/Data/JobTrackerContext.cs index 03e9268..239383b 100644 --- a/JobTrackerApi/Data/JobTrackerContext.cs +++ b/JobTrackerApi/Data/JobTrackerContext.cs @@ -59,6 +59,8 @@ namespace JobTrackerApi.Data public DbSet AiUsageRecords => Set(); public DbSet ApplicationChecklistItems => Set(); public DbSet CoverLetterVersions => Set(); + public DbSet SubmittedApplicationPackages => Set(); + public DbSet SubmittedPackageAttachments => Set(); public DbSet InterviewPrepItems => Set(); public DbSet UserOperations => Set(); public DbSet UserNotifications => Set(); @@ -550,6 +552,33 @@ namespace JobTrackerApi.Data .HasForeignKey(x => x.JobApplicationId) .OnDelete(DeleteBehavior.Cascade); + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId); + modelBuilder.Entity().Property(x => x.OwnerUserId).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.CvVariantName).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.CvThemeId).HasMaxLength(64); + modelBuilder.Entity() + .HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Version }) + .IsUnique(); + modelBuilder.Entity() + .HasOne(x => x.JobApplication) + .WithMany() + .HasForeignKey(x => x.JobApplicationId) + .OnDelete(DeleteBehavior.Cascade); + + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId != null && x.Package != null && x.Package.OwnerUserId == CurrentUserId); + modelBuilder.Entity().Property(x => x.FileName).HasMaxLength(500); + modelBuilder.Entity().Property(x => x.FileType).HasMaxLength(255); + modelBuilder.Entity().Property(x => x.Purpose).HasMaxLength(64); + modelBuilder.Entity().Property(x => x.Sha256).HasMaxLength(64); + modelBuilder.Entity() + .HasOne(x => x.Package) + .WithMany(x => x.Attachments) + .HasForeignKey(x => x.SubmittedApplicationPackageId) + .HasConstraintName("FK_SubmittedPackageAttachments_Packages_PackageId") + .OnDelete(DeleteBehavior.Cascade); + // Phase 5.5: user-owned interview preparation. Unlike InterviewPrepNote (an AI cache), nothing // regenerates this. docs/architecture/application-workspace.md. modelBuilder.Entity() diff --git a/JobTrackerApi/Migrations/20260830135000_AdoptSubmittedApplicationPackageSchema.cs b/JobTrackerApi/Migrations/20260830135000_AdoptSubmittedApplicationPackageSchema.cs new file mode 100644 index 0000000..112921e --- /dev/null +++ b/JobTrackerApi/Migrations/20260830135000_AdoptSubmittedApplicationPackageSchema.cs @@ -0,0 +1,74 @@ +using JobTrackerApi.Data; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace JobTrackerApi.Migrations; + +[DbContext(typeof(JobTrackerContext))] +[Migration("20260830135000_AdoptSubmittedApplicationPackageSchema")] +public sealed class AdoptSubmittedApplicationPackageSchema : Migration +{ + protected override void Up(MigrationBuilder migrationBuilder) => + migrationBuilder.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? MySqlDdl : SqliteDdl); + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) + ? "DROP TABLE IF EXISTS `SubmittedPackageAttachments`; DROP TABLE IF EXISTS `SubmittedApplicationPackages`;" + : "DROP TABLE IF EXISTS \"SubmittedPackageAttachments\"; DROP TABLE IF EXISTS \"SubmittedApplicationPackages\";"); + } + + private const string SqliteDdl = """ + CREATE TABLE IF NOT EXISTS "SubmittedApplicationPackages" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SubmittedApplicationPackages" PRIMARY KEY AUTOINCREMENT, + "OwnerUserId" TEXT NOT NULL, "JobApplicationId" INTEGER NOT NULL, "Version" INTEGER NOT NULL, + "CvVariantId" INTEGER NULL, "CvVariantName" TEXT NULL, "CvVariantVersion" INTEGER NULL, + "CvThemeId" TEXT NULL, "CvSettingsJson" TEXT NULL, "CareerProfileJson" TEXT NULL, + "RenderedCvHtml" TEXT NULL, "CoverLetterText" TEXT NULL, + "ApplicationMaterialJson" TEXT NOT NULL, "CreatedAtUtc" TEXT NOT NULL, + CONSTRAINT "FK_SubmittedApplicationPackages_JobApplications_JobApplicationId" + FOREIGN KEY ("JobApplicationId") REFERENCES "JobApplications" ("Id") ON DELETE CASCADE); + CREATE UNIQUE INDEX IF NOT EXISTS "IX_SubmittedApplicationPackages_Owner_Job_Version" + ON "SubmittedApplicationPackages" ("OwnerUserId", "JobApplicationId", "Version"); + CREATE INDEX IF NOT EXISTS "IX_SubmittedApplicationPackages_JobApplicationId" + ON "SubmittedApplicationPackages" ("JobApplicationId"); + CREATE TABLE IF NOT EXISTS "SubmittedPackageAttachments" ( + "Id" INTEGER NOT NULL CONSTRAINT "PK_SubmittedPackageAttachments" PRIMARY KEY AUTOINCREMENT, + "SubmittedApplicationPackageId" INTEGER NOT NULL, "FileName" TEXT NOT NULL, + "FileType" TEXT NOT NULL, "Purpose" TEXT NULL, "FileSize" INTEGER NOT NULL, + "Sha256" TEXT NOT NULL, "FilePath" TEXT NOT NULL, + CONSTRAINT "FK_SubmittedPackageAttachments_Packages_PackageId" + FOREIGN KEY ("SubmittedApplicationPackageId") REFERENCES "SubmittedApplicationPackages" ("Id") ON DELETE CASCADE); + CREATE INDEX IF NOT EXISTS "IX_SubmittedPackageAttachments_SubmittedApplicationPackageId" + ON "SubmittedPackageAttachments" ("SubmittedApplicationPackageId"); + """; + + private const string MySqlDdl = """ + CREATE TABLE IF NOT EXISTS `SubmittedApplicationPackages` ( + `Id` int NOT NULL AUTO_INCREMENT, `OwnerUserId` varchar(255) NOT NULL, + `JobApplicationId` int NOT NULL, `Version` int NOT NULL, `CvVariantId` int NULL, + `CvVariantName` varchar(255) NULL, `CvVariantVersion` int NULL, `CvThemeId` varchar(64) NULL, + `CvSettingsJson` longtext NULL, `CareerProfileJson` longtext NULL, `RenderedCvHtml` longtext NULL, + `CoverLetterText` longtext NULL, `ApplicationMaterialJson` longtext NOT NULL, + `CreatedAtUtc` datetime(6) NOT NULL, PRIMARY KEY (`Id`), + CONSTRAINT `FK_SubmittedApplicationPackages_JobApplications_JobApplicationId` + FOREIGN KEY (`JobApplicationId`) REFERENCES `JobApplications` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE UNIQUE INDEX IF NOT EXISTS `IX_SubmittedApplicationPackages_Owner_Job_Version` + ON `SubmittedApplicationPackages` (`OwnerUserId`, `JobApplicationId`, `Version`); + CREATE INDEX IF NOT EXISTS `IX_SubmittedApplicationPackages_JobApplicationId` + ON `SubmittedApplicationPackages` (`JobApplicationId`); + CREATE TABLE IF NOT EXISTS `SubmittedPackageAttachments` ( + `Id` int NOT NULL AUTO_INCREMENT, `SubmittedApplicationPackageId` int NOT NULL, + `FileName` varchar(500) NOT NULL, `FileType` varchar(255) NOT NULL, `Purpose` varchar(64) NULL, + `FileSize` bigint NOT NULL, `Sha256` varchar(64) NOT NULL, `FilePath` longtext NOT NULL, + PRIMARY KEY (`Id`), + CONSTRAINT `FK_SubmittedPackageAttachments_Packages_PackageId` + FOREIGN KEY (`SubmittedApplicationPackageId`) REFERENCES `SubmittedApplicationPackages` (`Id`) ON DELETE CASCADE) + CHARACTER SET=utf8mb4; + CREATE INDEX IF NOT EXISTS `IX_SubmittedPackageAttachments_SubmittedApplicationPackageId` + ON `SubmittedPackageAttachments` (`SubmittedApplicationPackageId`); + """; +} diff --git a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs index b415f76..3c0c28e 100644 --- a/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs +++ b/JobTrackerApi/Migrations/JobTrackerContextModelSnapshot.cs @@ -1086,6 +1086,43 @@ namespace JobTrackerApi.Migrations b.ToTable("CoverLetterVersions"); }); + modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER"); + b.Property("ApplicationMaterialJson").IsRequired().HasColumnType("TEXT"); + b.Property("CareerProfileJson").HasColumnType("TEXT"); + b.Property("CoverLetterText").HasColumnType("TEXT"); + b.Property("CreatedAtUtc").HasColumnType("TEXT"); + b.Property("CvSettingsJson").HasColumnType("TEXT"); + b.Property("CvThemeId").HasMaxLength(64).HasColumnType("TEXT"); + b.Property("CvVariantId").HasColumnType("INTEGER"); + b.Property("CvVariantName").HasMaxLength(255).HasColumnType("TEXT"); + b.Property("CvVariantVersion").HasColumnType("INTEGER"); + b.Property("JobApplicationId").HasColumnType("INTEGER"); + b.Property("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT"); + b.Property("RenderedCvHtml").HasColumnType("TEXT"); + b.Property("Version").HasColumnType("INTEGER"); + b.HasKey("Id"); + b.HasIndex("JobApplicationId"); + b.HasIndex("OwnerUserId", "JobApplicationId", "Version").IsUnique(); + b.ToTable("SubmittedApplicationPackages"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SubmittedPackageAttachment", b => + { + b.Property("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER"); + b.Property("FileName").IsRequired().HasMaxLength(500).HasColumnType("TEXT"); + b.Property("FilePath").IsRequired().HasColumnType("TEXT"); + b.Property("FileSize").HasColumnType("INTEGER"); + b.Property("FileType").IsRequired().HasMaxLength(255).HasColumnType("TEXT"); + b.Property("Purpose").HasMaxLength(64).HasColumnType("TEXT"); + b.Property("Sha256").IsRequired().HasMaxLength(64).HasColumnType("TEXT"); + b.Property("SubmittedApplicationPackageId").HasColumnType("INTEGER"); + b.HasKey("Id"); + b.HasIndex("SubmittedApplicationPackageId"); + b.ToTable("SubmittedPackageAttachments"); + }); + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => { b.Property("Id") @@ -2666,6 +2703,29 @@ namespace JobTrackerApi.Migrations b.Navigation("JobApplication"); }); + modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b => + { + b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication") + .WithMany() + .HasForeignKey("JobApplicationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("JobApplication"); + }); + + modelBuilder.Entity("JobTrackerApi.Models.SubmittedPackageAttachment", b => + { + b.HasOne("JobTrackerApi.Models.SubmittedApplicationPackage", "Package") + .WithMany("Attachments") + .HasForeignKey("SubmittedApplicationPackageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_SubmittedPackageAttachments_Packages_PackageId"); + + b.Navigation("Package"); + }); + modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => { b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") @@ -2893,6 +2953,11 @@ namespace JobTrackerApi.Migrations b.Navigation("TailoredCvDraft"); }); + + modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b => + { + b.Navigation("Attachments"); + }); #pragma warning restore 612, 618 } } diff --git a/JobTrackerApi/Models/SubmittedApplicationPackage.cs b/JobTrackerApi/Models/SubmittedApplicationPackage.cs new file mode 100644 index 0000000..8c77508 --- /dev/null +++ b/JobTrackerApi/Models/SubmittedApplicationPackage.cs @@ -0,0 +1,37 @@ +namespace JobTrackerApi.Models; + +// Append-only evidence of what the user sent for an application. The snapshot owns copies of the +// rendered CV, its source data, the cover letter, application material and attachment bytes, so +// later edits to the live workspace cannot rewrite submission history. +public sealed class SubmittedApplicationPackage +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = string.Empty; + public int JobApplicationId { get; set; } + public JobApplication? JobApplication { get; set; } + public int Version { get; set; } + public int? CvVariantId { get; set; } + public string? CvVariantName { get; set; } + public int? CvVariantVersion { get; set; } + public string? CvThemeId { get; set; } + public string? CvSettingsJson { get; set; } + public string? CareerProfileJson { get; set; } + public string? RenderedCvHtml { get; set; } + public string? CoverLetterText { get; set; } + public string ApplicationMaterialJson { get; set; } = "{}"; + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public List Attachments { get; set; } = new(); +} + +public sealed class SubmittedPackageAttachment +{ + public int Id { get; set; } + public int SubmittedApplicationPackageId { get; set; } + public SubmittedApplicationPackage? Package { get; set; } + public string FileName { get; set; } = string.Empty; + public string FileType { get; set; } = string.Empty; + public string? Purpose { get; set; } + public long FileSize { get; set; } + public string Sha256 { get; set; } = string.Empty; + public string FilePath { get; set; } = string.Empty; +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 8a03229..fdf7742 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -76,6 +76,7 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddSingleton(); diff --git a/JobTrackerApi/Services/AccountDataExportService.cs b/JobTrackerApi/Services/AccountDataExportService.cs index fb86731..e2d4f0f 100644 --- a/JobTrackerApi/Services/AccountDataExportService.cs +++ b/JobTrackerApi/Services/AccountDataExportService.cs @@ -178,6 +178,11 @@ public sealed class AccountDataExportService( var aiUsage = await db.AiUsageRecords.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); var checklist = await db.ApplicationChecklistItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); var coverLetters = await db.CoverLetterVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); + var submittedPackages = await db.SubmittedApplicationPackages.IgnoreQueryFilters().AsNoTracking() + .Include(item => item.Attachments) + .Where(item => item.OwnerUserId == ownerUserId) + .OrderBy(item => item.Id) + .ToListAsync(cancellationToken); var interviewItems = await db.InterviewPrepItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken); var emailDrafts = await db.EmailDrafts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken); var emailAttempts = await db.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc) @@ -192,10 +197,17 @@ public sealed class AccountDataExportService( AiUsage = aiUsage, ChecklistItems = checklist, CoverLetterVersions = coverLetters, + SubmittedApplicationPackages = submittedPackages.Select(item => new + { + item.Id, item.JobApplicationId, item.Version, item.CvVariantId, item.CvVariantName, + item.CvVariantVersion, item.CvThemeId, item.CvSettingsJson, item.CareerProfileJson, + item.RenderedCvHtml, item.CoverLetterText, item.ApplicationMaterialJson, item.CreatedAtUtc, + Attachments = item.Attachments.Select(file => new { file.Id, file.FileName, file.FileType, file.Purpose, file.FileSize, file.Sha256 }), + }), InterviewPrepItems = interviewItems, EmailDrafts = emailDrafts, EmailSendAttempts = emailAttempts, - }, tailoredDrafts.Count + interviewNotes.Count + aiNotes.Count + aiInteractions.Count + aiUsage.Count + checklist.Count + coverLetters.Count + interviewItems.Count + emailDrafts.Count + emailAttempts.Count); + }, tailoredDrafts.Count + interviewNotes.Count + aiNotes.Count + aiInteractions.Count + aiUsage.Count + checklist.Count + coverLetters.Count + submittedPackages.Count + submittedPackages.Sum(item => item.Attachments.Count) + interviewItems.Count + emailDrafts.Count + emailAttempts.Count); var operations = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc) .Select(item => new diff --git a/JobTrackerApi/Services/AccountDeletionService.cs b/JobTrackerApi/Services/AccountDeletionService.cs index 9c86255..8b11a52 100644 --- a/JobTrackerApi/Services/AccountDeletionService.cs +++ b/JobTrackerApi/Services/AccountDeletionService.cs @@ -227,6 +227,9 @@ public sealed class AccountDeletionService( 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); + var packageIds = await db.SubmittedApplicationPackages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken); + deleted += await db.SubmittedPackageAttachments.IgnoreQueryFilters().Where(item => packageIds.Contains(item.SubmittedApplicationPackageId)).ExecuteDeleteAsync(cancellationToken); + deleted += await db.SubmittedApplicationPackages.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); diff --git a/JobTrackerApi/Services/AccountOwnedFileInventory.cs b/JobTrackerApi/Services/AccountOwnedFileInventory.cs index 831285a..e6009c9 100644 --- a/JobTrackerApi/Services/AccountOwnedFileInventory.cs +++ b/JobTrackerApi/Services/AccountOwnedFileInventory.cs @@ -25,6 +25,17 @@ public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths pat { AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath); } + var submittedAttachments = await db.SubmittedPackageAttachments.IgnoreQueryFilters().AsNoTracking() + .Include(item => item.Package) + .Where(item => item.Package != null && item.Package.OwnerUserId == ownerUserId) + .OrderBy(item => item.Id) + .ToListAsync(cancellationToken); + foreach (var attachment in submittedAttachments) + { + AddPath(files, warnings, attachment.FilePath, + $"files/submitted-packages/{attachment.SubmittedApplicationPackageId}/{attachment.Id}/{SafeSegment(attachment.FileName)}", + "submitted-package", attachmentStorage.IsManagedPath); + } var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking() .Where(item => item.OwnerUserId == ownerUserId) diff --git a/JobTrackerApi/Services/AttachmentStorage.cs b/JobTrackerApi/Services/AttachmentStorage.cs index 594bdbb..ff997a3 100644 --- a/JobTrackerApi/Services/AttachmentStorage.cs +++ b/JobTrackerApi/Services/AttachmentStorage.cs @@ -4,14 +4,17 @@ using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Services; public sealed record AttachmentReconciliationResult(int Promoted, int Restored, int Purged, int Missing, int UnknownOrphans, int UnsafePaths, int Failures); +public sealed record StoredSnapshotFile(string Path, long Length, string Sha256); public interface IAttachmentStorage { string CreateFinalPath(int jobId, string storedFileName); string StagePath(string finalPath); string DeletePath(string finalPath); + string CreatePackagePath(int jobId, int packageVersion, string storedFileName); bool IsManagedPath(string path); Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken); + Task SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken); void Promote(string stagePath, string finalPath); void Quarantine(string finalPath, string deletePath); void Restore(string deletePath, string finalPath); @@ -43,6 +46,14 @@ public sealed class AttachmentStorage : IAttachmentStorage public string StagePath(string finalPath) => EnsureManagedPath(finalPath, true) + UploadSuffix; public string DeletePath(string finalPath) => EnsureManagedPath(finalPath, true) + DeleteSuffix; + public string CreatePackagePath(int jobId, int packageVersion, string storedFileName) + { + var folder = Path.Combine(_root, jobId.ToString(System.Globalization.CultureInfo.InvariantCulture), "submitted", packageVersion.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Directory.CreateDirectory(folder); + EnsureManagedPath(folder, allowMissingLeaf: false); + return EnsureManagedPath(Path.Combine(folder, Path.GetFileName(storedFileName)), allowMissingLeaf: true); + } + public bool IsManagedPath(string path) { try @@ -75,6 +86,36 @@ public sealed class AttachmentStorage : IAttachmentStorage } } + public async Task SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken) + { + var source = EnsureManagedPath(sourcePath, allowMissingLeaf: false); + var destination = EnsureManagedPath(destinationPath, allowMissingLeaf: true); + if (File.Exists(destination)) throw new IOException("A submitted package file already exists at the destination."); + + try + { + await using var input = new FileStream(source, FileMode.Open, FileAccess.Read, FileShare.Read); + await using var output = new FileStream(destination, FileMode.CreateNew, FileAccess.Write, FileShare.None); + using var hash = System.Security.Cryptography.IncrementalHash.CreateHash(System.Security.Cryptography.HashAlgorithmName.SHA256); + var buffer = new byte[81920]; + int read; + long length = 0; + while ((read = await input.ReadAsync(buffer, cancellationToken)) > 0) + { + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken); + hash.AppendData(buffer, 0, read); + length += read; + } + await output.FlushAsync(cancellationToken); + return new StoredSnapshotFile(destination, length, Convert.ToHexString(hash.GetHashAndReset()).ToLowerInvariant()); + } + catch + { + if (File.Exists(destination)) File.Delete(destination); + throw; + } + } + public void Promote(string stagePath, string finalPath) => File.Move(EnsureManagedPath(stagePath, false), EnsureManagedPath(finalPath, true), overwrite: false); @@ -95,6 +136,9 @@ public sealed class AttachmentStorage : IAttachmentStorage var storedPaths = await db.Attachments.IgnoreQueryFilters().AsNoTracking() .Select(x => x.FilePath) .ToListAsync(cancellationToken); + storedPaths.AddRange(await db.SubmittedPackageAttachments.IgnoreQueryFilters().AsNoTracking() + .Select(x => x.FilePath) + .ToListAsync(cancellationToken)); var known = new HashSet(storedPaths.Where(IsManagedPath).Select(Path.GetFullPath), _comparer); var unsafePaths = storedPaths.Count(path => !IsManagedPath(path)); var promoted = 0; diff --git a/JobTrackerApi/Services/StartupSchemaOwnership.cs b/JobTrackerApi/Services/StartupSchemaOwnership.cs index 06f3b8a..1a04544 100644 --- a/JobTrackerApi/Services/StartupSchemaOwnership.cs +++ b/JobTrackerApi/Services/StartupSchemaOwnership.cs @@ -50,6 +50,8 @@ internal static class StartupSchemaOwnership "Jobs", "MicrosoftGraphConnections", "RuleSettings", + "SubmittedApplicationPackages", + "SubmittedPackageAttachments", "SystemEmailSettings", "TailoredCvDrafts", "TrustedDevices", diff --git a/JobTrackerApi/Services/SubmittedApplicationPackageService.cs b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs new file mode 100644 index 0000000..781b8a3 --- /dev/null +++ b/JobTrackerApi/Services/SubmittedApplicationPackageService.cs @@ -0,0 +1,175 @@ +using System.Text.Json; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using Microsoft.EntityFrameworkCore; + +namespace JobTrackerApi.Services; + +public sealed record SubmittedPackageAttachmentDto(int Id, string FileName, string FileType, string? Purpose, long FileSize, string Sha256); +public sealed record SubmittedPackageDto( + int Id, + int Version, + int? CvVariantId, + string? CvVariantName, + int? CvVariantVersion, + string? CvThemeId, + bool HasCv, + bool HasCoverLetter, + DateTimeOffset CreatedAtUtc, + IReadOnlyList Attachments); +public sealed record SubmittedPackageFile(string Path, string FileName, string ContentType); +public sealed record SubmittedPackageDetailDto( + SubmittedPackageDto Summary, + string? RenderedCvHtml, + string? CoverLetterText, + string ApplicationMaterialJson); + +public interface ISubmittedApplicationPackageService +{ + Task?> ListAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); + Task CaptureAsync(string ownerUserId, int jobApplicationId, CvRenderPerson person, CancellationToken ct); + Task GetAsync(string ownerUserId, int jobApplicationId, int packageId, CancellationToken ct); + Task GetAttachmentAsync(string ownerUserId, int jobApplicationId, int packageId, int attachmentId, CancellationToken ct); +} + +public sealed class SubmittedApplicationPackageService : ISubmittedApplicationPackageService +{ + private readonly JobTrackerContext _db; + private readonly ICvVariantService _variants; + private readonly IAttachmentStorage _storage; + + public SubmittedApplicationPackageService(JobTrackerContext db, ICvVariantService variants, IAttachmentStorage storage) + { + _db = db; + _variants = variants; + _storage = storage; + } + + public async Task?> ListAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) + { + if (!await _db.JobApplications.AsNoTracking().AnyAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct)) return null; + var packages = await _db.SubmittedApplicationPackages.AsNoTracking() + .Include(x => x.Attachments) + .Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId) + .OrderByDescending(x => x.Version) + .ToListAsync(ct); + return packages.Select(ToDto).ToList(); + } + + public async Task CaptureAsync(string ownerUserId, int jobApplicationId, CvRenderPerson person, CancellationToken ct) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .Include(j => j.Attachments) + .FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct); + if (job is null) return null; + + var variant = await _db.CvVariants.AsNoTracking() + .Where(v => v.OwnerUserId == ownerUserId && v.JobApplicationId == jobApplicationId) + .OrderByDescending(v => v.UpdatedAtUtc) + .FirstOrDefaultAsync(ct); + var profile = await _db.CareerProfiles.AsNoTracking().FirstOrDefaultAsync(p => p.OwnerUserId == ownerUserId, ct); + var render = variant is null ? null : await _variants.RenderAsync(ownerUserId, variant.Id, person, ct); + var version = (await _db.SubmittedApplicationPackages.AsNoTracking() + .Where(x => x.OwnerUserId == ownerUserId && x.JobApplicationId == jobApplicationId) + .Select(x => (int?)x.Version) + .MaxAsync(ct) ?? 0) + 1; + + var package = new SubmittedApplicationPackage + { + OwnerUserId = ownerUserId, + JobApplicationId = jobApplicationId, + Version = version, + CvVariantId = variant?.Id, + CvVariantName = variant?.Name, + CvVariantVersion = variant?.Version, + CvThemeId = render?.ThemeId, + CvSettingsJson = variant?.SettingsJson, + CareerProfileJson = profile?.ProfileJson, + RenderedCvHtml = render?.Html, + CoverLetterText = job.CoverLetterText, + ApplicationMaterialJson = JsonSerializer.Serialize(new + { + job.TailoredCvText, + job.RecruiterMessageDraft, + job.Notes, + job.Status, + job.DateApplied, + job.JobTitle, + Company = job.Company?.Name, + job.Description, + job.JobUrl, + }), + CreatedAtUtc = DateTimeOffset.UtcNow, + }; + + var copiedPaths = new List(); + await using var transaction = _db.Database.IsRelational() ? await _db.Database.BeginTransactionAsync(ct) : null; + try + { + foreach (var attachment in job.Attachments.OrderBy(x => x.Id)) + { + if (!_storage.IsManagedPath(attachment.FilePath) || !File.Exists(attachment.FilePath)) + throw new IOException($"Attachment '{attachment.FileName}' is unavailable and the submitted package was not created."); + + var safeName = $"{attachment.Id}-{Path.GetFileName(attachment.FilePath)}"; + var destination = _storage.CreatePackagePath(jobApplicationId, version, safeName); + var stored = await _storage.SnapshotAsync(attachment.FilePath, destination, ct); + copiedPaths.Add(stored.Path); + package.Attachments.Add(new SubmittedPackageAttachment + { + FileName = attachment.FileName, + FileType = attachment.FileType, + Purpose = attachment.Purpose, + FileSize = stored.Length, + Sha256 = stored.Sha256, + FilePath = stored.Path, + }); + } + + _db.SubmittedApplicationPackages.Add(package); + await _db.SaveChangesAsync(ct); + if (transaction is not null) await transaction.CommitAsync(ct); + return ToDto(package); + } + catch + { + if (transaction is not null) await transaction.RollbackAsync(CancellationToken.None); + foreach (var path in copiedPaths) + { + try { _storage.Purge(path); } catch { } + } + throw; + } + } + + public async Task GetAsync(string ownerUserId, int jobApplicationId, int packageId, CancellationToken ct) + { + var package = await _db.SubmittedApplicationPackages.AsNoTracking() + .Include(x => x.Attachments) + .FirstOrDefaultAsync(x => x.Id == packageId && x.JobApplicationId == jobApplicationId && x.OwnerUserId == ownerUserId, ct); + return package is null ? null : new SubmittedPackageDetailDto(ToDto(package), package.RenderedCvHtml, package.CoverLetterText, package.ApplicationMaterialJson); + } + + public async Task GetAttachmentAsync(string ownerUserId, int jobApplicationId, int packageId, int attachmentId, CancellationToken ct) + { + var attachment = await _db.SubmittedPackageAttachments.AsNoTracking() + .Include(x => x.Package) + .FirstOrDefaultAsync(x => x.Id == attachmentId && x.SubmittedApplicationPackageId == packageId + && x.Package != null && x.Package.JobApplicationId == jobApplicationId && x.Package.OwnerUserId == ownerUserId, ct); + if (attachment is null || !_storage.IsManagedPath(attachment.FilePath) || !File.Exists(attachment.FilePath)) return null; + return new SubmittedPackageFile(attachment.FilePath, Path.GetFileName(attachment.FileName), string.IsNullOrWhiteSpace(attachment.FileType) ? "application/octet-stream" : attachment.FileType); + } + + private static SubmittedPackageDto ToDto(SubmittedApplicationPackage value) => new( + value.Id, + value.Version, + value.CvVariantId, + value.CvVariantName, + value.CvVariantVersion, + value.CvThemeId, + !string.IsNullOrWhiteSpace(value.RenderedCvHtml), + !string.IsNullOrWhiteSpace(value.CoverLetterText), + value.CreatedAtUtc, + value.Attachments.Select(x => new SubmittedPackageAttachmentDto(x.Id, x.FileName, x.FileType, x.Purpose, x.FileSize, x.Sha256)).ToList()); +} diff --git a/docs/architecture/application-workspace.md b/docs/architecture/application-workspace.md index 6e195f1..69f6256 100644 --- a/docs/architecture/application-workspace.md +++ b/docs/architecture/application-workspace.md @@ -14,7 +14,9 @@ application's home. ## What it is NOT -The workspace **owns no data and duplicates none**. It is an aggregate read plus a navigation shell: +The workspace normally composes existing data. Its one deliberate ownership boundary is an +append-only submitted-package snapshot: immutable evidence must duplicate what was sent so later +profile, CV, cover-letter, and attachment edits cannot rewrite history. | Section | Backed by (existing system) | |---|---| @@ -26,12 +28,13 @@ The workspace **owns no data and duplicates none**. It is an aggregate read plus | Application answers / recruiter draft | compatibility fields on `JobApplication`, exposed as separate workspace fields | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | | Documents | `Attachment` | +| Submitted package history | `SubmittedApplicationPackage` + copied, SHA-256-identified attachment bytes | | Communication | `Correspondence` | | Activity / Timeline | `JobEvent` | | Stage semantics | `JobPipeline` | -No career data is copied into the application. Nothing in this feature writes to the master profile, -a CV variant, or a cover letter. +Nothing in this feature writes back to the master profile, a CV variant, or a cover letter. A user- +initiated submission snapshot copies their current values into an append-only evidence record. ## Backend @@ -234,6 +237,19 @@ With no profile it returns score 0 and asks the user to build one, rather than i ## Application assets (Phase 5.4) +### Submitted application packages + +`POST /api/jobapplications/{id}/submitted-packages` freezes the current package without mutating its +sources. It records the exact career-profile JSON, variant settings/version, rendered CV HTML, +cover-letter text, application drafts/notes, opportunity context, and independent copies of every +live attachment. Each copied file has its own SHA-256 digest and remains downloadable from the +package even when the live attachment is later renamed, replaced, or deleted. + +Packages are append-only and numbered per application. There is no update or delete endpoint. +List/detail/download routes are tenant-scoped, and account export/deletion includes both snapshot +metadata and bytes. Capture fails as a unit if any selected attachment is unavailable; it never +creates a package that silently omits a file. + The workspace becomes the place an application is prepared. The rule is one-directional: ``` diff --git a/docs/work-programmes/master-progress.md b/docs/work-programmes/master-progress.md index fa43ff3..3ef2fbc 100644 --- a/docs/work-programmes/master-progress.md +++ b/docs/work-programmes/master-progress.md @@ -1,6 +1,6 @@ # JobTracker master programme progress -Updated: 2026-08-30 +Updated: 2026-08-31 ## 2026-08-29 audit implementation programme @@ -68,10 +68,11 @@ Updated: 2026-08-30 - Moved durable interview-preparation items into a provider-aware migration; user and AI content, answers, practice state, sources, ordering, and application cascades are preserved. - Moved the complete Career Profile aggregate into a provider-aware migration; canonical and long-tail JSON, version history, all six relational child types, stable ordering, indexes, and cascades are preserved. - Completed JT-019 by moving all seven ASP.NET Identity tables into provider-aware migration ownership; accounts, credentials, 2FA state, preferences, roles, claims, external logins, tokens, indexes, and cascades are preserved. Startup schema code is now repair-only. +- Added immutable submitted-application packages to the Job Workspace. Each append-only snapshot freezes the career-profile/CV source, rendered CV, cover letter, application material and SHA-256-identified copies of all attachments; history can be reviewed and preserved files downloaded without following mutable live references. Tenant filtering, account export/deletion, storage reconciliation, SQLite/MariaDB migrations and EN/NB UI are included. ### In progress -- No repository implementation package is currently in progress. SEC-006/SEC-007 await a running Linux Docker daemon for image/runtime proof; production activation remains separately gated. +- Continue the prioritized product-value roadmap with the career evidence bank after the submitted-package batch completes its full regression gate. SEC-006/SEC-007 still await a running Linux Docker daemon for image/runtime proof; production activation remains separately gated. ### Remaining @@ -104,6 +105,8 @@ Updated: 2026-08-30 - Status lifecycle extraction: Release build passed with 0 warnings/errors and the complete backend suite passed 736/736, including applied-date preservation, status suggestions, and cross-tenant not-found behavior. - Accessibility package: public axe flow passed 1/1 and authenticated axe flow passed 1/1 across six workspaces; ESLint passed with zero warnings; all 60 frontend suites and 260/260 tests passed; optimized Next build and integrated TypeScript passed; npm audit reported zero vulnerabilities after adding `@axe-core/playwright`. - Prebuilt-export browser path: focused Playwright passed 1/1 while serving the existing optimized export without invoking another Next build. +- Submitted-package focused verification: storage/snapshot plus provider migration tests passed 6/6; the complete migration chain passed 18/18; application-assets UI passed 16/16; ESLint and the optimized Next build/TypeScript passed. +- Submitted-package complete regression: backend 740/740 and frontend 60 suites with 261/261 tests passed. The full run also exposed and fixed a date-sensitive reminder fixture that mixed a fixed worker clock with `DateTime.Now` seed data. - Focused frontend: 2 suites, 6 tests passed. - Full frontend: 64 suites, 272 tests passed. - Next production build and TypeScript: passed. diff --git a/job-tracker-ui/src/application-assets.test.tsx b/job-tracker-ui/src/application-assets.test.tsx index 834728a..34c17f8 100644 --- a/job-tracker-ui/src/application-assets.test.tsx +++ b/job-tracker-ui/src/application-assets.test.tsx @@ -62,6 +62,7 @@ function routeGet(overrides: Record = {}) { mockedApi.get.mockImplementation((url: string) => { if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any); if (url.endsWith("/cover-letter")) return Promise.resolve({ data: overrides.coverLetter ?? coverLetter } as any); + if (url.endsWith("/submitted-packages")) return Promise.resolve({ data: overrides.submittedPackages ?? [] } as any); return Promise.resolve({ data: overrides.cv ?? cv } as any); }); } @@ -126,6 +127,21 @@ test("tailoring asks for a career profile when there is none", async () => { expect(await screen.findByText(/Build your career profile/i)).toBeInTheDocument(); }); +test("submitted package capture appends an immutable history entry", async () => { + routeGet(); + mockedApi.post.mockResolvedValue({ data: { + id: 12, version: 1, cvVariantId: 3, cvVariantName: "Backend CV", cvVariantVersion: 4, + cvThemeId: "nordic", hasCv: true, hasCoverLetter: true, createdAtUtc: "2026-08-31T12:00:00Z", attachments: [], + } } as any); + + render(); + fireEvent.click(await screen.findByRole("button", { name: /Freeze current package/i })); + + await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/jobapplications/7/submitted-packages")); + expect(await screen.findByText("Submission package v1")).toBeInTheDocument(); + expect(screen.getByText(/Backend CV · v4/)).toBeInTheDocument(); +}); + // ---------- Cover letter ---------- test("cover letter loads the current text and its history", async () => { diff --git a/job-tracker-ui/src/applicationWorkspace.ts b/job-tracker-ui/src/applicationWorkspace.ts index 0647066..019a0ba 100644 --- a/job-tracker-ui/src/applicationWorkspace.ts +++ b/job-tracker-ui/src/applicationWorkspace.ts @@ -246,6 +246,35 @@ export type CoverLetter = { aiSuggestionCount: number; }; +export type SubmittedPackageAttachment = { + id: number; + fileName: string; + fileType: string; + purpose: string | null; + fileSize: number; + sha256: string; +}; + +export type SubmittedPackage = { + id: number; + version: number; + cvVariantId: number | null; + cvVariantName: string | null; + cvVariantVersion: number | null; + cvThemeId: string | null; + hasCv: boolean; + hasCoverLetter: boolean; + createdAtUtc: string; + attachments: SubmittedPackageAttachment[]; +}; + +export type SubmittedPackageDetail = { + summary: SubmittedPackage; + renderedCvHtml: string | null; + coverLetterText: string | null; + applicationMaterialJson: string; +}; + export const applicationAssetsApi = { cv: (jobId: number) => api.get(`/jobapplications/${jobId}/cv`).then((r) => r.data), attachVariant: (jobId: number, variantId: number | null) => @@ -258,6 +287,14 @@ export const applicationAssetsApi = { api.put(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data), restoreCoverLetter: (jobId: number, version: number) => api.post(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data), + submittedPackages: (jobId: number) => + api.get(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data), + captureSubmittedPackage: (jobId: number) => + api.post(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data), + submittedPackage: (jobId: number, packageId: number) => + api.get(`/jobapplications/${jobId}/submitted-packages/${packageId}`).then((r) => r.data), + downloadSubmittedAttachment: (jobId: number, packageId: number, attachmentId: number) => + api.get(`/jobapplications/${jobId}/submitted-packages/${packageId}/attachments/${attachmentId}`, { responseType: "blob" }).then((r) => r.data), saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) => api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined), }; diff --git a/job-tracker-ui/src/components/ApplicationAssets.tsx b/job-tracker-ui/src/components/ApplicationAssets.tsx index dc51c60..c945967 100644 --- a/job-tracker-ui/src/components/ApplicationAssets.tsx +++ b/job-tracker-ui/src/components/ApplicationAssets.tsx @@ -1,17 +1,19 @@ import React, { useCallback, useEffect, useState } from "react"; import { - Alert, Box, Button, Chip, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, + Alert, Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, IconButton, InputLabel, MenuItem, Paper, Select, Skeleton, Stack, TextField, Tooltip, Typography, } from "@mui/material"; import RichTextField from "./RichTextField"; import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import RestoreIcon from "@mui/icons-material/Restore"; import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; +import ArchiveOutlinedIcon from "@mui/icons-material/ArchiveOutlined"; +import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined"; import { getApiErrorMessage } from "../api"; import { - ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi, + ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi, } from "../applicationWorkspace"; import { cvBuilderApi } from "../cvBuilder"; import { aiWorkspaceApi } from "../aiWorkspace"; @@ -196,10 +198,132 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) { + ); } +function SubmittedPackagesSection({ jobId }: { jobId: number }) { + const { language, t } = useI18n(); + const { data, error, loading, setData, setError } = useAsset( + () => applicationAssetsApi.submittedPackages(jobId), + [jobId], + ); + const [busy, setBusy] = useState(false); + const [detail, setDetail] = useState(null); + const [detailBusy, setDetailBusy] = useState(false); + + const capture = async () => { + setBusy(true); + try { + const created = await applicationAssetsApi.captureSubmittedPackage(jobId); + setData([created, ...(data ?? [])]); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, t("assetsPackageCaptureFailed"))); + } finally { + setBusy(false); + } + }; + + const download = async (item: SubmittedPackage, attachment: SubmittedPackage["attachments"][number]) => { + try { + const blob = await applicationAssetsApi.downloadSubmittedAttachment(jobId, item.id, attachment.id); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = attachment.fileName; + anchor.click(); + URL.revokeObjectURL(url); + } catch (err) { + setError(getApiErrorMessage(err, t("assetsPackageDownloadFailed"))); + } + }; + + const view = async (item: SubmittedPackage) => { + setDetailBusy(true); + try { + setDetail(await applicationAssetsApi.submittedPackage(jobId, item.id)); + setError(null); + } catch (err) { + setError(getApiErrorMessage(err, t("assetsPackageLoadFailed"))); + } finally { + setDetailBusy(false); + } + }; + + const material = (() => { + if (!detail) return null; + try { return JSON.parse(detail.applicationMaterialJson) as Record; } catch { return null; } + })(); + + return ( + + + + {(data?.length ?? 0) === 0 ? ( + {t("assetsNoSubmittedPackages")} + ) : data?.map((item) => ( + + + + + {t("assetsSubmittedPackageVersion", { version: item.version })} + + {new Date(item.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")} + + + + + + + + + {item.attachments.map((attachment) => ( + + ))} + + + ))} + + setDetail(null)} fullWidth maxWidth="lg" aria-labelledby="submitted-package-title"> + + {detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")} + + + + {detail?.renderedCvHtml ? ( + + {t("workspaceCv")} + + + ) : {t("assetsPackageNoCv")}} + + {t("workspaceCoverLetter")} + + {detail?.coverLetterText || t("assetsPackageNoCoverLetter")} + + + {material && ["TailoredCvText", "RecruiterMessageDraft", "Notes"].map((key) => typeof material[key] === "string" && material[key] ? ( + + {t(`assetsPackage${key}` as "assetsPackageTailoredCvText")} + {String(material[key])} + + ) : null)} + + + + + + ); +} + // ---------- Tailoring ---------- export function ApplicationTailoringSection({ jobId }: { jobId: number }) { diff --git a/job-tracker-ui/src/i18n/translations.ts b/job-tracker-ui/src/i18n/translations.ts index 99d7800..8c927bb 100644 --- a/job-tracker-ui/src/i18n/translations.ts +++ b/job-tracker-ui/src/i18n/translations.ts @@ -413,6 +413,22 @@ export const translations = { intelligenceRelevantProjects: "Relevant projects", intelligenceSuggestions: "Suggestions", assetsCvSubtitle: "The CV variant used for this application. Variants tailor the master career profile without duplicating it.", + assetsSubmittedPackages: "Submitted packages", + assetsSubmittedPackagesSubtitle: "Freeze the exact CV, cover letter, application material, and attached files used for a submission.", + assetsPackageCapture: "Freeze current package", + assetsPackageCapturing: "Freezing package…", + assetsNoSubmittedPackages: "No submitted package has been preserved yet.", + assetsSubmittedPackageVersion: "Submission package v{version}", + assetsPackageNoCv: "No CV", + assetsPackageNoCoverLetter: "No cover letter", + assetsPackageCaptureFailed: "Could not preserve the submitted package. Check that every attached file is still available and try again.", + assetsPackageDownloadFailed: "Could not download the preserved file.", + assetsPackageLoadFailed: "Could not load the preserved package.", + assetsPackageView: "View preserved package", + assetsPackageCvPreview: "Preserved CV preview", + assetsPackageTailoredCvText: "Tailored CV text", + assetsPackageRecruiterMessageDraft: "Recruiter message", + assetsPackageNotes: "Application answers and notes", assetsLoadFailed: "Could not load this section.", assetsAttachCvFailed: "Could not change the attached CV.", assetsDuplicateCvFailed: "Could not create a tailored CV copy.", @@ -2729,6 +2745,22 @@ export const translations = { intelligenceRelevantProjects: "Relevante prosjekter", intelligenceSuggestions: "Forslag", assetsCvSubtitle: "CV-varianten som brukes for denne søknaden. Varianter tilpasser karriereprofilen uten å duplisere den.", + assetsSubmittedPackages: "Innsendte pakker", + assetsSubmittedPackagesSubtitle: "Frys den nøyaktige CV-en, søknadsbrevet, søknadsmaterialet og vedleggene som ble sendt.", + assetsPackageCapture: "Frys gjeldende pakke", + assetsPackageCapturing: "Fryser pakken…", + assetsNoSubmittedPackages: "Ingen innsendt pakke er bevart ennå.", + assetsSubmittedPackageVersion: "Innsendt pakke v{version}", + assetsPackageNoCv: "Ingen CV", + assetsPackageNoCoverLetter: "Intet søknadsbrev", + assetsPackageCaptureFailed: "Kunne ikke bevare den innsendte pakken. Kontroller at alle vedlegg fortsatt er tilgjengelige, og prøv igjen.", + assetsPackageDownloadFailed: "Kunne ikke laste ned den bevarte filen.", + assetsPackageLoadFailed: "Kunne ikke laste den bevarte pakken.", + assetsPackageView: "Vis bevart pakke", + assetsPackageCvPreview: "Forhåndsvisning av bevart CV", + assetsPackageTailoredCvText: "Tilpasset CV-tekst", + assetsPackageRecruiterMessageDraft: "Melding til rekrutterer", + assetsPackageNotes: "Søknadssvar og notater", assetsLoadFailed: "Kunne ikke laste denne delen.", assetsAttachCvFailed: "Kunne ikke endre den tilknyttede CV-en.", assetsDuplicateCvFailed: "Kunne ikke opprette en tilpasset CV-kopi.",