diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index 92d062f..2be3dd0 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -56,6 +56,47 @@ namespace JobTrackerApi.Controllers return "Hi there,"; } + private sealed record CvSectionRecord(string? Name, string? Content, int? WordCount); + + private static string BuildStructuredCvContext(ApplicationUser? user) + { + if (string.IsNullOrWhiteSpace(user?.ProfileCvStructureJson)) return string.Empty; + + try + { + var sections = JsonSerializer.Deserialize>(user.ProfileCvStructureJson); + if (sections is null || sections.Count == 0) return string.Empty; + + var preferredOrder = new[] + { + "Professional Summary", + "Core Skills", + "Experience Highlights", + "Selected Achievements", + "Projects", + "Education", + "Certifications", + }; + + var ordered = preferredOrder + .Select(name => sections.FirstOrDefault(section => string.Equals(section.Name?.Trim(), name, StringComparison.OrdinalIgnoreCase))) + .Where(section => section is not null) + .Concat(sections.Where(section => !preferredOrder.Contains(section.Name ?? string.Empty, StringComparer.OrdinalIgnoreCase))) + .Where(section => !string.IsNullOrWhiteSpace(section?.Content)) + .Take(6) + .Select(section => $"{section!.Name}:\n{section.Content!.Trim()}") + .ToList(); + + return ordered.Count > 0 + ? $"Structured CV sections:\n{string.Join("\n\n", ordered)}" + : string.Empty; + } + catch + { + return string.Empty; + } + } + private async Task> BuildListFromAiAsync(string instruction, string context, CancellationToken cancellationToken, string fallbackPrefix) { var raw = await _summarizer.SummarizeSectionAsync(instruction, context, 220, 70); @@ -1525,6 +1566,7 @@ namespace JobTrackerApi.Controllers var jobTags = SkillTagger.Detect(jobText).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); var strengths = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList(); var gaps = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(8).ToList(); + var structuredCvContext = BuildStructuredCvContext(user); var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var jobContext = $@"Job title: {job.JobTitle} @@ -1535,7 +1577,7 @@ Job description and notes: {jobText} Candidate CV/profile: -{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; +{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; var matchSummary = await _summarizer.SummarizeSectionAsync( "Write a concise candidate-fit assessment. Explain overall alignment, strongest evidence, biggest risks, and how competitive the candidate appears.", @@ -1648,6 +1690,7 @@ Candidate CV/profile: var normalizedCv = cvText.ToLowerInvariant(); var matchedTags = jobTags.Where(tag => normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList(); var missingTags = jobTags.Where(tag => !normalizedCv.Contains(tag.ToLowerInvariant())).Take(5).ToList(); + var structuredCvContext = BuildStructuredCvContext(user); var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var context = $@"Job title: {job.JobTitle} @@ -1657,7 +1700,7 @@ Job description and notes: {jobText} Candidate master CV: -{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; +{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; var strategicSummary = await _summarizer.SummarizeSectionAsync( "Write a concise strategy summary for how the candidate should approach this role. Focus on what matters most in the posting, what evidence to lead with, and where to be careful.", @@ -1824,6 +1867,7 @@ Candidate master CV: var packageModeInstruction = BuildPackageModeInstruction(mode); var coverLetterStyleInstruction = BuildCoverLetterStyleInstruction(coverLetterStyle); + var structuredCvContext = BuildStructuredCvContext(user); var attachmentContext = await BuildAttachmentContextAsync(id, cancellationToken, attachmentIds); var packageContext = $@"Job title: {job.JobTitle} @@ -1836,7 +1880,7 @@ Job context: {jobText} Candidate master CV: -{cvText}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; +{cvText}{(!string.IsNullOrWhiteSpace(structuredCvContext) ? $"\n\n{structuredCvContext}" : string.Empty)}{(attachmentContext is not null ? $"\n\n{attachmentContext.Context}" : string.Empty)}"; var tailoredCvText = await _summarizer.SummarizeSectionAsync( $"Rewrite the candidate CV into a tailored role-specific resume draft. Keep it credible, structured, and focused on the strongest overlaps with this job. {packageModeInstruction}", diff --git a/job-tracker-ui/src/profile-page.test.tsx b/job-tracker-ui/src/profile-page.test.tsx new file mode 100644 index 0000000..73fc300 --- /dev/null +++ b/job-tracker-ui/src/profile-page.test.tsx @@ -0,0 +1,110 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import ProfilePage from './pages/ProfilePage'; +import { api } from './api'; + +jest.mock('./api', () => ({ + api: { + get: jest.fn(), + post: jest.fn(), + put: jest.fn(), + patch: jest.fn(), + delete: jest.fn(), + interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } }, + }, +})); + +jest.mock('./components/GoogleAuthCard', () => () => null); +jest.mock('./components/CropImageDialog', () => () => null); + +const mockedApi = api as jest.Mocked; + +function renderPage() { + return render( + + + + + , + ); +} + +beforeEach(() => { + mockedApi.get.mockImplementation((url: string) => { + if (url === '/auth/me') { + return Promise.resolve({ + data: { + provider: 'local', + email: 'demo@example.com', + userName: 'demo', + firstName: 'Demo', + lastName: 'User', + displayName: 'Demo User', + profileCvText: 'Professional Summary\nBuilt backend systems', + profileCvStructureJson: JSON.stringify([ + { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, + ]), + googleLink: { linked: false }, + }, + } as any); + } + return Promise.resolve({ data: {} } as any); + }); + mockedApi.post.mockImplementation((url: string) => { + if (url === '/profile-cv/parse') { + return Promise.resolve({ + data: { + sections: [ + { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, + { name: 'Core Skills', content: '.NET\nSQL\nAzure', wordCount: 3 }, + ], + }, + } as any); + } + return Promise.resolve({ data: {} } as any); + }); + mockedApi.put.mockResolvedValue({ data: {} } as any); + window.localStorage.clear(); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +test('profile page loads persisted cv sections and can re-parse them', async () => { + renderPage(); + + expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); + expect(screen.getByText(/cv structure overview/i)).toBeInTheDocument(); + expect(screen.getAllByText(/professional summary/i).length).toBeGreaterThan(0); + + const analyzeButton = screen.getByRole('button', { name: /analyze sections/i }); + await waitFor(() => expect(analyzeButton).toBeEnabled()); + fireEvent.click(analyzeButton); + + await waitFor(() => { + expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/parse', { text: 'Professional Summary\nBuilt backend systems' }); + }); + + expect(await screen.findByText(/core skills/i)).toBeInTheDocument(); +}); + +test('saving profile persists structured cv json', async () => { + renderPage(); + + expect(await screen.findByText(/cv ready/i)).toBeInTheDocument(); + const saveButton = screen.getByRole('button', { name: /save changes/i }); + await waitFor(() => expect(saveButton).toBeEnabled()); + fireEvent.click(saveButton); + + await waitFor(() => { + expect(mockedApi.put).toHaveBeenCalledWith('/auth/profile', expect.objectContaining({ + profileCvStructureJson: JSON.stringify([ + { name: 'Professional Summary', content: 'Built backend systems', wordCount: 3 }, + ]), + })); + }); +});