using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; namespace JobTrackerApi.Controllers; [ApiController] [Route("api/jobapplications")] [Authorize(AuthenticationSchemes = "local")] public sealed class JobApplicationLifecycleController : ControllerBase { private readonly JobTrackerContext _db; public JobApplicationLifecycleController(JobTrackerContext db) { _db = db; } /// Canonical ordered pipeline stages so the UI renders one source of truth. [HttpGet("pipeline")] public ActionResult> GetPipeline() => Ok(JobPipeline.Stages.Select(stage => new PipelineStageDto( stage.Key, stage.Order, stage.Category.ToString(), stage.Group.ToString()))); [HttpPatch("{id:int}/status")] public async Task UpdateStatus( [FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken) { var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); if (job is null) return NotFound(); if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required."); var oldStatus = job.Status; var changedAt = DateTime.Now; job.Status = JobPipeline.Normalize(request.Status); JobLifecycleEvents.SyncAppliedDateWithHistory(_db, job, changedAt); JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, changedAt); await _db.SaveChangesAsync(cancellationToken); return NoContent(); } /// /// Suggests a pipeline status from the job's latest inbound message. Applying it remains a /// separate, user-confirmed status update. /// [HttpGet("{id:int}/status-suggestion")] public async Task> GetStatusSuggestion( [FromRoute] int id, CancellationToken cancellationToken) { var job = await _db.JobApplications.AsNoTracking() .FirstOrDefaultAsync(item => item.Id == id, cancellationToken); if (job is null) return NotFound(); var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null); var latestInbound = await _db.Correspondences .AsNoTracking() .Where(item => item.JobApplicationId == id && item.Direction != "outbound" && item.From != "Me") .OrderByDescending(item => item.Date) .FirstOrDefaultAsync(cancellationToken); if (latestInbound is null) return Ok(none); var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content); if (suggestion is null) return Ok(none); var currentOrder = JobPipeline.OrderOf(job.Status); var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus); if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder) { return Ok(none); } return Ok(new StatusSuggestionDto( HasSuggestion: true, SuggestedStatus: suggestion.SuggestedStatus, CurrentStatus: job.Status, Signal: suggestion.Signal, Confidence: suggestion.Confidence, MessageDate: latestInbound.Date, MessageSubject: latestInbound.Subject)); } [HttpDelete("{id:int}")] public async Task SoftDelete([FromRoute] int id, CancellationToken cancellationToken) { var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); if (job is null) return NotFound(); if (!job.IsDeleted) { job.IsDeleted = true; job.DeletedAt = DateTime.Now; _db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Deleted", At = DateTime.Now }); await _db.SaveChangesAsync(cancellationToken); } return NoContent(); } [HttpPost("{id:int}/restore")] public async Task Restore([FromRoute] int id, CancellationToken cancellationToken) { var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); if (job is null) return NotFound(); if (job.IsDeleted) { job.IsDeleted = false; job.DeletedAt = null; _db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "Restored", At = DateTime.Now }); await _db.SaveChangesAsync(cancellationToken); } return NoContent(); } [HttpPatch("{id:int}/followup")] public async Task SetFollowUp( [FromRoute] int id, [FromBody] FollowUpRequest request, CancellationToken cancellationToken) { var job = await _db.JobApplications.FirstOrDefaultAsync(item => item.Id == id, cancellationToken); if (job is null) return NotFound(); var old = job.FollowUpAt?.ToString("o"); job.FollowUpAt = request.FollowUpAt; _db.JobEvents.Add(new JobEvent { JobApplicationId = job.Id, Type = "FollowUpSet", OldValue = old, NewValue = request.FollowUpAt?.ToString("o"), At = DateTime.Now }); await _db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpGet("{id:int}/history")] public async Task>> GetHistory( [FromRoute] int id, CancellationToken cancellationToken) { var exists = await _db.JobApplications.AnyAsync(item => item.Id == id, cancellationToken); if (!exists) return NotFound(); var items = await _db.JobEvents .AsNoTracking() .Where(item => item.JobApplicationId == id) .OrderByDescending(item => item.At) .Select(item => new JobEventDto( item.Id, item.Type, item.OldValue, item.NewValue, item.Note, item.At)) .ToListAsync(cancellationToken); return Ok(items); } }