2461 lines
119 KiB
C#
2461 lines
119 KiB
C#
using Microsoft.AspNetCore.Authorization;
|
|
using Microsoft.AspNetCore.Mvc;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Caching.Memory;
|
|
using JobTrackerApi.Data;
|
|
using JobTrackerApi.Models;
|
|
using JobTrackerApi.Services;
|
|
using JobTrackerApi.Services.JobImport;
|
|
using System.Security.Claims;
|
|
using System.Security.Cryptography;
|
|
using System.Text;
|
|
using System.Text.Json;
|
|
using Microsoft.AspNetCore.Identity;
|
|
using static JobTrackerApi.Services.JobApplicationHelpers;
|
|
|
|
namespace JobTrackerApi.Controllers
|
|
{
|
|
[ApiController]
|
|
// Explicitly authorized. These endpoints are all tenant-scoped user data, so they must not
|
|
// depend on the Auth:Require fallback policy being switched on: a deployment that lost that flag
|
|
// would otherwise serve them anonymously. docs/production-readiness-review.md.
|
|
[Route("api/jobapplications")]
|
|
[Authorize(AuthenticationSchemes = "local")]
|
|
public class JobApplicationsController : ControllerBase
|
|
{
|
|
private readonly JobTrackerContext _db;
|
|
private readonly ISummarizerService _summarizer;
|
|
private readonly IAppEmailSender _email;
|
|
private readonly UserManager<ApplicationUser> _users;
|
|
private readonly ILogger<JobApplicationsController> _logger;
|
|
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
|
private readonly ICvPdfExporter _cvPdfExporter;
|
|
private readonly AnalyticsService _analytics;
|
|
private readonly IJobCvMatchService _matchService;
|
|
private readonly IMemoryCache _cache;
|
|
private readonly IApplicationChecklistService _checklist;
|
|
|
|
public JobApplicationsController(JobTrackerContext db, ISummarizerService summarizer, IAppEmailSender email, UserManager<ApplicationUser> users, ILogger<JobApplicationsController> logger, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null, AnalyticsService? analytics = null, IJobCvMatchService? matchService = null, IMemoryCache? cache = null, IApplicationChecklistService? checklist = null)
|
|
{
|
|
_checklist = checklist ?? new ApplicationChecklistService(db);
|
|
_db = db;
|
|
_summarizer = summarizer;
|
|
_email = email;
|
|
_users = users;
|
|
_logger = logger;
|
|
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
|
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
|
_analytics = analytics ?? new AnalyticsService(db);
|
|
_matchService = matchService ?? new JobCvMatchService();
|
|
_cache = cache ?? new MemoryCache(new MemoryCacheOptions());
|
|
}
|
|
|
|
// ponytail: RulesEngine.GetSettings is per-user (falls back to the global RuleSettings
|
|
// singleton only when the user has no override), so the cache key must include the
|
|
// current user id -- a single global key would leak one user's follow-up rules to another.
|
|
private async Task<RuleSettings> GetCachedRuleSettingsAsync(CancellationToken cancellationToken)
|
|
{
|
|
var cacheKey = $"rulesettings:{_db.CurrentUserId ?? "anon"}";
|
|
var cached = await _cache.GetOrCreateAsync(cacheKey, async entry =>
|
|
{
|
|
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromSeconds(30);
|
|
return await RulesEngine.GetSettings(_db, cancellationToken);
|
|
});
|
|
return cached!;
|
|
}
|
|
|
|
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
|
{
|
|
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
|
{
|
|
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
|
|
}
|
|
}
|
|
|
|
private string? CurrentUserId =>
|
|
User?.FindFirstValue(ClaimTypes.NameIdentifier) ?? User?.FindFirstValue("sub");
|
|
|
|
private async Task<ApplicationUser?> GetCurrentUserAsync(CancellationToken cancellationToken)
|
|
{
|
|
var userId = CurrentUserId;
|
|
if (string.IsNullOrWhiteSpace(userId)) return null;
|
|
return await _users.FindByIdAsync(userId);
|
|
}
|
|
|
|
private async Task<TailoredCvDraft?> FindTailoredCvDraftAsync(int jobId, CancellationToken cancellationToken)
|
|
{
|
|
return await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == jobId, cancellationToken);
|
|
}
|
|
|
|
private TailoredCvRenderResult RenderTailoredCv(JobApplication job, TailoredCvDocument document, ApplicationUser? user, string? photoDataUrl)
|
|
{
|
|
return _cvTemplateRenderer.Render(
|
|
document,
|
|
document.TemplateId,
|
|
GetPreferredDisplayName(user),
|
|
job.JobTitle,
|
|
job.Company?.Name,
|
|
photoDataUrl);
|
|
}
|
|
|
|
private async Task<TailoredCvDraft> UpsertGeneratedTailoredCvDraftAsync(JobApplication job, ApplicationUser user, string? mode, CancellationToken cancellationToken)
|
|
{
|
|
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
|
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, job.JobUrl }
|
|
.Where(value => !string.IsNullOrWhiteSpace(value)));
|
|
var structuredCvContext = BuildStructuredCvContext(user);
|
|
var generationContext = $@"Job title: {job.JobTitle}
|
|
Company: {job.Company?.Name}
|
|
Status: {job.Status}
|
|
Generation mode: {mode ?? "default"}
|
|
|
|
Job context:
|
|
{jobText}
|
|
|
|
Canonical profile:
|
|
{structuredCvContext}
|
|
";
|
|
|
|
var headline = await _summarizer.SummarizeSectionAsync(
|
|
"Write a short, role-specific CV headline for this candidate. Keep it factual, scannable, and under 12 words. Return headline text only.",
|
|
generationContext,
|
|
48,
|
|
24);
|
|
|
|
var summary = await BuildListFromAiAsync(
|
|
$"Write 4 short CV summary bullets tailored to this job. Use only facts supported by the canonical profile. Keep each line tight and credible. {BuildPackageModeInstruction(mode)}",
|
|
generationContext,
|
|
cancellationToken,
|
|
fallbackPrefix: job.JobTitle);
|
|
|
|
var selectedSkills = SelectTailoredSkills(structured, jobText);
|
|
var matchedTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
var experience = structured.Jobs
|
|
.OrderByDescending(entry => ScoreTailoredExperience(entry, matchedTags))
|
|
.ThenByDescending(entry => entry.IsCurrent)
|
|
.Take(4)
|
|
.Select(entry => new TailoredCvExperienceItem
|
|
{
|
|
Title = entry.Title,
|
|
Company = entry.Company,
|
|
Location = entry.Location,
|
|
Start = entry.Start,
|
|
End = entry.End,
|
|
IsCurrent = entry.IsCurrent,
|
|
Bullets = entry.Bullets.Take(4).ToList(),
|
|
})
|
|
.ToList();
|
|
|
|
var education = structured.Education
|
|
.Take(3)
|
|
.Select(entry => new TailoredCvEducationItem
|
|
{
|
|
Qualification = entry.Qualification,
|
|
Institution = entry.Institution,
|
|
Location = entry.Location,
|
|
Start = entry.Start,
|
|
End = entry.End,
|
|
Details = entry.Details.Take(3).ToList(),
|
|
})
|
|
.ToList();
|
|
|
|
var customSections = new List<TailoredCvCustomSection>();
|
|
if (structured.Languages.Count > 0)
|
|
{
|
|
customSections.Add(new TailoredCvCustomSection
|
|
{
|
|
Title = "Languages",
|
|
Items = structured.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
|
});
|
|
}
|
|
customSections.AddRange(structured.OtherSections.Take(2).Select(section => new TailoredCvCustomSection
|
|
{
|
|
Title = section.Title,
|
|
Items = section.Items.Take(4).ToList(),
|
|
}));
|
|
|
|
var document = TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
|
{
|
|
TemplateId = "ats-minimal",
|
|
Headline = string.IsNullOrWhiteSpace(headline) ? structured.Contact.Headline ?? job.JobTitle : headline.Trim(),
|
|
Summary = summary,
|
|
SelectedSkills = selectedSkills,
|
|
Experience = experience,
|
|
Education = education,
|
|
CustomSections = customSections,
|
|
RenderOptions = new TailoredCvRenderOptions(),
|
|
});
|
|
|
|
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == job.Id, cancellationToken)
|
|
?? new TailoredCvDraft
|
|
{
|
|
OwnerUserId = user.Id,
|
|
JobApplicationId = job.Id,
|
|
};
|
|
|
|
draft.OwnerUserId = user.Id;
|
|
draft.CanonicalProfileVersion = user.CurrentCvProfileVersion;
|
|
draft.GenerationContextHash = ComputeGenerationContextHash(generationContext);
|
|
draft.LastGeneratedAtUtc = DateTimeOffset.UtcNow;
|
|
draft.Status = "generated";
|
|
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
|
|
|
if (draft.Id == 0)
|
|
{
|
|
_db.TailoredCvDrafts.Add(draft);
|
|
}
|
|
|
|
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
|
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return draft;
|
|
}
|
|
|
|
private async Task<List<string>> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix)
|
|
{
|
|
var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70);
|
|
var items = (raw ?? string.Empty)
|
|
.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
|
|
.Select(x => x.Trim().TrimStart('-', '•', '*', ' '))
|
|
.Where(x => !string.IsNullOrWhiteSpace(x))
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(5)
|
|
.ToList();
|
|
|
|
if (items.Count > 0) return items;
|
|
|
|
return new List<string>
|
|
{
|
|
$"Lead with clear evidence tied to {fallbackPrefix}.",
|
|
"Use concrete outcomes, metrics, or scope whenever possible.",
|
|
"Keep the language specific to this role instead of generic.",
|
|
};
|
|
}
|
|
|
|
private async Task<AttachmentContextResult?> BuildAttachmentContextAsync(int jobId, CancellationToken cancellationToken, string? attachmentIdsCsv = null)
|
|
{
|
|
HashSet<int>? allowedIds = null;
|
|
if (!string.IsNullOrWhiteSpace(attachmentIdsCsv))
|
|
{
|
|
allowedIds = attachmentIdsCsv
|
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.Select(value => int.TryParse(value, out var id) ? id : 0)
|
|
.Where(id => id > 0)
|
|
.ToHashSet();
|
|
}
|
|
|
|
var query = _db.Attachments
|
|
.AsNoTracking()
|
|
.Where(a => a.JobApplicationId == jobId);
|
|
|
|
if (allowedIds is not null && allowedIds.Count > 0)
|
|
{
|
|
query = query.Where(a => allowedIds.Contains(a.Id));
|
|
}
|
|
else
|
|
{
|
|
query = query.Where(a => a.UseForAi);
|
|
}
|
|
|
|
var attachments = await query
|
|
.OrderByDescending(a => a.UploadDate)
|
|
.Take(4)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
if (attachments.Count == 0) return null;
|
|
|
|
var metadata = attachments
|
|
.Select(a => $"- {a.FileName} ({a.FileType}, {Math.Max(1, a.FileSize / 1024)} KB)")
|
|
.ToList();
|
|
|
|
var extractedSections = new List<string>();
|
|
var usedFiles = new List<string>();
|
|
|
|
foreach (var attachment in attachments.Take(3))
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attachment.FilePath) || !System.IO.File.Exists(attachment.FilePath)) continue;
|
|
if (attachment.FileSize <= 0 || attachment.FileSize > 5 * 1024 * 1024) continue;
|
|
|
|
var ext = Path.GetExtension(attachment.FileName ?? string.Empty);
|
|
if (!IsExtractableAttachmentExtension(ext)) continue;
|
|
|
|
try
|
|
{
|
|
await using var stream = System.IO.File.OpenRead(attachment.FilePath);
|
|
var extracted = await _summarizer.ExtractTextAsync(stream, attachment.FileName ?? "attachment", attachment.FileType, cancellationToken);
|
|
var text = extracted?.Text?.Trim();
|
|
if (string.IsNullOrWhiteSpace(text)) continue;
|
|
|
|
var condensed = text.Length > 1400
|
|
? await _summarizer.SummarizeSectionAsync(
|
|
"Extract the most relevant job-application signals from this attachment. Focus on skills, achievements, metrics, proof points, and wording that would help tailor a CV or cover letter. Return compact plain text only.",
|
|
text,
|
|
220,
|
|
80) ?? text[..Math.Min(text.Length, 1400)]
|
|
: text[..Math.Min(text.Length, 1400)];
|
|
|
|
extractedSections.Add($"Attachment: {attachment.FileName}\n{condensed.Trim()}");
|
|
usedFiles.Add(attachment.FileName ?? "attachment");
|
|
}
|
|
catch
|
|
{
|
|
// Best effort only; attachment context should never break generation.
|
|
}
|
|
}
|
|
|
|
var signals = new List<string>();
|
|
if (usedFiles.Count > 0)
|
|
{
|
|
var signalContext = string.Join("\n\n", extractedSections);
|
|
signals = await BuildListFromAiAsync(
|
|
"List up to 4 concrete job-application signals from these attachments. Focus on evidence, achievements, quantified results, named tools, and wording worth reusing. Return one short signal per line with no numbering.",
|
|
signalContext,
|
|
cancellationToken,
|
|
fallbackPrefix: usedFiles.First());
|
|
}
|
|
|
|
var context = new StringBuilder();
|
|
context.AppendLine("Attachment inventory:");
|
|
foreach (var line in metadata) context.AppendLine(line);
|
|
if (extractedSections.Count > 0)
|
|
{
|
|
context.AppendLine();
|
|
context.AppendLine("Attachment-derived context:");
|
|
context.AppendLine(string.Join("\n\n", extractedSections));
|
|
}
|
|
|
|
return new AttachmentContextResult(context.ToString().Trim(), signals, usedFiles);
|
|
}
|
|
|
|
private async Task<CorrespondenceContextResult?> BuildCorrespondenceContextAsync(int jobId, CancellationToken cancellationToken)
|
|
{
|
|
var messages = await _db.Correspondences
|
|
.AsNoTracking()
|
|
.Where(message => message.JobApplicationId == jobId)
|
|
.OrderByDescending(message => message.Date)
|
|
.Take(6)
|
|
.ToListAsync(cancellationToken);
|
|
|
|
if (messages.Count == 0) return null;
|
|
|
|
messages = messages
|
|
.OrderBy(message => message.Date)
|
|
.ToList();
|
|
|
|
var participants = messages
|
|
.SelectMany(message => new[] { message.ExternalFrom, message.ExternalTo })
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value!.Trim())
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(6)
|
|
.ToList();
|
|
|
|
var threadIds = messages
|
|
.Select(message => message.ExternalThreadId)
|
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
|
.Select(value => value!.Trim())
|
|
.Distinct(StringComparer.Ordinal)
|
|
.Take(4)
|
|
.ToList();
|
|
|
|
var timeline = messages.Select(message =>
|
|
{
|
|
var content = (message.Content ?? string.Empty).Trim();
|
|
if (content.Length > 320)
|
|
{
|
|
content = content[..320].TrimEnd() + "…";
|
|
}
|
|
|
|
return $"- {message.Date:yyyy-MM-dd} | From={message.From} | Subject={message.Subject ?? "(no subject)"} | ExternalFrom={message.ExternalFrom ?? ""} | ExternalTo={message.ExternalTo ?? ""}\n {content}";
|
|
}).ToList();
|
|
|
|
var context = new StringBuilder();
|
|
context.AppendLine("Imported correspondence context:");
|
|
if (participants.Count > 0)
|
|
{
|
|
context.AppendLine($"Participants: {string.Join(", ", participants)}");
|
|
}
|
|
if (threadIds.Count > 0)
|
|
{
|
|
context.AppendLine($"Threads: {string.Join(", ", threadIds)}");
|
|
}
|
|
context.AppendLine("Timeline:");
|
|
context.AppendLine(string.Join("\n", timeline));
|
|
|
|
var signals = await BuildListFromAiAsync(
|
|
"List up to 4 concrete application-package signals from this imported correspondence. Focus on recruiter priorities, specific role language, next steps, constraints, and phrasing that should influence a tailored CV, cover letter, or recruiter message. Return one short signal per line with no numbering.",
|
|
context.ToString(),
|
|
cancellationToken,
|
|
fallbackPrefix: messages.Last().Subject ?? "imported correspondence");
|
|
|
|
return new CorrespondenceContextResult(context.ToString().Trim(), signals, participants, threadIds);
|
|
}
|
|
|
|
private async Task<List<string>> BuildDraftVariantsAsync(string baseInstruction, string context, CancellationToken cancellationToken, params string[] styles)
|
|
{
|
|
var variants = new List<string>();
|
|
|
|
foreach (var style in styles.Where(x => !string.IsNullOrWhiteSpace(x)))
|
|
{
|
|
var draft = await _summarizer.SummarizeSectionAsync(
|
|
$"{baseInstruction} Style: {style}. Return only the final draft text.",
|
|
context,
|
|
220,
|
|
80);
|
|
|
|
var normalized = draft?.Trim();
|
|
if (!string.IsNullOrWhiteSpace(normalized) && !variants.Contains(normalized, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
variants.Add(normalized);
|
|
}
|
|
}
|
|
|
|
return variants;
|
|
}
|
|
|
|
private JobApplicationDto BuildJobApplicationDto(JobApplication job, FollowUpDecision followUpDecision, string? followUpReasonOverride = null, string? fullSummary = null)
|
|
{
|
|
var workflowSignal = BuildWorkflowSignal(job, followUpDecision);
|
|
|
|
return new JobApplicationDto(
|
|
Id: job.Id,
|
|
CompanyId: job.CompanyId,
|
|
Company: job.Company,
|
|
JobTitle: job.JobTitle,
|
|
Status: job.Status,
|
|
DateApplied: job.DateApplied,
|
|
SavedAt: job.SavedAt,
|
|
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,
|
|
SalaryMin: job.SalaryMin,
|
|
SalaryMax: job.SalaryMax,
|
|
SalaryCurrency: job.SalaryCurrency,
|
|
SalaryPeriod: job.SalaryPeriod,
|
|
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: followUpDecision.NeedsFollowUp,
|
|
FollowUpReason: followUpReasonOverride ?? followUpDecision.Reason,
|
|
TailoredCvText: job.TailoredCvText,
|
|
WorkflowSignal: workflowSignal,
|
|
ShortSummary: job.ShortSummary,
|
|
FullSummary: 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
|
|
.AsNoTracking()
|
|
.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 GetCachedRuleSettingsAsync(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(BuildJobApplicationDto(j, d));
|
|
}
|
|
|
|
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(BuildJobApplicationDto(j, d));
|
|
}
|
|
|
|
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
|
|
.AsNoTracking()
|
|
.Include(j => j.Company)
|
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
|
|
if (job is null) return NotFound();
|
|
|
|
var settings = await GetCachedRuleSettingsAsync(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);
|
|
// Prefer translated content for the detailed summary so Norwegian postings
|
|
// surface readable English analysis while the original text remains available.
|
|
var full = await _summarizer.SummarizeAsync(BuildSummarySource(job), 250, 40);
|
|
|
|
return Ok(BuildJobApplicationDto(job, d, fullSummary: full));
|
|
}
|
|
|
|
[HttpGet("board")]
|
|
public async Task<ActionResult<List<JobApplication>>> GetBoard(
|
|
[FromQuery] bool includeDeleted = false,
|
|
CancellationToken cancellationToken = default
|
|
)
|
|
{
|
|
var query = _db.JobApplications
|
|
.AsNoTracking()
|
|
.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 GetCachedRuleSettingsAsync(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
|
|
.AsNoTracking()
|
|
.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;
|
|
var workflowSignal = BuildWorkflowSignal(j, d);
|
|
if (!workflowSignal.NeedsAttention && !upcoming) continue;
|
|
|
|
dtos.Add(BuildJobApplicationDto(j, d, followUpReasonOverride: workflowSignal.Reason));
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
private static void SyncOpportunity(JobApplication application, Job opportunity)
|
|
{
|
|
opportunity.OwnerUserId = application.OwnerUserId;
|
|
opportunity.CompanyId = application.CompanyId;
|
|
opportunity.JobTitle = application.JobTitle;
|
|
opportunity.Location = application.Location;
|
|
opportunity.JobUrl = application.JobUrl;
|
|
opportunity.Description = application.Description;
|
|
opportunity.TranslatedDescription = application.TranslatedDescription;
|
|
opportunity.DescriptionLanguage = application.DescriptionLanguage;
|
|
opportunity.ShortSummary = application.ShortSummary;
|
|
opportunity.Tags = application.Tags;
|
|
opportunity.Deadline = application.Deadline;
|
|
opportunity.Salary = application.Salary;
|
|
opportunity.SalaryMin = application.SalaryMin;
|
|
opportunity.SalaryMax = application.SalaryMax;
|
|
opportunity.SalaryCurrency = application.SalaryCurrency;
|
|
opportunity.SalaryPeriod = application.SalaryPeriod;
|
|
opportunity.SavedAt = application.SavedAt;
|
|
}
|
|
|
|
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
|
decimal? min, decimal? max, string? currency, string? period)
|
|
{
|
|
if (min is < 0) min = null;
|
|
if (max is < 0) max = null;
|
|
if (min.HasValue && max.HasValue && min > max) (min, max) = (max, min);
|
|
|
|
var cur = (currency ?? "").Trim().ToUpperInvariant();
|
|
if (cur.Length > 8) cur = cur[..8];
|
|
|
|
var per = (period ?? "").Trim().ToLowerInvariant();
|
|
if (per is not ("year" or "month" or "hour")) per = "";
|
|
|
|
return (min, max, cur.Length == 0 ? null : cur, per.Length == 0 ? null : per);
|
|
}
|
|
|
|
[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.");
|
|
|
|
// Scoped by the Company query filter, so this also rejects another user's companyId.
|
|
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 = JobPipeline.Normalize(request.Status),
|
|
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/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
|
// Attachment rows (see AttachmentsController.RecomputeAttachmentFlagsAsync), not
|
|
// settable here -- they start false and get set correctly once files are uploaded.
|
|
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,
|
|
};
|
|
|
|
var source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim().ToLowerInvariant();
|
|
if (source?.Length > 32) source = source[..32];
|
|
var countryCode = string.IsNullOrWhiteSpace(request.CountryCode) ? null : request.CountryCode.Trim().ToUpperInvariant();
|
|
if (countryCode?.Length != 2) countryCode = null;
|
|
job.Job = new Job { Source = source, CountryCode = countryCode };
|
|
|
|
// A job created straight into a pre-application stage has not been applied to, so it
|
|
// must not carry an applied date. SyncAppliedDate also covers the reverse: a create
|
|
// that omits DateApplied but names a real stage still gets stamped.
|
|
JobPipeline.SyncAppliedDate(job, DateTime.Now);
|
|
|
|
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
|
|
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
|
|
|
|
// Generate and persist a short summary at creation time to avoid repeated model calls.
|
|
try
|
|
{
|
|
var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
|
|
job.ShortSummary = shortSum;
|
|
}
|
|
catch
|
|
{
|
|
// ignore summarizer failures at create time
|
|
}
|
|
|
|
SyncOpportunity(job, job.Job);
|
|
_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);
|
|
}
|
|
|
|
[HttpPut("{id:int}")]
|
|
public async Task<IActionResult> Update([FromRoute] int id, [FromBody] UpdateJobApplicationRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications.Include(j => j.Job).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 : JobPipeline.Normalize(request.Status);
|
|
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.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
|
|
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
|
|
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
|
job.FollowUpAt = request.FollowUpAt;
|
|
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
|
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
|
// Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync.
|
|
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;
|
|
// Status may have changed above; keep DateApplied consistent with the stage.
|
|
SyncAppliedDateWithHistory(job);
|
|
|
|
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
|
|
});
|
|
}
|
|
// Records StatusChanged plus any lifecycle event the transition implies.
|
|
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
|
|
|
|
if (job.Job is not null) SyncOpportunity(job, job.Job);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>Canonical ordered pipeline stages so the UI renders one source of truth.</summary>
|
|
[HttpGet("pipeline")]
|
|
public ActionResult<IEnumerable<PipelineStageDto>> GetPipeline()
|
|
=> Ok(JobPipeline.Stages.Select(s => new PipelineStageDto(s.Key, s.Order, s.Category.ToString(), s.Group.ToString())));
|
|
|
|
[HttpPatch("{id:int}/status")]
|
|
public async Task<IActionResult> UpdateStatus([FromRoute] int id, [FromBody] UpdateStatusRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications.FirstOrDefaultAsync(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 = JobPipeline.Normalize(request.Status);
|
|
// Stamps DateApplied when the job leaves the pre-application stages (e.g. the user
|
|
// drags Preparing -> Applied), and clears it if they move back.
|
|
SyncAppliedDateWithHistory(job);
|
|
JobLifecycleEvents.RecordStatusChange(_db, job, old, DateTime.Now);
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
|
|
return NoContent();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Applies the stage/DateApplied invariant and preserves any discarded application date as
|
|
/// a JobEvent, so moving a job backwards into a pre-application stage never destroys the
|
|
/// record that it was once applied to. Both update paths route through here rather than
|
|
/// calling JobPipeline.SyncAppliedDate directly, so the history cannot be forgotten in one
|
|
/// of them.
|
|
///
|
|
/// Not used by Create: there is no prior state to preserve there, only request
|
|
/// normalization.
|
|
/// </summary>
|
|
private void SyncAppliedDateWithHistory(JobApplication job)
|
|
{
|
|
var cleared = JobPipeline.SyncAppliedDate(job, DateTime.Now);
|
|
if (cleared is null) return;
|
|
|
|
_db.JobEvents.Add(new JobEvent
|
|
{
|
|
JobApplicationId = job.Id,
|
|
Type = JobPipeline.AppliedDateClearedEvent,
|
|
// Round-trip format so the date is machine-readable, not just prose.
|
|
OldValue = cleared.Value.ToString("o"),
|
|
NewValue = null,
|
|
Note = $"Moved to {job.Status} before applying; application date cleared.",
|
|
At = DateTime.Now,
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
|
|
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
|
|
/// </summary>
|
|
[HttpGet("{id:int}/status-suggestion")]
|
|
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications.AsNoTracking().FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
|
|
|
|
var latestInbound = await _db.Correspondences
|
|
.AsNoTracking()
|
|
.Where(c => c.JobApplicationId == id
|
|
&& c.Direction != "outbound"
|
|
&& c.From != "Me")
|
|
.OrderByDescending(c => c.Date)
|
|
.FirstOrDefaultAsync(cancellationToken);
|
|
if (latestInbound is null) return Ok(none);
|
|
|
|
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
|
|
if (suggestion is null) return Ok(none);
|
|
|
|
// Don't nag when the job is already in (or past) the suggested stage.
|
|
var currentOrder = JobPipeline.OrderOf(job.Status);
|
|
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
|
|
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
|
|
{
|
|
return Ok(none);
|
|
}
|
|
|
|
return Ok(new StatusSuggestionDto(
|
|
HasSuggestion: true,
|
|
SuggestedStatus: suggestion.SuggestedStatus,
|
|
CurrentStatus: job.Status,
|
|
Signal: suggestion.Signal,
|
|
Confidence: suggestion.Confidence,
|
|
MessageDate: latestInbound.Date,
|
|
MessageSubject: latestInbound.Subject));
|
|
}
|
|
|
|
|
|
[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 = BuildSummarySource(job);
|
|
if (string.IsNullOrWhiteSpace(sourceText))
|
|
{
|
|
return BadRequest("This job does not have enough translated text, 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 GetCachedRuleSettingsAsync(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(BuildJobApplicationDto(job, followUp));
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[HttpGet("{id:int}/history")]
|
|
public async Task<ActionResult<List<JobEventDto>>> GetHistory([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var exists = await _db.JobApplications.AnyAsync(j => j.Id == id, cancellationToken);
|
|
if (!exists) return NotFound();
|
|
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
[HttpGet("stats")]
|
|
public async Task<ActionResult<JobStats>> GetStats(CancellationToken cancellationToken)
|
|
=> Ok(await _analytics.GetStatsAsync(cancellationToken));
|
|
|
|
[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);
|
|
}
|
|
|
|
// DateApplied != null is explicit rather than implied by the range comparison: this is
|
|
// applied-volume-per-month, so jobs that have not been applied to must not appear.
|
|
var jobs = await _db.JobApplications
|
|
.AsNoTracking()
|
|
.Where(j => !j.IsDeleted && j.DateApplied != null && 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!.Value);
|
|
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);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
|
|
private static string BuildPackageModeInstruction(string? mode)
|
|
{
|
|
return (mode ?? string.Empty).Trim().ToLowerInvariant() switch
|
|
{
|
|
"concise" => "Prioritize brevity, clarity, and easy scanning. Use tight phrasing and trim filler.",
|
|
"ats" => "Prioritize ATS-friendly wording, direct skill alignment, standard section phrasing, and keyword coverage where accurate.",
|
|
"achievement" => "Prioritize impact, outcomes, ownership, scope, and measurable achievements.",
|
|
"interview" => "Prioritize talking points that are easy to defend in an interview and tie each claim to concrete examples.",
|
|
_ => "Keep the output balanced, credible, and practical for real applications.",
|
|
};
|
|
}
|
|
|
|
private static string BuildCoverLetterStyleInstruction(string? style)
|
|
{
|
|
return (style ?? string.Empty).Trim().ToLowerInvariant() switch
|
|
{
|
|
"concise" => "Keep the letter compact and efficient with minimal filler.",
|
|
"formal" => "Use a polished, professional, slightly more formal tone without sounding stiff.",
|
|
"bold" => "Use a confident, high-conviction tone while staying factual and credible.",
|
|
_ => "Use a balanced, modern, professional tone.",
|
|
};
|
|
}
|
|
|
|
// Builds CV text grouped by section so match coverage can show *where* the evidence sits.
|
|
private static Dictionary<string, string> BuildCvSections(ApplicationUser? user)
|
|
{
|
|
var structured = StructuredCvProfileJson.Deserialize(user?.ProfileCvStructureJson);
|
|
var sections = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
|
|
|
void Add(string name, IEnumerable<string?> values)
|
|
{
|
|
var text = string.Join("\n", values.Where(v => !string.IsNullOrWhiteSpace(v)));
|
|
if (!string.IsNullOrWhiteSpace(text)) sections[name] = text;
|
|
}
|
|
|
|
Add("Summary", new[] { structured.Contact.Headline }.Concat(structured.Summary));
|
|
Add("Skills", structured.Skills);
|
|
Add("Experience", structured.Jobs.SelectMany(job =>
|
|
new[] { job.Title, job.Company }.Concat(job.Bullets).Concat(job.Skills)));
|
|
Add("Education", structured.Education.SelectMany(ed =>
|
|
new[] { ed.Qualification, ed.Institution }.Concat(ed.Details)));
|
|
|
|
// Always include raw profile text (covers users who only pasted plain CV text, and
|
|
// catches keywords the structured sections missed).
|
|
if (!string.IsNullOrWhiteSpace(user?.ProfileCvText))
|
|
{
|
|
sections["Profile"] = user!.ProfileCvText!;
|
|
}
|
|
|
|
return sections;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Fast, deterministic CV↔job keyword coverage score. Unlike candidate-fit (AI narrative),
|
|
/// this makes no model calls, so it returns instantly and reproducibly.
|
|
/// </summary>
|
|
[HttpGet("{id:int}/match-score")]
|
|
public async Task<ActionResult<MatchScoreDto>> GetMatchScore([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 userId = CurrentUserId;
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
|
|
var user = await _db.Users.AsNoTracking().FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
|
|
var cvSections = BuildCvSections(user);
|
|
if (cvSections.Count == 0)
|
|
{
|
|
return BadRequest("Add your profile CV on the Profile page before running the match score.");
|
|
}
|
|
|
|
var jobText = string.Join("\n\n", new[] { 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 result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
|
|
|
|
return Ok(new MatchScoreDto(
|
|
Score: result.Score,
|
|
Band: result.Band,
|
|
MatchedCount: result.MatchedCount,
|
|
TotalKeywords: result.TotalKeywords,
|
|
MatchedKeywords: result.MatchedKeywords.ToList(),
|
|
MissingKeywords: result.MissingKeywords.ToList(),
|
|
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
|
|
HasEnoughSignal: result.HasEnoughSignal));
|
|
}
|
|
|
|
[HttpGet("{id:int}/candidate-fit")]
|
|
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, 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 userId = CurrentUserId;
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
|
|
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
|
if (!refresh)
|
|
{
|
|
var cached = await TryGetCachedAiNoteAsync<CandidateFitDto>(userId, id, "candidate-fit", attachmentSignature, cancellationToken);
|
|
if (cached is not null) return Ok(cached);
|
|
}
|
|
|
|
var user = await _db.Users.AsNoTracking().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 = BuildCvSearchCorpus(user).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 structuredCvContext = BuildStructuredCvContext(user);
|
|
|
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
|
var jobContext = $@"Job title: {job.JobTitle}
|
|
Company: {job.Company?.Name}
|
|
Status: {job.Status}
|
|
|
|
Job description and notes:
|
|
{jobText}
|
|
|
|
Candidate CV/profile:
|
|
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
|
|
|
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. Mention the exact role and one or two concrete overlaps from the posting or candidate background. Keep it warm, direct, and under 120 words.",
|
|
jobContext,
|
|
130,
|
|
50);
|
|
|
|
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."
|
|
});
|
|
|
|
var dto = 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);
|
|
|
|
await SaveAiNoteAsync(userId, id, "candidate-fit", attachmentSignature, dto, cancellationToken);
|
|
return Ok(dto);
|
|
}
|
|
|
|
[HttpGet("{id:int}/focus-plan")]
|
|
public async Task<ActionResult<FocusPlanDto>> GetFocusPlan([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, 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 userId = CurrentUserId;
|
|
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
|
|
|
|
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
|
if (!refresh)
|
|
{
|
|
var cached = await TryGetCachedAiNoteAsync<FocusPlanDto>(userId, id, "focus-plan", attachmentSignature, cancellationToken);
|
|
if (cached is not null) return Ok(cached);
|
|
}
|
|
|
|
var user = await _db.Users.AsNoTracking().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 a focus plan.");
|
|
}
|
|
|
|
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 a focus plan.");
|
|
}
|
|
|
|
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).Take(8).ToList();
|
|
var normalizedCv = cvText.ToLowerInvariant();
|
|
var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
|
var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList();
|
|
var structuredCvContext = BuildStructuredCvContext(user);
|
|
|
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
|
var context = $@"Job title: {job.JobTitle}
|
|
Company: {job.Company?.Name}
|
|
Status: {job.Status}
|
|
Job description and notes:
|
|
{jobText}
|
|
|
|
Candidate master CV:
|
|
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
|
|
|
var strategicSummary = await _summarizer.SummarizeSectionAsync(
|
|
"Write a concise strategy summary for how the candidate should approach this role. Focus on what matters most in the posting, what evidence to lead with, and where to be careful.",
|
|
context,
|
|
220,
|
|
90) ?? "Focus on the strongest overlap with the posting, lead with evidence, and keep your outreach specific and credible.";
|
|
|
|
var immediatePriorities = new List<string>();
|
|
immediatePriorities.AddRange(matchedTags.Take(3).Select(x => $"Lead with your strongest evidence for {x}."));
|
|
immediatePriorities.AddRange(missingTags.Take(2).Select(x => $"Address {x} carefully: show adjacent experience or a credible ramp-up story."));
|
|
if (!string.IsNullOrWhiteSpace(job.ShortSummary)) immediatePriorities.Add($"Use the role summary as a framing line: {job.ShortSummary.Trim().TrimEnd('.')}. ");
|
|
immediatePriorities = immediatePriorities.Distinct(StringComparer.OrdinalIgnoreCase).Take(5).ToList();
|
|
|
|
var cvBulletIdeas = await BuildListFromAiAsync(
|
|
"Write 4 resume bullet ideas tailored to this job. Each bullet should be specific, factual in tone, and outcome-oriented. Return one bullet per line with no numbering.",
|
|
context,
|
|
cancellationToken,
|
|
fallbackPrefix: matchedTags.FirstOrDefault() ?? job.JobTitle);
|
|
|
|
var proofPointsToLeadWith = await BuildListFromAiAsync(
|
|
"Write 4 short proof points the candidate should lead with for this role. Use evidence, scope, outcomes, and credibility. Return one point per line with no numbering.",
|
|
context,
|
|
cancellationToken,
|
|
fallbackPrefix: job.Company?.Name ?? job.JobTitle);
|
|
|
|
var coverLetterAngles = await BuildListFromAiAsync(
|
|
"Write 4 short cover-letter angles for this role. Focus on why this role, why this company, and the most relevant strengths. Return one angle per line with no numbering.",
|
|
context,
|
|
cancellationToken,
|
|
fallbackPrefix: matchedTags.FirstOrDefault() ?? "relevant experience");
|
|
|
|
var followUpApproach = BuildFollowUpApproach(job.Status, matchedTags, missingTags);
|
|
|
|
var dto = new FocusPlanDto(
|
|
ImmediatePriorities: immediatePriorities,
|
|
CvBulletIdeas: cvBulletIdeas,
|
|
ProofPointsToLeadWith: proofPointsToLeadWith,
|
|
CoverLetterAngles: coverLetterAngles,
|
|
FollowUpApproach: followUpApproach,
|
|
StrategicSummary: strategicSummary);
|
|
|
|
await SaveAiNoteAsync(userId, id, "focus-plan", attachmentSignature, dto, cancellationToken);
|
|
return Ok(dto);
|
|
}
|
|
|
|
private async Task<T?> TryGetCachedAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, CancellationToken cancellationToken) where T : class
|
|
{
|
|
var existing = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
|
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType && x.AttachmentContextSignature == attachmentSignature,
|
|
cancellationToken);
|
|
if (existing is null) return null;
|
|
return JsonSerializer.Deserialize<T>(existing.ResultJson);
|
|
}
|
|
|
|
private async Task SaveAiNoteAsync<T>(string userId, int jobApplicationId, string noteType, string attachmentSignature, T dto, CancellationToken cancellationToken)
|
|
{
|
|
var note = await _db.AiWorkspaceNotes.FirstOrDefaultAsync(
|
|
x => x.OwnerUserId == userId && x.JobApplicationId == jobApplicationId && x.NoteType == noteType,
|
|
cancellationToken);
|
|
if (note is null)
|
|
{
|
|
note = new AiWorkspaceNote { OwnerUserId = userId, JobApplicationId = jobApplicationId, NoteType = noteType };
|
|
_db.AiWorkspaceNotes.Add(note);
|
|
}
|
|
note.AttachmentContextSignature = attachmentSignature;
|
|
note.ResultJson = JsonSerializer.Serialize(dto);
|
|
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
[HttpGet("{id:int}/interview-prep")]
|
|
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, [FromQuery] string? attachmentIds, [FromQuery] bool refresh, 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 userId = CurrentUserId;
|
|
var attachmentSignature = NormalizeAttachmentIdsSignature(attachmentIds);
|
|
|
|
if (!refresh && userId is not null)
|
|
{
|
|
var existing = await _db.InterviewPrepNotes.FirstOrDefaultAsync(
|
|
x => x.OwnerUserId == userId && x.JobApplicationId == id && x.AttachmentContextSignature == attachmentSignature,
|
|
cancellationToken);
|
|
if (existing is not null)
|
|
{
|
|
return Ok(new InterviewPrepDto(
|
|
existing.Summary,
|
|
JsonSerializer.Deserialize<List<string>>(existing.TalkingPointsJson) ?? new List<string>(),
|
|
JsonSerializer.Deserialize<List<string>>(existing.LikelyQuestionsJson) ?? new List<string>(),
|
|
JsonSerializer.Deserialize<List<string>>(existing.WeakSpotsJson) ?? new List<string>()));
|
|
}
|
|
}
|
|
|
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
|
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary, attachmentContext?.Context }
|
|
.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.";
|
|
|
|
if (userId is not null)
|
|
{
|
|
var note = await _db.InterviewPrepNotes.FirstOrDefaultAsync(x => x.OwnerUserId == userId && x.JobApplicationId == id, cancellationToken);
|
|
if (note is null)
|
|
{
|
|
note = new InterviewPrepNote { OwnerUserId = userId, JobApplicationId = id };
|
|
_db.InterviewPrepNotes.Add(note);
|
|
}
|
|
note.AttachmentContextSignature = attachmentSignature;
|
|
note.Summary = summary;
|
|
note.TalkingPointsJson = JsonSerializer.Serialize(talkingPoints);
|
|
note.LikelyQuestionsJson = JsonSerializer.Serialize(likelyQuestions);
|
|
note.WeakSpotsJson = JsonSerializer.Serialize(weakSpots);
|
|
note.GeneratedAtUtc = DateTimeOffset.UtcNow;
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
}
|
|
|
|
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
|
|
}
|
|
|
|
private static string NormalizeAttachmentIdsSignature(string? attachmentIds)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(attachmentIds)) return string.Empty;
|
|
var ids = attachmentIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
.OrderBy(x => x, StringComparer.Ordinal);
|
|
return string.Join(",", ids);
|
|
}
|
|
|
|
[HttpGet("{id:int}/readiness")]
|
|
public async Task<ActionResult<ReadinessDto>> GetReadiness([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 settings = await GetCachedRuleSettingsAsync(cancellationToken);
|
|
var now = DateTime.Now;
|
|
var lastMessageAt = await _db.Correspondences
|
|
.AsNoTracking()
|
|
.Where(c => c.JobApplicationId == id)
|
|
.MaxAsync(c => (DateTime?)c.Date, cancellationToken);
|
|
var followUpDecision = RulesEngine.Evaluate(settings, job, now, lastMessageAt);
|
|
var workflowSignal = BuildWorkflowSignal(job, followUpDecision);
|
|
|
|
// Phase 5 Milestone 2: readiness no longer runs its own parallel checklist. The persisted
|
|
// application checklist is the one workflow surface; readiness projects it into the score /
|
|
// completed / missing / reminders health view the dialog and dashboard already consume, so
|
|
// the two can never disagree. The DTO shape is unchanged on purpose.
|
|
// docs/architecture/application-workspace.md.
|
|
var checklist = job.OwnerUserId is null
|
|
? null
|
|
: await _checklist.GetAsync(job.OwnerUserId, id, cancellationToken);
|
|
var live = checklist?.Items.Where(i => i.Status != ChecklistStatuses.Dismissed).ToList()
|
|
?? new List<ChecklistItemDto>();
|
|
|
|
var completed = live.Where(i => i.Status == ChecklistStatuses.Done).Select(i => i.Title).ToList();
|
|
var missing = live.Where(i => i.Status == ChecklistStatuses.Pending).Select(i => i.Title).ToList();
|
|
|
|
var reminders = BuildReadinessReminders(job, workflowSignal);
|
|
var score = checklist?.Progress.Percent ?? 0;
|
|
var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
|
|
|
|
return Ok(new ReadinessDto(score, level, completed, missing, reminders, workflowSignal));
|
|
}
|
|
|
|
[HttpGet("{id:int}/tailored-cv-draft")]
|
|
public async Task<ActionResult<TailoredCvDraftDto>> GetTailoredCvDraft([FromRoute] int id, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
var draft = await _db.TailoredCvDrafts
|
|
.AsNoTracking()
|
|
.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken);
|
|
|
|
return Ok(draft is null ? ToLegacyTailoredCvDraftDto(job) : ToTailoredCvDraftDto(draft));
|
|
}
|
|
|
|
[HttpPost("{id:int}/tailored-cv-preview")]
|
|
public async Task<ActionResult<TailoredCvPreviewDto>> PreviewTailoredCv([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications
|
|
.Include(j => j.Company)
|
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
var user = await GetCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
|
|
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
|
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
|
request.TemplateId,
|
|
request.Headline,
|
|
request.Summary,
|
|
request.SelectedSkills,
|
|
request.Experience,
|
|
request.Education,
|
|
request.CustomSections,
|
|
request.RenderOptions,
|
|
draft?.Status ?? "generated"), draft, job);
|
|
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
|
? request!.PhotoDataUrl
|
|
: request?.UseProfileAvatar == false
|
|
? null
|
|
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
|
|
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
|
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
|
|
}
|
|
|
|
[HttpPost("{id:int}/export-tailored-cv-pdf")]
|
|
public async Task<IActionResult> ExportTailoredCvPdf([FromRoute] int id, [FromBody] TailoredCvRenderRequest? request, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications
|
|
.Include(j => j.Company)
|
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
var user = await GetCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
|
|
var draft = await FindTailoredCvDraftAsync(id, cancellationToken);
|
|
var document = BuildTailoredCvDocumentForRender(request is null ? null : new SaveTailoredCvDraftRequest(
|
|
request.TemplateId,
|
|
request.Headline,
|
|
request.Summary,
|
|
request.SelectedSkills,
|
|
request.Experience,
|
|
request.Education,
|
|
request.CustomSections,
|
|
request.RenderOptions,
|
|
draft?.Status ?? "generated"), draft, job);
|
|
var photoDataUrl = !string.IsNullOrWhiteSpace(request?.PhotoDataUrl)
|
|
? request!.PhotoDataUrl
|
|
: request?.UseProfileAvatar == false
|
|
? null
|
|
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
|
|
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
|
|
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
|
|
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
|
}
|
|
|
|
[HttpPost("{id:int}/generate-tailored-cv-draft")]
|
|
public async Task<ActionResult<TailoredCvDraftDto>> GenerateTailoredCvDraft([FromRoute] int id, [FromQuery] string? mode, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications
|
|
.Include(j => j.Company)
|
|
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
var user = await GetCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
if (string.IsNullOrWhiteSpace(user.ProfileCvText))
|
|
{
|
|
return BadRequest("Add your profile CV text on the Profile page before generating a tailored CV draft.");
|
|
}
|
|
|
|
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
|
if (structured.Summary.Count == 0 && structured.Jobs.Count == 0 && structured.Skills.Count == 0)
|
|
{
|
|
return BadRequest("Build and review your canonical structured CV on the Profile page before generating a tailored draft.");
|
|
}
|
|
|
|
var draft = await UpsertGeneratedTailoredCvDraftAsync(job, user, mode, cancellationToken);
|
|
return Ok(ToTailoredCvDraftDto(draft));
|
|
}
|
|
|
|
[HttpPut("{id:int}/tailored-cv-draft")]
|
|
public async Task<IActionResult> SaveTailoredCvDraft([FromRoute] int id, [FromBody] SaveTailoredCvDraftRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
var user = await GetCurrentUserAsync(cancellationToken);
|
|
if (user is null) return Unauthorized();
|
|
|
|
var draft = await _db.TailoredCvDrafts.FirstOrDefaultAsync(x => x.JobApplicationId == id, cancellationToken)
|
|
?? new TailoredCvDraft
|
|
{
|
|
OwnerUserId = user.Id,
|
|
JobApplicationId = id,
|
|
CanonicalProfileVersion = user.CurrentCvProfileVersion,
|
|
};
|
|
|
|
var document = new TailoredCvDocument
|
|
{
|
|
TemplateId = request.TemplateId ?? draft.TemplateId,
|
|
Headline = request.Headline,
|
|
Summary = request.Summary ?? new List<string>(),
|
|
SelectedSkills = request.SelectedSkills ?? new List<string>(),
|
|
Experience = request.Experience ?? new List<TailoredCvExperienceItem>(),
|
|
Education = request.Education ?? new List<TailoredCvEducationItem>(),
|
|
CustomSections = request.CustomSections ?? new List<TailoredCvCustomSection>(),
|
|
RenderOptions = request.RenderOptions ?? new TailoredCvRenderOptions(),
|
|
};
|
|
|
|
draft.OwnerUserId = user.Id;
|
|
draft.CanonicalProfileVersion ??= user.CurrentCvProfileVersion;
|
|
draft.Status = string.IsNullOrWhiteSpace(request.Status) ? "edited" : request.Status.Trim();
|
|
draft.LastEditedAtUtc = DateTimeOffset.UtcNow;
|
|
TailoredCvDraftJson.ApplyToDraft(draft, document);
|
|
|
|
if (draft.Id == 0)
|
|
{
|
|
_db.TailoredCvDrafts.Add(draft);
|
|
}
|
|
|
|
job.TailoredCvText = TailoredCvDraftJson.RenderPlainText(document);
|
|
job.TailoredCvUpdatedAt = DateTime.UtcNow;
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[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();
|
|
}
|
|
|
|
[HttpPut("{id:int}/application-drafts")]
|
|
public async Task<IActionResult> SaveApplicationDrafts([FromRoute] int id, [FromBody] SaveApplicationDraftsRequest request, CancellationToken cancellationToken)
|
|
{
|
|
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
|
if (job is null) return NotFound();
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.CoverLetterText))
|
|
{
|
|
job.CoverLetterText = request.CoverLetterText.Trim();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.Notes))
|
|
{
|
|
job.Notes = request.Notes.Trim();
|
|
}
|
|
|
|
if (!string.IsNullOrWhiteSpace(request.RecruiterMessageDraft))
|
|
{
|
|
job.RecruiterMessageDraft = request.RecruiterMessageDraft.Trim();
|
|
}
|
|
|
|
await _db.SaveChangesAsync(cancellationToken);
|
|
return NoContent();
|
|
}
|
|
|
|
[HttpPost("{id:int}/generate-application-package")]
|
|
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? coverLetterStyle, [FromQuery] string? attachmentIds, 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, job.JobUrl }
|
|
.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 packageModeInstruction = BuildPackageModeInstruction(mode);
|
|
var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle);
|
|
var structuredCvContext = BuildStructuredCvContext(user);
|
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
|
var correspondenceContext = await BuildCorrespondenceContextAsync(id, cancellationToken);
|
|
var savedPackageMaterial = new SavedPackageMaterial(job.TailoredCvText, job.CoverLetterText, job.RecruiterMessageDraft, job.Notes);
|
|
|
|
var recruiterContext = new StringBuilder();
|
|
recruiterContext.AppendLine($"Recruiter name: {job.Company?.RecruiterName ?? ""}");
|
|
recruiterContext.AppendLine($"Recruiter email: {job.Company?.RecruiterEmail ?? ""}");
|
|
recruiterContext.AppendLine($"Greeting baseline: {BuildGreeting(job)}");
|
|
|
|
var packageContext = $@"Job title: {job.JobTitle}
|
|
Company: {job.Company?.Name}
|
|
Status: {job.Status}
|
|
Generation mode: {mode ?? "default"}
|
|
Cover-letter style: {coverLetterStyle ?? "balanced"}
|
|
|
|
Recruiter and company context:
|
|
{recruiterContext.ToString().Trim()}
|
|
|
|
Job context:
|
|
{jobText}
|
|
{(correspondenceContext is not null ? $"\n\nImported correspondence:\n{correspondenceContext.Context}" : string.Empty)}
|
|
{(!string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft)
|
|
? $"\n\nExisting saved job material:\nTailored CV draft: {savedPackageMaterial.TailoredCvText ?? ""}\nCover letter draft: {savedPackageMaterial.CoverLetterText ?? ""}\nRecruiter message draft: {savedPackageMaterial.RecruiterMessageDraft ?? ""}"
|
|
: string.Empty)}
|
|
|
|
Candidate master CV:
|
|
{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
|
|
|
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. Use imported correspondence and recruiter language when it sharpens specificity, but do not invent facts. {packageModeInstruction}",
|
|
packageContext,
|
|
256,
|
|
120) ?? cvText;
|
|
|
|
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
|
|
$"Write a concise but high-quality cover letter for this candidate and job. Use the candidate CV as the source of evidence, mirror the priorities of the posting, incorporate relevant signals from imported correspondence when available, mention concrete overlap instead of generic enthusiasm, and make the letter feel specific to this company and role. Keep it credible, polished, and directly aligned to the role. {packageModeInstruction} {coverLetterStyleInstruction}",
|
|
packageContext,
|
|
260,
|
|
110);
|
|
|
|
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, use specific evidence from the CV and imported correspondence where helpful, and avoid generic filler. {packageModeInstruction}",
|
|
packageContext,
|
|
170,
|
|
70);
|
|
|
|
var coverLetterVariants = await BuildDraftVariantsAsync(
|
|
"Write a concise, job-specific cover letter for this candidate and role. Use concrete evidence from the CV, recruiter context, and imported correspondence where relevant, avoid generic enthusiasm, and keep the tone credible and polished.",
|
|
packageContext,
|
|
cancellationToken,
|
|
"concise and efficient",
|
|
"formal and polished",
|
|
"confident and high-conviction");
|
|
|
|
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
|
|
$"Write a short recruiter intro message for this candidate and role. Make it feel specific to the posting by mentioning the exact role, company, and one or two concrete overlaps from the candidate profile, job context, or imported correspondence. If recruiter details are available, use them naturally. Keep it warm, direct, and concise. {packageModeInstruction}",
|
|
packageContext,
|
|
140,
|
|
55);
|
|
|
|
var recruiterMessageVariants = await BuildDraftVariantsAsync(
|
|
"Write a short recruiter intro message for this candidate and role. Mention the exact role, company, recruiter context, and one or two concrete overlaps. Keep it natural, specific, and easy to respond to.",
|
|
packageContext,
|
|
cancellationToken,
|
|
"warm and conversational",
|
|
"direct and concise",
|
|
"polished and formal");
|
|
|
|
var keyPoints = SkillTagger.Detect(jobText)
|
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
.Take(4)
|
|
.Select(x => $"Lead with evidence of {x}.")
|
|
.ToList();
|
|
|
|
if (correspondenceContext is not null)
|
|
{
|
|
foreach (var signal in correspondenceContext.Signals)
|
|
{
|
|
if (!keyPoints.Contains(signal, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
keyPoints.Add(signal);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (attachmentContext is not null)
|
|
{
|
|
foreach (var signal in attachmentContext.Signals)
|
|
{
|
|
if (!keyPoints.Contains(signal, StringComparer.OrdinalIgnoreCase))
|
|
{
|
|
keyPoints.Add(signal);
|
|
}
|
|
}
|
|
}
|
|
|
|
keyPoints = keyPoints.Take(6).ToList();
|
|
|
|
return Ok(new GenerateApplicationPackageDto(
|
|
TailoredCvText: tailoredCvText,
|
|
CoverLetterDraft: coverLetterDraft,
|
|
ApplicationAnswerDraft: applicationAnswerDraft,
|
|
RecruiterMessageDraft: recruiterMessageDraft,
|
|
KeyPoints: keyPoints,
|
|
AttachmentSignals: attachmentContext?.Signals ?? new List<string>(),
|
|
AttachmentFilesUsed: attachmentContext?.UsedFiles ?? new List<string>(),
|
|
CoverLetterVariants: coverLetterVariants,
|
|
RecruiterMessageVariants: recruiterMessageVariants));
|
|
}
|
|
|
|
[HttpGet("analytics-overview")]
|
|
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
|
|
=> Ok(await _analytics.GetAnalyticsOverviewAsync(cancellationToken));
|
|
|
|
[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.Any(), matches));
|
|
}
|
|
|
|
[HttpGet("{id:int}/followup-draft")]
|
|
public async Task<ActionResult<FollowUpDraftDto>> GetFollowUpDraft([FromRoute] int id, [FromQuery] string? mode, [FromQuery] string? attachmentIds, 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 currentUser = await GetCurrentUserAsync(cancellationToken);
|
|
var signerName = GetPreferredDisplayName(currentUser);
|
|
var greeting = BuildGreeting(job);
|
|
var subject = BuildFollowUpSubject(job, lastMessage);
|
|
var reference = lastMessage?.Subject ?? job.JobTitle;
|
|
var summary = job.ShortSummary;
|
|
var appliedDate = job.DateApplied?.ToString("MMMM d, yyyy") ?? "not yet applied";
|
|
var tagHighlights = SplitTags(job.Tags).Take(4).ToList();
|
|
var companyName = job.Company?.Name ?? "your team";
|
|
var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds);
|
|
var correspondenceContext = await BuildCorrespondenceContextAsync(id, cancellationToken);
|
|
var savedPackageMaterial = new SavedPackageMaterial(job.TailoredCvText, job.CoverLetterText, job.RecruiterMessageDraft, job.Notes);
|
|
var savedApplicationAnswer = ExtractSavedApplicationAnswerDraft(job.Notes);
|
|
|
|
var requestedMode = string.IsNullOrWhiteSpace(mode)
|
|
? (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) ? "post-interview"
|
|
: job.Status == "Waiting" ? "waiting-update"
|
|
: job.Status == "Offer" ? "offer-checkin"
|
|
: job.Status == "Rejected" ? "feedback-request"
|
|
: "post-apply")
|
|
: mode.Trim().ToLowerInvariant();
|
|
|
|
var followUpContextSignals = BuildFollowUpContextSignals(job, lastMessage, correspondenceContext, savedPackageMaterial, savedApplicationAnswer);
|
|
var contextSummary = string.Join(" ", new[]
|
|
{
|
|
reason.Trim(),
|
|
lastMessage is not null ? $"Latest thread activity was on {lastMessage.Date:MMMM d, yyyy}." : "No imported thread activity exists yet.",
|
|
!string.IsNullOrWhiteSpace(savedPackageMaterial.CoverLetterText) || !string.IsNullOrWhiteSpace(savedPackageMaterial.RecruiterMessageDraft) || !string.IsNullOrWhiteSpace(savedPackageMaterial.TailoredCvText)
|
|
? "Saved application package material is available for reuse."
|
|
: "No saved application package material is available yet."
|
|
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
|
|
var aiContext = $@"Candidate name: {signerName}
|
|
Role: {job.JobTitle}
|
|
Company: {companyName}
|
|
Applied on: {appliedDate}
|
|
Current status: {job.Status}
|
|
Requested follow-up mode: {requestedMode}
|
|
Reason for follow-up: {reason}
|
|
Follow-up context summary: {contextSummary}
|
|
Last message subject: {lastMessage?.Subject ?? "None"}
|
|
Last message date: {(lastMessage is not null ? lastMessage.Date.ToString("MMMM d, yyyy") : "None")}
|
|
Last message from: {lastMessage?.ExternalFrom ?? lastMessage?.From ?? "None"}
|
|
Relevant skills/tags: {(tagHighlights.Count > 0 ? string.Join(", ", tagHighlights) : "None provided")}
|
|
Short fit summary: {summary ?? "None provided"}
|
|
|
|
Imported correspondence context:
|
|
{correspondenceContext?.Context ?? "No imported correspondence context available."}
|
|
|
|
Saved application package material:
|
|
Tailored CV: {savedPackageMaterial.TailoredCvText ?? "None saved"}
|
|
Cover letter: {savedPackageMaterial.CoverLetterText ?? "None saved"}
|
|
Recruiter message: {savedPackageMaterial.RecruiterMessageDraft ?? "None saved"}
|
|
Application answer: {savedApplicationAnswer ?? "None saved"}
|
|
|
|
Follow-up context signals:
|
|
{(followUpContextSignals.Count > 0 ? string.Join("\n", followUpContextSignals.Select(signal => $"- {signal}")) : "- No extra context signals available.")}
|
|
|
|
Job description:
|
|
{job.TranslatedDescription ?? job.Description ?? "No job description available."}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}";
|
|
|
|
var aiBody = await _summarizer.SummarizeSectionAsync(
|
|
$"Write a concise, professional follow-up email in first person for the mode '{requestedMode}'. Mention that the candidate applied on the provided date, reference the exact role and company, use the imported correspondence and saved application package material when they sharpen specificity, and keep the manual-send boundary intact by returning draft text only. Adjust the tone to the stage: post-apply should be light and interested, waiting-update should ask about progress, post-interview should thank them and reaffirm fit, offer-checkin should be warm and practical, feedback-request should be respectful and brief. Keep it specific, warm, and under 140 words. Return only the email body.",
|
|
aiContext,
|
|
210,
|
|
80);
|
|
|
|
var fallbackIntro = requestedMode switch
|
|
{
|
|
"post-interview" => $"I wanted to thank you again for the conversation about the {job.JobTitle} role and follow up on next steps.",
|
|
"waiting-update" => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}, and see whether there are any updates on the process.",
|
|
"offer-checkin" => $"I wanted to check in on the latest status for the {job.JobTitle} role and any next steps you would like from me.",
|
|
"feedback-request" => $"Thank you for the update on the {job.JobTitle} process. If you're open to it, I would be grateful for any brief feedback that could help me improve.",
|
|
_ => $"I wanted to follow up on my application for the {job.JobTitle} role that I submitted on {appliedDate}. I'm still very interested in the opportunity at {companyName}.",
|
|
};
|
|
|
|
var strongestOverlap = !string.IsNullOrWhiteSpace(savedApplicationAnswer)
|
|
? savedApplicationAnswer!.Split(new[] { '.', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries).FirstOrDefault()?.Trim()
|
|
: null;
|
|
|
|
var fallbackBody = string.Join("\n\n", new[]
|
|
{
|
|
greeting,
|
|
fallbackIntro,
|
|
!string.IsNullOrWhiteSpace(summary)
|
|
? $"The strongest overlap still looks like {summary.Trim().TrimEnd('.')}."
|
|
: !string.IsNullOrWhiteSpace(strongestOverlap)
|
|
? strongestOverlap
|
|
: tagHighlights.Count > 0
|
|
? $"The role's focus on {string.Join(", ", tagHighlights.Take(2))} especially stood out to me, and it lines up well with my experience."
|
|
: null,
|
|
lastMessage is not null && !string.IsNullOrWhiteSpace(lastMessage.Subject)
|
|
? $"I also wanted to keep the thread moving on {lastMessage.Subject.Trim()} if there is anything else you need from me."
|
|
: $"I would be glad to share any additional details that would be helpful as you move through next steps for {reference}.",
|
|
$"Thanks for your time,\n{signerName}"
|
|
}.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
|
|
var body = !string.IsNullOrWhiteSpace(aiBody) ? aiBody.Trim() : fallbackBody;
|
|
if (!body.StartsWith("Hi", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
body = string.Join("\n\n", new[] { greeting, body, $"Thanks,\n{signerName}" }.Where(x => !string.IsNullOrWhiteSpace(x)));
|
|
}
|
|
|
|
return Ok(new FollowUpDraftDto(
|
|
subject,
|
|
body,
|
|
reason,
|
|
DateTime.Today,
|
|
contextSummary,
|
|
followUpContextSignals,
|
|
lastMessage?.Subject,
|
|
lastMessage?.ExternalFrom ?? lastMessage?.From,
|
|
lastMessage?.Date));
|
|
}
|
|
|
|
[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.");
|
|
|
|
try
|
|
{
|
|
await _email.SendAsync(toEmail, request.Subject.Trim(), request.Body.Trim(), cancellationToken);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
_logger.LogError(ex, "Failed to send follow-up email for job {JobId} to {Email}", id, toEmail);
|
|
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Email delivery unavailable", detail: "Follow-up email could not be sent right now. Please try again later.");
|
|
}
|
|
|
|
_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("ai-metrics")]
|
|
[HttpGet("summarizer-metrics")]
|
|
public async Task<ActionResult<AiServiceMetrics>> GetSummarizerMetrics(CancellationToken cancellationToken)
|
|
{
|
|
var metrics = await _summarizer.GetMetricsAsync(cancellationToken);
|
|
return Ok(metrics);
|
|
}
|
|
}
|
|
}
|
|
|
|
|