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