diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index c621e56..d797617 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -17,6 +17,7 @@ namespace JobTrackerApi.Data public DbSet JobApplications => Set(); public DbSet Correspondences => Set(); public DbSet GmailConnections => Set(); + public DbSet GmailReviewDecisions => Set(); public DbSet Attachments => Set(); public DbSet RuleSettings => Set(); public DbSet UserRuleSettings => Set(); @@ -66,6 +67,9 @@ namespace JobTrackerApi.Data modelBuilder.Entity() .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + modelBuilder.Entity() + .HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId); + modelBuilder.Ignore(); modelBuilder.Entity() diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 3030c77..c6dfe02 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -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( diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 6df73a1..0b93dd7 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -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(); diff --git a/Models/GmailReviewDecision.cs b/Models/GmailReviewDecision.cs new file mode 100644 index 0000000..9764df6 --- /dev/null +++ b/Models/GmailReviewDecision.cs @@ -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; +} diff --git a/SMART_GMAIL_PROGRESS.md b/SMART_GMAIL_PROGRESS.md index 6ba5395..f44ac07 100644 --- a/SMART_GMAIL_PROGRESS.md +++ b/SMART_GMAIL_PROGRESS.md @@ -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. diff --git a/job-tracker-ui/src/gmail-review-page.test.tsx b/job-tracker-ui/src/gmail-review-page.test.tsx index 51d1c5c..c71f77d 100644 --- a/job-tracker-ui/src/gmail-review-page.test.tsx +++ b/job-tracker-ui/src/gmail-review-page.test.tsx @@ -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, + }); + }); + }); }); diff --git a/job-tracker-ui/src/pages/GmailReviewPage.tsx b/job-tracker-ui/src/pages/GmailReviewPage.tsx index 1be793a..5c9594d 100644 --- a/job-tracker-ui/src/pages/GmailReviewPage.tsx +++ b/job-tracker-ui/src/pages/GmailReviewPage.tsx @@ -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(null); const [loading, setLoading] = useState(false); + const [savingThreadId, setSavingThreadId] = useState(null); - const load = async () => { + const load = useCallback(async () => { setLoading(true); try { const res = await api.get("/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 ( Gmail review queue - Review medium-confidence Gmail correspondence routing and unmatched job-like threads. + + Review medium-confidence Gmail correspondence routing and unmatched job-like threads. + - + {data ? ( @@ -53,16 +84,57 @@ export default function GmailReviewPage() { {thread.subject} - {thread.messageCount} messages · {thread.routing} + + {thread.messageCount} messages · {thread.routing} + - {thread.matchedQueries.slice(0, 3).map((query) => )} + {thread.matchedQueries.slice(0, 3).map((query) => ( + + ))} {thread.jobCandidates.slice(0, 2).map((candidate) => ( - + ))} - {thread.jobCandidates[0] ? : null} + {thread.jobCandidates[0] ? ( + + ) : null} + {thread.jobCandidates[0] ? ( + + ) : null} + +