feat: add gmail review queue surface
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user