feat: add gmail review actions
This commit is contained in:
@@ -480,6 +480,51 @@ public sealed class GmailControllerTests
|
|||||||
gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Review_candidates_returns_threads_grouped_with_routing_summary()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", RecruiterEmail = "maria@acme.test", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
JobTitle = "Backend Developer",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
OwnerUserId = "user-1"
|
||||||
|
};
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var gmail = new Mock<IGmailOAuthService>();
|
||||||
|
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[]
|
||||||
|
{
|
||||||
|
new GmailQueryMatchedMessage(
|
||||||
|
new GmailMessageSummary(
|
||||||
|
"msg-top",
|
||||||
|
"thread-top",
|
||||||
|
"Backend Developer interview",
|
||||||
|
"Maria Recruiter <maria@acme.test>",
|
||||||
|
"user@example.test",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-2),
|
||||||
|
"Acme wants to schedule a backend developer interview."),
|
||||||
|
new[] { "\"Acme\" \"Backend Developer\" newer_than:365d" })
|
||||||
|
});
|
||||||
|
|
||||||
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value);
|
||||||
|
Assert.Equal(1, payload.CandidateThreadCount);
|
||||||
|
Assert.Single(payload.Threads);
|
||||||
|
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
|
||||||
|
Assert.True(payload.Threads[0].JobCandidates.Count > 0);
|
||||||
|
Assert.Contains(payload.Threads[0].Routing, new[] { "auto-link", "review", "unmatched" });
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Refresh_linked_threads_rejects_invalid_job_id()
|
public async Task Refresh_linked_threads_rejects_invalid_job_id()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1204,18 +1204,3 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
app.Run();
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseCors("AllowReact");
|
|
||||||
|
|
||||||
app.UseAuthentication();
|
|
||||||
app.UseAuthorization();
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
app.Run();
|
|
||||||
|
|||||||
@@ -42,6 +42,10 @@
|
|||||||
- backend `GET /api/gmail/review-candidates`
|
- backend `GET /api/gmail/review-candidates`
|
||||||
- frontend `/correspondence/review` page
|
- frontend `/correspondence/review` page
|
||||||
- focused review-page frontend test
|
- focused review-page frontend test
|
||||||
|
- Review queue is now actionable:
|
||||||
|
- backend `POST /api/gmail/review-decision`
|
||||||
|
- frontend actions for link/reject/keep-in-review
|
||||||
|
- focused action test and successful frontend build
|
||||||
- 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.
|
- 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
|
||||||
|
|||||||
@@ -310,7 +310,3 @@ export default function App() {
|
|||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
mProvider>
|
|
||||||
</ToastProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Box,
|
Box,
|
||||||
@@ -45,7 +45,7 @@ export default function CorrespondenceInboxPage() {
|
|||||||
const [direction, setDirection] = useState<string>("all");
|
const [direction, setDirection] = useState<string>("all");
|
||||||
const [linkState, setLinkState] = useState<string>("all");
|
const [linkState, setLinkState] = useState<string>("all");
|
||||||
|
|
||||||
const load = async () => {
|
const load = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
||||||
@@ -61,11 +61,11 @@ export default function CorrespondenceInboxPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [direction, linkState, query, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
}, []);
|
}, [load]);
|
||||||
|
|
||||||
const filteredSummary = useMemo(() => {
|
const filteredSummary = useMemo(() => {
|
||||||
const linked = items.filter((item) => item.externalThreadId).length;
|
const linked = items.filter((item) => item.externalThreadId).length;
|
||||||
|
|||||||
@@ -167,28 +167,6 @@ function initialsFrom(values: Array<string | undefined>) {
|
|||||||
return (joined[0][0] + joined[1][0]).toUpperCase();
|
return (joined[0][0] + joined[1][0]).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceCvSection(source: string, sectionName: string, sectionDraft: string) {
|
|
||||||
const trimmedSource = source.trim();
|
|
||||||
const trimmedDraft = sectionDraft.trim();
|
|
||||||
if (!trimmedDraft) return source;
|
|
||||||
if (!trimmedSource) return `${sectionName}\n${trimmedDraft}`;
|
|
||||||
|
|
||||||
const headingPattern = /^([A-Z][A-Za-z &/]+):?\s*$/gm;
|
|
||||||
const matches = Array.from(trimmedSource.matchAll(headingPattern));
|
|
||||||
const normalizedTarget = sectionName.trim().toLowerCase();
|
|
||||||
const targetIndex = matches.findIndex((match) => match[1].trim().toLowerCase() === normalizedTarget);
|
|
||||||
|
|
||||||
if (targetIndex === -1) {
|
|
||||||
return `${trimmedSource}\n\n${sectionName}\n${trimmedDraft}`.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
const start = matches[targetIndex].index ?? 0;
|
|
||||||
const end = targetIndex + 1 < matches.length ? (matches[targetIndex + 1].index ?? trimmedSource.length) : trimmedSource.length;
|
|
||||||
const before = trimmedSource.slice(0, start).trimEnd();
|
|
||||||
const after = trimmedSource.slice(end).trimStart();
|
|
||||||
return [before, `${sectionName}\n${trimmedDraft}`, after].filter(Boolean).join("\n\n").trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function confidenceTone(confidence?: number) {
|
function confidenceTone(confidence?: number) {
|
||||||
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
||||||
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
||||||
|
|||||||
Reference in New Issue
Block a user