186 lines
6.4 KiB
C#
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);
|
|
}
|
|
}
|