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 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?> ListAsync(string ownerUserId, int jobApplicationId, CancellationToken ct); Task CaptureAsync(string ownerUserId, int jobApplicationId, CvRenderPerson person, CancellationToken ct); Task GetAsync(string ownerUserId, int jobApplicationId, int packageId, CancellationToken ct); Task 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?> 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 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(); 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 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 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()); }