Files
jobtrackingapp/JobTrackerApi/Controllers/JobApplicationsController.cs
T

1780 lines
78 KiB
C#

using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.JobImport;
using System.Security.Claims;
using System.Text.Json;
namespace JobTrackerApi.Controllers
{
[ApiController]
[Route("api/jobapplications")]
public class JobApplicationsController : ControllerBase
{
private readonly JobTrackerContext _db;
private readonly ISummarizerService _summarizer;
private readonly IAppEmailSender _email;
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email)
{
_db = db;
_summarizer = summarizer;
_email = email;
}
private string? CurrentUserId =>
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
private static IEnumerable<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
var trimmed = s.Trim();
List<string>? jsonTags = null;
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
}
catch
{
jsonTags = null;
}
}
if (jsonTags is not null)
{
foreach (var x in jsonTags)
{
var t = (x ?? string.Empty).Trim();
if (t.Length == 0) continue;
yield return t;
}
yield break;
}
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
{
var t = raw.Trim();
if (t.Length == 0) continue;
yield return t;
}
}
private static string NormalizeForComparison(string value)
{
if (string.IsNullOrWhiteSpace(value)) return string.Empty;
return new string(value.Trim().ToLowerInvariant().Where(char.IsLetterOrDigit).ToArray());
}
private static string? NormalizeTags(string? raw)
{
var normalized = SplitTags(raw)
.Select(tag => tag.Trim())
.Where(tag => tag.Length > 0)
.GroupBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.Select(group =>
{
var first = group.First();
return string.Join(" ", first.Split(' ', StringSplitOptions.RemoveEmptyEntries).Select(part => char.ToUpperInvariant(part[0]) + part[1..].ToLowerInvariant()));
})
.OrderBy(tag => tag, StringComparer.OrdinalIgnoreCase)
.ToList();
return normalized.Count == 0 ? null : JsonSerializer.Serialize(normalized);
}
private static string? NormalizeUrl(string? url)
{
if (string.IsNullOrWhiteSpace(url)) return null;
var value = url.Trim();
return Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri.ToString() : value;
}
public sealed record PagedResult<T>(List<T> Items, int Total, int Page, int PageSize);
public sealed record JobApplicationDto(
int Id,
int CompanyId,
Company Company,
string JobTitle,
string Status,
DateTime DateApplied,
bool ResponseReceived,
DateTime? ResponseDate,
string? Notes,
string? CoverLetterText,
string? JobUrl,
string? Description,
string? TranslatedDescription,
string? DescriptionLanguage,
string? Tags,
DateTime? Deadline,
string? Location,
string? Salary,
string? NextAction,
DateTime? FollowUpAt,
DateTime? FeedbackRequestedAt,
bool HasResume,
bool HasCoverLetter,
bool HasPortfolio,
bool HasOtherAttachment,
bool IsDeleted,
DateTime? DeletedAt,
int DaysSince,
bool NeedsFollowUp,
string? FollowUpReason,
string? ShortSummary,
string? FullSummary
);
[HttpGet]
public async Task<ActionResult<PagedResult<JobApplicationDto>>> GetAll(
[FromQuery] int page = 1,
[FromQuery] int pageSize = 15,
[FromQuery] string? q = null,
[FromQuery] string? status = null,
[FromQuery] int? companyId = null,
[FromQuery] string? location = null,
[FromQuery] bool needsFollowUp = false,
[FromQuery] bool includeDeleted = false,
[FromQuery] bool deletedOnly = false,
[FromQuery] string? sortBy = null,
[FromQuery] string? sortDir = null,
CancellationToken cancellationToken = default
)
{
if (page < 1) page = 1;
if (pageSize is not (15 or 20 or 25)) pageSize = 15;
var query = _db.JobApplications
.Include(j => j.Company)
.AsQueryable();
if (deletedOnly)
{
query = query.Where(j => j.IsDeleted);
}
else if (!includeDeleted)
{
query = query.Where(j => !j.IsDeleted);
}
if (!string.IsNullOrWhiteSpace(q))
{
var like = $"%{q.Trim()}%";
// Avoid referencing nullable/possibly-missing columns in legacy SQLite DBs
// by searching correspondence content only. This prevents SQL errors
// when the `Subject` column hasn't been added to the DB schema yet.
query = query.Where(j =>
EF.Functions.Like(j.JobTitle, like) ||
EF.Functions.Like(j.Company.Name, like) ||
(j.Notes != null && EF.Functions.Like(j.Notes, like)) ||
_db.Correspondences.Any(c => c.JobApplicationId == j.Id && EF.Functions.Like(c.Content, like))
);
}
if (!string.IsNullOrWhiteSpace(status) && !string.Equals(status, "All", StringComparison.OrdinalIgnoreCase))
{
var st = status.Trim();
query = query.Where(j => j.Status == st);
}
if (companyId is not null && companyId.Value > 0)
{
var id = companyId.Value;
query = query.Where(j => j.CompanyId == id);
}
if (!string.IsNullOrWhiteSpace(location))
{
var like = $"%{location.Trim()}%";
query = query.Where(j => j.Location != null && EF.Functions.Like(j.Location, like));
}
var settings = await RulesEngine.GetSettings(_db, cancellationToken);
var now = DateTime.Now;
var lastMsg = await _db.Correspondences
.AsNoTracking()
.GroupBy(c => c.JobApplicationId)
.Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) })
.ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken);
// Sorting: keep it whitelisted to avoid exposing arbitrary ordering.
var dirDesc = string.Equals(sortDir, "desc", StringComparison.OrdinalIgnoreCase);
var key = (sortBy ?? "dateApplied").Trim();
if (needsFollowUp)
{
// NeedsFollowUp depends on rules + last correspondence date; evaluate in memory so filtering is correct.
var pre = await query.ToListAsync(cancellationToken);
var filtered = new List<JobApplication>();
foreach (var j in pre)
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
if (d.NeedsFollowUp) filtered.Add(j);
}
filtered = key switch
{
"company" => dirDesc ? filtered.OrderByDescending(j => j.Company.Name).ToList() : filtered.OrderBy(j => j.Company.Name).ToList(),
"jobTitle" => dirDesc ? filtered.OrderByDescending(j => j.JobTitle).ToList() : filtered.OrderBy(j => j.JobTitle).ToList(),
"status" => dirDesc ? filtered.OrderByDescending(j => j.Status).ToList() : filtered.OrderBy(j => j.Status).ToList(),
"location" => dirDesc ? filtered.OrderByDescending(j => j.Location).ToList() : filtered.OrderBy(j => j.Location).ToList(),
"daysSince" => dirDesc ? filtered.OrderBy(j => j.DateApplied).ToList() : filtered.OrderByDescending(j => j.DateApplied).ToList(),
_ => dirDesc ? filtered.OrderByDescending(j => j.DateApplied).ToList() : filtered.OrderBy(j => j.DateApplied).ToList(),
};
var totalCount = filtered.Count;
var pageItems = filtered.Skip((page - 1) * pageSize).Take(pageSize).ToList();
var dtoItems = new List<JobApplicationDto>();
foreach (var j in pageItems)
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
// Use persisted short summary when available to avoid repeated model calls.
var shortSummary = j.ShortSummary;
var summary = shortSummary; // list endpoints return the short summary only
dtoItems.Add(new JobApplicationDto(
Id: j.Id,
CompanyId: j.CompanyId,
Company: j.Company,
JobTitle: j.JobTitle,
Status: j.Status,
DateApplied: j.DateApplied,
ResponseReceived: j.ResponseReceived,
ResponseDate: j.ResponseDate,
Notes: j.Notes,
CoverLetterText: j.CoverLetterText,
JobUrl: j.JobUrl,
Description: j.Description,
TranslatedDescription: j.TranslatedDescription,
DescriptionLanguage: j.DescriptionLanguage,
Tags: j.Tags,
Deadline: j.Deadline,
Location: j.Location,
Salary: j.Salary,
NextAction: j.NextAction,
FollowUpAt: j.FollowUpAt,
FeedbackRequestedAt: j.FeedbackRequestedAt,
HasResume: j.HasResume,
HasCoverLetter: j.HasCoverLetter,
HasPortfolio: j.HasPortfolio,
HasOtherAttachment: j.HasOtherAttachment,
IsDeleted: j.IsDeleted,
DeletedAt: j.DeletedAt,
DaysSince: j.DaysSince,
NeedsFollowUp: d.NeedsFollowUp,
FollowUpReason: d.Reason,
ShortSummary: shortSummary,
FullSummary: null
));
}
return Ok(new PagedResult<JobApplicationDto>(dtoItems, totalCount, page, pageSize));
}
query = key switch
{
"company" => dirDesc ? query.OrderByDescending(j => j.Company.Name) : query.OrderBy(j => j.Company.Name),
"jobTitle" => dirDesc ? query.OrderByDescending(j => j.JobTitle) : query.OrderBy(j => j.JobTitle),
"status" => dirDesc ? query.OrderByDescending(j => j.Status) : query.OrderBy(j => j.Status),
"location" => dirDesc ? query.OrderByDescending(j => j.Location) : query.OrderBy(j => j.Location),
// daysSince sorts by DateApplied in the opposite direction
"daysSince" => dirDesc ? query.OrderBy(j => j.DateApplied) : query.OrderByDescending(j => j.DateApplied),
_ => dirDesc ? query.OrderByDescending(j => j.DateApplied) : query.OrderBy(j => j.DateApplied),
};
var total = await query.CountAsync(cancellationToken);
var items = await query
.Skip((page - 1) * pageSize)
.Take(pageSize)
.ToListAsync(cancellationToken);
var dtos = new List<JobApplicationDto>();
foreach (var j in items)
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
var shortSummary = j.ShortSummary;
var summary = shortSummary;
dtos.Add(new JobApplicationDto(
Id: j.Id,
CompanyId: j.CompanyId,
Company: j.Company,
JobTitle: j.JobTitle,
Status: j.Status,
DateApplied: j.DateApplied,
ResponseReceived: j.ResponseReceived,
ResponseDate: j.ResponseDate,
Notes: j.Notes,
CoverLetterText: j.CoverLetterText,
JobUrl: j.JobUrl,
Description: j.Description,
TranslatedDescription: j.TranslatedDescription,
DescriptionLanguage: j.DescriptionLanguage,
Tags: j.Tags,
Deadline: j.Deadline,
Location: j.Location,
Salary: j.Salary,
NextAction: j.NextAction,
FollowUpAt: j.FollowUpAt,
FeedbackRequestedAt: j.FeedbackRequestedAt,
HasResume: j.HasResume,
HasCoverLetter: j.HasCoverLetter,
HasPortfolio: j.HasPortfolio,
HasOtherAttachment: j.HasOtherAttachment,
IsDeleted: j.IsDeleted,
DeletedAt: j.DeletedAt,
DaysSince: j.DaysSince,
NeedsFollowUp: d.NeedsFollowUp,
FollowUpReason: d.Reason,
ShortSummary: shortSummary,
FullSummary: null
));
}
return Ok(new PagedResult<JobApplicationDto>(dtos, total, page, pageSize));
}
[HttpGet("{id:int}")]
public async Task<ActionResult<JobApplicationDto>> GetById([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var settings = await RulesEngine.GetSettings(_db, cancellationToken);
var now = DateTime.Now;
var lm = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id)
.MaxAsync(c => (DateTime?)c.Date, cancellationToken);
var d = RulesEngine.Evaluate(settings, job, now, lm);
// Return persisted short summary and compute a fuller summary on demand for the details view.
var full = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 250, 40);
return Ok(new JobApplicationDto(
Id: job.Id,
CompanyId: job.CompanyId,
Company: job.Company,
JobTitle: job.JobTitle,
Status: job.Status,
DateApplied: job.DateApplied,
ResponseReceived: job.ResponseReceived,
ResponseDate: job.ResponseDate,
Notes: job.Notes,
CoverLetterText: job.CoverLetterText,
JobUrl: job.JobUrl,
Description: job.Description,
TranslatedDescription: job.TranslatedDescription,
DescriptionLanguage: job.DescriptionLanguage,
Tags: job.Tags,
Deadline: job.Deadline,
Location: job.Location,
Salary: job.Salary,
NextAction: job.NextAction,
FollowUpAt: job.FollowUpAt,
FeedbackRequestedAt: job.FeedbackRequestedAt,
HasResume: job.HasResume,
HasCoverLetter: job.HasCoverLetter,
HasPortfolio: job.HasPortfolio,
HasOtherAttachment: job.HasOtherAttachment,
IsDeleted: job.IsDeleted,
DeletedAt: job.DeletedAt,
DaysSince: job.DaysSince,
NeedsFollowUp: d.NeedsFollowUp,
FollowUpReason: d.Reason,
ShortSummary: job.ShortSummary,
FullSummary: full
));
}
[HttpGet("board")]
public async Task<ActionResult<List<JobApplication>>> GetBoard(
[FromQuery] bool includeDeleted = false,
CancellationToken cancellationToken = default
)
{
var query = _db.JobApplications
.Include(j => j.Company)
.AsQueryable();
if (!includeDeleted) query = query.Where(j => !j.IsDeleted);
var items = await query
.OrderByDescending(j => j.DateApplied)
.ToListAsync(cancellationToken);
return Ok(items);
}
[HttpGet("reminders")]
public async Task<ActionResult<List<JobApplicationDto>>> GetReminders(
[FromQuery] int upcomingDays = 7,
CancellationToken cancellationToken = default
)
{
if (upcomingDays < 1) upcomingDays = 1;
if (upcomingDays > 90) upcomingDays = 90;
var settings = await RulesEngine.GetSettings(_db, cancellationToken);
var now = DateTime.Now;
var upcomingTo = now.AddDays(upcomingDays);
var lastMsg = await _db.Correspondences
.AsNoTracking()
.GroupBy(c => c.JobApplicationId)
.Select(g => new { JobApplicationId = g.Key, Last = g.Max(x => x.Date) })
.ToDictionaryAsync(x => x.JobApplicationId, x => (DateTime?)x.Last, cancellationToken);
var candidates = await _db.JobApplications
.Include(j => j.Company)
.Where(j => !j.IsDeleted)
.Where(j =>
j.FollowUpAt != null && j.FollowUpAt <= upcomingTo ||
j.Status == "Applied" ||
j.Status == "Waiting" ||
j.Status == "Offer" ||
(j.Status == "Rejected" && j.FeedbackRequestedAt != null)
)
.OrderByDescending(j => j.DateApplied)
.ToListAsync(cancellationToken);
var dtos = new List<JobApplicationDto>();
foreach (var j in candidates)
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo;
if (!d.NeedsFollowUp && !upcoming) continue;
var shortSummary = j.ShortSummary;
var reminderReason = d.Reason;
if (string.IsNullOrWhiteSpace(j.TailoredCvText))
{
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
? "Tailored CV missing for an active role."
: $"{reminderReason} Tailored CV missing.";
}
if (j.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(j.Notes))
{
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
? "Interview coming up but prep notes are missing."
: $"{reminderReason} Interview prep notes missing.";
}
if (!j.ResponseReceived && j.FollowUpAt is null)
{
reminderReason = string.IsNullOrWhiteSpace(reminderReason)
? "No response yet and no follow-up date is scheduled."
: $"{reminderReason} No follow-up date is scheduled.";
}
dtos.Add(new JobApplicationDto(
Id: j.Id,
CompanyId: j.CompanyId,
Company: j.Company,
JobTitle: j.JobTitle,
Status: j.Status,
DateApplied: j.DateApplied,
ResponseReceived: j.ResponseReceived,
ResponseDate: j.ResponseDate,
Notes: j.Notes,
CoverLetterText: j.CoverLetterText,
JobUrl: j.JobUrl,
Description: j.Description,
TranslatedDescription: j.TranslatedDescription,
DescriptionLanguage: j.DescriptionLanguage,
Tags: j.Tags,
Deadline: j.Deadline,
Location: j.Location,
Salary: j.Salary,
NextAction: j.NextAction,
FollowUpAt: j.FollowUpAt,
FeedbackRequestedAt: j.FeedbackRequestedAt,
HasResume: j.HasResume,
HasCoverLetter: j.HasCoverLetter,
HasPortfolio: j.HasPortfolio,
HasOtherAttachment: j.HasOtherAttachment,
IsDeleted: j.IsDeleted,
DeletedAt: j.DeletedAt,
DaysSince: j.DaysSince,
NeedsFollowUp: d.NeedsFollowUp,
FollowUpReason: reminderReason,
ShortSummary: shortSummary,
FullSummary: null
));
}
// Sort: needsFollowUp first, then nearest followUpAt.
dtos = dtos
.OrderByDescending(x => x.NeedsFollowUp)
.ThenBy(x => x.FollowUpAt ?? DateTime.MaxValue)
.ThenByDescending(x => x.DateApplied)
.ToList();
return Ok(dtos);
}
public sealed record CreateJobApplicationRequest(
string JobTitle,
int CompanyId,
string? Status,
string? Location,
string? Salary,
string? NextAction,
DateTime? FollowUpAt,
string? Notes,
string? Description,
string? TranslatedDescription,
string? DescriptionLanguage,
string? Tags,
DateTime? Deadline,
string? CoverLetterText,
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt,
bool? HasResume,
bool? HasCoverLetter,
bool? HasPortfolio,
bool? HasOtherAttachment
);
[HttpPost]
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var title = (request.JobTitle ?? "").Trim();
if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyOk) return BadRequest("companyId does not exist.");
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
if (!companyExists) return BadRequest("companyId does not exist.");
var job = new JobApplication
{
OwnerUserId = string.IsNullOrWhiteSpace(userId) ? null : userId,
JobTitle = title,
CompanyId = request.CompanyId,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim(),
Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim(),
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
FollowUpAt = request.FollowUpAt,
FeedbackRequestedAt = request.FeedbackRequestedAt,
HasResume = request.HasResume ?? false,
HasCoverLetter = request.HasCoverLetter ?? false,
HasPortfolio = request.HasPortfolio ?? false,
HasOtherAttachment = request.HasOtherAttachment ?? false,
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes,
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
DescriptionLanguage = string.IsNullOrWhiteSpace(request.DescriptionLanguage) ? null : request.DescriptionLanguage.Trim(),
Tags = NormalizeTags(request.Tags),
Deadline = request.Deadline,
CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText,
JobUrl = NormalizeUrl(request.JobUrl),
DateApplied = request.DateApplied ?? DateTime.Now,
ResponseReceived = false,
ResponseDate = null,
};
// Generate and persist a short summary at creation time to avoid repeated model calls.
try
{
var shortSum = await _summarizer.SummarizeAsync(job.Description ?? job.Notes ?? "", 160, 60);
job.ShortSummary = shortSum;
}
catch
{
// ignore summarizer failures at create time
}
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "Created",
At = DateTime.Now
});
await _db.SaveChangesAsync(cancellationToken);
// Return with Company populated for the UI.
var created = await _db.JobApplications
.Include(j => j.Company)
.FirstAsync(j => j.Id == job.Id, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
}
public sealed record UpdateJobApplicationRequest(
string JobTitle,
int CompanyId,
string Status,
bool ResponseReceived,
DateTime? ResponseDate,
string? Location,
string? Salary,
string? NextAction,
DateTime? FollowUpAt,
bool? HasResume,
bool? HasCoverLetter,
bool? HasPortfolio,
bool? HasOtherAttachment,
string? Notes,
string? Description,
string? TranslatedDescription,
string? DescriptionLanguage,
string? Tags,
DateTime? Deadline,
string? CoverLetterText,
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt,
DateTime? StatusChangedAt
);
[HttpPut("{id:int}")]
public async Task<IActionResult> Update([FromRoute] int id, [FromBody] UpdateJobApplicationRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var oldStatus = job.Status;
var oldResponseReceived = job.ResponseReceived;
var oldResponseDate = job.ResponseDate;
var title = (request.JobTitle ?? "").Trim();
if (title.Length == 0) return BadRequest("Job title is required.");
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
job.JobTitle = title;
job.CompanyId = request.CompanyId;
job.Status = string.IsNullOrWhiteSpace(request.Status) ? job.Status : request.Status.Trim();
job.ResponseReceived = request.ResponseReceived;
job.ResponseDate = request.ResponseDate;
job.Location = string.IsNullOrWhiteSpace(request.Location) ? null : request.Location.Trim();
job.Salary = string.IsNullOrWhiteSpace(request.Salary) ? null : request.Salary.Trim();
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
job.FollowUpAt = request.FollowUpAt;
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
if (request.HasResume is not null) job.HasResume = request.HasResume.Value;
if (request.HasCoverLetter is not null) job.HasCoverLetter = request.HasCoverLetter.Value;
if (request.HasPortfolio is not null) job.HasPortfolio = request.HasPortfolio.Value;
if (request.HasOtherAttachment is not null) job.HasOtherAttachment = request.HasOtherAttachment.Value;
job.Notes = request.Notes;
job.Description = request.Description;
job.TranslatedDescription = request.TranslatedDescription;
job.DescriptionLanguage = request.DescriptionLanguage;
job.Tags = NormalizeTags(request.Tags);
job.Deadline = request.Deadline;
job.CoverLetterText = request.CoverLetterText;
job.JobUrl = NormalizeUrl(request.JobUrl);
if (request.DateApplied is not null) job.DateApplied = request.DateApplied.Value;
if (oldResponseReceived != job.ResponseReceived || oldResponseDate != job.ResponseDate)
{
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "ResponseUpdated",
OldValue = $"{oldResponseReceived}:{oldResponseDate?.ToString("o")}",
NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}",
At = DateTime.Now
});
}
if (!string.Equals(oldStatus, job.Status, StringComparison.OrdinalIgnoreCase))
{
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "StatusChanged",
OldValue = oldStatus,
NewValue = job.Status,
At = request.StatusChangedAt ?? DateTime.Now
});
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
public sealed record UpdateStatusRequest(string Status);
[HttpPatch("{id:int}/status")]
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (string.IsNullOrWhiteSpace(request.Status)) return BadRequest("Status is required.");
var old = job.Status;
job.Status = request.Status.Trim();
if (!string.Equals(old, job.Status, StringComparison.OrdinalIgnoreCase))
{
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "StatusChanged",
OldValue = old,
NewValue = job.Status,
At = DateTime.Now
});
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:int}/refresh-ai")]
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var sourceText = string.Join("\n\n", new[] { job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(sourceText))
{
return BadRequest("This job does not have enough description or notes to generate a summary and skills.");
}
var tags = SkillTagger.Detect(sourceText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
job.Tags = tags.Count == 0 ? null : JsonSerializer.Serialize(tags);
var shortSummary = await _summarizer.SummarizeAsync(sourceText, 160, 60);
job.ShortSummary = string.IsNullOrWhiteSpace(shortSummary) ? job.ShortSummary : shortSummary;
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "AiRefreshed",
Note = "Summary and tags were manually refreshed.",
At = DateTime.Now
});
await _db.SaveChangesAsync(cancellationToken);
var settings = await RulesEngine.GetSettings(_db, cancellationToken);
var lastMsg = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id)
.OrderByDescending(c => c.Date)
.Select(c => (DateTime?)c.Date)
.FirstOrDefaultAsync(cancellationToken);
var followUp = RulesEngine.Evaluate(settings, job, DateTime.Now, lastMsg);
return Ok(new JobApplicationDto(
Id: job.Id,
CompanyId: job.CompanyId,
Company: job.Company,
JobTitle: job.JobTitle,
Status: job.Status,
DateApplied: job.DateApplied,
ResponseReceived: job.ResponseReceived,
ResponseDate: job.ResponseDate,
Notes: job.Notes,
CoverLetterText: job.CoverLetterText,
JobUrl: job.JobUrl,
Description: job.Description,
TranslatedDescription: job.TranslatedDescription,
DescriptionLanguage: job.DescriptionLanguage,
Tags: job.Tags,
Deadline: job.Deadline,
Location: job.Location,
Salary: job.Salary,
NextAction: job.NextAction,
FollowUpAt: job.FollowUpAt,
FeedbackRequestedAt: job.FeedbackRequestedAt,
HasResume: job.HasResume,
HasCoverLetter: job.HasCoverLetter,
HasPortfolio: job.HasPortfolio,
HasOtherAttachment: job.HasOtherAttachment,
IsDeleted: job.IsDeleted,
DeletedAt: job.DeletedAt,
DaysSince: job.DaysSince,
NeedsFollowUp: followUp.NeedsFollowUp,
FollowUpReason: followUp.Reason,
ShortSummary: job.ShortSummary,
FullSummary: null
));
}
[HttpDelete("{id:int}")]
public async Task<IActionResult> SoftDelete([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.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(j => j.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();
}
public sealed record FollowUpRequest(DateTime? FollowUpAt);
[HttpPatch("{id:int}/followup")]
public async Task<IActionResult> SetFollowUp([FromRoute] int id, [FromBody] FollowUpRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.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();
}
public sealed record JobEventDto(int Id, string Type, string? OldValue, string? NewValue, string? Note, DateTime At);
[HttpGet("{id:int}/history")]
public async Task<ActionResult<List<JobEventDto>>> GetHistory([FromRoute] int id, CancellationToken cancellationToken)
{
var items = await _db.JobEvents
.AsNoTracking()
.Where(e => e.JobApplicationId == id)
.OrderByDescending(e => e.At)
.Select(e => new JobEventDto(e.Id, e.Type, e.OldValue, e.NewValue, e.Note, e.At))
.ToListAsync(cancellationToken);
return Ok(items);
}
public sealed record TimelineItemDto(string Kind, DateTime At, object Data);
[HttpGet("{id:int}/timeline")]
public async Task<ActionResult<List<TimelineItemDto>>> GetTimeline([FromRoute] int id, CancellationToken cancellationToken)
{
var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken);
if (!exists) return NotFound();
var events = await _db.JobEvents
.AsNoTracking()
.Where(e => e.JobApplicationId == id)
.Select(e => new TimelineItemDto(
"event",
e.At,
new { e.Id, e.Type, e.OldValue, e.NewValue, e.Note }
))
.ToListAsync(cancellationToken);
var messages = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id)
.Select(c => new TimelineItemDto(
"message",
c.Date,
new { c.Id, c.From, c.Subject, c.Channel, c.Content }
))
.ToListAsync(cancellationToken);
var attachments = await _db.Attachments
.AsNoTracking()
.Where(a => a.JobApplicationId == id)
.Select(a => new TimelineItemDto(
"attachment",
a.UploadDate,
new { a.Id, a.FileName, a.FileType, a.FileSize }
))
.ToListAsync(cancellationToken);
var all = events
.Concat(messages)
.Concat(attachments)
.OrderByDescending(x => x.At)
.ToList();
return Ok(all);
}
public sealed record JobStats(
int Total,
int Active,
int Deleted,
Dictionary<string, int> ByStatus,
int AppliedLast30Days,
double AverageDaysSinceApplied
);
[HttpGet("stats")]
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
{
var now = DateTime.Now;
var all = await _db.JobApplications
.AsNoTracking()
.ToListAsync(cancellationToken);
var active = all.Where(j => !j.IsDeleted).ToList();
var byStatus = active
.GroupBy(j => string.IsNullOrWhiteSpace(j.Status) ? "Unknown" : j.Status)
.OrderByDescending(g => g.Count())
.ToDictionary(g => g.Key, g => g.Count());
var appliedLast30Days = active.Count(j => (now - j.DateApplied).TotalDays <= 30);
var avgDays = active.Count == 0
? 0
: active.Average(j => Math.Max(0, (now - j.DateApplied).TotalDays));
return Ok(new JobStats(
Total: all.Count,
Active: active.Count,
Deleted: all.Count - active.Count,
ByStatus: byStatus,
AppliedLast30Days: appliedLast30Days,
AverageDaysSinceApplied: Math.Round(avgDays, 1)
));
}
public sealed record AnalyticsPoint(string Month, int Applied, int Responses);
[HttpGet("analytics")]
public async Task<ActionResult<List<AnalyticsPoint>>> GetAnalytics(
[FromQuery] int months = 12,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default
)
{
if (months < 3) months = 3;
if (months > 36) months = 36;
var now = DateTime.Now;
DateTime startMonth;
DateTime endMonth;
if (from is not null || to is not null)
{
var toValue = to ?? now;
var fromValue = from ?? toValue.AddMonths(-months);
if (toValue < fromValue)
{
(fromValue, toValue) = (toValue, fromValue);
}
startMonth = new DateTime(fromValue.Year, fromValue.Month, 1);
endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1);
var spanMonths = ((endMonth.Year - startMonth.Year) * 12) + (endMonth.Month - startMonth.Month);
if (spanMonths < 3)
{
spanMonths = 3;
startMonth = endMonth.AddMonths(-spanMonths);
}
if (spanMonths > 36)
{
spanMonths = 36;
startMonth = endMonth.AddMonths(-spanMonths);
}
months = spanMonths;
}
else
{
endMonth = new DateTime(now.Year, now.Month, 1).AddMonths(1);
startMonth = endMonth.AddMonths(-months);
}
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth)
.Select(j => new { j.DateApplied, j.ResponseDate })
.ToListAsync(cancellationToken);
var applied = new Dictionary<string, int>(StringComparer.Ordinal);
var responses = new Dictionary<string, int>(StringComparer.Ordinal);
static string Key(DateTime d) => $"{d:yyyy-MM}";
foreach (var j in jobs)
{
var ak = Key(j.DateApplied);
applied[ak] = (applied.TryGetValue(ak, out var av) ? av : 0) + 1;
if (j.ResponseDate is not null)
{
var rk = Key(j.ResponseDate.Value);
responses[rk] = (responses.TryGetValue(rk, out var rv) ? rv : 0) + 1;
}
}
var outList = new List<AnalyticsPoint>(months);
for (var i = 0; i < months; i++)
{
var m = startMonth.AddMonths(i);
var k = Key(m);
applied.TryGetValue(k, out var a);
responses.TryGetValue(k, out var r);
outList.Add(new AnalyticsPoint(k, a, r));
}
return Ok(outList);
}
public sealed record TagPoint(string Tag, int Count);
[HttpGet("tags")]
public async Task<ActionResult<List<TagPoint>>> GetTags(
[FromQuery] int limit = 10,
[FromQuery] DateTime? from = null,
[FromQuery] DateTime? to = null,
CancellationToken cancellationToken = default
)
{
if (limit < 3) limit = 3;
if (limit > 50) limit = 50;
IQueryable<JobApplication> query = _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted);
if (from is not null || to is not null)
{
var now = DateTime.Now;
var toValue = to ?? now;
var fromValue = from ?? DateTime.MinValue;
if (toValue < fromValue)
{
(fromValue, toValue) = (toValue, fromValue);
}
var startMonth = fromValue == DateTime.MinValue
? (DateTime?)null
: new DateTime(fromValue.Year, fromValue.Month, 1);
var endMonth = new DateTime(toValue.Year, toValue.Month, 1).AddMonths(1);
if (startMonth is not null)
{
query = query.Where(j => j.DateApplied >= startMonth.Value);
}
query = query.Where(j => j.DateApplied < endMonth);
}
var tagStrings = await query
.Select(j => j.Tags)
.ToListAsync(cancellationToken);
static IEnumerable<string> SplitTags(string? s)
{
if (string.IsNullOrWhiteSpace(s)) yield break;
var trimmed = s.Trim();
List<string>? jsonTags = null;
if (trimmed.StartsWith("[") && trimmed.EndsWith("]"))
{
try
{
jsonTags = JsonSerializer.Deserialize<List<string>>(trimmed);
}
catch
{
jsonTags = null;
}
}
if (jsonTags is not null)
{
foreach (var x in jsonTags)
{
var t = (x ?? string.Empty).Trim();
if (t.Length == 0) continue;
yield return t;
}
yield break;
}
foreach (var raw in trimmed.Split(new[] { ',', ';', '\n', '\r', '\t' }, StringSplitOptions.RemoveEmptyEntries))
{
var t = raw.Trim();
if (t.Length == 0) continue;
yield return t;
}
}
var map = new Dictionary<string, (string Display, int Count)>(StringComparer.OrdinalIgnoreCase);
foreach (var s in tagStrings)
{
foreach (var t in SplitTags(s))
{
if (map.TryGetValue(t, out var v))
{
map[t] = (v.Display, v.Count + 1);
}
else
{
map[t] = (t, 1);
}
}
}
var outList = map.Values
.OrderByDescending(x => x.Count)
.ThenBy(x => x.Display, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.Select(x => new TagPoint(x.Display, x.Count))
.ToList();
return Ok(outList);
}
public sealed record FunnelStagePoint(string Label, int Count);
public sealed record ResponseRatePoint(string Label, int Total, int Responses, double Rate);
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
public sealed record TagTrendSeries(string Tag, List<int> Counts);
public sealed record TagTrendPoint(string Month, List<int> Counts);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
List<CompanyActivityPoint> TopCompanies,
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive
);
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn);
public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt);
public sealed record TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
public sealed record CandidateFitDto(
string MatchSummary,
string FitLevel,
int MatchScore,
List<string> Strengths,
List<string> Gaps,
List<string> Mention,
List<string> Avoid,
List<string> CvImprovements,
List<string> MissingKeywords,
List<string> InterviewPrep,
string TailoredPitch,
CandidateFitChannelGuidanceDto Guidance,
string? CoverLetterDraft,
string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before running candidate fit analysis.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var normalizedCv = cvText.ToLowerInvariant();
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var jobContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job description and notes:
{jobText}
Candidate CV/profile:
{cvText}";
var matchSummary = await _summarizer.SummarizeSectionAsync(
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
jobContext,
220,
90) ?? "No fit summary available yet.";
var strengthCount = strengths.Count;
var gapCount = gaps.Count;
var rawScore = 35 + (strengthCount * 10) - (gapCount * 4);
var matchScore = Math.Clamp(rawScore, 20, 96);
var fitLevel = matchScore >= 75 ? "Strong match" : matchScore >= 55 ? "Potential match" : "Stretch role";
var mention = strengths.Select(x => $"Show evidence of {x} with concrete results and outcomes.").Take(5).ToList();
if (!mention.Any() && jobTags.Any()) mention.Add($"Highlight directly relevant experience with {jobTags.First()}. ");
var avoid = new List<string>();
if (gaps.Any())
{
avoid.AddRange(gaps.Take(4).Select(x => $"Do not overclaim deep expertise in {x} unless you can back it up with recent examples."));
}
avoid.Add("Avoid generic claims without metrics, outcomes, or ownership details.");
var cvImprovements = new List<string>();
cvImprovements.AddRange(gaps.Take(4).Select(x => $"If you have experience with {x}, make it easier to find in your CV with a specific bullet and result."));
cvImprovements.Add("Quantify impact with numbers, scope, speed, revenue, quality, or customer outcomes where possible.");
cvImprovements.Add("Mirror the wording of the role where it is accurate, especially in your summary and recent experience.");
var missingKeywords = gaps.Take(6).ToList();
var interviewPrep = new List<string>();
interviewPrep.AddRange(strengths.Take(3).Select(x => $"Prepare a STAR example that proves your experience with {x}."));
interviewPrep.AddRange(gaps.Take(2).Select(x => $"Prepare a credible learning story for {x}: related work, fast ramp-up, and how you would close the gap."));
if (!interviewPrep.Any())
{
interviewPrep.Add("Prepare two strong examples showing measurable impact, collaboration, and delivery under constraints.");
}
var tailoredPitch = await _summarizer.SummarizeSectionAsync(
"Write a short tailored candidate pitch for this role in first person. Keep it practical and credible.",
jobContext,
120,
45) ?? "I bring relevant experience, measurable outcomes, and a clear understanding of the role priorities.";
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Draft a short cover letter opening and value proposition for this candidate and job. Keep it specific and credible.",
jobContext,
180,
70);
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
"Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.",
jobContext,
120,
45);
var guidance = new CandidateFitChannelGuidanceDto(
Cv: mention.Take(4).ToList(),
CoverLetter: strengths.Take(3).Select(x => $"Connect {x} to why you are interested in this company and role now.").ToList(),
Interview: interviewPrep.Take(5).ToList(),
RecruiterMessage: new List<string>
{
$"Lead with your strongest overlap: {(strengths.FirstOrDefault() ?? jobTags.FirstOrDefault() ?? "relevant experience")}. ",
"Keep the note concise and outcome-focused.",
"Close with a clear expression of interest and availability."
});
return Ok(new CandidateFitDto(
MatchSummary: matchSummary,
FitLevel: fitLevel,
MatchScore: matchScore,
Strengths: strengths,
Gaps: gaps,
Mention: mention,
Avoid: avoid.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
CvImprovements: cvImprovements.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
MissingKeywords: missingKeywords,
InterviewPrep: interviewPrep.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
TailoredPitch: tailoredPitch,
Guidance: guidance,
CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft));
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList();
var likelyQuestions = tags.Take(4).Select(x => $"How have you applied {x} in practice, and what impact did it have?").ToList();
var weakSpots = new List<string>();
if (string.IsNullOrWhiteSpace(job.TailoredCvText)) weakSpots.Add("You have not saved a tailored CV for this role yet.");
if (string.IsNullOrWhiteSpace(job.CoverLetterText)) weakSpots.Add("You do not have a saved cover letter draft for this role yet.");
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.NextAction)) weakSpots.Add("Your next action is not clearly documented.");
if (!weakSpots.Any()) weakSpots.Add("Prepare to explain why this role and company are a strong fit right now.");
var summary = await _summarizer.SummarizeSectionAsync(
"Create a concise interview prep brief. Focus on strongest talking points, likely topics, and preparation priorities.",
context,
180,
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
}
[HttpGet("{id:int}/readiness")]
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var completed = new List<string>();
var missing = new List<string>();
var reminders = new List<string>();
if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role");
if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft");
if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example");
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible");
if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear");
if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date");
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV.");
if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing.");
if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled.");
var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
return Ok(new ReadinessDto(score, level, completed, missing, reminders));
}
[HttpPut("{id:int}/tailored-cv")]
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:int}/generate-application-package")]
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before generating an application package.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to generate an application package.");
}
var packageContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job context:
{jobText}
Candidate master CV:
{cvText}";
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job.",
packageContext,
256,
120) ?? cvText;
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Write a concise, tailored cover letter for this candidate and job. Keep it specific, credible, and directly aligned to the role.",
packageContext,
220,
90);
var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync(
"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words.",
packageContext,
170,
70);
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
"Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.",
packageContext,
120,
45);
var keyPoints = SkillTagger.Detect(jobText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.Select(x => $"Lead with evidence of {x}.")
.ToList();
return Ok(new GenerateApplicationPackageDto(
TailoredCvText: tailoredCvText,
CoverLetterDraft: coverLetterDraft,
ApplicationAnswerDraft: applicationAnswerDraft,
RecruiterMessageDraft: recruiterMessageDraft,
KeyPoints: keyPoints));
}
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
{
var activeJobs = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted)
.ToListAsync(cancellationToken);
var funnelMap = new Dictionary<string, int>
{
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
};
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
var responseRateBySource = activeJobs
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
.Select(g => new ResponseRatePoint(
g.Key,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Total)
.ThenByDescending(x => x.Rate)
.Take(6)
.ToList();
var topCompanies = activeJobs
.GroupBy(j => new { j.CompanyId, Name = j.Company.Name })
.Select(g => new CompanyActivityPoint(
g.Key.CompanyId,
g.Key.Name,
g.Count(),
g.Count(x => x.ResponseReceived || x.ResponseDate is not null),
Math.Round(g.Count(x => x.ResponseReceived || x.ResponseDate is not null) * 100d / Math.Max(1, g.Count()), 1)
))
.OrderByDescending(x => x.Count)
.ThenByDescending(x => x.ResponseRate)
.Take(8)
.ToList();
var responseDays = activeJobs
.Where(j => (j.ResponseReceived || j.ResponseDate is not null) && j.ResponseDate is not null)
.Select(j => Math.Max(0, (j.ResponseDate!.Value - j.DateApplied).TotalDays))
.OrderBy(x => x)
.ToList();
double? medianDays = null;
if (responseDays.Count > 0)
{
var mid = responseDays.Count / 2;
medianDays = responseDays.Count % 2 == 0
? Math.Round((responseDays[mid - 1] + responseDays[mid]) / 2d, 1)
: Math.Round(responseDays[mid], 1);
}
return Ok(new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
TopCompanies: topCompanies,
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count
));
}
[HttpGet("tag-trends")]
public async Task<ActionResult<TagTrendResponse>> GetTagTrends(
[FromQuery] int months = 6,
[FromQuery] int limit = 5,
CancellationToken cancellationToken = default)
{
if (months < 3) months = 3;
if (months > 24) months = 24;
if (limit < 3) limit = 3;
if (limit > 10) limit = 10;
var endMonth = new DateTime(DateTime.Now.Year, DateTime.Now.Month, 1).AddMonths(1);
var startMonth = endMonth.AddMonths(-months);
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(j => !j.IsDeleted && j.DateApplied >= startMonth && j.DateApplied < endMonth)
.Select(j => new { j.DateApplied, j.Tags })
.ToListAsync(cancellationToken);
var overall = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
var monthKeys = Enumerable.Range(0, months).Select(i => startMonth.AddMonths(i).ToString("yyyy-MM")).ToList();
var seriesMap = new Dictionary<string, Dictionary<string, int>>(StringComparer.OrdinalIgnoreCase);
foreach (var job in jobs)
{
var key = $"{job.DateApplied:yyyy-MM}";
foreach (var tag in SplitTags(job.Tags))
{
overall[tag] = (overall.TryGetValue(tag, out var count) ? count : 0) + 1;
if (!seriesMap.TryGetValue(tag, out var byMonth))
{
byMonth = new Dictionary<string, int>(StringComparer.Ordinal);
seriesMap[tag] = byMonth;
}
byMonth[key] = (byMonth.TryGetValue(key, out var monthCount) ? monthCount : 0) + 1;
}
}
var topTags = overall
.OrderByDescending(x => x.Value)
.ThenBy(x => x.Key, StringComparer.OrdinalIgnoreCase)
.Take(limit)
.Select(x => x.Key)
.ToList();
var series = topTags
.Select(tag => new TagTrendSeries(
tag,
monthKeys.Select(month => seriesMap.TryGetValue(tag, out var byMonth) && byMonth.TryGetValue(month, out var count) ? count : 0).ToList()
))
.ToList();
return Ok(new TagTrendResponse(monthKeys, series));
}
[HttpGet("duplicate-check")]
public async Task<ActionResult<DuplicateCheckResult>> CheckDuplicates(
[FromQuery] int companyId,
[FromQuery] string? jobTitle,
[FromQuery] string? jobUrl,
[FromQuery] int? excludeId,
CancellationToken cancellationToken)
{
var normalizedTitle = NormalizeForComparison(jobTitle ?? string.Empty);
var normalizedUrl = (jobUrl ?? string.Empty).Trim();
if (companyId <= 0 && normalizedTitle.Length == 0 && normalizedUrl.Length == 0)
{
return Ok(new DuplicateCheckResult(false, new List<DuplicateCandidateDto>()));
}
var query = _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.Where(j => !j.IsDeleted);
if (excludeId is not null && excludeId.Value > 0)
{
query = query.Where(j => j.Id != excludeId.Value);
}
var candidates = await query
.OrderByDescending(j => j.DateApplied)
.Take(200)
.ToListAsync(cancellationToken);
var matches = candidates
.Select(j =>
{
var reasons = new List<string>();
if (!string.IsNullOrWhiteSpace(normalizedUrl) && string.Equals((j.JobUrl ?? string.Empty).Trim(), normalizedUrl, StringComparison.OrdinalIgnoreCase))
{
reasons.Add("same URL");
}
if (companyId > 0 && j.CompanyId == companyId && normalizedTitle.Length > 0)
{
var existingTitle = NormalizeForComparison(j.JobTitle);
if (existingTitle == normalizedTitle || existingTitle.Contains(normalizedTitle) || normalizedTitle.Contains(existingTitle))
{
reasons.Add("same company and similar title");
}
}
return new { Job = j, Reasons = reasons };
})
.Where(x => x.Reasons.Count > 0)
.Take(5)
.Select(x => new DuplicateCandidateDto(
x.Job.Id,
x.Job.JobTitle,
x.Job.Company?.Name ?? string.Empty,
x.Job.JobUrl,
x.Job.Status,
x.Job.DateApplied,
string.Join(", ", x.Reasons)
))
.ToList();
return Ok(new DuplicateCheckResult(matches.Count > 0, matches));
}
[HttpGet("{id:int}/followup-draft")]
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.AsNoTracking()
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var lastMessage = await _db.Correspondences
.AsNoTracking()
.Where(c => c.JobApplicationId == id)
.OrderByDescending(c => c.Date)
.FirstOrDefaultAsync(cancellationToken);
var reason = string.IsNullOrWhiteSpace(job.NextAction)
? (job.FollowUpAt is not null && job.FollowUpAt.Value.Date <= DateTime.Today ? "Scheduled follow-up is due." : "No recent response has been logged.")
: job.NextAction!;
var subject = $"Following up on {job.JobTitle} application";
var companyName = job.Company?.Name ?? "your team";
var reference = lastMessage?.Subject ?? job.JobTitle;
var summary = job.ShortSummary;
var body = string.Join("\n\n", new[]
{
$"Hi {companyName},",
$"I wanted to follow up on my application for the {job.JobTitle} role. I'm still very interested in the opportunity and would love to hear if there are any updates on next steps.",
!string.IsNullOrWhiteSpace(summary) ? $"Quick reminder of fit: {summary}" : null,
$"Context: {reason}",
$"If helpful, I can also provide any additional information related to {reference}.",
"Thanks for your time,\n[Your name]"
}.Where(x => !string.IsNullOrWhiteSpace(x)));
return Ok(new FollowUpDraftDto(subject, body, reason, DateTime.Today));
}
[HttpPost("{id:int}/send-followup")]
public async Task<IActionResult> SendFollowUp([FromRoute] int id, [FromBody] SendFollowUpRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
if (string.IsNullOrWhiteSpace(request.Subject)) return BadRequest("Subject is required.");
if (string.IsNullOrWhiteSpace(request.Body)) return BadRequest("Body is required.");
var toEmail = (request.ToEmail ?? job.Company?.RecruiterEmail ?? string.Empty).Trim();
if (string.IsNullOrWhiteSpace(toEmail)) return BadRequest("Recipient email is required.");
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken);
_db.Correspondences.Add(new Correspondence
{
JobApplicationId = id,
From = "Me",
Subject = request.Subject.Trim(),
Channel = "Email",
Content = request.Body.Trim(),
Date = DateTime.Now,
});
if (job.Company is not null)
{
job.Company.LastContactedAt = DateTime.Now;
if (request.NextFollowUpAt is not null)
{
job.Company.NextContactAt = request.NextFollowUpAt.Value;
}
}
if (request.NextFollowUpAt is not null)
{
job.FollowUpAt = request.NextFollowUpAt.Value;
}
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpGet("summarizer-metrics")]
public async Task<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
{
var metrics = await _summarizer.GetMetricsAsync(cancellationToken);
return Ok(metrics);
}
}
}