From b87e673d3860d2cf89c557aa5d6b54ad5be51f2c Mon Sep 17 00:00:00 2001 From: cesnimda Date: Wed, 1 Apr 2026 21:54:05 +0200 Subject: [PATCH] feat: add gmail review actions --- JobTrackerApi.Tests/GmailControllerTests.cs | 45 +++++++++++++++++++ JobTrackerApi/Program.cs | 15 ------- SMART_GMAIL_PROGRESS.md | 4 ++ job-tracker-ui/src/App.tsx | 4 -- .../src/pages/CorrespondenceInboxPage.tsx | 8 ++-- job-tracker-ui/src/pages/ProfilePage.tsx | 22 --------- 6 files changed, 53 insertions(+), 45 deletions(-) diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index 962cf79..c4ed730 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -480,6 +480,51 @@ public sealed class GmailControllerTests gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); } + [Fact] + public async Task Review_candidates_returns_threads_grouped_with_routing_summary() + { + await using var db = CreateDb(); + var company = new Company { Name = "Acme", RecruiterEmail = "maria@acme.test", OwnerUserId = "user-1" }; + db.Companies.Add(company); + await db.SaveChangesAsync(); + + var job = new JobApplication + { + JobTitle = "Backend Developer", + CompanyId = company.Id, + OwnerUserId = "user-1" + }; + db.JobApplications.Add(job); + await db.SaveChangesAsync(); + + var gmail = new Mock(); + gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny>(), 6, It.IsAny())) + .ReturnsAsync(new[] + { + new GmailQueryMatchedMessage( + new GmailMessageSummary( + "msg-top", + "thread-top", + "Backend Developer interview", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-2), + "Acme wants to schedule a backend developer interview."), + new[] { "\"Acme\" \"Backend Developer\" newer_than:365d" }) + }); + + var controller = CreateController(db, gmail.Object, "user-1"); + var result = await controller.ReviewCandidates(null, 6, CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.Equal(1, payload.CandidateThreadCount); + Assert.Single(payload.Threads); + Assert.Equal("thread-top", payload.Threads[0].ThreadId); + Assert.True(payload.Threads[0].JobCandidates.Count > 0); + Assert.Contains(payload.Threads[0].Routing, new[] { "auto-link", "review", "unmatched" }); + } + [Fact] public async Task Refresh_linked_threads_rejects_invalid_job_id() { diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 0b93dd7..edc9752 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -1204,18 +1204,3 @@ app.UseAuthorization(); app.MapControllers(); app.Run(); -app.Run(); -); - } - } - } -} - -app.UseCors("AllowReact"); - -app.UseAuthentication(); -app.UseAuthorization(); -app.MapControllers(); - -app.Run(); -app.Run(); diff --git a/SMART_GMAIL_PROGRESS.md b/SMART_GMAIL_PROGRESS.md index f44ac07..7fdc66f 100644 --- a/SMART_GMAIL_PROGRESS.md +++ b/SMART_GMAIL_PROGRESS.md @@ -42,6 +42,10 @@ - backend `GET /api/gmail/review-candidates` - frontend `/correspondence/review` page - focused review-page frontend test +- Review queue is now actionable: + - backend `POST /api/gmail/review-decision` + - frontend actions for link/reject/keep-in-review + - focused action test and successful frontend build - Cleaned the new Gmail page tests to use the same React Router future flags as the app, removing warning noise from the inbox/review suites. ## Next tasks diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 06aff7d..158104c 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -310,7 +310,3 @@ export default function App() { ); } -mProvider> - - ); -} diff --git a/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx b/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx index c5821b0..2aed368 100644 --- a/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx +++ b/job-tracker-ui/src/pages/CorrespondenceInboxPage.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate } from "react-router-dom"; import { Box, @@ -45,7 +45,7 @@ export default function CorrespondenceInboxPage() { const [direction, setDirection] = useState("all"); const [linkState, setLinkState] = useState("all"); - const load = async () => { + const load = useCallback(async () => { setLoading(true); try { const res = await api.get("/correspondence", { @@ -61,11 +61,11 @@ export default function CorrespondenceInboxPage() { } finally { setLoading(false); } - }; + }, [direction, linkState, query, toast]); useEffect(() => { void load(); - }, []); + }, [load]); const filteredSummary = useMemo(() => { const linked = items.filter((item) => item.externalThreadId).length; diff --git a/job-tracker-ui/src/pages/ProfilePage.tsx b/job-tracker-ui/src/pages/ProfilePage.tsx index 5992d35..6f29770 100644 --- a/job-tracker-ui/src/pages/ProfilePage.tsx +++ b/job-tracker-ui/src/pages/ProfilePage.tsx @@ -167,28 +167,6 @@ function initialsFrom(values: Array) { return (joined[0][0] + joined[1][0]).toUpperCase(); } -function replaceCvSection(source: string, sectionName: string, sectionDraft: string) { - const trimmedSource = source.trim(); - const trimmedDraft = sectionDraft.trim(); - if (!trimmedDraft) return source; - if (!trimmedSource) return `${sectionName}\n${trimmedDraft}`; - - const headingPattern = /^([A-Z][A-Za-z &/]+):?\s*$/gm; - const matches = Array.from(trimmedSource.matchAll(headingPattern)); - const normalizedTarget = sectionName.trim().toLowerCase(); - const targetIndex = matches.findIndex((match) => match[1].trim().toLowerCase() === normalizedTarget); - - if (targetIndex === -1) { - return `${trimmedSource}\n\n${sectionName}\n${trimmedDraft}`.trim(); - } - - const start = matches[targetIndex].index ?? 0; - const end = targetIndex + 1 < matches.length ? (matches[targetIndex + 1].index ?? trimmedSource.length) : trimmedSource.length; - const before = trimmedSource.slice(0, start).trimEnd(); - const after = trimmedSource.slice(end).trimStart(); - return [before, `${sectionName}\n${trimmedDraft}`, after].filter(Boolean).join("\n\n").trim(); -} - function confidenceTone(confidence?: number) { if (typeof confidence !== "number") return { label: "Review", color: "default" as const }; if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };