Files
jobtrackingapp/JobTrackerApi/Controllers/JobApplicationLifecycleController.cs
T
2026-08-31 11:59:49 +02:00

186 lines
6.4 KiB
C#

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;
}
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
[HttpGet("pipeline")]
public ActionResult<IEnumerable<PipelineStageDto>> 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<IActionResult> 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();
}
/// <summary>
/// Suggests a pipeline status from the job's latest inbound message. Applying it remains a
/// separate, user-confirmed status update.
/// </summary>
[HttpGet("{id:int}/status-suggestion")]
public async Task<ActionResult<StatusSuggestionDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<List<JobEventDto>>> 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);
}
}