feat(applications): preserve submitted packages
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in New Issue
Block a user