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"); public sealed record PagedResult(List 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>> 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(); 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(); 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(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(); 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(dtos, total, page, pageSize)); } [HttpGet("{id:int}")] public async Task> 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>> 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>> 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(); 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> 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 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 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 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 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 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>> 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>> 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 ByStatus, int AppliedLast30Days, double AverageDaysSinceApplied ); [HttpGet("stats")] public async Task> 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>> 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(StringComparer.Ordinal); var responses = new Dictionary(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(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>> 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 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 SplitTags(string? s) { if (string.IsNullOrWhiteSpace(s)) yield break; var trimmed = s.Trim(); List? jsonTags = null; if (trimmed.StartsWith("[") && trimmed.EndsWith("]")) { try { jsonTags = JsonSerializer.Deserialize>(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(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); } [HttpGet("summarizer-metrics")] public async Task> GetSummarizerMetrics(CancellationToken cancellationToken) { var metrics = await _summarizer.GetMetricsAsync(cancellationToken); return Ok(metrics); } } }