feat: add gmail review decisions

This commit is contained in:
2026-04-01 21:45:01 +02:00
parent a0e823facf
commit 161ecb4b94
7 changed files with 160 additions and 16 deletions
+4
View File
@@ -17,6 +17,7 @@ namespace JobTrackerApi.Data
public DbSet<JobApplication> JobApplications => Set<JobApplication>(); public DbSet<JobApplication> JobApplications => Set<JobApplication>();
public DbSet<Correspondence> Correspondences => Set<Correspondence>(); public DbSet<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>(); public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
public DbSet<Attachment> Attachments => Set<Attachment>(); public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>(); public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>(); public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -66,6 +67,9 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<GmailConnection>() modelBuilder.Entity<GmailConnection>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<GmailReviewDecision>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>(); modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
modelBuilder.Entity<GmailConnection>() modelBuilder.Entity<GmailConnection>()
+13 -2
View File
@@ -259,11 +259,16 @@ public sealed class GmailController : ControllerBase
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId)) .Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!) .Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal); .ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.AsNoTracking()
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
var groupedThreads = candidateMessages var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal) .GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.Select(group => .Select(group =>
{ {
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == group.Key);
var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList(); var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue); 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 subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)";
@@ -292,11 +297,17 @@ public sealed class GmailController : ControllerBase
var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0; var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0; var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
var routing = topScore >= 30 && topScore - secondScore >= 8 var routing = existingDecision?.Decision switch
{
"linked" => "linked",
"rejected" => "rejected",
"suggested" => "suggested",
_ => topScore >= 30 && topScore - secondScore >= 8
? "auto-link" ? "auto-link"
: topScore >= 16 : topScore >= 16
? "review" ? "review"
: "unmatched"; : "unmatched"
};
var messages = orderedMessages var messages = orderedMessages
.Select(item => new GmailJobMatchedMessageDto( .Select(item => new GmailJobMatchedMessageDto(
+26
View File
@@ -638,6 +638,18 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
"LastSyncStatus" TEXT NULL, "LastSyncStatus" TEXT NULL,
"LastSyncError" TEXT NULL "LastSyncError" TEXT NULL
); );
""");
Exec(c, """
CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"ThreadId" TEXT NOT NULL,
"JobApplicationId" INTEGER NULL,
"Decision" TEXT NOT NULL,
"Note" TEXT NULL,
"UpdatedAt" TEXT NOT NULL
);
"""); """);
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;"); EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
@@ -1193,3 +1205,17 @@ app.MapControllers();
app.Run(); app.Run();
app.Run(); app.Run();
);
}
}
}
}
app.UseCors("AllowReact");
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();
app.Run();
app.Run();
+12
View File
@@ -0,0 +1,12 @@
namespace JobTrackerApi.Models;
public sealed class GmailReviewDecision
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = "";
public string ThreadId { get; set; } = "";
public int? JobApplicationId { get; set; }
public string Decision { get; set; } = "review"; // review, linked, rejected, suggested
public string? Note { get; set; }
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
}
+5
View File
@@ -38,6 +38,11 @@
- deterministic Gmail matching logic now extracted into `JobTrackerApi/Services/GmailJobMatchingService.cs` - 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 - 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` - branch context has been merged into `main`; continue delivery directly on `main`
- Added a first review surface:
- backend `GET /api/gmail/review-candidates`
- frontend `/correspondence/review` page
- focused review-page frontend test
- 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 ## Next tasks
1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model. 1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model.
+15 -1
View File
@@ -1,6 +1,6 @@
import React from 'react'; import React from 'react';
import '@testing-library/jest-dom'; import '@testing-library/jest-dom';
import { render, screen, waitFor } from '@testing-library/react'; import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom'; import { MemoryRouter } from 'react-router-dom';
import { ToastProvider } from './toast'; import { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider'; import { I18nProvider } from './i18n/I18nProvider';
@@ -77,4 +77,18 @@ describe('GmailReviewPage', () => {
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates'); expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
}); });
}); });
test('persists a review decision for the top job', async () => {
renderPage();
fireEvent.click(await screen.findByRole('button', { name: /link top job/i }));
await waitFor(() => {
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', {
threadId: 'thread-1',
decision: 'linked',
jobApplicationId: 42,
});
});
});
}); });
+82 -10
View File
@@ -1,4 +1,4 @@
import React, { useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material"; import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material";
import { api, getApiErrorMessage } from "../api"; import { api, getApiErrorMessage } from "../api";
import { GmailReviewQueueResponse } from "../types"; import { GmailReviewQueueResponse } from "../types";
@@ -10,8 +10,9 @@ export default function GmailReviewPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null); const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
const load = async () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates"); const res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates");
@@ -21,18 +22,48 @@ export default function GmailReviewPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, [toast]);
useEffect(() => { void load(); }, []); useEffect(() => {
void load();
}, [load]);
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review", jobApplicationId?: number) => {
setSavingThreadId(threadId);
try {
await api.post("/gmail/review-decision", {
threadId,
decision,
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
});
await load();
toast(
decision === "linked"
? "Thread linked for review."
: decision === "rejected"
? "Thread rejected from review."
: "Thread returned to review.",
"success",
);
} catch (error) {
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error");
} finally {
setSavingThreadId(null);
}
}, [load, toast]);
return ( return (
<Paper sx={{ mt: 0, p: 2 }}> <Paper sx={{ mt: 0, p: 2 }}>
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
<Box> <Box>
<Typography variant="h5" sx={{ fontWeight: 900 }}>Gmail review queue</Typography> <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> <Typography variant="body2" sx={{ color: "text.secondary" }}>
Review medium-confidence Gmail correspondence routing and unmatched job-like threads.
</Typography>
</Box> </Box>
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button> <Button variant="contained" onClick={() => void load()} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</Button>
</Box> </Box>
{data ? ( {data ? (
@@ -53,16 +84,57 @@ export default function GmailReviewPage() {
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}> <Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
<Box sx={{ minWidth: 0 }}> <Box sx={{ minWidth: 0 }}>
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography> <Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
<Typography variant="body2" sx={{ color: "text.secondary" }}>{thread.messageCount} messages · {thread.routing}</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 }}> <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" />)} {thread.matchedQueries.slice(0, 3).map((query) => (
<Chip key={query} size="small" label={query} variant="outlined" />
))}
</Box> </Box>
</Box> </Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}> <Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{thread.jobCandidates.slice(0, 2).map((candidate) => ( {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'} /> <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} {thread.jobCandidates[0] ? (
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
Open top job
</Button>
) : null}
{thread.jobCandidates[0] ? (
<Button
size="small"
variant="contained"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
>
Link top job
</Button>
) : null}
<Button
size="small"
variant="outlined"
color="warning"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "review")}
>
Keep in review
</Button>
<Button
size="small"
variant="outlined"
color="error"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "rejected")}
>
Reject
</Button>
</Box> </Box>
</Box> </Box>
</Paper> </Paper>