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<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -66,6 +67,9 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<GmailConnection>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<GmailReviewDecision>()
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
modelBuilder.Entity<GmailConnection>()
+16 -5
View File
@@ -259,11 +259,16 @@ public sealed class GmailController : ControllerBase
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.AsNoTracking()
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.Select(group =>
{
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == group.Key);
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)";
@@ -292,11 +297,17 @@ public sealed class GmailController : ControllerBase
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 routing = existingDecision?.Decision switch
{
"linked" => "linked",
"rejected" => "rejected",
"suggested" => "suggested",
_ => topScore >= 30 && topScore - secondScore >= 8
? "auto-link"
: topScore >= 16
? "review"
: "unmatched"
};
var messages = orderedMessages
.Select(item => new GmailJobMatchedMessageDto(
+26
View File
@@ -638,6 +638,18 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
"LastSyncStatus" 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;");
@@ -1193,3 +1205,17 @@ app.MapControllers();
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`
- 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`
- 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
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 '@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 { ToastProvider } from './toast';
import { I18nProvider } from './i18n/I18nProvider';
@@ -77,4 +77,18 @@ describe('GmailReviewPage', () => {
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 { api, getApiErrorMessage } from "../api";
import { GmailReviewQueueResponse } from "../types";
@@ -10,8 +10,9 @@ export default function GmailReviewPage() {
const navigate = useNavigate();
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
const [loading, setLoading] = useState(false);
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
const load = async () => {
const load = useCallback(async () => {
setLoading(true);
try {
const res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates");
@@ -21,18 +22,48 @@ export default function GmailReviewPage() {
} finally {
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 (
<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>
<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>
<Button variant="contained" onClick={() => void load()} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</Button>
</Box>
{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={{ minWidth: 0 }}>
<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 }}>
{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 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'} />
<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>
</Paper>