diff --git a/JobTrackerApi/Controllers/AuthController.cs b/JobTrackerApi/Controllers/AuthController.cs index d2828db..1909027 100644 --- a/JobTrackerApi/Controllers/AuthController.cs +++ b/JobTrackerApi/Controllers/AuthController.cs @@ -32,7 +32,7 @@ public sealed class AuthController : ControllerBase public IActionResult Config() { var requireAuth = _cfg.GetValue("Auth:Require", false); - var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? "").Trim()); + var googleEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:GoogleClientId"] ?? string.Empty).Trim()); var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false); return Ok(new @@ -40,7 +40,7 @@ public sealed class AuthController : ControllerBase requireAuth, googleEnabled, localEnabled = true, - allowRegistration + allowRegistration, }); } @@ -56,17 +56,18 @@ public sealed class AuthController : ControllerBase string? FirstName, string? LastName, string? DisplayName, + string? ProfileCvText, IList Roles, GoogleLinkDto? GoogleLink); - public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName); + public sealed record UpdateProfileRequest(string? Email, string? UserName, string? FirstName, string? LastName, string? DisplayName, string? ProfileCvText); public sealed record GoogleTokenRequest(string Token); [HttpPost("login")] [AllowAnonymous] public async Task> Login([FromBody] LoginRequest request, CancellationToken cancellationToken) { - var email = (request.Email ?? "").Trim(); - var password = request.Password ?? ""; + var email = (request.Email ?? string.Empty).Trim(); + var password = request.Password ?? string.Empty; if (email.Length == 0) return BadRequest("Email is required."); if (password.Length == 0) return BadRequest("Password is required."); @@ -88,8 +89,8 @@ public sealed class AuthController : ControllerBase var allow = _cfg.GetValue("Auth:AllowRegistration", false); if (!allow) return StatusCode(403, "Registration is disabled."); - var email = (request.Email ?? "").Trim(); - var password = request.Password ?? ""; + var email = (request.Email ?? string.Empty).Trim(); + var password = request.Password ?? string.Empty; if (email.Length == 0) return BadRequest("Email is required."); if (password.Length == 0) return BadRequest("Password is required."); @@ -112,7 +113,7 @@ public sealed class AuthController : ControllerBase [AllowAnonymous] public async Task> ExchangeGoogleToken([FromBody] GoogleTokenRequest request, CancellationToken cancellationToken) { - var token = (request.Token ?? "").Trim(); + var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Google token is required."); GoogleTokenPrincipal google; @@ -150,16 +151,16 @@ public sealed class AuthController : ControllerBase [Authorize] public async Task Me(CancellationToken cancellationToken) { - var u = await _users.GetUserAsync(User); - if (u is not null) + var user = await _users.GetUserAsync(User); + if (user is not null) { - var roles = await _users.GetRolesAsync(u); - return Ok(ToMeResult(u, roles)); + var roles = await _users.GetRolesAsync(user); + return Ok(ToMeResult(user, roles)); } var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email"); var sub = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub"); - var iss = User.FindFirstValue("iss") ?? ""; + var iss = User.FindFirstValue("iss") ?? string.Empty; var provider = iss.Contains("accounts.google.com", StringComparison.OrdinalIgnoreCase) ? "google" : "external"; return Ok(new MeResult( @@ -170,6 +171,7 @@ public sealed class AuthController : ControllerBase FirstName: User.FindFirstValue("given_name"), LastName: User.FindFirstValue("family_name"), DisplayName: User.FindFirstValue("name"), + ProfileCvText: null, Roles: Array.Empty(), GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null)); } @@ -178,8 +180,8 @@ public sealed class AuthController : ControllerBase [Authorize(AuthenticationSchemes = "local")] public async Task UpdateProfile([FromBody] UpdateProfileRequest request) { - var u = await _users.GetUserAsync(User); - if (u is null) + var user = await _users.GetUserAsync(User); + if (user is null) { return StatusCode(501, "Profile updates are only supported for local username/password accounts."); } @@ -189,14 +191,16 @@ public sealed class AuthController : ControllerBase var firstName = TrimOrNull(request.FirstName); var lastName = TrimOrNull(request.LastName); var displayName = TrimOrNull(request.DisplayName); + var profileCvText = TrimOrNull(request.ProfileCvText); - if (email is not null) u.Email = email; - if (userName is not null) u.UserName = userName; - u.FirstName = firstName; - u.LastName = lastName; - u.DisplayName = displayName; + if (email is not null) user.Email = email; + if (userName is not null) user.UserName = userName; + user.FirstName = firstName; + user.LastName = lastName; + user.DisplayName = displayName; + user.ProfileCvText = profileCvText; - var res = await _users.UpdateAsync(u); + var res = await _users.UpdateAsync(user); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); @@ -213,7 +217,7 @@ public sealed class AuthController : ControllerBase return Unauthorized(); } - var token = (request.Token ?? "").Trim(); + var token = (request.Token ?? string.Empty).Trim(); if (token.Length == 0) return BadRequest("Google token is required."); GoogleTokenPrincipal google; @@ -279,8 +283,8 @@ public sealed class AuthController : ControllerBase [Authorize(AuthenticationSchemes = "local")] public async Task ChangePassword([FromBody] ChangePasswordRequest request) { - var u = await _users.GetUserAsync(User); - if (u is null) + var user = await _users.GetUserAsync(User); + if (user is null) { return StatusCode(501, "Password changes are only supported for local username/password accounts."); } @@ -288,7 +292,7 @@ public sealed class AuthController : ControllerBase if (string.IsNullOrWhiteSpace(request.CurrentPassword)) return BadRequest("CurrentPassword is required."); if (string.IsNullOrWhiteSpace(request.NewPassword)) return BadRequest("NewPassword is required."); - var res = await _users.ChangePasswordAsync(u, request.CurrentPassword, request.NewPassword); + var res = await _users.ChangePasswordAsync(user, request.CurrentPassword, request.NewPassword); if (!res.Succeeded) return BadRequest(string.Join("; ", res.Errors.Select(e => e.Description))); @@ -301,7 +305,7 @@ public sealed class AuthController : ControllerBase [AllowAnonymous] public async Task RequestPasswordReset([FromBody] RequestPasswordResetRequest request, CancellationToken cancellationToken) { - var email = (request.Email ?? "").Trim(); + var email = (request.Email ?? string.Empty).Trim(); if (email.Length == 0) return NoContent(); var user = await _users.FindByEmailAsync(email); @@ -312,7 +316,7 @@ public sealed class AuthController : ControllerBase var token = await _users.GeneratePasswordResetTokenAsync(user); - var baseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/'); + var baseUrl = (_cfg["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/'); if (string.IsNullOrWhiteSpace(baseUrl)) { baseUrl = $"{Request.Scheme}://{Request.Host}"; @@ -336,9 +340,9 @@ public sealed class AuthController : ControllerBase [AllowAnonymous] public async Task ResetPassword([FromBody] ResetPasswordRequest request) { - var email = (request.Email ?? "").Trim(); - var token = request.Token ?? ""; - var newPassword = request.NewPassword ?? ""; + var email = (request.Email ?? string.Empty).Trim(); + var token = request.Token ?? string.Empty; + var newPassword = request.NewPassword ?? string.Empty; if (email.Length == 0) return BadRequest("Email is required."); if (token.Length == 0) return BadRequest("Token is required."); @@ -369,6 +373,7 @@ public sealed class AuthController : ControllerBase FirstName: user.FirstName, LastName: user.LastName, DisplayName: user.DisplayName, + ProfileCvText: user.ProfileCvText, Roles: roles, GoogleLink: new GoogleLinkDto( Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject), @@ -376,5 +381,3 @@ public sealed class AuthController : ControllerBase LinkedAt: user.GoogleLinkedAt)); } } - - diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 62193d1..863bb48 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -461,6 +461,25 @@ namespace JobTrackerApi.Controllers var upcoming = j.FollowUpAt is not null && j.FollowUpAt.Value <= upcomingTo; if (!d.NeedsFollowUp && !upcoming) continue; var shortSummary = j.ShortSummary; + var reminderReason = d.Reason; + if (string.IsNullOrWhiteSpace(j.TailoredCvText)) + { + reminderReason = string.IsNullOrWhiteSpace(reminderReason) + ? "Tailored CV missing for an active role." + : $"{reminderReason} Tailored CV missing."; + } + if (j.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(j.Notes)) + { + reminderReason = string.IsNullOrWhiteSpace(reminderReason) + ? "Interview coming up but prep notes are missing." + : $"{reminderReason} Interview prep notes missing."; + } + if (!j.ResponseReceived && j.FollowUpAt is null) + { + reminderReason = string.IsNullOrWhiteSpace(reminderReason) + ? "No response yet and no follow-up date is scheduled." + : $"{reminderReason} No follow-up date is scheduled."; + } dtos.Add(new JobApplicationDto( Id: j.Id, @@ -492,7 +511,7 @@ namespace JobTrackerApi.Controllers DeletedAt: j.DeletedAt, DaysSince: j.DaysSince, NeedsFollowUp: d.NeedsFollowUp, - FollowUpReason: d.Reason, + FollowUpReason: reminderReason, ShortSummary: shortSummary, FullSummary: null )); @@ -1186,6 +1205,290 @@ namespace JobTrackerApi.Controllers public sealed record FollowUpDraftDto(string Subject, string Body, string Reason, DateTime SuggestedSendOn); public sealed record SendFollowUpRequest(string? ToEmail, string Subject, string Body, DateTime? NextFollowUpAt); public sealed record TagTrendResponse(List Months, List Series); + public sealed record CandidateFitChannelGuidanceDto(List Cv, List CoverLetter, List Interview, List RecruiterMessage); + public sealed record CandidateFitDto( + string MatchSummary, + string FitLevel, + int MatchScore, + List Strengths, + List Gaps, + List Mention, + List Avoid, + List CvImprovements, + List MissingKeywords, + List InterviewPrep, + string TailoredPitch, + CandidateFitChannelGuidanceDto Guidance, + string? CoverLetterDraft, + string? RecruiterMessageDraft); + public sealed record SaveTailoredCvRequest(string? TailoredCvText); + public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List KeyPoints); + public sealed record InterviewPrepDto(string Summary, List TalkingPoints, List LikelyQuestions, List WeakSpots); + public sealed record ReadinessDto(int Score, string Level, List Completed, List Missing, List Reminders); + + [HttpGet("{id:int}/candidate-fit")] + public async Task> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var userId = CurrentUserId; + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + + var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var cvText = user?.ProfileCvText; + if (string.IsNullOrWhiteSpace(cvText)) + { + return BadRequest("Add your profile CV text on the Profile page before running candidate fit analysis."); + } + + var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes } + .Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(jobText)) + { + return BadRequest("This job does not have enough description or notes to compare against your CV."); + } + + var normalizedCv = cvText.ToLowerInvariant(); + var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList(); + var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList(); + + var jobContext = $@"Job title: {job.JobTitle} +Company: {job.Company?.Name} +Status: {job.Status} + +Job description and notes: +{jobText} + +Candidate CV/profile: +{cvText}"; + + var matchSummary = await _summarizer.SummarizeSectionAsync( + "Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.", + jobContext, + 220, + 90) ?? "No fit summary available yet."; + + var strengthCount = strengths.Count; + var gapCount = gaps.Count; + var rawScore = 35 + (strengthCount * 10) - (gapCount * 4); + var matchScore = Math.Clamp(rawScore, 20, 96); + var fitLevel = matchScore >= 75 ? "Strong match" : matchScore >= 55 ? "Potential match" : "Stretch role"; + + var mention = strengths.Select(x => $"Show evidence of {x} with concrete results and outcomes.").Take(5).ToList(); + if (!mention.Any() && jobTags.Any()) mention.Add($"Highlight directly relevant experience with {jobTags.First()}. "); + + var avoid = new List(); + 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(); + 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(); + interviewPrep.AddRange(strengths.Take(3).Select(x => $"Prepare a STAR example that proves your experience with {x}.")); + interviewPrep.AddRange(gaps.Take(2).Select(x => $"Prepare a credible learning story for {x}: related work, fast ramp-up, and how you would close the gap.")); + if (!interviewPrep.Any()) + { + interviewPrep.Add("Prepare two strong examples showing measurable impact, collaboration, and delivery under constraints."); + } + + var tailoredPitch = await _summarizer.SummarizeSectionAsync( + "Write a short tailored candidate pitch for this role in first person. Keep it practical and credible.", + jobContext, + 120, + 45) ?? "I bring relevant experience, measurable outcomes, and a clear understanding of the role priorities."; + + var coverLetterDraft = await _summarizer.SummarizeSectionAsync( + "Draft a short cover letter opening and value proposition for this candidate and job. Keep it specific and credible.", + jobContext, + 180, + 70); + + var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync( + "Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.", + jobContext, + 120, + 45); + + var guidance = new CandidateFitChannelGuidanceDto( + Cv: mention.Take(4).ToList(), + CoverLetter: strengths.Take(3).Select(x => $"Connect {x} to why you are interested in this company and role now.").ToList(), + Interview: interviewPrep.Take(5).ToList(), + RecruiterMessage: new List + { + $"Lead with your strongest overlap: {(strengths.FirstOrDefault() ?? jobTags.FirstOrDefault() ?? "relevant experience")}. ", + "Keep the note concise and outcome-focused.", + "Close with a clear expression of interest and availability." + }); + + return Ok(new CandidateFitDto( + MatchSummary: matchSummary, + FitLevel: fitLevel, + MatchScore: matchScore, + Strengths: strengths, + Gaps: gaps, + Mention: mention, + Avoid: avoid.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(), + CvImprovements: cvImprovements.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(), + MissingKeywords: missingKeywords, + InterviewPrep: interviewPrep.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(), + TailoredPitch: tailoredPitch, + Guidance: guidance, + CoverLetterDraft: coverLetterDraft, + RecruiterMessageDraft: recruiterMessageDraft)); + } + + [HttpGet("{id:int}/interview-prep")] + public async Task> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary } + .Where(x => !string.IsNullOrWhiteSpace(x))); + var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); + var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList(); + var likelyQuestions = tags.Take(4).Select(x => $"How have you applied {x} in practice, and what impact did it have?").ToList(); + var weakSpots = new List(); + if (string.IsNullOrWhiteSpace(job.TailoredCvText)) weakSpots.Add("You have not saved a tailored CV for this role yet."); + if (string.IsNullOrWhiteSpace(job.CoverLetterText)) weakSpots.Add("You do not have a saved cover letter draft for this role yet."); + if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.NextAction)) weakSpots.Add("Your next action is not clearly documented."); + if (!weakSpots.Any()) weakSpots.Add("Prepare to explain why this role and company are a strong fit right now."); + + var summary = await _summarizer.SummarizeSectionAsync( + "Create a concise interview prep brief. Focus on strongest talking points, likely topics, and preparation priorities.", + context, + 180, + 70) ?? "Prepare concise, outcome-focused stories that match the core role requirements."; + + return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots)); + } + + [HttpGet("{id:int}/readiness")] + public async Task> GetReadiness([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var completed = new List(); + var missing = new List(); + var reminders = new List(); + + if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role"); + if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft"); + if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example"); + if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible"); + if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear"); + if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date"); + + if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV."); + if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing."); + if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled."); + + var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100); + var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work"; + + return Ok(new ReadinessDto(score, level, completed, missing, reminders)); + } + + [HttpPut("{id:int}/tailored-cv")] + public async Task SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken) + { + var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim(); + job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow; + await _db.SaveChangesAsync(cancellationToken); + return NoContent(); + } + + [HttpPost("{id:int}/generate-application-package")] + public async Task> GenerateApplicationPackage([FromRoute] int id, CancellationToken cancellationToken) + { + var job = await _db.JobApplications + .Include(j => j.Company) + .FirstOrDefaultAsync(j => j.Id == id, cancellationToken); + if (job is null) return NotFound(); + + var userId = CurrentUserId; + if (string.IsNullOrWhiteSpace(userId)) return Unauthorized(); + + var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken); + var cvText = user?.ProfileCvText; + if (string.IsNullOrWhiteSpace(cvText)) + { + return BadRequest("Add your profile CV text on the Profile page before generating an application package."); + } + + var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary } + .Where(x => !string.IsNullOrWhiteSpace(x))); + if (string.IsNullOrWhiteSpace(jobText)) + { + return BadRequest("This job does not have enough description or notes to generate an application package."); + } + + var packageContext = $@"Job title: {job.JobTitle} +Company: {job.Company?.Name} +Status: {job.Status} + +Job context: +{jobText} + +Candidate master CV: +{cvText}"; + + var tailoredCvText = await _summarizer.SummarizeSectionAsync( + "Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job.", + packageContext, + 256, + 120) ?? cvText; + + var coverLetterDraft = await _summarizer.SummarizeSectionAsync( + "Write a concise, tailored cover letter for this candidate and job. Keep it specific, credible, and directly aligned to the role.", + packageContext, + 220, + 90); + + var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync( + "Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words.", + packageContext, + 170, + 70); + + var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync( + "Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.", + packageContext, + 120, + 45); + + var keyPoints = SkillTagger.Detect(jobText) + .Distinct(StringComparer.OrdinalIgnoreCase) + .Take(5) + .Select(x => $"Lead with evidence of {x}.") + .ToList(); + + return Ok(new GenerateApplicationPackageDto( + TailoredCvText: tailoredCvText, + CoverLetterDraft: coverLetterDraft, + ApplicationAnswerDraft: applicationAnswerDraft, + RecruiterMessageDraft: recruiterMessageDraft, + KeyPoints: keyPoints)); + } [HttpGet("analytics-overview")] public async Task> GetAnalyticsOverview(CancellationToken cancellationToken) diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs new file mode 100644 index 0000000..a19a17d --- /dev/null +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -0,0 +1,98 @@ +using System.Text; +using System.Text.RegularExpressions; +using JobTrackerApi.Models; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/profile-cv")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class ProfileCvController : ControllerBase +{ + private static readonly HashSet AllowedExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".txt", + ".md", + ".pdf", + ".docx", + }; + + private const long MaxFileSizeBytes = 512 * 1024; + + private readonly UserManager _users; + + public ProfileCvController(UserManager users) + { + _users = users; + } + + [HttpPost("upload")] + [RequestSizeLimit(MaxFileSizeBytes)] + public async Task Upload([FromForm] IFormFile file) + { + var user = await _users.GetUserAsync(User); + if (user is null) return Unauthorized(); + if (file is null || file.Length == 0) return BadRequest("Select a CV file to upload."); + if (file.Length > MaxFileSizeBytes) return BadRequest("CV import file is too large."); + + var extension = Path.GetExtension(file.FileName ?? string.Empty); + if (!AllowedExtensions.Contains(extension)) + { + return BadRequest("Only .txt, .md, .pdf, and .docx CV imports are supported right now."); + } + + var text = (await ExtractTextAsync(file, extension)).Trim(); + if (string.IsNullOrWhiteSpace(text)) + { + return BadRequest("The uploaded CV file could not be read or was empty."); + } + + user.ProfileCvText = text; + var result = await _users.UpdateAsync(user); + if (!result.Succeeded) + { + return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description))); + } + + return Ok(new { imported = true, characters = text.Length }); + } + + private static async Task ExtractTextAsync(IFormFile file, string extension) + { + if (string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase) || string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)) + { + using var stream = file.OpenReadStream(); + using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + return (await reader.ReadToEndAsync()).Trim(); + } + + await using var memory = new MemoryStream(); + await file.CopyToAsync(memory); + var bytes = memory.ToArray(); + + if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase)) + { + var raw = Encoding.UTF8.GetString(bytes); + var scrubbed = Regex.Replace(raw, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " "); + return Regex.Replace(scrubbed, @"\s+", " ").Trim(); + } + + if (string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase)) + { + using var archive = new System.IO.Compression.ZipArchive(new MemoryStream(bytes), System.IO.Compression.ZipArchiveMode.Read, leaveOpen: false); + var entry = archive.GetEntry("word/document.xml"); + if (entry is null) return string.Empty; + using var entryStream = entry.Open(); + using var reader = new StreamReader(entryStream, Encoding.UTF8); + var xml = await reader.ReadToEndAsync(); + var withoutTags = Regex.Replace(xml, "<[^>]+>", " "); + var decoded = System.Net.WebUtility.HtmlDecode(withoutTags) ?? string.Empty; + return Regex.Replace(decoded, @"\s+", " ").Trim(); + } + + return string.Empty; + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 044c4c0..1dd7967 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -459,6 +459,7 @@ CREATE TABLE IF NOT EXISTS "AspNetUserTokens" ( EnsureColumn(conn, "AspNetUsers", "FirstName", "ALTER TABLE AspNetUsers ADD COLUMN FirstName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "LastName", "ALTER TABLE AspNetUsers ADD COLUMN LastName TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "DisplayName", "ALTER TABLE AspNetUsers ADD COLUMN DisplayName TEXT NULL;"); + EnsureColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE AspNetUsers ADD COLUMN ProfileCvText TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleSubject", "ALTER TABLE AspNetUsers ADD COLUMN GoogleSubject TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleEmail", "ALTER TABLE AspNetUsers ADD COLUMN GoogleEmail TEXT NULL;"); EnsureColumn(conn, "AspNetUsers", "GoogleLinkedAt", "ALTER TABLE AspNetUsers ADD COLUMN GoogleLinkedAt TEXT NULL;"); @@ -547,6 +548,8 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" ( // Some dev DBs may not match the "legacy" fingerprint above but still lack // the ShortSummary column. Ensure it exists unconditionally if missing. EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;"); + EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;"); // Ensure ownership columns exist even on non-legacy DBs. EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;"); diff --git a/JobTrackerApi/Services/SummarizerService.cs b/JobTrackerApi/Services/SummarizerService.cs index d4c70e3..c29c749 100644 --- a/JobTrackerApi/Services/SummarizerService.cs +++ b/JobTrackerApi/Services/SummarizerService.cs @@ -35,6 +35,7 @@ namespace JobTrackerApi.Services public interface ISummarizerService { Task SummarizeAsync(string text, int maxLength = 150, int minLength = 30); + Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40); Task RunProbeAsync(CancellationToken cancellationToken = default); Task GetMetricsAsync(CancellationToken cancellationToken = default); } @@ -75,6 +76,19 @@ namespace JobTrackerApi.Services { if (string.IsNullOrWhiteSpace(text)) return null; + return await SummarizeCoreAsync(text, maxLength, minLength); + } + + public Task SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40) + { + if (string.IsNullOrWhiteSpace(instruction) || string.IsNullOrWhiteSpace(text)) return Task.FromResult(null); + + var composed = $"{instruction.Trim()}\n\n{text.Trim()}"; + return SummarizeCoreAsync(composed, maxLength, minLength); + } + + private async Task SummarizeCoreAsync(string text, int maxLength, int minLength) + { // Use a deterministic content hash instead of string.GetHashCode() so cache keys // are collision-resistant and stable across process restarts. var key = BuildCacheKey(text, maxLength, minLength); diff --git a/Models/ApplicationUser.cs b/Models/ApplicationUser.cs index 2dd6309..b2e00d8 100644 --- a/Models/ApplicationUser.cs +++ b/Models/ApplicationUser.cs @@ -7,6 +7,7 @@ public sealed class ApplicationUser : IdentityUser public string? FirstName { get; set; } public string? LastName { get; set; } public string? DisplayName { get; set; } + public string? ProfileCvText { get; set; } public string? GoogleSubject { get; set; } public string? GoogleEmail { get; set; } public DateTimeOffset? GoogleLinkedAt { get; set; } diff --git a/Models/JobApplication.cs b/Models/JobApplication.cs index f5473e2..f23b724 100644 --- a/Models/JobApplication.cs +++ b/Models/JobApplication.cs @@ -41,6 +41,8 @@ public class JobApplication public DateTime? Deadline { get; set; } // Short summary generated at creation time and persisted to avoid repeated model calls. public string? ShortSummary { get; set; } + public string? TailoredCvText { get; set; } + public DateTime? TailoredCvUpdatedAt { get; set; } public List Messages { get; set; } = new(); public List Attachments { get; set; } = new(); diff --git a/job-tracker-ui/src/components/DashboardView.tsx b/job-tracker-ui/src/components/DashboardView.tsx index 783c2ef..86a8771 100644 --- a/job-tracker-ui/src/components/DashboardView.tsx +++ b/job-tracker-ui/src/components/DashboardView.tsx @@ -178,7 +178,7 @@ export default function DashboardView() { { label: "Applied (30 days)", value: stats?.appliedLast30Days ?? "-", sub: "New applications" }, { label: "Median first response", value: overview?.medianDaysToFirstResponse ?? "-", sub: "Days until first reply" }, { label: "Responses logged", value: overview?.totalResponses ?? 0, sub: "Across active jobs" }, - { label: "In trash", value: stats?.deleted ?? "-", sub: "Soft-deleted" }, + { label: "Low readiness", value: reminderJobs.filter((job) => !job.tailoredCvText).length, sub: "Reminder jobs missing tailored CV" }, ]; const togglePref = (key: keyof Prefs) => { @@ -433,3 +433,13 @@ export default function DashboardView() { +cent summarizer errors recorded."} + + + ) : null} + + ); +} + + + diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index 0b3c8a5..e0578d2 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { Box, @@ -15,8 +15,9 @@ import { } from "@mui/material"; import { api } from "../api"; -import { JobApplication } from "../types"; +import { ApplicationPackageResponse, CandidateFit, InterviewPrepResponse, JobApplication, ReadinessResponse } from "../types"; import { useToast } from "../toast"; +import { useDialogActions } from "../dialogs"; import Correspondence from "./Correspondence"; import Attachments from "./Attachments"; @@ -50,8 +51,21 @@ function statusChipColor(status: string): "default" | "primary" | "warning" | "e } } +function getFitLevel(candidateFit: CandidateFit | null): { label: string; color: "success" | "warning" | "default" } | null { + if (!candidateFit) return null; + if (candidateFit.fitLevel === "Strong match") return { label: candidateFit.fitLevel, color: "success" }; + if (candidateFit.fitLevel === "Potential match") return { label: candidateFit.fitLevel, color: "warning" }; + return { label: candidateFit.fitLevel, color: "default" }; +} + +function copyLines(items: string[]) { + return navigator.clipboard.writeText(items.map((item) => `• ${item}`).join("\n")); +} + export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const { toast } = useToast(); + const { confirmAction } = useDialogActions(); + const [job, setJob] = useState(null); const [tab, setTab] = useState(0); const [history, setHistory] = useState<{ id: number; type: string; oldValue?: string; newValue?: string; note?: string; at: string }[]>([]); @@ -60,6 +74,16 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const [loadingDraft, setLoadingDraft] = useState(false); const [sendingDraft, setSendingDraft] = useState(false); const [refreshingAi, setRefreshingAi] = useState(false); + const [candidateFit, setCandidateFit] = useState(null); + const [loadingCandidateFit, setLoadingCandidateFit] = useState(false); + const [interviewPrep, setInterviewPrep] = useState(null); + const [loadingInterviewPrep, setLoadingInterviewPrep] = useState(false); + const [readiness, setReadiness] = useState(null); + const [loadingReadiness, setLoadingReadiness] = useState(false); + const [savingTailoredCv, setSavingTailoredCv] = useState(false); + const [generatingPackage, setGeneratingPackage] = useState(false); + const [applicationPackage, setApplicationPackage] = useState(null); + const [tailoredCvText, setTailoredCvText] = useState(""); const [draftRecipient, setDraftRecipient] = useState(""); const [draftSubject, setDraftSubject] = useState(""); const [draftBody, setDraftBody] = useState(""); @@ -68,27 +92,47 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { if (!open || !jobId) return; setTab(0); setFollowUpDraft(null); - api.get(`/jobapplications/${jobId}`).then((r) => { setJob(r.data); setDraftRecipient(r.data.company?.recruiterEmail ?? ""); }); - api - .get(`/auth/me`) - .then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))) - .catch(() => setIsAdmin(false)); - api - .get(`/jobapplications/${jobId}/history`) - .then((r) => setHistory(r.data)) - .catch(() => setHistory([])); + setCandidateFit(null); + setInterviewPrep(null); + setReadiness(null); + setApplicationPackage(null); + api.get(`/jobapplications/${jobId}`).then((r) => { + setJob(r.data); + setTailoredCvText(r.data.tailoredCvText ?? ""); + setDraftRecipient(r.data.company?.recruiterEmail ?? ""); + }); + api.get(`/auth/me`).then((r) => setIsAdmin(Boolean(r.data?.roles?.includes("Admin")))).catch(() => setIsAdmin(false)); + api.get(`/jobapplications/${jobId}/history`).then((r) => setHistory(r.data)).catch(() => setHistory([])); }, [open, jobId]); useEffect(() => { if (!open || !jobId || tab !== 4 || followUpDraft) return; setLoadingDraft(true); - api - .get(`/jobapplications/${jobId}/followup-draft`) - .then((r) => { setFollowUpDraft(r.data); setDraftSubject(r.data.subject); setDraftBody(r.data.body); }) - .catch(() => setFollowUpDraft(null)) - .finally(() => setLoadingDraft(false)); + api.get(`/jobapplications/${jobId}/followup-draft`).then((r) => { + setFollowUpDraft(r.data); + setDraftSubject(r.data.subject); + setDraftBody(r.data.body); + }).catch(() => setFollowUpDraft(null)).finally(() => setLoadingDraft(false)); }, [open, jobId, tab, followUpDraft]); + useEffect(() => { + if (!open || !jobId || tab !== 5 || candidateFit) return; + setLoadingCandidateFit(true); + api.get(`/jobapplications/${jobId}/candidate-fit`).then((r) => setCandidateFit(r.data)).catch(() => setCandidateFit(null)).finally(() => setLoadingCandidateFit(false)); + }, [open, jobId, tab, candidateFit]); + + useEffect(() => { + if (!open || !jobId || tab !== 6 || interviewPrep) return; + setLoadingInterviewPrep(true); + api.get(`/jobapplications/${jobId}/interview-prep`).then((r) => setInterviewPrep(r.data)).catch(() => setInterviewPrep(null)).finally(() => setLoadingInterviewPrep(false)); + }, [open, jobId, tab, interviewPrep]); + + useEffect(() => { + if (!open || !jobId || tab !== 7 || readiness) return; + setLoadingReadiness(true); + api.get(`/jobapplications/${jobId}/readiness`).then((r) => setReadiness(r.data)).catch(() => setReadiness(null)).finally(() => setLoadingReadiness(false)); + }, [open, jobId, tab, readiness]); + const tags: string[] = (() => { const raw = job?.tags; if (!raw) return []; @@ -102,9 +146,12 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const title = job ? `${job.company?.name ?? ""} - ${job.jobTitle}` : "Job Application"; const checklist = [job?.hasResume ? "Resume" : null, job?.hasCoverLetter ? "Cover letter" : null, job?.hasPortfolio ? "Portfolio" : null, job?.hasOtherAttachment ? "Other" : null].filter(Boolean).join(", ") || ""; + const summaryFirstText = job?.fullSummary ?? job?.shortSummary ?? "No summary yet."; + const rawDescriptionText = job?.translatedDescription || job?.description || ""; + const fitLevel = useMemo(() => getFitLevel(candidateFit), [candidateFit]); return ( - + @@ -118,182 +165,215 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { - - {job?.fullSummary ?? job?.shortSummary ?? "Track company context, communication, files, and next steps in one place."} - + {summaryFirstText} - setTab(v)} sx={{ mb: 2 }}> + setTab(v)} sx={{ mb: 2 }} variant="scrollable" allowScrollButtonsMobile> - + + + + {isAdmin ? : null} {tab === 0 && ( - - Date Applied - {job ? new Date(job.dateApplied).toLocaleDateString() : ""} - - - Days Since - {job?.daysSince ?? ""} - - - Location - {job?.location ?? ""} - - - Salary - {job?.salary ?? ""} - - - Next Action - {job?.nextAction ?? ""} - - - Follow Up - {job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""} - - - Deadline - {job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""} - - - Tags - - {tags.length === 0 ? - : tags.map((t) => )} - - - - Attachment Types - {checklist} - - - Job URL - - {job?.jobUrl ? ( - - {job.jobUrl} - - ) : ( - "" - )} - - - - Description (original) - {job?.description ?? ""} - - {job?.translatedDescription ? ( - - Translated description - {job.translatedDescription} - - ) : null} + Date Applied{job ? new Date(job.dateApplied).toLocaleDateString() : ""} + Days Since{job?.daysSince ?? ""} + Location{job?.location ?? ""} + Salary{job?.salary ?? ""} + Next Action{job?.nextAction ?? ""} + Follow Up{job?.followUpAt ? new Date(job.followUpAt).toLocaleDateString() : ""} + Deadline{job?.deadline ? new Date(job.deadline).toLocaleDateString() : ""} + Tags{tags.length === 0 ? - : tags.map((t) => )} + Attachment Types{checklist} + Job URL{job?.jobUrl ? {job.jobUrl} : ""} Summary and skills - + - {job?.fullSummary ?? job?.shortSummary ?? "No summary yet."} - - - Notes - {job?.notes ?? ""} + {summaryFirstText} + {rawDescriptionText ? Original role text{rawDescriptionText} : null} + Notes{job?.notes ?? ""} )} {tab === 1 && jobId && } {tab === 2 && jobId && } + {tab === 3 && ( - - - + + + + Tailored CV for this role + + + + + + + + + Start from your master CV, generate a tailored application package, then edit the resume specifically for this company, role, and interview process. + setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." /> + Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"} - {job?.coverLetterText ?? ""} + + {applicationPackage ? ( + + + + + + + ) : null} )} + {tab === 4 && ( - {loadingDraft ? ( - - - - ) : followUpDraft ? ( + {loadingDraft ? : followUpDraft ? ( - - Reason - {followUpDraft.reason} - - - Suggested send date - {new Date(followUpDraft.suggestedSendOn).toLocaleDateString()} - + Reason{followUpDraft.reason} + Suggested send date{new Date(followUpDraft.suggestedSendOn).toLocaleDateString()} setDraftRecipient(e.target.value)} helperText="Defaults to the company recruiter email when available." /> setDraftSubject(e.target.value)} /> setDraftBody(e.target.value)} /> - + - ) : ( - No draft available. - )} + ) : No draft available.} )} - {tab === 5 && isAdmin && ( + {tab === 5 && ( + + {loadingCandidateFit ? : candidateFit ? ( + + + How you match{candidateFit.matchSummary} + + = 75 ? "success" : candidateFit.matchScore >= 55 ? "warning" : "default"} size="small" /> + {fitLevel ? : null} + + + + + + + + + + + + + + + ) : Add your profile CV text on the Profile page to generate a candidate fit analysis for this role.} + + )} + + {tab === 6 && ( + + {loadingInterviewPrep ? : interviewPrep ? ( + + + + + + ) : No interview prep available yet.} + + )} + + {tab === 7 && ( + + {loadingReadiness ? : readiness ? ( + + + Application readiness + + = 80 ? "success" : readiness.score >= 60 ? "warning" : "default"} /> + + + + + + + ) : No readiness analysis available yet.} + + )} + + {tab === 8 && isAdmin && ( - {history.length === 0 ? ( - No history yet. - ) : ( - history.map((e) => ) - )} + {history.length === 0 ? No history yet. : history.map((entry) => )} )} @@ -301,16 +381,61 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { ); } +function SectionChips({ title, items, color, outlined }: { title: string; items: string[]; color: "success" | "warning"; outlined?: boolean }) { + return ( + + + {title} + + + + {items.length ? items.map((item) => ) : Nothing highlighted yet.} + + + ); +} + +function TwoColumnSection({ leftTitle, leftItems, rightTitle, rightItems }: { leftTitle: string; leftItems: string[]; rightTitle: string; rightItems: string[] }) { + return ( + + + + + ); +} + +function ListCard({ title, items }: { title: string; items: string[] }) { + return ( + + + {title} + + + + {items.length ? items.map((item, index) => • {item}) : Nothing highlighted yet.} + + + ); +} + +function DraftCard({ title, content }: { title: string; content: string }) { + return ( + + + {title} + + + {content} + + ); +} + function PaperRow({ type, oldValue, newValue, at, note }: { type: string; oldValue?: string; newValue?: string; at: string; note?: string }) { return ( {type} - {oldValue || newValue ? ( - - {" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""}) - - ) : null} + {oldValue || newValue ? {" "}({oldValue ?? ""} {oldValue || newValue ? "->" : ""} {newValue ?? ""}) : null} {at ? new Date(at).toLocaleString() : ""} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index d76c11f..f6759c7 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -265,6 +265,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col }; const generateOverview = (job: JobApplication) => { + if (job.fullSummary) return job.fullSummary; if (job.shortSummary) return job.shortSummary; const src = (job.description || job.notes || "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim(); return src.length > 220 ? `${src.slice(0, 220)}...` : src; @@ -357,6 +358,8 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col {job.jobTitle} {job.needsFollowUp ? : null} + {!job.tailoredCvText && !job.isDeleted ? : null} + {job.tailoredCvText ? : null} {columns.status ? : null} diff --git a/job-tracker-ui/src/components/RemindersView.tsx b/job-tracker-ui/src/components/RemindersView.tsx index 0f4bd88..4fb76b9 100644 --- a/job-tracker-ui/src/components/RemindersView.tsx +++ b/job-tracker-ui/src/components/RemindersView.tsx @@ -1,12 +1,6 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; -import { - Box, - Button, - Chip, - Paper, - Typography, -} from "@mui/material"; +import { Box, Button, Chip, Divider, Paper, Typography } from "@mui/material"; import { api } from "../api"; import { JobApplication } from "../types"; @@ -14,15 +8,63 @@ import { useToast } from "../toast"; import JobDetailsDialog from "./JobDetailsDialog"; +type ReminderGroups = { + missingCv: JobApplication[]; + missingInterviewNotes: JobApplication[]; + overdueFollowUp: JobApplication[]; + other: JobApplication[]; +}; + +function groupItems(items: JobApplication[]): ReminderGroups { + const groups: ReminderGroups = { missingCv: [], missingInterviewNotes: [], overdueFollowUp: [], other: [] }; + items.forEach((item) => { + const reason = (item.followUpReason ?? "").toLowerCase(); + if (reason.includes("tailored cv")) groups.missingCv.push(item); + else if (reason.includes("interview prep") || reason.includes("prep notes")) groups.missingInterviewNotes.push(item); + else if (reason.includes("follow-up") || reason.includes("follow up")) groups.overdueFollowUp.push(item); + else groups.other.push(item); + }); + return groups; +} + +function ReminderSection({ title, items, onOpen, onSetFollowUp }: { title: string; items: JobApplication[]; onOpen: (id: number) => void; onSetFollowUp: (id: number, days: number | null) => void }) { + if (items.length === 0) return null; + + return ( + + {title} + {items.map((j) => ( + + + + {j.company?.name ?? ""} {j.jobTitle} + + + {j.needsFollowUp ? : null} + {j.followUpReason ? : null} + {j.followUpAt ? : null} + + + + + + + + + + + ))} + + ); +} + export default function RemindersView() { const { toast } = useToast(); const [items, setItems] = useState([]); const [openJobId, setOpenJobId] = useState(null); const load = async () => { - const res = await api.get("/jobapplications/reminders", { - params: { upcomingDays: 14 }, - }); + const res = await api.get("/jobapplications/reminders", { params: { upcomingDays: 14 } }); setItems(res.data); }; @@ -30,17 +72,12 @@ export default function RemindersView() { void load(); }, []); + const grouped = useMemo(() => groupItems(items), [items]); + const setFollowUp = async (id: number, daysFromNow: number | null) => { try { - const d = - daysFromNow === null - ? null - : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000) - .toISOString() - .slice(0, 10); - await api.patch(`/jobapplications/${id}/followup`, { - followUpAt: d, - }); + const d = daysFromNow === null ? null : new Date(Date.now() + daysFromNow * 24 * 60 * 60 * 1000).toISOString().slice(0, 10); + await api.patch(`/jobapplications/${id}/followup`, { followUpAt: d }); toast(daysFromNow === null ? "Follow-up cleared." : "Follow-up set.", "success"); await load(); } catch { @@ -50,97 +87,26 @@ export default function RemindersView() { return ( - - Needs Follow-up - + Needs Follow-up - Based on your rules and upcoming follow-up dates. + Grouped by the most useful next action so you can fix gaps faster. - - {items.map((j) => ( - - - - {j.company?.name ?? ""}{" "} - Â�{" "} - {j.jobTitle} - - - {j.needsFollowUp ? ( - - ) : null} - {j.followUpReason ? ( - - ) : null} - {j.followUpAt ? ( - - ) : null} - - - + + + + + - - - - - - - - - ))} - - {items.length === 0 && ( - - Nothing to follow up right now. - - )} + {items.length === 0 ? Nothing to follow up right now. : null} - setOpenJobId(null)} - /> + + + Tip: focus on tailored CV and interview prep first for the highest-value roles. + + + setOpenJobId(null)} /> ); } - -lign: "center", py: 3 }}> - Nothing to follow up right now. - - )} - - - setOpenJobId(null)} - /> - - ); -} - diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 9be2560..12b2755 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -1,6 +1,6 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useEffect, useMemo, useRef, useState } from "react"; -import { Alert, Avatar, Box, Button, Chip, Divider, Paper, TextField, Typography } from "@mui/material"; +import { Alert, Avatar, Box, Button, Chip, Divider, LinearProgress, Paper, TextField, Typography } from "@mui/material"; import { api } from "../api"; import GoogleAuthCard from "../components/GoogleAuthCard"; @@ -14,6 +14,7 @@ type MeResponse = { firstName?: string; lastName?: string; displayName?: string; + profileCvText?: string; roles?: string[]; googleLink?: { linked: boolean; @@ -35,8 +36,10 @@ function initialsFrom(values: Array) { export default function ProfilePage() { const { toast } = useToast(); + const fileInputRef = useRef(null); const [me, setMe] = useState(null); const [loading, setLoading] = useState(false); + const [uploadingCv, setUploadingCv] = useState(false); const [email, setEmail] = useState(""); const [userName, setUserName] = useState(""); @@ -44,6 +47,7 @@ export default function ProfilePage() { const [lastName, setLastName] = useState(""); const [displayName, setDisplayName] = useState(""); const [headline, setHeadline] = useState(""); + const [profileCvText, setProfileCvText] = useState(""); const [currentPassword, setCurrentPassword] = useState(""); const [newPassword, setNewPassword] = useState(""); @@ -57,6 +61,7 @@ export default function ProfilePage() { setFirstName(r.data?.firstName ?? ""); setLastName(r.data?.lastName ?? ""); setDisplayName(r.data?.displayName ?? ""); + setProfileCvText(r.data?.profileCvText ?? ""); setHeadline(window.localStorage.getItem("profileHeadline") ?? ""); } catch { setMe(null); @@ -70,6 +75,7 @@ export default function ProfilePage() { const initials = useMemo(() => initialsFrom([me?.displayName, me?.firstName, me?.lastName, me?.userName, me?.email]), [me]); const isLocal = me?.provider === "local"; const fullName = [me?.firstName, me?.lastName].filter(Boolean).join(" "); + const cvWordCount = profileCvText.trim() ? profileCvText.trim().split(/\s+/).length : 0; return ( @@ -87,6 +93,7 @@ export default function ProfilePage() { + @@ -115,6 +122,67 @@ export default function ProfilePage() { fullWidth /> + + + + Master CV + + Paste your resume text here or import a .txt/.md version. The app uses it to explain fit, gaps, interview talking points, and tailored messaging. + + + + { + const file = event.target.files?.[0]; + event.target.value = ""; + if (!file) return; + const formData = new FormData(); + formData.append("file", file); + setUploadingCv(true); + try { + await api.post("/profile-cv/upload", formData, { headers: { "Content-Type": "multipart/form-data" } }); + await loadProfile(); + toast("CV text imported.", "success"); + } catch (e: any) { + toast(String(e?.response?.data || e?.message || "Failed to import CV text."), "error"); + } finally { + setUploadingCv(false); + } + }} + /> + + + + + {uploadingCv ? : null} + setProfileCvText(e.target.value)} + helperText="Keep this updated and specific. Include recent roles, tools, achievements, measurable outcomes, and the work you want to be hired for next." + multiline + minRows={12} + disabled={!isLocal} + fullWidth + /> + + + {cvWordCount} words + + + Tip: plain text works best right now. + + + + Google account: {me?.googleLink?.linked ? `Linked${me.googleLink.email ? ` to ${me.googleLink.email}` : ""}` : "Not linked"} @@ -125,7 +193,7 @@ export default function ProfilePage() { onClick={async () => { setLoading(true); try { - await api.put("/auth/profile", { email, userName, firstName, lastName, displayName }); + await api.put("/auth/profile", { email, userName, firstName, lastName, displayName, profileCvText }); window.localStorage.setItem("profileHeadline", headline.trim()); await loadProfile(); toast("Profile updated.", "success"); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 4f4a104..c1fff5f 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -48,6 +48,53 @@ export interface JobApplication { followUpReason?: string; } +export interface CandidateFitChannelGuidance { + cv: string[]; + coverLetter: string[]; + interview: string[]; + recruiterMessage: string[]; +} + +export interface CandidateFit { + matchSummary: string; + fitLevel: string; + matchScore: number; + strengths: string[]; + gaps: string[]; + mention: string[]; + avoid: string[]; + cvImprovements: string[]; + missingKeywords: string[]; + interviewPrep: string[]; + tailoredPitch: string; + guidance: CandidateFitChannelGuidance; + coverLetterDraft?: string | null; + recruiterMessageDraft?: string | null; +} + +export interface InterviewPrepResponse { + summary: string; + talkingPoints: string[]; + likelyQuestions: string[]; + weakSpots: string[]; +} + +export interface ReadinessResponse { + score: number; + level: string; + completed: string[]; + missing: string[]; + reminders: string[]; +} + +export interface ApplicationPackageResponse { + tailoredCvText: string; + coverLetterDraft?: string | null; + applicationAnswerDraft?: string | null; + recruiterMessageDraft?: string | null; + keyPoints: string[]; +} + export interface CorrespondenceMessage { id: number; jobApplicationId: number; diff --git a/tools/summarizer/app.py b/tools/summarizer/app.py index df1e224..1a86f58 100644 --- a/tools/summarizer/app.py +++ b/tools/summarizer/app.py @@ -11,25 +11,24 @@ app = FastAPI(title="Local Summarizer") MODEL_NAME = "sshleifer/distilbart-cnn-12-6" MAX_INPUT_CHARS = 20000 -# The local summarizer is intentionally simple, but we still validate request sizes -# so accidental giant pastes do not cause avoidable latency or memory spikes. tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME) model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME) model.eval() device = torch.device("cuda" if torch.cuda.is_available() else "cpu") model.to(device) -cache = TTLCache(maxsize=1024, ttl=60 * 60) # 1 hour cache +cache = TTLCache(maxsize=1024, ttl=60 * 60) class SummarizeRequest(BaseModel): text: str = Field(min_length=1, max_length=MAX_INPUT_CHARS) max_length: int = Field(default=160, ge=24, le=256) min_length: int = Field(default=45, ge=8, le=180) + top_skills: int = Field(default=8, ge=3, le=12) -def _key(text: str, max_length: int, min_length: int) -> str: +def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str: h = hashlib.sha256(text.encode("utf-8")).hexdigest() - return f"{h}:{max_length}:{min_length}" + return f"{h}:{max_length}:{min_length}:{top_skills}" @app.get("/health") @@ -38,52 +37,39 @@ async def health(): _TECH = [ - "python", - "c#", - "dotnet", - ".net", - "java", - "javascript", - "typescript", - "react", - "node", - "sql", - "postgres", - "postgresql", - "mysql", - "sqlite", - "mongodb", - "redis", - "aws", - "azure", - "gcp", - "docker", - "kubernetes", - "terraform", - "linux", - "git", - "ci/cd", - "graphql", - "rest", + "python", "c#", "dotnet", ".net", "java", "javascript", "typescript", "react", "node", "sql", + "postgres", "postgresql", "mysql", "sqlite", "mongodb", "redis", "aws", "azure", "gcp", + "docker", "kubernetes", "terraform", "linux", "git", "ci/cd", "graphql", "rest", ] _SOFT = [ - "communication", - "collaboration", - "teamwork", - "problem solving", - "leadership", - "mentoring", - "ownership", - "initiative", - "adaptability", - "stakeholder management", - "detail oriented", + "communication", "collaboration", "teamwork", "problem solving", "leadership", "mentoring", + "ownership", "initiative", "adaptability", "stakeholder management", "detail oriented", +] + +_TECH_PRIORITY = [ + "python", "c#", ".net", "dotnet", "typescript", "javascript", "react", "node", + "sql", "postgresql", "postgres", "mysql", "sqlite", "docker", "kubernetes", + "aws", "azure", "gcp", "terraform", "graphql", "rest", "git", ] +def _rank_tech_skills(skills): + ordered = [] + seen = set() + for preferred in _TECH_PRIORITY: + for skill in skills: + if skill == preferred and skill not in seen: + ordered.append(skill) + seen.add(skill) + for skill in skills: + if skill not in seen: + ordered.append(skill) + seen.add(skill) + return ordered + + def _strip_html(text: str) -> str: - # Good enough for job descriptions pasted from the web. text = re.sub(r"<\s*br\s*/?>", "\n", text, flags=re.IGNORECASE) text = re.sub(r"", "\n", text, flags=re.IGNORECASE) text = re.sub(r"<[^>]+>", " ", text) @@ -105,6 +91,21 @@ def _extract_bullets(lines, max_items=8): return out +def _top_keywords(text: str, limit=6): + words = re.findall(r"[a-zA-Z][a-zA-Z+#./-]{2,}", text.lower()) + stop = { + "with", "from", "that", "this", "will", "have", "your", "their", "about", "role", "team", "work", + "experience", "skills", "requirements", "responsibilities", "company", "using", "ability", "years", + } + counts = {} + for word in words: + if word in stop or word in _TECH or word in _SOFT: + continue + counts[word] = counts.get(word, 0) + 1 + ordered = sorted(counts.items(), key=lambda item: (-item[1], item[0])) + return [word for word, _ in ordered[:limit]] + + def _role_focused_excerpt(text: str) -> dict: cleaned = _strip_html(text) lines = [ln.strip() for ln in cleaned.splitlines()] @@ -117,10 +118,10 @@ def _role_focused_excerpt(text: str) -> dict: def match_heading(s: str): sl = s.lower().strip(":-\x7f ") - for k, words in headings.items(): - for w in words: - if sl == w or sl.startswith(w + " "): - return k + for key, words in headings.items(): + for word in words: + if sl == word or sl.startswith(word + " "): + return key return None section = None @@ -131,11 +132,10 @@ def _role_focused_excerpt(text: str) -> dict: for ln in lines: if not ln: continue - h = match_heading(ln) - if h: - section = h + heading = match_heading(ln) + if heading: + section = heading continue - if section == "responsibilities": resp_lines.append(ln) elif section == "requirements": @@ -157,7 +157,6 @@ def _role_focused_excerpt(text: str) -> dict: if s in low: soft_found.append(s) - # Fallback: pick bullet-like lines anywhere if sections are missing. if not responsibilities and not requirements: any_bullets = _extract_bullets(lines, max_items=10) responsibilities = any_bullets[:6] @@ -170,8 +169,6 @@ def _role_focused_excerpt(text: str) -> dict: focused_parts.append("Requirements:\n- " + "\n- ".join(requirements)) if nice: focused_parts.append("Nice to have:\n- " + "\n- ".join(nice)) - - # Always include a small slice of the original for context. focused_parts.append("Context:\n" + cleaned[:1500]) return { @@ -182,6 +179,7 @@ def _role_focused_excerpt(text: str) -> dict: "nice": nice, "tech": tech_found, "soft": soft_found, + "keywords": _top_keywords(cleaned), } @@ -206,20 +204,18 @@ async def summarize(req: SummarizeRequest): if req.min_length >= req.max_length: raise HTTPException(status_code=400, detail="min_length must be smaller than max_length.") - key = _key(req.text, req.max_length, req.min_length) + key = _key(req.text, req.max_length, req.min_length, req.top_skills) if key in cache: return {"summary": cache[key], "cached": True} info = _role_focused_excerpt(req.text) - - # Summarize the role-focused excerpt instead of the whole job post. summary = _model_summarize(info["focused_input"], req.max_length, req.min_length) lines = ["Role summary:", summary] if info["requirements"]: lines.append("") - lines.append("What they need from you:") + lines.append("What the company wants most:") for x in info["requirements"][:7]: lines.append(f"- {x}") @@ -241,7 +237,7 @@ async def summarize(req: SummarizeRequest): if t not in uniq: uniq.append(t) lines.append("") - lines.append("Top hard skills: " + ", ".join(uniq[:10])) + lines.append("Top hard skills: " + ", ".join(uniq[: req.top_skills])) if info["soft"]: uniq_soft = [] @@ -251,18 +247,20 @@ async def summarize(req: SummarizeRequest): lines.append("") lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8])) - out = "\n".join(lines).strip() - cache[key] = out - return {"summary": out, "cached": False} -skills: " + ", ".join(uniq[:14])) - - if info["soft"]: - uniq_soft = [] - for s in info["soft"]: - if s not in uniq_soft: - uniq_soft.append(s) + if info["keywords"]: lines.append("") - lines.append("Relevant soft skills: " + ", ".join(uniq_soft[:8])) + lines.append("Key themes: " + ", ".join(info["keywords"][:6])) + + lines.append("") + lines.append("Interview focus:") + if info["requirements"]: + for x in info["requirements"][:3]: + lines.append(f"- Prepare examples that demonstrate: {x}") + elif info["tech"]: + for x in _rank_tech_skills(info["tech"])[:3]: + lines.append(f"- Be ready to explain your hands-on experience with {x}") + else: + lines.append("- Prepare examples showing relevant impact, collaboration, and delivery.") out = "\n".join(lines).strip() cache[key] = out