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
@@ -23,11 +23,13 @@ public sealed class ApplicationAssetsController : ControllerBase
private readonly UserManager<ApplicationUser> _users;
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;
_assets = assets;
_packages = packages;
}
[HttpGet("cv")]
@@ -85,5 +87,50 @@ public sealed class ApplicationAssetsController : ControllerBase
return result is null ? NotFound() : Ok(result);
}
[HttpGet("submitted-packages")]
public async Task<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));
}
}
+29
View File
@@ -59,6 +59,8 @@ namespace JobTrackerApi.Data
public DbSet<AiUsageRecord> AiUsageRecords => Set<AiUsageRecord>();
public DbSet<ApplicationChecklistItem> ApplicationChecklistItems => Set<ApplicationChecklistItem>();
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<UserOperation> UserOperations => Set<UserOperation>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
@@ -550,6 +552,33 @@ namespace JobTrackerApi.Data
.HasForeignKey(x => x.JobApplicationId)
.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
// regenerates this. docs/architecture/application-workspace.md.
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");
});
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 =>
{
b.Property<int>("Id")
@@ -2666,6 +2703,29 @@ namespace JobTrackerApi.Migrations
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
.WithMany()
.HasForeignKey("JobApplicationId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.SubmittedPackageAttachment", b =>
{
b.HasOne("JobTrackerApi.Models.SubmittedApplicationPackage", "Package")
.WithMany("Attachments")
.HasForeignKey("SubmittedApplicationPackageId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired()
.HasConstraintName("FK_SubmittedPackageAttachments_Packages_PackageId");
b.Navigation("Package");
});
modelBuilder.Entity("JobTrackerApi.Models.CvExtractionRun", b =>
{
b.HasOne("JobTrackerApi.Models.CvUploadArtifact", "Artifact")
@@ -2893,6 +2953,11 @@ namespace JobTrackerApi.Migrations
b.Navigation("TailoredCvDraft");
});
modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b =>
{
b.Navigation("Attachments");
});
#pragma warning restore 612, 618
}
}
@@ -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<IApplicationIntelligenceService, ApplicationIntelligenceService>();
builder.Services.AddScoped<IApplicationAssetsService, ApplicationAssetsService>();
builder.Services.AddScoped<ISubmittedApplicationPackageService, SubmittedApplicationPackageService>();
builder.Services.AddScoped<IInterviewPrepService, InterviewPrepService>();
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 checklist = await db.ApplicationChecklistItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var coverLetters = await db.CoverLetterVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var submittedPackages = await db.SubmittedApplicationPackages.IgnoreQueryFilters().AsNoTracking()
.Include(item => item.Attachments)
.Where(item => item.OwnerUserId == ownerUserId)
.OrderBy(item => item.Id)
.ToListAsync(cancellationToken);
var interviewItems = await db.InterviewPrepItems.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var emailDrafts = await db.EmailDrafts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
var emailAttempts = await db.EmailSendAttempts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
@@ -192,10 +197,17 @@ public sealed class AccountDataExportService(
AiUsage = aiUsage,
ChecklistItems = checklist,
CoverLetterVersions = coverLetters,
SubmittedApplicationPackages = submittedPackages.Select(item => new
{
item.Id, item.JobApplicationId, item.Version, item.CvVariantId, item.CvVariantName,
item.CvVariantVersion, item.CvThemeId, item.CvSettingsJson, item.CareerProfileJson,
item.RenderedCvHtml, item.CoverLetterText, item.ApplicationMaterialJson, item.CreatedAtUtc,
Attachments = item.Attachments.Select(file => new { file.Id, file.FileName, file.FileType, file.Purpose, file.FileSize, file.Sha256 }),
}),
InterviewPrepItems = interviewItems,
EmailDrafts = emailDrafts,
EmailSendAttempts = emailAttempts,
}, tailoredDrafts.Count + interviewNotes.Count + aiNotes.Count + aiInteractions.Count + aiUsage.Count + checklist.Count + coverLetters.Count + interviewItems.Count + emailDrafts.Count + emailAttempts.Count);
}, tailoredDrafts.Count + interviewNotes.Count + aiNotes.Count + aiInteractions.Count + aiUsage.Count + checklist.Count + coverLetters.Count + submittedPackages.Count + submittedPackages.Sum(item => item.Attachments.Count) + interviewItems.Count + emailDrafts.Count + emailAttempts.Count);
var operations = await db.UserOperations.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc)
.Select(item => new
@@ -227,6 +227,9 @@ public sealed class AccountDeletionService(
deleted += await db.EmailSendAttempts.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.ApplicationChecklistItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CoverLetterVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
var packageIds = await db.SubmittedApplicationPackages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
deleted += await db.SubmittedPackageAttachments.IgnoreQueryFilters().Where(item => packageIds.Contains(item.SubmittedApplicationPackageId)).ExecuteDeleteAsync(cancellationToken);
deleted += await db.SubmittedApplicationPackages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.InterviewPrepItems.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.InterviewPrepNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.AiWorkspaceNotes.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
@@ -25,6 +25,17 @@ public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths pat
{
AddPath(files, warnings, attachment.FilePath, $"files/attachments/{attachment.Id}/{SafeSegment(attachment.FileName)}", "attachment", attachmentStorage.IsManagedPath);
}
var submittedAttachments = await db.SubmittedPackageAttachments.IgnoreQueryFilters().AsNoTracking()
.Include(item => item.Package)
.Where(item => item.Package != null && item.Package.OwnerUserId == ownerUserId)
.OrderBy(item => item.Id)
.ToListAsync(cancellationToken);
foreach (var attachment in submittedAttachments)
{
AddPath(files, warnings, attachment.FilePath,
$"files/submitted-packages/{attachment.SubmittedApplicationPackageId}/{attachment.Id}/{SafeSegment(attachment.FileName)}",
"submitted-package", attachmentStorage.IsManagedPath);
}
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking()
.Where(item => item.OwnerUserId == ownerUserId)
@@ -4,14 +4,17 @@ using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public sealed record AttachmentReconciliationResult(int Promoted, int Restored, int Purged, int Missing, int UnknownOrphans, int UnsafePaths, int Failures);
public sealed record StoredSnapshotFile(string Path, long Length, string Sha256);
public interface IAttachmentStorage
{
string CreateFinalPath(int jobId, string storedFileName);
string StagePath(string finalPath);
string DeletePath(string finalPath);
string CreatePackagePath(int jobId, int packageVersion, string storedFileName);
bool IsManagedPath(string path);
Task StageAsync(IFormFile file, string stagePath, CancellationToken cancellationToken);
Task<StoredSnapshotFile> SnapshotAsync(string sourcePath, string destinationPath, CancellationToken cancellationToken);
void Promote(string stagePath, string finalPath);
void Quarantine(string finalPath, string deletePath);
void Restore(string deletePath, string finalPath);
@@ -43,6 +46,14 @@ public sealed class AttachmentStorage : IAttachmentStorage
public string StagePath(string finalPath) => EnsureManagedPath(finalPath, true) + UploadSuffix;
public string DeletePath(string finalPath) => EnsureManagedPath(finalPath, true) + DeleteSuffix;
public string CreatePackagePath(int jobId, int packageVersion, string storedFileName)
{
var folder = Path.Combine(_root, jobId.ToString(System.Globalization.CultureInfo.InvariantCulture), "submitted", packageVersion.ToString(System.Globalization.CultureInfo.InvariantCulture));
Directory.CreateDirectory(folder);
EnsureManagedPath(folder, allowMissingLeaf: false);
return EnsureManagedPath(Path.Combine(folder, Path.GetFileName(storedFileName)), allowMissingLeaf: true);
}
public bool IsManagedPath(string path)
{
try
@@ -75,6 +86,36 @@ public sealed class AttachmentStorage : IAttachmentStorage
}
}
public async Task<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) =>
File.Move(EnsureManagedPath(stagePath, false), EnsureManagedPath(finalPath, true), overwrite: false);
@@ -95,6 +136,9 @@ public sealed class AttachmentStorage : IAttachmentStorage
var storedPaths = await db.Attachments.IgnoreQueryFilters().AsNoTracking()
.Select(x => x.FilePath)
.ToListAsync(cancellationToken);
storedPaths.AddRange(await db.SubmittedPackageAttachments.IgnoreQueryFilters().AsNoTracking()
.Select(x => x.FilePath)
.ToListAsync(cancellationToken));
var known = new HashSet<string>(storedPaths.Where(IsManagedPath).Select(Path.GetFullPath), _comparer);
var unsafePaths = storedPaths.Count(path => !IsManagedPath(path));
var promoted = 0;
@@ -50,6 +50,8 @@ internal static class StartupSchemaOwnership
"Jobs",
"MicrosoftGraphConnections",
"RuleSettings",
"SubmittedApplicationPackages",
"SubmittedPackageAttachments",
"SystemEmailSettings",
"TailoredCvDrafts",
"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());
}