Files
jobtrackingapp/JobTrackerApi/Services/SubmittedApplicationPackageService.cs
T
2026-08-31 16:54:41 +02:00

176 lines
8.2 KiB
C#

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());
}