diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 430b150..3030c77 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -71,6 +71,10 @@ public sealed class GmailController : ControllerBase int CandidateThreadCount, IReadOnlyList Threads); + public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); + public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); + public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList Threads); + public sealed record GmailConnectionStatusDto( bool Connected, string? GmailAddress, @@ -217,6 +221,114 @@ public sealed class GmailController : ControllerBase threads)); } + [HttpGet("review-candidates")] + public async Task> ReviewCandidates( + [FromQuery] string? queryOverride, + [FromQuery] int maxResultsPerQuery = 6, + CancellationToken cancellationToken = default) + { + var ownerUserId = GetRequiredOwnerUserId(); + var jobs = await _db.JobApplications + .Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted) + .Include(x => x.Company) + .Include(x => x.Messages) + .OrderByDescending(x => x.DateApplied) + .Take(100) + .ToListAsync(cancellationToken); + if (jobs.Count == 0) + { + return Ok(new GmailReviewQueueResponseDto(Array.Empty(), 0, 0, 0, 0, Array.Empty())); + } + + var querySet = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var job in jobs) + { + foreach (var query in _matching.BuildJobQueries(job, queryOverride)) + { + querySet.Add(query); + } + } + var queries = querySet.Take(18).ToList(); + var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken); + + var allImportedMessageIds = jobs.SelectMany(job => job.Messages) + .Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId)) + .Select(message => message.ExternalMessageId!) + .ToHashSet(StringComparer.Ordinal); + var allImportedThreadIds = jobs.SelectMany(job => job.Messages) + .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId)) + .Select(message => message.ExternalThreadId!) + .ToHashSet(StringComparer.Ordinal); + + var groupedThreads = candidateMessages + .GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal) + .Select(group => + { + var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList(); + var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue); + var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)"; + var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIds.Contains(item.Message.Id) || allImportedThreadIds.Contains(item.Message.ThreadId)); + + var jobCandidates = jobs + .Select(job => + { + var best = orderedMessages + .Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId))) + .OrderByDescending(score => score.Score) + .First(); + return new GmailReviewJobCandidateDto( + job.Id, + job.JobTitle, + job.Company?.Name ?? string.Empty, + best.Score, + best.Confidence, + best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList()); + }) + .Where(candidate => candidate.Score > 0) + .OrderByDescending(candidate => candidate.Score) + .Take(3) + .ToList(); + + var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0; + var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0; + var routing = topScore >= 30 && topScore - secondScore >= 8 + ? "auto-link" + : topScore >= 16 + ? "review" + : "unmatched"; + + var messages = orderedMessages + .Select(item => new GmailJobMatchedMessageDto( + item.Message.Id, + item.Message.ThreadId, + item.Message.Subject, + item.Message.From, + item.Message.To, + item.Message.Date, + item.Message.Snippet, + item.MatchedQueries.Count * 4, + item.MatchedQueries.Count >= 2 ? "medium" : "low", + allImportedMessageIds.Contains(item.Message.Id), + item.MatchedQueries, + Array.Empty())) + .ToList(); + + return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, matchedQueries, jobCandidates, messages); + }) + .OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue) + .Take(100) + .ToList(); + + return Ok(new GmailReviewQueueResponseDto( + queries, + groupedThreads.Count, + groupedThreads.Count(thread => thread.Routing == "auto-link"), + groupedThreads.Count(thread => thread.Routing == "review"), + groupedThreads.Count(thread => thread.Routing == "unmatched"), + groupedThreads)); + } + [AllowAnonymous] [HttpGet("oauth/callback")] public async Task Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken) diff --git a/SMART_GMAIL_PROGRESS.md b/SMART_GMAIL_PROGRESS.md index 5cbdd45..6ba5395 100644 --- a/SMART_GMAIL_PROGRESS.md +++ b/SMART_GMAIL_PROGRESS.md @@ -35,8 +35,9 @@ - new global correspondence inbox page and nav route - focused frontend test for inbox filtering/refresh behavior - Current next focus: - - extract deterministic cross-job Gmail matching logic into a reusable service - - build review/unmatched routing on top of that service + - deterministic Gmail matching logic now extracted into `JobTrackerApi/Services/GmailJobMatchingService.cs` + - next step is to build cross-job routing/review behavior on top of that reusable matching seam + - branch context has been merged into `main`; continue delivery directly on `main` ## Next tasks 1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model. diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index 87d8381..06aff7d 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -14,6 +14,7 @@ import AlarmIcon from "@mui/icons-material/Alarm"; import AccountCircleIcon from "@mui/icons-material/AccountCircle"; import ShieldIcon from "@mui/icons-material/Shield"; import SearchIcon from "@mui/icons-material/Search"; +import MailOutlineIcon from "@mui/icons-material/MailOutline"; import MemoryIcon from "@mui/icons-material/Memory"; import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom"; @@ -46,6 +47,8 @@ const ProfilePage = lazy(() => import("./pages/ProfilePage")); const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage")); const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage")); const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage")); +const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage")); +const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage")); const NotFoundPage = lazy(() => import("./pages/NotFoundPage")); type AuthConfig = { requireAuth: boolean }; @@ -67,6 +70,8 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] { if (path.startsWith("/reminders")) return [t("home"), t("reminders")]; if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")]; if (path.startsWith("/companies")) return [t("home"), t("companies")]; + if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"]; + if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"]; if (path.startsWith("/trash")) return [t("home"), t("trash")]; if (path.startsWith("/settings")) return [t("home"), t("settings")]; if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")]; @@ -82,6 +87,8 @@ function titleFor(path: string, t: (k: any) => string): string { if (path.startsWith("/jobs")) return t("jobApplications"); if (path.startsWith("/kanban")) return t("kanbanBoard"); if (path.startsWith("/companies")) return t("companies"); + if (path.startsWith("/correspondence/review")) return "Gmail review queue"; + if (path.startsWith("/correspondence")) return "Correspondence inbox"; if (path.startsWith("/trash")) return t("trash"); if (path.startsWith("/settings")) return t("settings"); if (path.startsWith("/profile")) return t("profile"); @@ -154,6 +161,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo { to: "/reminders", label: t("reminders"), icon: , badgeCount: notifCount, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") }, { to: "/companies", label: t("companies"), icon: , section: t("manage") }, + { to: "/correspondence", label: "Correspondence", icon: , section: t("manage") }, + { to: "/correspondence/review", label: "Gmail review", icon: , section: t("manage") }, { to: "/trash", label: t("trash"), icon: , section: t("manage") }, ]; @@ -226,6 +235,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> } /> + } /> } /> } /> } /> diff --git a/job-tracker-ui/src/gmail-review-page.test.tsx b/job-tracker-ui/src/gmail-review-page.test.tsx new file mode 100644 index 0000000..75df4d0 --- /dev/null +++ b/job-tracker-ui/src/gmail-review-page.test.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import '@testing-library/jest-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { ToastProvider } from './toast'; +import { I18nProvider } from './i18n/I18nProvider'; +import GmailReviewPage from './pages/GmailReviewPage'; +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() } }, + }, + getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.', +})); + +const mockedApi = api as jest.Mocked; + +function renderPage() { + return render( + + + + + + + , + ); +} + +describe('GmailReviewPage', () => { + beforeEach(() => { + mockedApi.get.mockResolvedValue({ + data: { + queries: ['"Acme" "Backend Developer" newer_than:365d'], + candidateThreadCount: 2, + autoLinkThreadCount: 1, + reviewThreadCount: 1, + unmatchedThreadCount: 0, + threads: [ + { + threadId: 'thread-1', + subject: 'Backend Developer interview', + latestDate: new Date().toISOString(), + messageCount: 2, + routing: 'review', + hasImportedMessages: false, + matchedQueries: ['"Acme" "Backend Developer" newer_than:365d'], + jobCandidates: [ + { jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] }, + ], + messages: [], + }, + ], + }, + } as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test('renders Gmail review queue summary and candidate threads', async () => { + renderPage(); + + expect(await screen.findByText(/gmail review queue/i)).toBeInTheDocument(); + expect(await screen.findByText(/2 candidate threads/i)).toBeInTheDocument(); + expect(screen.getByText(/backend developer interview/i)).toBeInTheDocument(); + expect(screen.getByText(/acme • backend developer \(24\)/i)).toBeInTheDocument(); + + await waitFor(() => { + expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates'); + }); + }); +}); diff --git a/job-tracker-ui/src/pages/GmailReviewPage.tsx b/job-tracker-ui/src/pages/GmailReviewPage.tsx new file mode 100644 index 0000000..1be793a --- /dev/null +++ b/job-tracker-ui/src/pages/GmailReviewPage.tsx @@ -0,0 +1,73 @@ +import React, { useEffect, useState } from "react"; +import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material"; +import { api, getApiErrorMessage } from "../api"; +import { GmailReviewQueueResponse } from "../types"; +import { useToast } from "../toast"; +import { useNavigate } from "react-router-dom"; + +export default function GmailReviewPage() { + const { toast } = useToast(); + const navigate = useNavigate(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + + const load = async () => { + setLoading(true); + try { + const res = await api.get("/gmail/review-candidates"); + setData(res.data); + } catch (error) { + toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error"); + } finally { + setLoading(false); + } + }; + + useEffect(() => { void load(); }, []); + + return ( + + + + Gmail review queue + Review medium-confidence Gmail correspondence routing and unmatched job-like threads. + + + + + {data ? ( + + + + + + + ) : null} + + {loading ? : null} + {!loading && data && data.threads.length === 0 ? No Gmail review candidates right now. : null} + + + {data?.threads.map((thread) => ( + + + + {thread.subject} + {thread.messageCount} messages · {thread.routing} + + {thread.matchedQueries.slice(0, 3).map((query) => )} + + + + {thread.jobCandidates.slice(0, 2).map((candidate) => ( + + ))} + {thread.jobCandidates[0] ? : null} + + + + ))} + + + ); +} diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 9c4dad8..d44df15 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -299,6 +299,36 @@ export interface GmailThreadRefreshResult { threads: GmailThreadRefreshThreadResult[]; } +export interface GmailReviewJobCandidate { + jobApplicationId: number; + jobTitle: string; + companyName: string; + score: number; + confidence: string; + reasons: GmailJobMatchReason[]; +} + +export interface GmailReviewThread { + threadId: string; + subject: string; + latestDate?: string; + messageCount: number; + routing: string; + hasImportedMessages: boolean; + matchedQueries: string[]; + jobCandidates: GmailReviewJobCandidate[]; + messages: GmailJobMatchedMessage[]; +} + +export interface GmailReviewQueueResponse { + queries: string[]; + candidateThreadCount: number; + autoLinkThreadCount: number; + reviewThreadCount: number; + unmatchedThreadCount: number; + threads: GmailReviewThread[]; +} + export interface GmailStatus { connected: boolean; gmailAddress?: string;