feat(applications): preserve submitted packages
This commit is contained in:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> CurrentUserIdAsync() => (await _users.GetUserAsync(User))?.Id;
|
[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 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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 }) {
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
Reference in New Issue
Block a user