feat(applications): preserve submitted packages

This commit is contained in:
cesnimda
2026-08-31 16:54:41 +02:00
parent 47e8e3e50e
commit 5337ba3a5e
22 changed files with 903 additions and 11 deletions
@@ -275,10 +275,12 @@ public sealed class AttachmentConsistencyTests
public bool FailPromote { get; init; } public bool FailPromote { get; init; }
public bool FailDeletePurge { get; init; } public bool FailDeletePurge { get; init; }
public string CreateFinalPath(int jobId, string storedFileName) => inner.CreateFinalPath(jobId, storedFileName); 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 StagePath(string finalPath) => inner.StagePath(finalPath);
public string DeletePath(string finalPath) => inner.DeletePath(finalPath); public string DeletePath(string finalPath) => inner.DeletePath(finalPath);
public bool IsManagedPath(string path) => inner.IsManagedPath(path); public bool IsManagedPath(string path) => inner.IsManagedPath(path);
public Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) => inner.StageAsync(file, stagePath, cancellationToken); public Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken) => inner.StageAsync(file, stagePath, cancellationToken);
public Task<StoredSnapshotFile> SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken) => inner.SnapshotAsync(sourcePath, destinationPath, cancellationToken);
public void Promote(string stagePath, string finalPath) public void Promote(string stagePath, string finalPath)
{ {
if (FailPromote) throw new IOException("Synthetic promotion failure."); if (FailPromote) throw new IOException("Synthetic promotion failure.");
@@ -428,8 +428,8 @@ public sealed class BackgroundWorkerTenantTests
db.Companies.AddRange(companies); db.Companies.AddRange(companies);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
db.JobApplications.AddRange( 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-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 ?? DateTime.Now.AddDays(-30), Description = "description-user-2" }); 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(); await db.SaveChangesAsync();
} }
@@ -30,6 +30,10 @@ public sealed class MigrationChainTests
SELECT COUNT(*) FROM sqlite_master SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords'); WHERE type = 'table' AND name IN ('AspNetUsers', 'AiInteractions', 'AiUsageRecords');
""")); """));
Assert.Equal(2, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM sqlite_master
WHERE type = 'table' AND name IN ('SubmittedApplicationPackages', 'SubmittedPackageAttachments');
"""));
Assert.Equal(10, await ScalarAsync<long>(connection, """ Assert.Equal(10, await ScalarAsync<long>(connection, """
SELECT COUNT(*) FROM pragma_table_info('JobApplications') SELECT COUNT(*) FROM pragma_table_info('JobApplications')
WHERE name IN ('OwnerUserId', 'ShortSummary', 'TailoredCvText', 'TailoredCvUpdatedAt', 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 `CvVariantVersions`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `ApplicationChecklistItems`", 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 `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 `InterviewPrepItems`", script, StringComparison.Ordinal);
Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfiles`", 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); Assert.Contains("CREATE TABLE IF NOT EXISTS `CareerProfileVersions`", script, StringComparison.Ordinal);
@@ -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<string, string?> { ["Data:Root"] = root }).Build();
var environment = new Mock<IHostEnvironment>();
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<string>(), It.IsAny<CancellationToken>()), 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<string>())).Returns(true);
await Assert.ThrowsAsync<IOException>(() => service.CaptureAsync("owner-1", job.Id, new CvRenderPerson("Ada", null), default));
Assert.Empty(db.SubmittedApplicationPackages);
}
private static (JobTrackerContext Db, SubmittedApplicationPackageService Service, Mock<IAttachmentStorage> Storage) New(string userId)
{
var currentUser = new Mock<ICurrentUserService>();
currentUser.SetupGet(x => x.UserId).Returns(userId);
var options = new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options;
var db = new JobTrackerContext(options, currentUser.Object);
var variants = new Mock<ICvVariantService>();
variants.Setup(x => x.RenderAsync(userId, It.IsAny<int>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new ThemedCvRenderResult("nordic", "cv.pdf", "<html>Frozen CV</html>"));
var storage = new Mock<IAttachmentStorage>();
storage.Setup(x => x.IsManagedPath(It.IsAny<string>())).Returns(true);
storage.Setup(x => x.CreatePackagePath(It.IsAny<int>(), It.IsAny<int>(), It.IsAny<string>())).Returns("snapshot.pdf");
storage.Setup(x => x.SnapshotAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new StoredSnapshotFile("snapshot.pdf", 21, new string('a', 64)));
return (db, new SubmittedApplicationPackageService(db, variants.Object, storage.Object), storage);
}
private static async Task<JobApplication> 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;
}
}
@@ -23,11 +23,13 @@ public sealed class ApplicationAssetsController : ControllerBase
private readonly UserManager<ApplicationUser> _users; private readonly UserManager<ApplicationUser> _users;
private readonly IApplicationAssetsService _assets; private readonly IApplicationAssetsService _assets;
private readonly ISubmittedApplicationPackageService _packages;
public ApplicationAssetsController(UserManager<ApplicationUser> users, IApplicationAssetsService assets) public ApplicationAssetsController(UserManager<ApplicationUser> users, IApplicationAssetsService assets, ISubmittedApplicationPackageService packages)
{ {
_users = users; _users = users;
_assets = assets; _assets = assets;
_packages = packages;
} }
[HttpGet("cv")] [HttpGet("cv")]
@@ -85,5 +87,50 @@ public sealed class ApplicationAssetsController : ControllerBase
return result is null ? NotFound() : Ok(result); return result is null ? NotFound() : Ok(result);
} }
[HttpGet("submitted-packages")]
public async Task<ActionResult<IReadOnlyList<SubmittedPackageDto>>> 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<ActionResult<SubmittedPackageDto>> 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<ActionResult<SubmittedPackageDetailDto>> 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<IActionResult> 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<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id; private async Task<string?> 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));
}
} }
+29
View File
@@ -59,6 +59,8 @@ namespace JobTrackerApi.Data
public DbSet<AiUsageRecord> AiUsageRecords => Set<AiUsageRecord>(); public DbSet<AiUsageRecord> AiUsageRecords => Set<AiUsageRecord>();
public DbSet<ApplicationChecklistItem> ApplicationChecklistItems => Set<ApplicationChecklistItem>(); public DbSet<ApplicationChecklistItem> ApplicationChecklistItems => Set<ApplicationChecklistItem>();
public DbSet<CoverLetterVersion> CoverLetterVersions => Set<CoverLetterVersion>(); public DbSet<CoverLetterVersion> CoverLetterVersions => Set<CoverLetterVersion>();
public DbSet<SubmittedApplicationPackage> SubmittedApplicationPackages => Set<SubmittedApplicationPackage>();
public DbSet<SubmittedPackageAttachment> SubmittedPackageAttachments => Set<SubmittedPackageAttachment>();
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>(); public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
public DbSet<UserOperation> UserOperations => Set<UserOperation>(); public DbSet<UserOperation> UserOperations => Set<UserOperation>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>(); public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
@@ -550,6 +552,33 @@ namespace JobTrackerApi.Data
.HasForeignKey(x => x.JobApplicationId) .HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade); .OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SubmittedApplicationPackage>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<SubmittedApplicationPackage>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<SubmittedApplicationPackage>().Property(x => x.CvVariantName).HasMaxLength(255);
modelBuilder.Entity<SubmittedApplicationPackage>().Property(x => x.CvThemeId).HasMaxLength(64);
modelBuilder.Entity<SubmittedApplicationPackage>()
.HasIndex(x => new { x.OwnerUserId, x.JobApplicationId, x.Version })
.IsUnique();
modelBuilder.Entity<SubmittedApplicationPackage>()
.HasOne(x => x.JobApplication)
.WithMany()
.HasForeignKey(x => x.JobApplicationId)
.OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<SubmittedPackageAttachment>()
.HasQueryFilter(x => CurrentUserId != null && x.Package != null && x.Package.OwnerUserId == CurrentUserId);
modelBuilder.Entity<SubmittedPackageAttachment>().Property(x => x.FileName).HasMaxLength(500);
modelBuilder.Entity<SubmittedPackageAttachment>().Property(x => x.FileType).HasMaxLength(255);
modelBuilder.Entity<SubmittedPackageAttachment>().Property(x => x.Purpose).HasMaxLength(64);
modelBuilder.Entity<SubmittedPackageAttachment>().Property(x => x.Sha256).HasMaxLength(64);
modelBuilder.Entity<SubmittedPackageAttachment>()
.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 // Phase 5.5: user-owned interview preparation. Unlike InterviewPrepNote (an AI cache), nothing
// regenerates this. docs/architecture/application-workspace.md. // regenerates this. docs/architecture/application-workspace.md.
modelBuilder.Entity<InterviewPrepItem>() modelBuilder.Entity<InterviewPrepItem>()
@@ -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`);
""";
}
@@ -1086,6 +1086,43 @@ namespace JobTrackerApi.Migrations
b.ToTable("CoverLetterVersions"); b.ToTable("CoverLetterVersions");
}); });
modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("ApplicationMaterialJson").IsRequired().HasColumnType("TEXT");
b.Property<string>("CareerProfileJson").HasColumnType("TEXT");
b.Property<string>("CoverLetterText").HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAtUtc").HasColumnType("TEXT");
b.Property<string>("CvSettingsJson").HasColumnType("TEXT");
b.Property<string>("CvThemeId").HasMaxLength(64).HasColumnType("TEXT");
b.Property<int?>("CvVariantId").HasColumnType("INTEGER");
b.Property<string>("CvVariantName").HasMaxLength(255).HasColumnType("TEXT");
b.Property<int?>("CvVariantVersion").HasColumnType("INTEGER");
b.Property<int>("JobApplicationId").HasColumnType("INTEGER");
b.Property<string>("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("RenderedCvHtml").HasColumnType("TEXT");
b.Property<int>("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<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("FileName").IsRequired().HasMaxLength(500).HasColumnType("TEXT");
b.Property<string>("FilePath").IsRequired().HasColumnType("TEXT");
b.Property<long>("FileSize").HasColumnType("INTEGER");
b.Property<string>("FileType").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("Purpose").HasMaxLength(64).HasColumnType("TEXT");
b.Property<string>("Sha256").IsRequired().HasMaxLength(64).HasColumnType("TEXT");
b.Property<int>("SubmittedApplicationPackageId").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SubmittedApplicationPackageId");
b.ToTable("SubmittedPackageAttachments");
});
modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b => modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b =>
{ {
b.Property<int>("Id") b.Property<int>("Id")
@@ -2666,6 +2703,29 @@ namespace JobTrackerApi.Migrations
b.Navigation("JobApplication"); 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 => modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b =>
{ {
b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact") b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact")
@@ -2893,6 +2953,11 @@ namespace JobTrackerApi.Migrations
b.Navigation("TailoredCvDraft"); b.Navigation("TailoredCvDraft");
}); });
modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }
@@ -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<SubmittedPackageAttachment> 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;
}
+1
View File
@@ -76,6 +76,7 @@ builder.Services.AddScoped<IApplicationChecklistService, ApplicationChecklistSer
builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>(); builder.Services.AddScoped<IApplicationTimelineService, ApplicationTimelineService>();
builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>(); builder.Services.AddScoped<IApplicationIntelligenceService, ApplicationIntelligenceService>();
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>(); builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
builder.Services.AddScoped<ISubmittedApplicationPackageService, SubmittedApplicationPackageService>();
builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>(); builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>();
builder.Services.AddSingleton<AppPaths>(); builder.Services.AddSingleton<AppPaths>();
@@ -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 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 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 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 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 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) 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, AiUsage = aiUsage,
ChecklistItems = checklist, ChecklistItems = checklist,
CoverLetterVersions = coverLetters, 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, InterviewPrepItems = interviewItems,
EmailDrafts = emailDrafts, EmailDrafts = emailDrafts,
EmailSendAttempts = emailAttempts, 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) var operations = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
.Select(item => new .Select(item => new
@@ -227,6 +227,9 @@ public sealed class AccountDeletionService(
deleted += await db.EmailSendAttempts.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.ApplicationChecklistItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CoverLetterVersions.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.InterviewPrepItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.InterviewPrepNotes.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.AiWorkspaceNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
@@ -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); 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() var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking()
.Where(item => item.OwnerUserId == ownerUserId) .Where(item => item.OwnerUserId == ownerUserId)
@@ -4,14 +4,17 @@ using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services; namespace JobTrackerApi.Services;
public sealed record AttachmentReconciliationResult(int Promoted, int Restored, int Purged, int Missing, int UnknownOrphans, int UnsafePaths, int Failures); 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 public interface IAttachmentStorage
{ {
string CreateFinalPath(int jobId, string storedFileName); string CreateFinalPath(int jobId, string storedFileName);
string StagePath(string finalPath); string StagePath(string finalPath);
string DeletePath(string finalPath); string DeletePath(string finalPath);
string CreatePackagePath(int jobId, int packageVersion, string storedFileName);
bool IsManagedPath(string path); bool IsManagedPath(string path);
Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken); Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken);
Task<StoredSnapshotFile> SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken);
void Promote(string stagePath, string finalPath); void Promote(string stagePath, string finalPath);
void Quarantine(string finalPath, string deletePath); void Quarantine(string finalPath, string deletePath);
void Restore(string deletePath, string finalPath); 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 StagePath(string finalPath) => EnsureManagedPath(finalPath, true) + UploadSuffix;
public string DeletePath(string finalPath) => EnsureManagedPath(finalPath, true) + DeleteSuffix; 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) public bool IsManagedPath(string path)
{ {
try try
@@ -75,6 +86,36 @@ public sealed class AttachmentStorage : IAttachmentStorage
} }
} }
public async Task<StoredSnapshotFile> 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) => public void Promote(string stagePath, string finalPath) =>
File.Move(EnsureManagedPath(stagePath, false), EnsureManagedPath(finalPath, true), overwrite: false); 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() var storedPaths = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
.Select(x => x.FilePath) .Select(x => x.FilePath)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
storedPaths.AddRange(await db.SubmittedPackageAttachments.IgnoreQueryFilters().AsNoTracking()
.Select(x => x.FilePath)
.ToListAsync(cancellationToken));
var known = new HashSet<string>(storedPaths.Where(IsManagedPath).Select(Path.GetFullPath), _comparer); var known = new HashSet<string>(storedPaths.Where(IsManagedPath).Select(Path.GetFullPath), _comparer);
var unsafePaths = storedPaths.Count(path => !IsManagedPath(path)); var unsafePaths = storedPaths.Count(path => !IsManagedPath(path));
var promoted = 0; var promoted = 0;
@@ -50,6 +50,8 @@ internal static class StartupSchemaOwnership
"Jobs", "Jobs",
"MicrosoftGraphConnections", "MicrosoftGraphConnections",
"RuleSettings", "RuleSettings",
"SubmittedApplicationPackages",
"SubmittedPackageAttachments",
"SystemEmailSettings", "SystemEmailSettings",
"TailoredCvDrafts", "TailoredCvDrafts",
"TrustedDevices", "TrustedDevices",
@@ -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<SubmittedPackageAttachmentDto> 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<IReadOnlyList<SubmittedPackageDto>?> ListAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
Task<SubmittedPackageDto?> CaptureAsync(string ownerUserId, int jobApplicationId, CvRenderPerson person, CancellationToken ct);
Task<SubmittedPackageDetailDto?> GetAsync(string ownerUserId, int jobApplicationId, int packageId, CancellationToken ct);
Task<SubmittedPackageFile?> 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<IReadOnlyList<SubmittedPackageDto>?> 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<SubmittedPackageDto?> 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<string>();
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<SubmittedPackageDetailDto?> 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<SubmittedPackageFile?> 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());
}
+19 -3
View File
@@ -14,7 +14,9 @@ application's home.
## What it is NOT ## 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) | | 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 | | Application answers / recruiter draft | compatibility fields on `JobApplication`, exposed as separate workspace fields |
| Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history | | Analysis / Match / Interview | Phase 5 `AiWorkspacePanel` + `AiInteraction` history |
| Documents | `Attachment` | | Documents | `Attachment` |
| Submitted package history | `SubmittedApplicationPackage` + copied, SHA-256-identified attachment bytes |
| Communication | `Correspondence` | | Communication | `Correspondence` |
| Activity / Timeline | `JobEvent` | | Activity / Timeline | `JobEvent` |
| Stage semantics | `JobPipeline` | | Stage semantics | `JobPipeline` |
No career data is copied into the application. Nothing in this feature writes to the master profile, Nothing in this feature writes back to the master profile, a CV variant, or a cover letter. A user-
a CV variant, or a cover letter. initiated submission snapshot copies their current values into an append-only evidence record.
## Backend ## 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) ## 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: The workspace becomes the place an application is prepared. The rule is one-directional:
``` ```
+5 -2
View File
@@ -1,6 +1,6 @@
# JobTracker master programme progress # JobTracker master programme progress
Updated: 2026-08-30 Updated: 2026-08-31
## 2026-08-29 audit implementation programme ## 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 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. - 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. - 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 ### 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 ### 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. - 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`. - 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. - 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. - Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed. - Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed. - Next production build and TypeScript: passed.
@@ -62,6 +62,7 @@ function routeGet(overrides: Record<string, any> = {}) {
mockedApi.get.mockImplementation((url: string) => { mockedApi.get.mockImplementation((url: string) => {
if (url.endsWith("/tailoring")) return Promise.resolve({ data: overrides.tailoring ?? tailoring } as any); 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("/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); 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(); 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(<ApplicationCvSection jobId={7} />);
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 ---------- // ---------- Cover letter ----------
test("cover letter loads the current text and its history", async () => { test("cover letter loads the current text and its history", async () => {
@@ -246,6 +246,35 @@ export type CoverLetter = {
aiSuggestionCount: number; 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 = { export const applicationAssetsApi = {
cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data), cv: (jobId: number) => api.get<ApplicationCv>(`/jobapplications/${jobId}/cv`).then((r) => r.data),
attachVariant: (jobId: number, variantId: number | null) => attachVariant: (jobId: number, variantId: number | null) =>
@@ -258,6 +287,14 @@ export const applicationAssetsApi = {
api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data), api.put<CoverLetter>(`/jobapplications/${jobId}/cover-letter`, { text, source, aiAction }).then((r) => r.data),
restoreCoverLetter: (jobId: number, version: number) => restoreCoverLetter: (jobId: number, version: number) =>
api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data), api.post<CoverLetter>(`/jobapplications/${jobId}/cover-letter/versions/${version}/restore`).then((r) => r.data),
submittedPackages: (jobId: number) =>
api.get<SubmittedPackage[]>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
captureSubmittedPackage: (jobId: number) =>
api.post<SubmittedPackage>(`/jobapplications/${jobId}/submitted-packages`).then((r) => r.data),
submittedPackage: (jobId: number, packageId: number) =>
api.get<SubmittedPackageDetail>(`/jobapplications/${jobId}/submitted-packages/${packageId}`).then((r) => r.data),
downloadSubmittedAttachment: (jobId: number, packageId: number, attachmentId: number) =>
api.get<Blob>(`/jobapplications/${jobId}/submitted-packages/${packageId}/attachments/${attachmentId}`, { responseType: "blob" }).then((r) => r.data),
saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) => saveApplicationDrafts: (jobId: number, applicationAnswerDraft: string, recruiterMessageDraft: string) =>
api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined), api.put(`/jobapplications/${jobId}/application-drafts`, { applicationAnswerDraft, recruiterMessageDraft }).then(() => undefined),
}; };
@@ -1,17 +1,19 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { 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, Skeleton, Stack, TextField, Tooltip, Typography,
} from "@mui/material"; } from "@mui/material";
import RichTextField from "./RichTextField"; import RichTextField from "./RichTextField";
import OpenInNewIcon from "@mui/icons-material/OpenInNew"; import OpenInNewIcon from "@mui/icons-material/OpenInNew";
import RestoreIcon from "@mui/icons-material/Restore"; import RestoreIcon from "@mui/icons-material/Restore";
import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; 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 { getApiErrorMessage } from "../api";
import { import {
ApplicationCv, CoverLetter, TailoringPlan, applicationAssetsApi, ApplicationCv, CoverLetter, SubmittedPackage, SubmittedPackageDetail, TailoringPlan, applicationAssetsApi,
} from "../applicationWorkspace"; } from "../applicationWorkspace";
import { cvBuilderApi } from "../cvBuilder"; import { cvBuilderApi } from "../cvBuilder";
import { aiWorkspaceApi } from "../aiWorkspace"; import { aiWorkspaceApi } from "../aiWorkspace";
@@ -196,10 +198,132 @@ export function ApplicationCvSection({ jobId }: { jobId: number }) {
</Shell> </Shell>
<ApplicationTailoringSection jobId={jobId} /> <ApplicationTailoringSection jobId={jobId} />
<SubmittedPackagesSection jobId={jobId} />
</Stack> </Stack>
); );
} }
function SubmittedPackagesSection({ jobId }: { jobId: number }) {
const { language, t } = useI18n();
const { data, error, loading, setData, setError } = useAsset<SubmittedPackage[]>(
() => applicationAssetsApi.submittedPackages(jobId),
[jobId],
);
const [busy, setBusy] = useState(false);
const [detail, setDetail] = useState<SubmittedPackageDetail | null>(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<string, unknown>; } catch { return null; }
})();
return (
<Shell title={t("assetsSubmittedPackages")} subtitle={t("assetsSubmittedPackagesSubtitle")} loading={loading} error={error}>
<Stack spacing={1.5}>
<Button variant="contained" startIcon={<ArchiveOutlinedIcon />} disabled={busy} onClick={() => void capture()} sx={{ alignSelf: "flex-start" }}>
{busy ? t("assetsPackageCapturing") : t("assetsPackageCapture")}
</Button>
{(data?.length ?? 0) === 0 ? (
<Typography variant="body2" color="text.secondary">{t("assetsNoSubmittedPackages")}</Typography>
) : data?.map((item) => (
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}>
<Stack spacing={1}>
<Stack direction="row" justifyContent="space-between" alignItems="flex-start" gap={1}>
<Box>
<Typography variant="body2" sx={{ fontWeight: 700 }}>{t("assetsSubmittedPackageVersion", { version: item.version })}</Typography>
<Typography variant="caption" color="text.secondary">
{new Date(item.createdAtUtc).toLocaleString(language === "nb" ? "nb-NO" : "en")}
</Typography>
</Box>
<Stack direction="row" gap={0.5} flexWrap="wrap" justifyContent="flex-end">
<Chip size="small" variant="outlined" color={item.hasCv ? "success" : "default"} label={item.hasCv ? `${item.cvVariantName ?? t("workspaceCv")} · v${item.cvVariantVersion ?? "—"}` : t("assetsPackageNoCv")} />
<Chip size="small" variant="outlined" color={item.hasCoverLetter ? "success" : "default"} label={item.hasCoverLetter ? t("workspaceCoverLetter") : t("assetsPackageNoCoverLetter")} />
</Stack>
</Stack>
<Button size="small" variant="outlined" disabled={detailBusy} onClick={() => void view(item)} sx={{ alignSelf: "flex-start" }}>
{t("assetsPackageView")}
</Button>
{item.attachments.map((attachment) => (
<Button key={attachment.id} size="small" variant="text" startIcon={<DownloadOutlinedIcon />} onClick={() => void download(item, attachment)} sx={{ alignSelf: "flex-start" }}>
{attachment.fileName} · {(attachment.fileSize / 1024).toFixed(1)} KB
</Button>
))}
</Stack>
</Paper>
))}
</Stack>
<Dialog open={detail !== null} onClose={() => setDetail(null)} fullWidth maxWidth="lg" aria-labelledby="submitted-package-title">
<DialogTitle id="submitted-package-title">
{detail ? t("assetsSubmittedPackageVersion", { version: detail.summary.version }) : t("assetsSubmittedPackages")}
</DialogTitle>
<DialogContent dividers>
<Stack spacing={2.5}>
{detail?.renderedCvHtml ? (
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCv")}</Typography>
<Box component="iframe" title={t("assetsPackageCvPreview")} srcDoc={detail.renderedCvHtml} sandbox="" sx={{ width: "100%", height: { xs: 520, md: 760 }, border: 1, borderColor: "divider", bgcolor: "white" }} />
</Box>
) : <Alert severity="info">{t("assetsPackageNoCv")}</Alert>}
<Box>
<Typography variant="subtitle2" gutterBottom>{t("workspaceCoverLetter")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>
{detail?.coverLetterText || t("assetsPackageNoCoverLetter")}
</Typography>
</Box>
{material && ["TailoredCvText", "RecruiterMessageDraft", "Notes"].map((key) => typeof material[key] === "string" && material[key] ? (
<Box key={key}>
<Typography variant="subtitle2">{t(`assetsPackage${key}` as "assetsPackageTailoredCvText")}</Typography>
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", overflowWrap: "anywhere" }}>{String(material[key])}</Typography>
</Box>
) : null)}
</Stack>
</DialogContent>
<DialogActions><Button onClick={() => setDetail(null)}>{t("close")}</Button></DialogActions>
</Dialog>
</Shell>
);
}
// ---------- Tailoring ---------- // ---------- Tailoring ----------
export function ApplicationTailoringSection({ jobId }: { jobId: number }) { export function ApplicationTailoringSection({ jobId }: { jobId: number }) {
+32
View File
@@ -413,6 +413,22 @@ export const translations = {
intelligenceRelevantProjects: "Relevant projects", intelligenceRelevantProjects: "Relevant projects",
intelligenceSuggestions: "Suggestions", intelligenceSuggestions: "Suggestions",
assetsCvSubtitle: "The CV variant used for this application. Variants tailor the master career profile without duplicating it.", 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.", assetsLoadFailed: "Could not load this section.",
assetsAttachCvFailed: "Could not change the attached CV.", assetsAttachCvFailed: "Could not change the attached CV.",
assetsDuplicateCvFailed: "Could not create a tailored CV copy.", assetsDuplicateCvFailed: "Could not create a tailored CV copy.",
@@ -2729,6 +2745,22 @@ export const translations = {
intelligenceRelevantProjects: "Relevante prosjekter", intelligenceRelevantProjects: "Relevante prosjekter",
intelligenceSuggestions: "Forslag", intelligenceSuggestions: "Forslag",
assetsCvSubtitle: "CV-varianten som brukes for denne søknaden. Varianter tilpasser karriereprofilen uten å duplisere den.", 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.", assetsLoadFailed: "Kunne ikke laste denne delen.",
assetsAttachCvFailed: "Kunne ikke endre den tilknyttede CV-en.", assetsAttachCvFailed: "Kunne ikke endre den tilknyttede CV-en.",
assetsDuplicateCvFailed: "Kunne ikke opprette en tilpasset CV-kopi.", assetsDuplicateCvFailed: "Kunne ikke opprette en tilpasset CV-kopi.",