1321 lines
55 KiB
C#
1321 lines
55 KiB
C#
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
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;
|
|
|
|
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer)
|
|
{
|
|
_db = db;
|
|
_summarizer = summarizer;
|
|
}
|
|
|
|
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());
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
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
|
|
));
|
|
}
|
|
|
|
// 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 = string.IsNullOrWhiteSpace(request.Tags) ? null : request.Tags,
|
|
Deadline = request.Deadline,
|
|
CoverLetterText = string.IsNullOrWhiteSpace(request.CoverLetterText) ? null : request.CoverLetterText,
|
|
JobUrl = string.IsNullOrWhiteSpace(request.JobUrl) ? null : 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 ?? "", 80, 20);
|
|
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
|
|
);
|
|
|
|
[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 = request.Tags;
|
|
job.Deadline = request.Deadline;
|
|
job.CoverLetterText = request.CoverLetterText;
|
|
job.JobUrl = 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 = 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();
|
|
}
|
|
|
|
[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 TagTrendResponse(List<string> Months, List<TagTrendSeries> Series);
|
|
|
|
[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));
|
|
}
|
|
|
|
[HttpGet("summarizer-metrics")]
|
|
public async Task<ActionResult<SummarizerMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
|
|
{
|
|
var metrics = await _summarizer.GetMetricsAsync(cancellationToken);
|
|
return Ok(metrics);
|
|
}
|
|
}
|
|
}
|