feat: add application draft saving modes and reminder grouping
This commit is contained in:
@@ -0,0 +1,34 @@
|
|||||||
|
using System.Reflection;
|
||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class JobApplicationsControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Application_package_record_exposes_expected_fields()
|
||||||
|
{
|
||||||
|
var type = typeof(JobApplicationsController).GetNestedType("GenerateApplicationPackageDto", BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(type);
|
||||||
|
|
||||||
|
var props = type!.GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(x => x.Name).ToHashSet();
|
||||||
|
Assert.Contains("TailoredCvText", props);
|
||||||
|
Assert.Contains("CoverLetterDraft", props);
|
||||||
|
Assert.Contains("ApplicationAnswerDraft", props);
|
||||||
|
Assert.Contains("RecruiterMessageDraft", props);
|
||||||
|
Assert.Contains("KeyPoints", props);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_application_drafts_request_supports_cover_letter_and_notes()
|
||||||
|
{
|
||||||
|
var type = typeof(JobApplicationsController).GetNestedType("SaveApplicationDraftsRequest", BindingFlags.Public | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(type);
|
||||||
|
|
||||||
|
var ctor = type!.GetConstructors().Single();
|
||||||
|
var parameters = ctor.GetParameters().Select(x => x.Name).ToArray();
|
||||||
|
Assert.Contains("coverLetterText", parameters);
|
||||||
|
Assert.Contains("notes", parameters);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
using System.Security.Claims;
|
||||||
|
using System.Text;
|
||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Identity;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class ProfileCvControllerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_rejects_unsupported_extension()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser();
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
|
||||||
|
var controller = new ProfileCvController(userManager.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
|
||||||
|
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("hello")), 0, 5, "file", "resume.exe");
|
||||||
|
var result = await controller.Upload(file);
|
||||||
|
|
||||||
|
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||||
|
Assert.Contains("supported", StringComparison.OrdinalIgnoreCase, badRequest.Value?.ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Upload_accepts_markdown_cv_and_saves_text()
|
||||||
|
{
|
||||||
|
var user = new ApplicationUser();
|
||||||
|
var userManager = CreateUserManager();
|
||||||
|
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
|
||||||
|
userManager.Setup(x => x.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
|
||||||
|
|
||||||
|
var controller = new ProfileCvController(userManager.Object)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
|
||||||
|
var file = new FormFile(new MemoryStream(Encoding.UTF8.GetBytes("# CV\nBuilt APIs and UIs")), 0, 23, "file", "resume.md");
|
||||||
|
var result = await controller.Upload(file);
|
||||||
|
|
||||||
|
Assert.IsType<OkObjectResult>(result);
|
||||||
|
Assert.Contains("Built APIs", user.ProfileCvText);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Mock<UserManager<ApplicationUser>> CreateUserManager()
|
||||||
|
{
|
||||||
|
var store = new Mock<IUserStore<ApplicationUser>>();
|
||||||
|
return new Mock<UserManager<ApplicationUser>>(
|
||||||
|
store.Object,
|
||||||
|
Options.Create(new IdentityOptions()),
|
||||||
|
new PasswordHasher<ApplicationUser>(),
|
||||||
|
Array.Empty<IUserValidator<ApplicationUser>>(),
|
||||||
|
Array.Empty<IPasswordValidator<ApplicationUser>>(),
|
||||||
|
new UpperInvariantLookupNormalizer(),
|
||||||
|
new IdentityErrorDescriber(),
|
||||||
|
null!,
|
||||||
|
new NullLogger<UserManager<ApplicationUser>>()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1223,6 +1223,7 @@ namespace JobTrackerApi.Controllers
|
|||||||
string? RecruiterMessageDraft);
|
string? RecruiterMessageDraft);
|
||||||
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
public sealed record SaveTailoredCvRequest(string? TailoredCvText);
|
||||||
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
|
public sealed record GenerateApplicationPackageDto(string TailoredCvText, string? CoverLetterDraft, string? ApplicationAnswerDraft, string? RecruiterMessageDraft, List<string> KeyPoints);
|
||||||
|
public sealed record SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes);
|
||||||
public sealed record InterviewPrepDto(string Summary, List<string> TalkingPoints, List<string> LikelyQuestions, List<string> WeakSpots);
|
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);
|
public sealed record ReadinessDto(int Score, string Level, List<string> Completed, List<string> Missing, List<string> Reminders);
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,13 @@ public sealed class ProfileCvController : ControllerBase
|
|||||||
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
var raw = Encoding.UTF8.GetString(bytes);
|
var raw = Encoding.UTF8.GetString(bytes);
|
||||||
var scrubbed = Regex.Replace(raw, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " ");
|
var textMatches = Regex.Matches(raw, @"\((.*?)\)Tj", RegexOptions.Singleline)
|
||||||
|
.Select(match => match.Groups[1].Value)
|
||||||
|
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var joined = textMatches.Count > 0 ? string.Join(" ", textMatches) : raw;
|
||||||
|
var scrubbed = Regex.Replace(joined, @"[\x00-\x08\x0B\x0C\x0E-\x1F]", " ");
|
||||||
return Regex.Replace(scrubbed, @"\s+", " ").Trim();
|
return Regex.Replace(scrubbed, @"\s+", " ").Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,10 @@ import {
|
|||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -30,6 +34,8 @@ type FollowUpDraft = {
|
|||||||
suggestedSendOn: string;
|
suggestedSendOn: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview";
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
jobId: number | null;
|
jobId: number | null;
|
||||||
@@ -81,8 +87,10 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
const [readiness, setReadiness] = useState<ReadinessResponse | null>(null);
|
||||||
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
const [loadingReadiness, setLoadingReadiness] = useState(false);
|
||||||
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
const [savingTailoredCv, setSavingTailoredCv] = useState(false);
|
||||||
|
const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false);
|
||||||
const [generatingPackage, setGeneratingPackage] = useState(false);
|
const [generatingPackage, setGeneratingPackage] = useState(false);
|
||||||
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
const [applicationPackage, setApplicationPackage] = useState<ApplicationPackageResponse | null>(null);
|
||||||
|
const [generationMode, setGenerationMode] = useState<GenerationMode>("default");
|
||||||
const [tailoredCvText, setTailoredCvText] = useState("");
|
const [tailoredCvText, setTailoredCvText] = useState("");
|
||||||
const [draftRecipient, setDraftRecipient] = useState("");
|
const [draftRecipient, setDraftRecipient] = useState("");
|
||||||
const [draftSubject, setDraftSubject] = useState("");
|
const [draftSubject, setDraftSubject] = useState("");
|
||||||
@@ -224,7 +232,17 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
<Typography variant="overline">Tailored CV for this role</Typography>
|
<Typography variant="overline">Tailored CV for this role</Typography>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||||
|
<FormControl size="small" sx={{ minWidth: 180 }}>
|
||||||
|
<InputLabel>Generation mode</InputLabel>
|
||||||
|
<Select value={generationMode} label="Generation mode" onChange={(e) => setGenerationMode(e.target.value as GenerationMode)}>
|
||||||
|
<MenuItem value="default">Balanced</MenuItem>
|
||||||
|
<MenuItem value="concise">Concise</MenuItem>
|
||||||
|
<MenuItem value="ats">ATS focused</MenuItem>
|
||||||
|
<MenuItem value="achievement">Achievement focused</MenuItem>
|
||||||
|
<MenuItem value="interview">Interview focused</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
<Button size="small" variant="outlined" onClick={async () => {
|
<Button size="small" variant="outlined" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
const me = await api.get<{ profileCvText?: string | null }>("/auth/me");
|
||||||
@@ -238,7 +256,7 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
setGeneratingPackage(true);
|
setGeneratingPackage(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`);
|
const res = await api.post<ApplicationPackageResponse>(`/jobapplications/${jobId}/generate-application-package`, null, { params: { mode: generationMode } });
|
||||||
setApplicationPackage(res.data);
|
setApplicationPackage(res.data);
|
||||||
setTailoredCvText(res.data.tailoredCvText ?? "");
|
setTailoredCvText(res.data.tailoredCvText ?? "");
|
||||||
toast("Application package generated.", "success");
|
toast("Application package generated.", "success");
|
||||||
@@ -267,15 +285,40 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) {
|
|||||||
}}>{savingTailoredCv ? "Saving..." : "Save tailored CV"}</Button>
|
}}>{savingTailoredCv ? "Saving..." : "Save tailored CV"}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Start from your master CV, generate a tailored application package, then edit the resume specifically for this company, role, and interview process.</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>Generate a full application package, then edit and save the tailored resume you actually want to use for this role.</Typography>
|
||||||
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." />
|
<TextField value={tailoredCvText} onChange={(e) => setTailoredCvText(e.target.value)} multiline minRows={14} fullWidth placeholder="Paste or rewrite the version of your CV you want to use for this role." />
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"}</Typography>
|
<Typography variant="caption" sx={{ color: "text.secondary", mt: 1, display: "block" }}>Last updated: {job?.tailoredCvUpdatedAt ? new Date(job.tailoredCvUpdatedAt).toLocaleString() : "Not saved yet"}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{applicationPackage ? (
|
{applicationPackage ? (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 2 }}>
|
||||||
<DraftCard title="Cover letter draft" content={applicationPackage.coverLetterDraft ?? "No draft available."} />
|
<DraftCard title="Cover letter draft" content={applicationPackage.coverLetterDraft ?? "No draft available."} onSave={async (content) => {
|
||||||
<DraftCard title="Short application answer" content={applicationPackage.applicationAnswerDraft ?? "No draft available."} />
|
if (!jobId) return;
|
||||||
|
setSavingApplicationDrafts(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/jobapplications/${jobId}/application-drafts`, { coverLetterText: content });
|
||||||
|
setJob((prev) => prev ? { ...prev, coverLetterText: content } : prev);
|
||||||
|
setReadiness(null);
|
||||||
|
toast("Cover letter saved to this job.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.response?.data || "Failed to save cover letter.", "error");
|
||||||
|
} finally {
|
||||||
|
setSavingApplicationDrafts(false);
|
||||||
|
}
|
||||||
|
}} saving={savingApplicationDrafts} />
|
||||||
|
<DraftCard title="Short application answer" content={applicationPackage.applicationAnswerDraft ?? "No draft available."} onSave={async (content) => {
|
||||||
|
if (!jobId) return;
|
||||||
|
setSavingApplicationDrafts(true);
|
||||||
|
try {
|
||||||
|
await api.put(`/jobapplications/${jobId}/application-drafts`, { notes: `Application answer draft:\n${content}` });
|
||||||
|
setReadiness(null);
|
||||||
|
toast("Application answer saved to notes.", "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(error?.response?.data || "Failed to save application answer.", "error");
|
||||||
|
} finally {
|
||||||
|
setSavingApplicationDrafts(false);
|
||||||
|
}
|
||||||
|
}} saving={savingApplicationDrafts} />
|
||||||
<DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} />
|
<DraftCard title="Recruiter message draft" content={applicationPackage.recruiterMessageDraft ?? "No draft available."} />
|
||||||
<ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} />
|
<ListCard title="Key points to emphasize" items={applicationPackage.keyPoints} />
|
||||||
</Box>
|
</Box>
|
||||||
@@ -418,12 +461,15 @@ function ListCard({ title, items }: { title: string; items: string[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DraftCard({ title, content }: { title: string; content: string }) {
|
function DraftCard({ title, content, onSave, saving }: { title: string; content: string; onSave?: (content: string) => Promise<void> | void; saving?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
<Box sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default" }}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap", mb: 1 }}>
|
||||||
<Typography variant="overline">{title}</Typography>
|
<Typography variant="overline">{title}</Typography>
|
||||||
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(content)}>Copy</Button>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Button size="small" variant="outlined" onClick={() => navigator.clipboard.writeText(content)}>Copy</Button>
|
||||||
|
{onSave ? <Button size="small" variant="contained" disabled={saving} onClick={() => onSave(content)}>{saving ? "Saving..." : "Save"}</Button> : null}
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Typography sx={{ whiteSpace: "pre-wrap" }}>{content}</Typography>
|
<Typography sx={{ whiteSpace: "pre-wrap" }}>{content}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -344,7 +344,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{jobs.map((job) => {
|
{filteredJobs.map((job) => {
|
||||||
const open = expanded.includes(job.id);
|
const open = expanded.includes(job.id);
|
||||||
const toneName = statusTone(job.status);
|
const toneName = statusTone(job.status);
|
||||||
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
const tone = toneName === "error" ? theme.palette.error.main : toneName === "warning" ? theme.palette.warning.main : toneName === "success" ? theme.palette.success.main : toneName === "info" ? theme.palette.info.main : theme.palette.primary.main;
|
||||||
|
|||||||
@@ -95,6 +95,11 @@ export interface ApplicationPackageResponse {
|
|||||||
keyPoints: string[];
|
keyPoints: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SaveApplicationDraftsRequest {
|
||||||
|
coverLetterText?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface CorrespondenceMessage {
|
export interface CorrespondenceMessage {
|
||||||
id: number;
|
id: number;
|
||||||
jobApplicationId: number;
|
jobApplicationId: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user