From 8041b43f47d8dbbe87860be2316f02cd7e2dd13f Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 22 Mar 2026 18:37:55 +0100 Subject: [PATCH] feat: add application draft saving modes and reminder grouping --- .../JobApplicationsControllerTests.cs | 34 +++++++++ .../ProfileCvControllerTests.cs | 73 +++++++++++++++++++ .../Controllers/JobApplicationsController.cs | 1 + .../Controllers/ProfileCvController.cs | 8 +- .../src/components/JobDetailsDialog.tsx | 60 +++++++++++++-- job-tracker-ui/src/components/JobTable.tsx | 2 +- job-tracker-ui/src/types.ts | 5 ++ 7 files changed, 174 insertions(+), 9 deletions(-) create mode 100644 JobTrackerApi.Tests/JobApplicationsControllerTests.cs create mode 100644 JobTrackerApi.Tests/ProfileCvControllerTests.cs diff --git a/JobTrackerApi.Tests/JobApplicationsControllerTests.cs b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs new file mode 100644 index 0000000..3d696ae --- /dev/null +++ b/JobTrackerApi.Tests/JobApplicationsControllerTests.cs @@ -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); + } +} diff --git a/JobTrackerApi.Tests/ProfileCvControllerTests.cs b/JobTrackerApi.Tests/ProfileCvControllerTests.cs new file mode 100644 index 0000000..aa727ce --- /dev/null +++ b/JobTrackerApi.Tests/ProfileCvControllerTests.cs @@ -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())).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(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())).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(result); + Assert.Contains("Built APIs", user.ProfileCvText); + } + + private static Mock> CreateUserManager() + { + var store = new Mock>(); + return new Mock>( + store.Object, + Options.Create(new IdentityOptions()), + new PasswordHasher(), + Array.Empty>(), + Array.Empty>(), + new UpperInvariantLookupNormalizer(), + new IdentityErrorDescriber(), + null!, + new NullLogger>() + ); + } +} diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 863bb48..b61999a 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -1223,6 +1223,7 @@ namespace JobTrackerApi.Controllers 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 SaveApplicationDraftsRequest(string? CoverLetterText, string? Notes); 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); diff --git a/JobTrackerApi/Controllers/ProfileCvController.cs b/JobTrackerApi/Controllers/ProfileCvController.cs index a19a17d..6d2c161 100644 --- a/JobTrackerApi/Controllers/ProfileCvController.cs +++ b/JobTrackerApi/Controllers/ProfileCvController.cs @@ -76,7 +76,13 @@ public sealed class ProfileCvController : ControllerBase if (string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase)) { 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(); } diff --git a/job-tracker-ui/src/components/JobDetailsDialog.tsx b/job-tracker-ui/src/components/JobDetailsDialog.tsx index e0578d2..8622148 100644 --- a/job-tracker-ui/src/components/JobDetailsDialog.tsx +++ b/job-tracker-ui/src/components/JobDetailsDialog.tsx @@ -8,6 +8,10 @@ import { Dialog, DialogContent, DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, Tab, Tabs, TextField, @@ -30,6 +34,8 @@ type FollowUpDraft = { suggestedSendOn: string; }; +type GenerationMode = "default" | "concise" | "ats" | "achievement" | "interview"; + interface Props { open: boolean; jobId: number | null; @@ -81,8 +87,10 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { const [readiness, setReadiness] = useState(null); const [loadingReadiness, setLoadingReadiness] = useState(false); const [savingTailoredCv, setSavingTailoredCv] = useState(false); + const [savingApplicationDrafts, setSavingApplicationDrafts] = useState(false); const [generatingPackage, setGeneratingPackage] = useState(false); const [applicationPackage, setApplicationPackage] = useState(null); + const [generationMode, setGenerationMode] = useState("default"); const [tailoredCvText, setTailoredCvText] = useState(""); const [draftRecipient, setDraftRecipient] = useState(""); const [draftSubject, setDraftSubject] = useState(""); @@ -224,7 +232,17 @@ export default function JobDetailsDialog({ open, jobId, onClose }: Props) { Tailored CV for this role - + + + Generation mode + + - Start from your master CV, generate a tailored application package, then edit the resume specifically for this company, role, and interview process. + Generate a full application package, then edit and save the tailored resume you actually want to use for this role. 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"} {applicationPackage ? ( - - + { + 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} /> + { + 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} /> @@ -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; saving?: boolean }) { return ( {title} - + + + {onSave ? : null} + {content} diff --git a/job-tracker-ui/src/components/JobTable.tsx b/job-tracker-ui/src/components/JobTable.tsx index f6759c7..e826bf5 100644 --- a/job-tracker-ui/src/components/JobTable.tsx +++ b/job-tracker-ui/src/components/JobTable.tsx @@ -344,7 +344,7 @@ export default function JobTable({ refreshToken, pageSize, onPageSizeChange, col - {jobs.map((job) => { + {filteredJobs.map((job) => { const open = expanded.includes(job.id); 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; diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index c1fff5f..c6f39b2 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -95,6 +95,11 @@ export interface ApplicationPackageResponse { keyPoints: string[]; } +export interface SaveApplicationDraftsRequest { + coverLetterText?: string | null; + notes?: string | null; +} + export interface CorrespondenceMessage { id: number; jobApplicationId: number;