1780 lines
78 KiB
C#
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
|