feat(applications): preserve submitted packages
This commit is contained in:
@@ -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