feat: add gmail review queue surface

This commit is contained in:
2026-04-01 17:16:00 +02:00
parent 69e78d8951
commit 5af2c66616
6 changed files with 308 additions and 2 deletions
@@ -71,6 +71,10 @@ public sealed class GmailController : ControllerBase
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> 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<ActionResult<GmailReviewQueueResponseDto>> 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<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
}
var querySet = new HashSet<string>(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<GmailJobMatchReasonDto>()))
.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<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
+3 -2
View File
@@ -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.
+10
View File
@@ -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: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/correspondence/review", label: "Gmail review", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
];
@@ -226,6 +235,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
<Route path="/kanban" element={<KanbanBoard />} />
<Route path="/companies" element={<CompaniesTable />} />
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
<Route path="/correspondence/review" element={<GmailReviewPage />} />
<Route path="/profile" element={<ProfilePage />} />
<Route path="/admin/audit" element={<AdminAuditPage />} />
<Route path="/admin/users" element={<AdminUsersPage />} />
@@ -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<typeof api>;
function renderPage() {
return render(
<ToastProvider>
<I18nProvider>
<MemoryRouter>
<GmailReviewPage />
</MemoryRouter>
</I18nProvider>
</ToastProvider>,
);
}
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');
});
});
});
@@ -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<GmailReviewQueueResponse | null>(null);
const [loading, setLoading] = useState(false);
const load = async () => {
setLoading(true);
try {
const res = await api.get<GmailReviewQueueResponse>("/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 (
<Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Gmail review queue</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>Review medium-confidence Gmail correspondence routing and unmatched job-like threads.</Typography>
</Box>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
</Box>
{data ? (
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
<Chip label={`${data.candidateThreadCount} candidate threads`} variant="outlined" />
<Chip label={`${data.autoLinkThreadCount} auto-link`} color="success" variant="outlined" />
<Chip label={`${data.reviewThreadCount} review`} color="warning" variant="outlined" />
<Chip label={`${data.unmatchedThreadCount} unmatched`} variant="outlined" />
</Box>
) : null}
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
{!loading && data && data.threads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates right now.</Typography> : null}
<Stack spacing={1.25}>
{data?.threads.map((thread) => (
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{thread.messageCount} messages · {thread.routing}</Typography>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
{thread.matchedQueries.slice(0, 3).map((query) => <Chip key={query} size="small" label={query} variant="outlined" />)}
</Box>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{thread.jobCandidates.slice(0, 2).map((candidate) => (
<Chip key={candidate.jobApplicationId} label={`${candidate.companyName}${candidate.jobTitle} (${candidate.score})`} variant="outlined" color={candidate.confidence === 'high' ? 'success' : candidate.confidence === 'medium' ? 'warning' : 'default'} />
))}
{thread.jobCandidates[0] ? <Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>Open top job</Button> : null}
</Box>
</Box>
</Paper>
))}
</Stack>
</Paper>
);
}
+30
View File
@@ -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;