feat: add application package generation and grouped readiness workflows

This commit is contained in:
cesnimda
2026-03-22 18:28:02 +01:00
parent f1c7c38a19
commit 9188039e9d
14 changed files with 1014 additions and 373 deletions
+35 -32
View File
@@ -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<string> 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<ActionResult<AuthResult>> 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<ActionResult<AuthResult>> 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<IActionResult> 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<string>(),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null));
}
@@ -178,8 +180,8 @@ public sealed class AuthController : ControllerBase
[Authorize(AuthenticationSchemes = "local")]
public async Task<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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));
}
}
@@ -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<string> Months, List<TagTrendSeries> Series);
public sealed record CandidateFitChannelGuidanceDto(List<string> Cv, List<string> CoverLetter, List<string> Interview, List<string> RecruiterMessage);
public sealed record CandidateFitDto(
string MatchSummary,
string FitLevel,
int MatchScore,
List<string> Strengths,
List<string> Gaps,
List<string> Mention,
List<string> Avoid,
List<string> CvImprovements,
List<string> MissingKeywords,
List<string> InterviewPrep,
string TailoredPitch,
CandidateFitChannelGuidanceDto Guidance,
string? CoverLetterDraft,
string? RecruiterMessageDraft);
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
[HttpGet("{id:int}/candidate-fit")]
public async Task<ActionResult<CandidateFitDto>> GetCandidateFit([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before running candidate fit analysis.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to compare against your CV.");
}
var normalizedCv = cvText.ToLowerInvariant();
var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList();
var jobContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job description and notes:
{jobText}
Candidate CV/profile:
{cvText}";
var matchSummary = await _summarizer.SummarizeSectionAsync(
"Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.",
jobContext,
220,
90) ?? "No fit summary available yet.";
var strengthCount = strengths.Count;
var gapCount = gaps.Count;
var rawScore = 35 + (strengthCount * 10) - (gapCount * 4);
var matchScore = Math.Clamp(rawScore, 20, 96);
var fitLevel = matchScore >= 75 ? "Strong match" : matchScore >= 55 ? "Potential match" : "Stretch role";
var mention = strengths.Select(x => $"Show evidence of {x} with concrete results and outcomes.").Take(5).ToList();
if (!mention.Any() && jobTags.Any()) mention.Add($"Highlight directly relevant experience with {jobTags.First()}. ");
var avoid = new List<string>();
if (gaps.Any())
{
avoid.AddRange(gaps.Take(4).Select(x => $"Do not overclaim deep expertise in {x} unless you can back it up with recent examples."));
}
avoid.Add("Avoid generic claims without metrics, outcomes, or ownership details.");
var cvImprovements = new List<string>();
cvImprovements.AddRange(gaps.Take(4).Select(x => $"If you have experience with {x}, make it easier to find in your CV with a specific bullet and result."));
cvImprovements.Add("Quantify impact with numbers, scope, speed, revenue, quality, or customer outcomes where possible.");
cvImprovements.Add("Mirror the wording of the role where it is accurate, especially in your summary and recent experience.");
var missingKeywords = gaps.Take(6).ToList();
var interviewPrep = new List<string>();
interviewPrep.AddRange(strengths.Take(3).Select(x => $"Prepare a STAR example that proves your experience with {x}."));
interviewPrep.AddRange(gaps.Take(2).Select(x => $"Prepare a credible learning story for {x}: related work, fast ramp-up, and how you would close the gap."));
if (!interviewPrep.Any())
{
interviewPrep.Add("Prepare two strong examples showing measurable impact, collaboration, and delivery under constraints.");
}
var tailoredPitch = await _summarizer.SummarizeSectionAsync(
"Write a short tailored candidate pitch for this role in first person. Keep it practical and credible.",
jobContext,
120,
45) ?? "I bring relevant experience, measurable outcomes, and a clear understanding of the role priorities.";
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Draft a short cover letter opening and value proposition for this candidate and job. Keep it specific and credible.",
jobContext,
180,
70);
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
"Draft a concise recruiter message for this candidate and job. Keep it warm, direct, and under 120 words.",
jobContext,
120,
45);
var guidance = new CandidateFitChannelGuidanceDto(
Cv: mention.Take(4).ToList(),
CoverLetter: strengths.Take(3).Select(x => $"Connect {x} to why you are interested in this company and role now.").ToList(),
Interview: interviewPrep.Take(5).ToList(),
RecruiterMessage: new List<string>
{
$"Lead with your strongest overlap: {(strengths.FirstOrDefault() ?? jobTags.FirstOrDefault() ?? "relevant experience")}. ",
"Keep the note concise and outcome-focused.",
"Close with a clear expression of interest and availability."
});
return Ok(new CandidateFitDto(
MatchSummary: matchSummary,
FitLevel: fitLevel,
MatchScore: matchScore,
Strengths: strengths,
Gaps: gaps,
Mention: mention,
Avoid: avoid.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
CvImprovements: cvImprovements.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
MissingKeywords: missingKeywords,
InterviewPrep: interviewPrep.Distinct(StringComparer.OrdinalIgnoreCase).Take(6).ToList(),
TailoredPitch: tailoredPitch,
Guidance: guidance,
CoverLetterDraft: coverLetterDraft,
RecruiterMessageDraft: recruiterMessageDraft));
}
[HttpGet("{id:int}/interview-prep")]
public async Task<ActionResult<InterviewPrepDto>> GetInterviewPrep([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var context = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
var tags = SkillTagger.Detect(context).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
var talkingPoints = tags.Take(4).Select(x => $"Describe a concrete example where you delivered results with {x}.").ToList();
var likelyQuestions = tags.Take(4).Select(x => $"How have you applied {x} in practice, and what impact did it have?").ToList();
var weakSpots = new List<string>();
if (string.IsNullOrWhiteSpace(job.TailoredCvText)) weakSpots.Add("You have not saved a tailored CV for this role yet.");
if (string.IsNullOrWhiteSpace(job.CoverLetterText)) weakSpots.Add("You do not have a saved cover letter draft for this role yet.");
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.NextAction)) weakSpots.Add("Your next action is not clearly documented.");
if (!weakSpots.Any()) weakSpots.Add("Prepare to explain why this role and company are a strong fit right now.");
var summary = await _summarizer.SummarizeSectionAsync(
"Create a concise interview prep brief. Focus on strongest talking points, likely topics, and preparation priorities.",
context,
180,
70) ?? "Prepare concise, outcome-focused stories that match the core role requirements.";
return Ok(new InterviewPrepDto(summary, talkingPoints, likelyQuestions, weakSpots));
}
[HttpGet("{id:int}/readiness")]
public async Task<ActionResult<ReadinessDto>> GetReadiness([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var completed = new List<string>();
var missing = new List<string>();
var reminders = new List<string>();
if (!string.IsNullOrWhiteSpace(job.TailoredCvText)) completed.Add("Tailored CV saved"); else missing.Add("Tailor your CV for this role");
if (!string.IsNullOrWhiteSpace(job.CoverLetterText)) completed.Add("Cover letter draft ready"); else missing.Add("Create a cover letter draft");
if (job.HasPortfolio) completed.Add("Portfolio attached"); else missing.Add("Consider adding a relevant portfolio example");
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail)) completed.Add("Recruiter contact available"); else missing.Add("Capture recruiter contact details if possible");
if (!string.IsNullOrWhiteSpace(job.NextAction)) completed.Add("Next action captured"); else missing.Add("Write the next action so follow-up is clear");
if (job.FollowUpAt is not null) completed.Add("Follow-up scheduled"); else missing.Add("Schedule a follow-up date");
if (!job.ResponseReceived && string.IsNullOrWhiteSpace(job.TailoredCvText)) reminders.Add("This role is active but still missing a tailored CV.");
if (job.Status.Contains("Interview", StringComparison.OrdinalIgnoreCase) && string.IsNullOrWhiteSpace(job.Notes)) reminders.Add("Interview stage reached but prep notes are still missing.");
if (!job.ResponseReceived && job.FollowUpAt is null) reminders.Add("No response yet and no follow-up is scheduled.");
var score = Math.Clamp(completed.Count * 15 + (string.IsNullOrWhiteSpace(job.Description) ? 0 : 10), 20, 100);
var level = score >= 80 ? "Ready" : score >= 60 ? "Needs polish" : "Needs work";
return Ok(new ReadinessDto(score, level, completed, missing, reminders));
}
[HttpPut("{id:int}/tailored-cv")]
public async Task<IActionResult> SaveTailoredCv([FromRoute] int id, [FromBody] SaveTailoredCvRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
job.TailoredCvText = string.IsNullOrWhiteSpace(request.TailoredCvText) ? null : request.TailoredCvText.Trim();
job.TailoredCvUpdatedAt = job.TailoredCvText is null ? null : DateTime.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
[HttpPost("{id:int}/generate-application-package")]
public async Task<ActionResult<GenerateApplicationPackageDto>> GenerateApplicationPackage([FromRoute] int id, CancellationToken cancellationToken)
{
var job = await _db.JobApplications
.Include(j => j.Company)
.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var userId = CurrentUserId;
if (string.IsNullOrWhiteSpace(userId)) return Unauthorized();
var user = await _db.Users.FirstOrDefaultAsync(x => x.Id == userId, cancellationToken);
var cvText = user?.ProfileCvText;
if (string.IsNullOrWhiteSpace(cvText))
{
return BadRequest("Add your profile CV text on the Profile page before generating an application package.");
}
var jobText = string.Join("\n\n", new[] { job.JobTitle, job.Company?.Name, job.Description, job.TranslatedDescription, job.Notes, job.ShortSummary }
.Where(x => !string.IsNullOrWhiteSpace(x)));
if (string.IsNullOrWhiteSpace(jobText))
{
return BadRequest("This job does not have enough description or notes to generate an application package.");
}
var packageContext = $@"Job title: {job.JobTitle}
Company: {job.Company?.Name}
Status: {job.Status}
Job context:
{jobText}
Candidate master CV:
{cvText}";
var tailoredCvText = await _summarizer.SummarizeSectionAsync(
"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job.",
packageContext,
256,
120) ?? cvText;
var coverLetterDraft = await _summarizer.SummarizeSectionAsync(
"Write a concise, tailored cover letter for this candidate and job. Keep it specific, credible, and directly aligned to the role.",
packageContext,
220,
90);
var applicationAnswerDraft = await _summarizer.SummarizeSectionAsync(
"Write a short application answer for why this candidate is a fit for the role. Keep it under 180 words.",
packageContext,
170,
70);
var recruiterMessageDraft = await _summarizer.SummarizeSectionAsync(
"Write a short recruiter intro message for this candidate and role. Keep it warm, direct, and concise.",
packageContext,
120,
45);
var keyPoints = SkillTagger.Detect(jobText)
.Distinct(StringComparer.OrdinalIgnoreCase)
.Take(5)
.Select(x => $"Lead with evidence of {x}.")
.ToList();
return Ok(new GenerateApplicationPackageDto(
TailoredCvText: tailoredCvText,
CoverLetterDraft: coverLetterDraft,
ApplicationAnswerDraft: applicationAnswerDraft,
RecruiterMessageDraft: recruiterMessageDraft,
KeyPoints: keyPoints));
}
[HttpGet("analytics-overview")]
public async Task<ActionResult<AnalyticsOverviewDto>> GetAnalyticsOverview(CancellationToken cancellationToken)
@@ -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<string> AllowedExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".txt",
".md",
".pdf",
".docx",
};
private const long MaxFileSizeBytes = 512 * 1024;
private readonly UserManager<ApplicationUser> _users;
public ProfileCvController(UserManager<ApplicationUser> users)
{
_users = users;
}
[HttpPost("upload")]
[RequestSizeLimit(MaxFileSizeBytes)]
public async Task<IActionResult> 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<string> 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;
}
}