using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using System.Security.Cryptography; namespace JobTrackerApi.Controllers { [ApiController] [Route("api/attachments")] 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; public AttachmentsController(AppPaths paths, JobTrackerContext db) { _paths = paths; _db = db; } 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"; } [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; } if (!string.IsNullOrWhiteSpace(request.Purpose)) { att.Purpose = request.Purpose.Trim().ToLowerInvariant(); } var rawName = (request.FileName ?? string.Empty).Trim(); if (rawName.Length == 0) { 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); 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; _db.Attachments.Remove(att); 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."); 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); return Ok(); } } }