using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using System.Security.Cryptography; namespace JobTrackerApi.Controllers { [ApiController] [Route("api/attachments")] [Authorize(AuthenticationSchemes = "local")] public class AttachmentsController : ControllerBase { private const long MaxFileSizeBytes = 10 * 1024 * 1024; // 10 MB per file keeps local storage use predictable. private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) { ".pdf", ".doc", ".docx", ".txt", ".rtf", ".png", ".jpg", ".jpeg", ".webp" }; private readonly AppPaths _paths; private readonly JobTrackerContext _db; private readonly UserManager? _users; public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager? users = null) { _paths = paths; _db = db; _users = users; } public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi); // Child entities are accessed by raw integer ids in a few endpoints below. // Always resolve them through the parent JobApplication query so the global job-level // ownership filter is still enforced for multi-user environments. private Task FindOwnedAttachmentAsync(int attachmentId, CancellationToken cancellationToken) { return _db.Attachments .Include(a => a.JobApplication) .FirstOrDefaultAsync(a => a.Id == attachmentId, cancellationToken); } private static string BuildStoredFileName(string originalName) { var ext = Path.GetExtension(originalName); var suffix = Convert.ToHexString(RandomNumberGenerator.GetBytes(6)).ToLowerInvariant(); return $"{DateTime.UtcNow:yyyyMMddHHmmssfff}-{suffix}{ext}"; } private static string GuessPurpose(string fileName) { var n = (fileName ?? string.Empty).ToLowerInvariant(); if (n.Contains("cover")) return "cover-letter"; if (n.Contains("resume") || n.Contains("résumé") || n.Contains(" cv") || n.EndsWith("cv.pdf")) return "resume"; if (n.Contains("portfolio")) return "portfolio"; if (n.Contains("case") || n.Contains("sample")) return "case-study"; if (n.Contains("cert")) return "certificate"; return "other"; } // JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived // from actual Attachment rows, not manually settable -- this is the single place they're // written, called after every attachment mutation (upload/delete/purpose change) so they // can never drift from what's actually attached. private async Task RecomputeAttachmentFlagsAsync(int jobId, CancellationToken cancellationToken) { var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken); if (job is null) return; var purposes = await _db.Attachments .Where(a => a.JobApplicationId == jobId) .Select(a => a.Purpose) .ToListAsync(cancellationToken); job.HasResume = purposes.Any(p => p == "resume"); job.HasCoverLetter = purposes.Any(p => p == "cover-letter"); job.HasPortfolio = purposes.Any(p => p == "portfolio"); job.HasOtherAttachment = purposes.Any(p => p is not ("resume" or "cover-letter" or "portfolio")); } [HttpGet("{jobId:int}")] public async Task>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken) { var jobOk = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken); if (!jobOk) return NotFound(); var items = await _db.Attachments .AsNoTracking() .Where(a => a.JobApplicationId == jobId) .OrderByDescending(a => a.UploadDate) .Select(a => new AttachmentDto(a.Id, a.FileName, a.UploadDate, a.FileType, a.FileSize, a.Purpose, a.UseForAi)) .ToListAsync(cancellationToken); return Ok(items); } [HttpGet("download/{id:int}")] public async Task Download([FromRoute] int id, CancellationToken cancellationToken) { var att = await FindOwnedAttachmentAsync(id, cancellationToken); if (att is null) return NotFound(); if (string.IsNullOrWhiteSpace(att.FilePath) || !System.IO.File.Exists(att.FilePath)) return NotFound(); var contentType = string.IsNullOrWhiteSpace(att.FileType) ? "application/octet-stream" : att.FileType; var fileName = Path.GetFileName(att.FileName); return PhysicalFile(att.FilePath, contentType, fileName); } public sealed record UpdateAttachmentRequest(string? FileName, string? Purpose, bool? UseForAi); [HttpPatch("{id:int}")] public async Task Rename([FromRoute] int id, [FromBody] UpdateAttachmentRequest request, CancellationToken cancellationToken) { var att = await FindOwnedAttachmentAsync(id, cancellationToken); if (att is null) return NotFound(); if (request.UseForAi is not null) { att.UseForAi = request.UseForAi.Value; } var purposeChanged = !string.IsNullOrWhiteSpace(request.Purpose); if (purposeChanged) { att.Purpose = request.Purpose!.Trim().ToLowerInvariant(); } var rawName = (request.FileName ?? string.Empty).Trim(); if (rawName.Length == 0) { await _db.SaveChangesAsync(cancellationToken); if (purposeChanged) { // Recompute needs the Purpose change committed first -- a fresh query // wouldn't see the pending change yet. await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); } return NoContent(); } var name = Path.GetFileName(rawName); var ext = Path.GetExtension(name); if (!AllowedExtensions.Contains(ext)) return BadRequest("That file type is not allowed."); var folder = Path.GetDirectoryName(att.FilePath) ?? _paths.AttachmentsRoot; var newPath = Path.Combine(folder, BuildStoredFileName(name)); if (System.IO.File.Exists(att.FilePath) && !string.Equals(att.FilePath, newPath, StringComparison.OrdinalIgnoreCase)) { System.IO.File.Move(att.FilePath, newPath, overwrite: false); } att.FileName = name; att.FilePath = newPath; await _db.SaveChangesAsync(cancellationToken); if (purposeChanged) { await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); } return NoContent(); } [HttpDelete("{id:int}")] public async Task Delete([FromRoute] int id, CancellationToken cancellationToken) { var att = await FindOwnedAttachmentAsync(id, cancellationToken); if (att is null) return NotFound(); var path = att.FilePath; var jobId = att.JobApplicationId; _db.Attachments.Remove(att); await _db.SaveChangesAsync(cancellationToken); await RecomputeAttachmentFlagsAsync(jobId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); try { if (!string.IsNullOrWhiteSpace(path) && System.IO.File.Exists(path)) System.IO.File.Delete(path); } catch { // best effort } return NoContent(); } [HttpPost] public async Task Upload([FromForm] IFormFileCollection files, [FromForm] int jobId, CancellationToken cancellationToken) { if (jobId <= 0) return BadRequest("Valid jobId is required."); if (files is null || files.Count == 0) return BadRequest("At least one file is required."); var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken); if (!jobExists) return BadRequest("jobId does not exist."); if (_users is not null) { var user = await _users.GetUserAsync(User); if (user is null) return Unauthorized(); var roles = await _users.GetRolesAsync(user); var limit = AccountPlans.ForRoles(roles).StorageBytes; var used = await _db.Attachments.Where(a => a.JobApplication.OwnerUserId == user.Id).SumAsync(a => (long?)a.FileSize, cancellationToken) ?? 0; var incoming = files.Sum(file => file.Length); if (incoming > limit - used) return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan."); } var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString()); Directory.CreateDirectory(folder); foreach (var file in files) { if (file.Length == 0) continue; if (file.Length > MaxFileSizeBytes) return BadRequest($"{file.FileName} exceeds the 10 MB upload limit."); var displayName = Path.GetFileName(file.FileName); var ext = Path.GetExtension(displayName); if (!AllowedExtensions.Contains(ext)) return BadRequest($"{displayName} is not an allowed file type."); // Store uploads under unique generated filenames so re-uploads never overwrite // earlier files with the same visible name. var storedName = BuildStoredFileName(displayName); var path = Path.Combine(folder, storedName); await using var stream = new FileStream(path, FileMode.CreateNew, FileAccess.Write, FileShare.None); await file.CopyToAsync(stream, cancellationToken); _db.Attachments.Add(new Attachment { JobApplicationId = jobId, FileName = displayName, FilePath = path, UploadDate = DateTime.Now, FileType = string.IsNullOrWhiteSpace(file.ContentType) ? "application/octet-stream" : file.ContentType, FileSize = file.Length, Purpose = GuessPurpose(displayName), UseForAi = true, }); } await _db.SaveChangesAsync(cancellationToken); await RecomputeAttachmentFlagsAsync(jobId, cancellationToken); await _db.SaveChangesAsync(cancellationToken); return Ok(); } } }