Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8c91a22b6 | |||
| 170f1390a9 | |||
| a22ce08913 | |||
| f7efad7337 | |||
| 947d4eeab9 | |||
| f61da1869d | |||
| 463d4277cd | |||
| 7b9a97323e | |||
| 5cd34f17bb | |||
| 1f34eb42d2 | |||
| b87e673d38 | |||
| 161ecb4b94 | |||
| a0e823facf | |||
| 5af2c66616 | |||
| 69e78d8951 | |||
| 61c12d3479 | |||
| 3f04849fe6 | |||
| 289c2f47ad |
@@ -3,12 +3,14 @@
|
|||||||
# everything first and then opt back into only the source folders it needs.
|
# everything first and then opt back into only the source folders it needs.
|
||||||
*
|
*
|
||||||
!JobTrackerApi/
|
!JobTrackerApi/
|
||||||
|
!JobTrackerBackend/
|
||||||
!Data/
|
!Data/
|
||||||
!Models/
|
!Models/
|
||||||
!.dockerignore
|
!.dockerignore
|
||||||
|
|
||||||
# Include the source trees.
|
# Include the source trees.
|
||||||
!JobTrackerApi/**
|
!JobTrackerApi/**
|
||||||
|
!JobTrackerBackend/**
|
||||||
!Data/**
|
!Data/**
|
||||||
!Models/**
|
!Models/**
|
||||||
|
|
||||||
|
|||||||
@@ -89,30 +89,27 @@ jobs:
|
|||||||
docker compose ps
|
docker compose ps
|
||||||
AI_CONTAINER_ID="$(docker compose ps -q ai-service)"
|
AI_CONTAINER_ID="$(docker compose ps -q ai-service)"
|
||||||
if [ -z "$AI_CONTAINER_ID" ]; then
|
if [ -z "$AI_CONTAINER_ID" ]; then
|
||||||
echo "AI service container id could not be resolved after deploy."
|
echo "AI service container id could not be resolved after deploy. Continuing because AI is not a deploy gate for the core app."
|
||||||
docker compose ps
|
else
|
||||||
docker compose logs --tail=200 ai-service || true
|
ATTEMPTS=90
|
||||||
exit 1
|
SLEEP_SECS=2
|
||||||
fi
|
i=1
|
||||||
ATTEMPTS=90
|
while [ "$i" -le "$ATTEMPTS" ]; do
|
||||||
SLEEP_SECS=2
|
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
|
||||||
i=1
|
if [ "$HEALTH_STATUS" = "healthy" ]; then
|
||||||
while [ "$i" -le "$ATTEMPTS" ]; do
|
break
|
||||||
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
|
fi
|
||||||
if [ "$HEALTH_STATUS" = "healthy" ]; then
|
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
|
||||||
break
|
echo "AI service became unhealthy during deploy readiness wait. Continuing because AI is not a deploy gate for the core app."
|
||||||
fi
|
docker compose logs --tail=200 ai-service || true
|
||||||
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
|
break
|
||||||
echo "AI service became unhealthy during deploy readiness wait."
|
fi
|
||||||
|
sleep "$SLEEP_SECS"
|
||||||
|
i=$((i + 1))
|
||||||
|
done
|
||||||
|
if [ "${HEALTH_STATUS:-unknown}" != "healthy" ]; then
|
||||||
|
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}. Continuing because AI is not a deploy gate for the core app."
|
||||||
|
docker compose ps
|
||||||
docker compose logs --tail=200 ai-service || true
|
docker compose logs --tail=200 ai-service || true
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
sleep "$SLEEP_SECS"
|
|
||||||
i=$((i + 1))
|
|
||||||
done
|
|
||||||
if [ "$HEALTH_STATUS" != "healthy" ]; then
|
|
||||||
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}"
|
|
||||||
docker compose ps
|
|
||||||
docker compose logs --tail=200 ai-service || true
|
|
||||||
exit 1
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
@@ -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()
|
||||||
{
|
{
|
||||||
@@ -545,9 +590,319 @@ public sealed class GmailControllerTests
|
|||||||
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Save_review_decision_links_thread_and_imports_messages()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", 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.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[]
|
||||||
|
{
|
||||||
|
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Interview invite")
|
||||||
|
});
|
||||||
|
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||||
|
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailMessageDetail(
|
||||||
|
"msg-1",
|
||||||
|
"thread-1",
|
||||||
|
"Backend Developer interview",
|
||||||
|
"Maria Recruiter <maria@acme.test>",
|
||||||
|
"user@example.test",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
"Interview invite",
|
||||||
|
"Body text",
|
||||||
|
null,
|
||||||
|
new[] { "INBOX" },
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result);
|
||||||
|
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||||
|
Assert.Equal("linked", decision.Decision);
|
||||||
|
Assert.Equal(job.Id, decision.JobApplicationId);
|
||||||
|
Assert.Equal("Strong recruiter match", decision.Note);
|
||||||
|
var imported = await db.Correspondences.SingleAsync();
|
||||||
|
Assert.Equal("thread-1", imported.ExternalThreadId);
|
||||||
|
Assert.Equal("msg-1", imported.ExternalMessageId);
|
||||||
|
Assert.NotNull(ok.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Manual_sync_auto_links_high_confidence_thread()
|
||||||
|
{
|
||||||
|
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.Is<IEnumerable<string>>(queries => queries.Any(query => query.Contains("-in:spam")) && queries.Any(query => query.Contains("-in:trash")) && queries.All(query => query.Contains("newer_than:365d"))),
|
||||||
|
8,
|
||||||
|
It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[]
|
||||||
|
{
|
||||||
|
new GmailQueryMatchedMessage(
|
||||||
|
new GmailMessageSummary(
|
||||||
|
"msg-1",
|
||||||
|
"thread-1",
|
||||||
|
"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 -in:spam -in:trash",
|
||||||
|
"(from:maria@acme.test OR to:maria@acme.test) newer_than:365d -in:spam -in:trash"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[]
|
||||||
|
{
|
||||||
|
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Invite")
|
||||||
|
});
|
||||||
|
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||||
|
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailMessageDetail(
|
||||||
|
"msg-1",
|
||||||
|
"thread-1",
|
||||||
|
"Backend Developer interview",
|
||||||
|
"Maria Recruiter <maria@acme.test>",
|
||||||
|
"user@example.test",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-2),
|
||||||
|
"Invite",
|
||||||
|
"Interview details",
|
||||||
|
null,
|
||||||
|
new[] { "INBOX" },
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
|
||||||
|
Assert.Equal(1, payload.AutoLinkedThreadCount);
|
||||||
|
Assert.Equal(1, payload.ImportedThreads);
|
||||||
|
Assert.Equal(1, payload.ImportedMessages);
|
||||||
|
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||||
|
Assert.Equal("linked", decision.Decision);
|
||||||
|
Assert.Equal(job.Id, decision.JobApplicationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Suggested_jobs_and_create_suggested_job_create_job_and_link_thread()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var gmail = new Mock<IGmailOAuthService>();
|
||||||
|
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
|
||||||
|
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-suggested", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[]
|
||||||
|
{
|
||||||
|
new GmailMessageSummary("msg-s1", "thread-suggested", "Platform Engineer interview", "Nina Recruiter <nina@beta.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Let's talk about the role")
|
||||||
|
});
|
||||||
|
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||||
|
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-s1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailMessageDetail(
|
||||||
|
"msg-s1",
|
||||||
|
"thread-suggested",
|
||||||
|
"Platform Engineer interview",
|
||||||
|
"Nina Recruiter <nina@beta.test>",
|
||||||
|
"user@example.test",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
"Let's talk about the role",
|
||||||
|
"Interview details",
|
||||||
|
null,
|
||||||
|
new[] { "INBOX" },
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
|
db.GmailReviewDecisions.Add(new GmailReviewDecision
|
||||||
|
{
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
ThreadId = "thread-suggested",
|
||||||
|
Decision = "suggested",
|
||||||
|
UpdatedAt = DateTimeOffset.UtcNow
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
|
||||||
|
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
|
||||||
|
Array.Empty<string>(),
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new GmailController.GmailReviewThreadDto(
|
||||||
|
"thread-suggested",
|
||||||
|
"Platform Engineer interview",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
1,
|
||||||
|
"suggested",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
|
||||||
|
new[]
|
||||||
|
{
|
||||||
|
new GmailController.GmailJobMatchedMessageDto(
|
||||||
|
"msg-s1",
|
||||||
|
"thread-suggested",
|
||||||
|
"Platform Engineer interview",
|
||||||
|
"Nina Recruiter <nina@beta.test>",
|
||||||
|
"user@example.test",
|
||||||
|
DateTimeOffset.UtcNow.AddDays(-1),
|
||||||
|
"Let's talk about the role",
|
||||||
|
0,
|
||||||
|
"low",
|
||||||
|
false,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
Array.Empty<GmailController.GmailJobMatchReasonDto>())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
|
||||||
|
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
|
||||||
|
|
||||||
|
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
|
||||||
|
var createOk = Assert.IsType<OkObjectResult>(create.Result);
|
||||||
|
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
|
||||||
|
Assert.True(created.JobApplicationId > 0);
|
||||||
|
Assert.Equal(1, created.Imported);
|
||||||
|
Assert.Equal("thread-suggested", created.ThreadId);
|
||||||
|
Assert.Equal(1, await db.JobApplications.CountAsync());
|
||||||
|
Assert.Equal(1, await db.Correspondences.CountAsync());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Unlink_thread_removes_messages_and_sets_review_decision()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", 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();
|
||||||
|
|
||||||
|
db.Correspondences.AddRange(
|
||||||
|
new Correspondence { JobApplicationId = job.Id, From = "Company", Content = "First", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1" },
|
||||||
|
new Correspondence { JobApplicationId = job.Id, From = "Me", Content = "Second", ExternalMessageId = "msg-2", ExternalThreadId = "thread-1" });
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||||
|
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
|
||||||
|
Assert.Equal(2, payload.RemovedMessages);
|
||||||
|
Assert.Equal("review", payload.Decision);
|
||||||
|
Assert.Empty(await db.Correspondences.ToListAsync());
|
||||||
|
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||||
|
Assert.Equal("review", decision.Decision);
|
||||||
|
Assert.Equal("Need manual review", decision.Note);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Relink_thread_can_move_messages_from_other_jobs()
|
||||||
|
{
|
||||||
|
await using var db = CreateDb();
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var sourceJob = new JobApplication { JobTitle = "Source", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||||
|
var targetJob = new JobApplication { JobTitle = "Target", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||||
|
db.JobApplications.AddRange(sourceJob, targetJob);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.Correspondences.Add(new Correspondence
|
||||||
|
{
|
||||||
|
JobApplicationId = sourceJob.Id,
|
||||||
|
From = "Company",
|
||||||
|
Content = "Existing import",
|
||||||
|
ExternalMessageId = "msg-1",
|
||||||
|
ExternalThreadId = "thread-1"
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var gmail = new Mock<IGmailOAuthService>();
|
||||||
|
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new[]
|
||||||
|
{
|
||||||
|
new GmailMessageSummary("msg-1", "thread-1", "Interview", "Maria <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow, "Snippet")
|
||||||
|
});
|
||||||
|
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||||
|
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(new GmailMessageDetail(
|
||||||
|
"msg-1",
|
||||||
|
"thread-1",
|
||||||
|
"Interview",
|
||||||
|
"Maria <maria@acme.test>",
|
||||||
|
"user@example.test",
|
||||||
|
DateTimeOffset.UtcNow,
|
||||||
|
"Snippet",
|
||||||
|
"Body",
|
||||||
|
null,
|
||||||
|
Array.Empty<string>(),
|
||||||
|
Array.Empty<GmailMessageAttachment>()));
|
||||||
|
|
||||||
|
var controller = CreateController(db, gmail.Object, "user-1");
|
||||||
|
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
|
||||||
|
Assert.Equal(1, payload.UnlinkedMessages);
|
||||||
|
Assert.Equal(1, payload.Imported);
|
||||||
|
var stored = await db.Correspondences.SingleAsync();
|
||||||
|
Assert.Equal(targetJob.Id, stored.JobApplicationId);
|
||||||
|
Assert.Equal("thread-1", stored.ExternalThreadId);
|
||||||
|
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||||
|
Assert.Equal(targetJob.Id, decision.JobApplicationId);
|
||||||
|
Assert.Equal("linked", decision.Decision);
|
||||||
|
}
|
||||||
|
|
||||||
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
|
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
|
||||||
{
|
{
|
||||||
var controller = new GmailController(gmail, db, BuildConfig())
|
var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig())
|
||||||
{
|
{
|
||||||
ControllerContext = new ControllerContext
|
ControllerContext = new ControllerContext
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -25,6 +25,84 @@ namespace JobTrackerApi.Controllers
|
|||||||
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
|
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record CorrespondenceInboxItemDto(
|
||||||
|
int Id,
|
||||||
|
int JobApplicationId,
|
||||||
|
string? CompanyName,
|
||||||
|
string? JobTitle,
|
||||||
|
string From,
|
||||||
|
string? Direction,
|
||||||
|
string? Subject,
|
||||||
|
string? Channel,
|
||||||
|
DateTime Date,
|
||||||
|
string ContentPreview,
|
||||||
|
string? ExternalThreadId,
|
||||||
|
string? ExternalFrom,
|
||||||
|
string? ExternalTo,
|
||||||
|
int LabelCount,
|
||||||
|
int AttachmentCount);
|
||||||
|
|
||||||
|
[HttpGet]
|
||||||
|
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
|
||||||
|
[FromQuery] string? q,
|
||||||
|
[FromQuery] string? direction,
|
||||||
|
[FromQuery] string? linkState,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var query = _db.Correspondences
|
||||||
|
.Include(c => c.JobApplication)
|
||||||
|
.ThenInclude(j => j.Company)
|
||||||
|
.AsQueryable();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(q))
|
||||||
|
{
|
||||||
|
var needle = q.Trim();
|
||||||
|
query = query.Where(c =>
|
||||||
|
(c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) ||
|
||||||
|
EF.Functions.Like(c.Content, $"%{needle}%") ||
|
||||||
|
(c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) ||
|
||||||
|
(c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) ||
|
||||||
|
(c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%")));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.Direction == direction);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.ExternalThreadId != null);
|
||||||
|
}
|
||||||
|
else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
query = query.Where(c => c.ExternalThreadId == null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = await query
|
||||||
|
.OrderByDescending(c => c.Date)
|
||||||
|
.Take(200)
|
||||||
|
.Select(c => new CorrespondenceInboxItemDto(
|
||||||
|
c.Id,
|
||||||
|
c.JobApplicationId,
|
||||||
|
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
|
||||||
|
c.JobApplication.JobTitle,
|
||||||
|
c.From,
|
||||||
|
c.Direction,
|
||||||
|
c.Subject,
|
||||||
|
c.Channel,
|
||||||
|
c.Date,
|
||||||
|
c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
|
||||||
|
c.ExternalThreadId,
|
||||||
|
c.ExternalFrom,
|
||||||
|
c.ExternalTo,
|
||||||
|
c.ExternalLabelsJson != null ? 1 : 0,
|
||||||
|
c.AttachmentMetadataJson != null ? 1 : 0))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Ok(items);
|
||||||
|
}
|
||||||
|
|
||||||
// GET all messages for a job
|
// GET all messages for a job
|
||||||
[HttpGet("{jobId:int}")]
|
[HttpGet("{jobId:int}")]
|
||||||
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -15,12 +15,14 @@ namespace JobTrackerApi.Controllers;
|
|||||||
public sealed class GmailController : ControllerBase
|
public sealed class GmailController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGmailOAuthService _gmail;
|
private readonly IGmailOAuthService _gmail;
|
||||||
|
private readonly IGmailJobMatchingService _matching;
|
||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly IConfiguration _cfg;
|
private readonly IConfiguration _cfg;
|
||||||
|
|
||||||
public GmailController(IGmailOAuthService gmail, JobTrackerContext db, IConfiguration cfg)
|
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
|
||||||
{
|
{
|
||||||
_gmail = gmail;
|
_gmail = gmail;
|
||||||
|
_matching = matching;
|
||||||
_db = db;
|
_db = db;
|
||||||
_cfg = cfg;
|
_cfg = cfg;
|
||||||
}
|
}
|
||||||
@@ -69,6 +71,21 @@ public sealed class GmailController : ControllerBase
|
|||||||
int CandidateThreadCount,
|
int CandidateThreadCount,
|
||||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||||
|
|
||||||
|
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||||
|
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||||
|
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||||
|
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||||
|
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||||
|
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||||
|
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||||
|
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||||
|
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||||
|
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||||
|
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||||
|
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||||
|
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||||
|
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||||
|
|
||||||
public sealed record GmailConnectionStatusDto(
|
public sealed record GmailConnectionStatusDto(
|
||||||
bool Connected,
|
bool Connected,
|
||||||
string? GmailAddress,
|
string? GmailAddress,
|
||||||
@@ -136,7 +153,7 @@ public sealed class GmailController : ControllerBase
|
|||||||
.ToHashSet(StringComparer.Ordinal);
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
|
||||||
var rankedMessages = candidateMessages
|
var rankedMessages = candidateMessages
|
||||||
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
|
.Select(message => _matching.ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
|
||||||
.Where(result => result.Score > 0 || result.AlreadyImported)
|
.Where(result => result.Score > 0 || result.AlreadyImported)
|
||||||
.OrderByDescending(result => result.Score)
|
.OrderByDescending(result => result.Score)
|
||||||
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
|
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
|
||||||
@@ -162,6 +179,7 @@ public sealed class GmailController : ControllerBase
|
|||||||
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
||||||
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
||||||
.Take(8)
|
.Take(8)
|
||||||
|
.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points))
|
||||||
.ToList();
|
.ToList();
|
||||||
var matchedQueries = ordered
|
var matchedQueries = ordered
|
||||||
.SelectMany(item => item.MatchedQueries)
|
.SelectMany(item => item.MatchedQueries)
|
||||||
@@ -196,7 +214,7 @@ public sealed class GmailController : ControllerBase
|
|||||||
ToConfidence(item.Score),
|
ToConfidence(item.Score),
|
||||||
item.AlreadyImported,
|
item.AlreadyImported,
|
||||||
item.MatchedQueries,
|
item.MatchedQueries,
|
||||||
item.Reasons)).ToList());
|
item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList());
|
||||||
})
|
})
|
||||||
.OrderByDescending(thread => thread.Score)
|
.OrderByDescending(thread => thread.Score)
|
||||||
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||||||
@@ -214,6 +232,499 @@ public sealed class GmailController : ControllerBase
|
|||||||
threads));
|
threads));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HttpGet("review-candidates")]
|
||||||
|
public async Task<ActionResult<GmailReviewQueueResponseDto>> ReviewCandidates(
|
||||||
|
[FromQuery] string? queryOverride,
|
||||||
|
[FromQuery] int maxResultsPerQuery = 6,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var jobs = await _db.JobApplications
|
||||||
|
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
|
||||||
|
.Include(x => x.Company)
|
||||||
|
.Include(x => x.Messages)
|
||||||
|
.OrderByDescending(x => x.DateApplied)
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (jobs.Count == 0)
|
||||||
|
{
|
||||||
|
return Ok(new GmailReviewQueueResponseDto(Array.Empty<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
|
||||||
|
}
|
||||||
|
|
||||||
|
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var job in jobs)
|
||||||
|
{
|
||||||
|
foreach (var query in _matching.BuildJobQueries(job, queryOverride))
|
||||||
|
{
|
||||||
|
querySet.Add(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var queries = querySet.Take(18).ToList();
|
||||||
|
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||||
|
|
||||||
|
var allImportedMessageIds = jobs.SelectMany(job => job.Messages)
|
||||||
|
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||||
|
.Select(message => message.ExternalMessageId!)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var allImportedThreadIds = jobs.SelectMany(job => job.Messages)
|
||||||
|
.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)";
|
||||||
|
var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIds.Contains(item.Message.Id) || allImportedThreadIds.Contains(item.Message.ThreadId));
|
||||||
|
|
||||||
|
var jobCandidates = jobs
|
||||||
|
.Select(job =>
|
||||||
|
{
|
||||||
|
var best = orderedMessages
|
||||||
|
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
|
||||||
|
.OrderByDescending(score => score.Score)
|
||||||
|
.First();
|
||||||
|
return new GmailReviewJobCandidateDto(
|
||||||
|
job.Id,
|
||||||
|
job.JobTitle,
|
||||||
|
job.Company?.Name ?? string.Empty,
|
||||||
|
best.Score,
|
||||||
|
best.Confidence,
|
||||||
|
best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList());
|
||||||
|
})
|
||||||
|
.Where(candidate => candidate.Score > 0)
|
||||||
|
.OrderByDescending(candidate => candidate.Score)
|
||||||
|
.Take(3)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
|
||||||
|
var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
|
||||||
|
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(
|
||||||
|
item.Message.Id,
|
||||||
|
item.Message.ThreadId,
|
||||||
|
item.Message.Subject,
|
||||||
|
item.Message.From,
|
||||||
|
item.Message.To,
|
||||||
|
item.Message.Date,
|
||||||
|
item.Message.Snippet,
|
||||||
|
item.MatchedQueries.Count * 4,
|
||||||
|
item.MatchedQueries.Count >= 2 ? "medium" : "low",
|
||||||
|
allImportedMessageIds.Contains(item.Message.Id),
|
||||||
|
item.MatchedQueries,
|
||||||
|
Array.Empty<GmailJobMatchReasonDto>()))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
|
||||||
|
})
|
||||||
|
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||||||
|
.Take(100)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new GmailReviewQueueResponseDto(
|
||||||
|
queries,
|
||||||
|
groupedThreads.Count,
|
||||||
|
groupedThreads.Count(thread => thread.Routing == "auto-link"),
|
||||||
|
groupedThreads.Count(thread => thread.Routing == "review"),
|
||||||
|
groupedThreads.Count(thread => thread.Routing == "unmatched"),
|
||||||
|
groupedThreads));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("review-decision")]
|
||||||
|
public async Task<IActionResult> SaveReviewDecision([FromBody] SaveGmailReviewDecisionRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||||
|
var decision = (request.Decision ?? string.Empty).Trim().ToLowerInvariant();
|
||||||
|
if (decision is not ("linked" or "rejected" or "review" or "suggested"))
|
||||||
|
{
|
||||||
|
return BadRequest("Decision must be linked, rejected, review, or suggested.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
JobApplication? job = null;
|
||||||
|
if (decision == "linked")
|
||||||
|
{
|
||||||
|
if (request.JobApplicationId is null or <= 0) return BadRequest("jobApplicationId is required when linking a thread.");
|
||||||
|
job = await _db.JobApplications
|
||||||
|
.Where(x => x.OwnerUserId == ownerUserId)
|
||||||
|
.Include(x => x.Company)
|
||||||
|
.Include(x => x.Messages)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
|
||||||
|
if (job is null) return NotFound("Job application not found.");
|
||||||
|
|
||||||
|
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||||
|
var distinctMessageIds = threadMessages
|
||||||
|
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
|
||||||
|
.Select(message => message.Id)
|
||||||
|
.Distinct(StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
var existingMessageIds = await _db.Correspondences
|
||||||
|
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||||
|
.Select(message => message.ExternalMessageId!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var messageId in distinctMessageIds)
|
||||||
|
{
|
||||||
|
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) continue;
|
||||||
|
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var existing = await _db.GmailReviewDecisions.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
|
||||||
|
if (existing is null)
|
||||||
|
{
|
||||||
|
existing = new GmailReviewDecision
|
||||||
|
{
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
ThreadId = request.ThreadId.Trim(),
|
||||||
|
};
|
||||||
|
_db.GmailReviewDecisions.Add(existing);
|
||||||
|
}
|
||||||
|
|
||||||
|
existing.Decision = decision switch
|
||||||
|
{
|
||||||
|
"review" => "review",
|
||||||
|
_ => decision,
|
||||||
|
};
|
||||||
|
existing.JobApplicationId = decision == "linked" ? request.JobApplicationId : null;
|
||||||
|
existing.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
|
||||||
|
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new
|
||||||
|
{
|
||||||
|
existing.ThreadId,
|
||||||
|
existing.Decision,
|
||||||
|
existing.JobApplicationId,
|
||||||
|
existing.Note,
|
||||||
|
existing.UpdatedAt,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("manual-sync")]
|
||||||
|
public async Task<ActionResult<GmailManualSyncResultDto>> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var jobs = await _db.JobApplications
|
||||||
|
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
|
||||||
|
.Include(x => x.Company)
|
||||||
|
.Include(x => x.Messages)
|
||||||
|
.OrderByDescending(x => x.DateApplied)
|
||||||
|
.Take(100)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (jobs.Count == 0)
|
||||||
|
{
|
||||||
|
return Ok(new GmailManualSyncResultDto(0, 0, 0, 0, 0, 0, 0, 0, 365, false, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
var lookbackDays = Math.Clamp(request?.LookbackDays ?? 365, 30, 365);
|
||||||
|
var maxResultsPerQuery = Math.Clamp(request?.MaxResultsPerQuery ?? 8, 1, 25);
|
||||||
|
var includeSpamTrash = request?.IncludeSpamTrash ?? false;
|
||||||
|
var autoImportHighConfidence = request?.AutoImportHighConfidence ?? true;
|
||||||
|
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
foreach (var jobItem in jobs)
|
||||||
|
{
|
||||||
|
foreach (var query in _matching.BuildJobQueries(jobItem, null))
|
||||||
|
{
|
||||||
|
var bounded = ApplySyncBoundary(query, lookbackDays, includeSpamTrash);
|
||||||
|
querySet.Add(bounded);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var queries = querySet.Take(24).ToList();
|
||||||
|
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||||
|
var allImportedMessageIds = jobs.SelectMany(jobItem => jobItem.Messages)
|
||||||
|
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||||
|
.Select(message => message.ExternalMessageId!)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var allImportedThreadIds = jobs.SelectMany(jobItem => jobItem.Messages)
|
||||||
|
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
|
||||||
|
.Select(message => message.ExternalThreadId!)
|
||||||
|
.ToHashSet(StringComparer.Ordinal);
|
||||||
|
var reviewDecisions = await _db.GmailReviewDecisions
|
||||||
|
.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)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var autoLinked = 0;
|
||||||
|
var reviewCount = 0;
|
||||||
|
var unmatchedCount = 0;
|
||||||
|
var importedMessages = 0;
|
||||||
|
var importedThreads = 0;
|
||||||
|
var skippedMessages = 0;
|
||||||
|
|
||||||
|
foreach (var threadGroup in groupedThreads)
|
||||||
|
{
|
||||||
|
var threadId = threadGroup.Key;
|
||||||
|
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == threadId);
|
||||||
|
if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
unmatchedCount++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedMessages = threadGroup.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
|
||||||
|
var candidates = jobs
|
||||||
|
.Select(jobItem =>
|
||||||
|
{
|
||||||
|
var best = orderedMessages
|
||||||
|
.Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
|
||||||
|
.OrderByDescending(score => score.Score)
|
||||||
|
.First();
|
||||||
|
return new { Job = jobItem, Best = best };
|
||||||
|
})
|
||||||
|
.Where(x => x.Best.Score > 0)
|
||||||
|
.OrderByDescending(x => x.Best.Score)
|
||||||
|
.Take(3)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var top = candidates.FirstOrDefault();
|
||||||
|
var secondScore = candidates.Skip(1).FirstOrDefault()?.Best.Score ?? 0;
|
||||||
|
if (top is not null && autoImportHighConfidence && top.Best.Score >= 30 && top.Best.Score - secondScore >= 8)
|
||||||
|
{
|
||||||
|
var distinctMessageIds = orderedMessages.Select(item => item.Message.Id).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
var existingIds = await _db.Correspondences
|
||||||
|
.Where(message => message.JobApplicationId == top.Job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||||
|
.Select(message => message.ExternalMessageId!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
foreach (var messageId in distinctMessageIds)
|
||||||
|
{
|
||||||
|
if (existingIds.Contains(messageId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
skippedMessages++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken);
|
||||||
|
allImportedMessageIds.Add(messageId);
|
||||||
|
importedMessages++;
|
||||||
|
}
|
||||||
|
|
||||||
|
importedThreads++;
|
||||||
|
autoLinked++;
|
||||||
|
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", top.Job.Id, existingDecision?.Note);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (top is not null && top.Best.Score >= 16)
|
||||||
|
{
|
||||||
|
reviewCount++;
|
||||||
|
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, existingDecision?.Decision == "suggested" ? "suggested" : "review", null, existingDecision?.Note);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
unmatchedCount++;
|
||||||
|
if (LooksLikeJobRelatedThread(orderedMessages))
|
||||||
|
{
|
||||||
|
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "suggested", null, existingDecision?.Note);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new GmailManualSyncResultDto(queries.Count, groupedThreads.Count, autoLinked, reviewCount, unmatchedCount, importedMessages, importedThreads, skippedMessages, lookbackDays, includeSpamTrash, DateTimeOffset.UtcNow));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpGet("suggested-jobs")]
|
||||||
|
public async Task<ActionResult<GmailSuggestedJobsResponseDto>> SuggestedJobs(CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var reviewThreads = await ReviewCandidates(null, 6, cancellationToken);
|
||||||
|
if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload)
|
||||||
|
{
|
||||||
|
return BadRequest("Unable to compute Gmail suggested jobs.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var items = payload.Threads
|
||||||
|
.Where(thread => thread.Routing is "unmatched" or "suggested")
|
||||||
|
.Select(thread => new GmailSuggestedJobCandidateDto(
|
||||||
|
thread.ThreadId,
|
||||||
|
thread.Subject,
|
||||||
|
thread.LatestDate,
|
||||||
|
ExtractCompanyName(thread.Messages.FirstOrDefault()?.From, thread.Subject),
|
||||||
|
ExtractRecruiterName(thread.Messages.FirstOrDefault()?.From),
|
||||||
|
ExtractFirstEmail(thread.Messages.FirstOrDefault()?.From),
|
||||||
|
ExtractRoleFromSubject(thread.Subject),
|
||||||
|
thread.Routing,
|
||||||
|
thread.MatchedQueries,
|
||||||
|
thread.Messages.FirstOrDefault()?.Snippet ?? string.Empty))
|
||||||
|
.Where(item => !string.IsNullOrWhiteSpace(item.CompanyName) || !string.IsNullOrWhiteSpace(item.SuggestedJobTitle))
|
||||||
|
.Take(50)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Ok(new GmailSuggestedJobsResponseDto(items.Count, items));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("create-suggested-job")]
|
||||||
|
public async Task<ActionResult<CreatedSuggestedGmailJobDto>> CreateSuggestedJob([FromBody] CreateSuggestedGmailJobRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.CompanyName)) return BadRequest("CompanyName is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.JobTitle)) return BadRequest("JobTitle is required.");
|
||||||
|
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var companyName = request.CompanyName.Trim();
|
||||||
|
var jobTitle = request.JobTitle.Trim();
|
||||||
|
var company = await _db.Companies.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.Name.ToLower() == companyName.ToLower(), cancellationToken);
|
||||||
|
if (company is null)
|
||||||
|
{
|
||||||
|
company = new Company
|
||||||
|
{
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
Name = companyName,
|
||||||
|
RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim(),
|
||||||
|
RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim(),
|
||||||
|
};
|
||||||
|
_db.Companies.Add(company);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(company.RecruiterName) && !string.IsNullOrWhiteSpace(request.RecruiterName)) company.RecruiterName = request.RecruiterName.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var job = new JobApplication
|
||||||
|
{
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
CompanyId = company.Id,
|
||||||
|
JobTitle = jobTitle,
|
||||||
|
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
|
||||||
|
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
||||||
|
DateApplied = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
_db.JobApplications.Add(job);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||||
|
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
var imported = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
foreach (var messageId in distinctMessageIds)
|
||||||
|
{
|
||||||
|
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
|
||||||
|
if (existing)
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("relink-thread")]
|
||||||
|
public async Task<ActionResult<GmailRelinkResultDto>> RelinkThread([FromBody] RelinkGmailThreadRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||||
|
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.Where(x => x.OwnerUserId == ownerUserId)
|
||||||
|
.Include(x => x.Company)
|
||||||
|
.Include(x => x.Messages)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
|
||||||
|
if (job is null) return NotFound("Job application not found.");
|
||||||
|
|
||||||
|
var threadId = request.ThreadId.Trim();
|
||||||
|
var unlinkedMessages = 0;
|
||||||
|
if (request.RemoveFromOtherJobs)
|
||||||
|
{
|
||||||
|
var otherMessages = await _db.Correspondences
|
||||||
|
.Include(message => message.JobApplication)
|
||||||
|
.Where(message => message.ExternalThreadId == threadId && message.JobApplicationId != job.Id && message.JobApplication.OwnerUserId == ownerUserId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (otherMessages.Count > 0)
|
||||||
|
{
|
||||||
|
_db.Correspondences.RemoveRange(otherMessages);
|
||||||
|
unlinkedMessages = otherMessages.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||||
|
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||||
|
var existingMessageIds = await _db.Correspondences
|
||||||
|
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||||
|
.Select(message => message.ExternalMessageId!)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
var imported = 0;
|
||||||
|
var skipped = 0;
|
||||||
|
foreach (var messageId in distinctMessageIds)
|
||||||
|
{
|
||||||
|
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
|
||||||
|
{
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
||||||
|
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
|
||||||
|
}
|
||||||
|
|
||||||
|
[HttpPost("unlink-thread")]
|
||||||
|
public async Task<ActionResult<GmailUnlinkResultDto>> UnlinkThread([FromBody] UnlinkGmailThreadRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||||
|
|
||||||
|
var ownerUserId = GetRequiredOwnerUserId();
|
||||||
|
var job = await _db.JobApplications
|
||||||
|
.Where(x => x.OwnerUserId == ownerUserId)
|
||||||
|
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
|
||||||
|
if (job is null) return NotFound("Job application not found.");
|
||||||
|
|
||||||
|
var threadId = request.ThreadId.Trim();
|
||||||
|
var messages = await _db.Correspondences
|
||||||
|
.Where(message => message.JobApplicationId == job.Id && message.ExternalThreadId == threadId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
if (messages.Count > 0)
|
||||||
|
{
|
||||||
|
_db.Correspondences.RemoveRange(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
||||||
|
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
|
||||||
|
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
|
||||||
|
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
|
||||||
|
}
|
||||||
|
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
[HttpGet("oauth/callback")]
|
[HttpGet("oauth/callback")]
|
||||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||||
@@ -460,168 +971,63 @@ public sealed class GmailController : ControllerBase
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
private IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||||
{
|
{
|
||||||
var queries = new List<string>();
|
return _matching.BuildJobQueries(job, queryOverride);
|
||||||
void Add(string? query)
|
|
||||||
{
|
|
||||||
if (!string.IsNullOrWhiteSpace(query))
|
|
||||||
{
|
|
||||||
queries.Add(query.Trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Add(queryOverride);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
|
|
||||||
{
|
|
||||||
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
|
|
||||||
{
|
|
||||||
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
|
|
||||||
{
|
|
||||||
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
|
|
||||||
{
|
|
||||||
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.JobTitle))
|
|
||||||
{
|
|
||||||
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var subject in job.Messages
|
|
||||||
.Select(message => message.Subject)
|
|
||||||
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Take(2))
|
|
||||||
{
|
|
||||||
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (queries.Count == 0)
|
|
||||||
{
|
|
||||||
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
|
|
||||||
}
|
|
||||||
|
|
||||||
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
|
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||||
{
|
{
|
||||||
var reasons = new List<GmailJobMatchReasonDto>();
|
var bounded = (query ?? string.Empty).Trim();
|
||||||
var score = 0;
|
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||||
var message = candidate.Message;
|
|
||||||
var subject = message.Subject ?? string.Empty;
|
|
||||||
var from = message.From ?? string.Empty;
|
|
||||||
var to = message.To ?? string.Empty;
|
|
||||||
var snippet = message.Snippet ?? string.Empty;
|
|
||||||
var haystack = $"{subject} {from} {to} {snippet}";
|
|
||||||
|
|
||||||
if (candidate.MatchedQueries.Count > 0)
|
|
||||||
{
|
{
|
||||||
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
|
bounded = string.IsNullOrWhiteSpace(bounded)
|
||||||
score += queryHitPoints;
|
? $"newer_than:{lookbackDays}d"
|
||||||
reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
|
: $"{bounded} newer_than:{lookbackDays}d";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
|
if (!includeSpamTrash)
|
||||||
{
|
{
|
||||||
score += 18;
|
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
|
||||||
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18));
|
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
|
return bounded.Trim();
|
||||||
{
|
|
||||||
score += 20;
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
|
|
||||||
{
|
|
||||||
score += 12;
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var token in SplitTerms(job.JobTitle).Take(4))
|
|
||||||
{
|
|
||||||
if (!ContainsValue(haystack, token)) continue;
|
|
||||||
score += 5;
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5));
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var subjectLine in job.Messages
|
|
||||||
.Select(existing => existing.Subject)
|
|
||||||
.Where(existing => !string.IsNullOrWhiteSpace(existing))
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
|
||||||
.Take(2))
|
|
||||||
{
|
|
||||||
if (!ContainsValue(subject, subjectLine!)) continue;
|
|
||||||
score += 8;
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (message.Date is { } messageDate)
|
|
||||||
{
|
|
||||||
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
|
|
||||||
if (ageDays <= 45)
|
|
||||||
{
|
|
||||||
score += 4;
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4));
|
|
||||||
}
|
|
||||||
else if (ageDays <= 180)
|
|
||||||
{
|
|
||||||
score += 2;
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (threadAlreadyImported && !alreadyImported)
|
|
||||||
{
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (alreadyImported)
|
|
||||||
{
|
|
||||||
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
reasons = reasons
|
|
||||||
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
|
|
||||||
.Select(group => group.First())
|
|
||||||
.OrderByDescending(reason => reason.Points)
|
|
||||||
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
|
||||||
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool ContainsValue(string haystack, string? value)
|
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||||
{
|
{
|
||||||
return !string.IsNullOrWhiteSpace(value)
|
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||||
&& haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
|
if (string.IsNullOrWhiteSpace(sample)) return false;
|
||||||
|
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static IEnumerable<string> SplitTerms(string? value)
|
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(value)) yield break;
|
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
|
||||||
|
if (existing is null)
|
||||||
foreach (var token in value
|
|
||||||
.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
|
||||||
.Where(token => token.Length >= 3)
|
|
||||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
|
||||||
{
|
{
|
||||||
yield return token;
|
existing = new GmailReviewDecision
|
||||||
|
{
|
||||||
|
OwnerUserId = ownerUserId,
|
||||||
|
ThreadId = threadId,
|
||||||
|
};
|
||||||
|
decisions.Add(existing);
|
||||||
|
_db.GmailReviewDecisions.Add(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
existing.Decision = decision;
|
||||||
|
existing.JobApplicationId = jobApplicationId;
|
||||||
|
if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
|
||||||
|
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string ToConfidence(int score)
|
private static string ToConfidence(int score)
|
||||||
@@ -634,6 +1040,44 @@ public sealed class GmailController : ControllerBase
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ExtractFirstEmail(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||||
|
return match.Success ? match.Value : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractRecruiterName(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||||
|
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
||||||
|
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractCompanyName(string? from, string? subject)
|
||||||
|
{
|
||||||
|
var subjectText = (subject ?? string.Empty).Trim();
|
||||||
|
if (!string.IsNullOrWhiteSpace(subjectText))
|
||||||
|
{
|
||||||
|
var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
|
if (parts.Length >= 2) return parts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
var recruiterName = ExtractRecruiterName(from);
|
||||||
|
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? ExtractRoleFromSubject(string? subject)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(subject)) return null;
|
||||||
|
var trimmed = subject.Trim();
|
||||||
|
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
|
||||||
|
}
|
||||||
|
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
||||||
|
}
|
||||||
|
|
||||||
private string GetRequiredOwnerUserId()
|
private string GetRequiredOwnerUserId()
|
||||||
{
|
{
|
||||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||||
@@ -679,11 +1123,4 @@ public sealed class GmailController : ControllerBase
|
|||||||
</body>
|
</body>
|
||||||
</html>";
|
</html>";
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed record GmailScoredMessage(
|
|
||||||
GmailMessageSummary Message,
|
|
||||||
bool AlreadyImported,
|
|
||||||
int Score,
|
|
||||||
IReadOnlyList<string> MatchedQueries,
|
|
||||||
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
|||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
|
|
||||||
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
|
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
|
||||||
|
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
|
||||||
COPY Data/ Data/
|
COPY Data/ Data/
|
||||||
COPY Models/ Models/
|
COPY Models/ Models/
|
||||||
COPY JobTrackerApi/ JobTrackerApi/
|
COPY JobTrackerApi/ JobTrackerApi/
|
||||||
|
COPY JobTrackerBackend/ JobTrackerBackend/
|
||||||
|
|
||||||
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
|
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||||
|
|
||||||
|
|||||||
@@ -10,12 +10,7 @@
|
|||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Remove="Controllers\**\*.cs" />
|
<Compile Remove="Controllers\**\*.cs" />
|
||||||
<Compile Remove="Services\**\*.cs" />
|
<Compile Remove="Services\**\*.cs" />
|
||||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14">
|
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14">
|
|
||||||
<PrivateAssets>all</PrivateAssets>
|
|
||||||
</PackageReference>
|
|
||||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -135,6 +135,7 @@ builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
|||||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||||
|
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||||
|
|
||||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||||
@@ -637,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;");
|
||||||
@@ -1191,4 +1204,3 @@ app.UseAuthorization();
|
|||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
app.Run();
|
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public sealed record GmailMatchReason(string Label, string Value, int Points);
|
||||||
|
public sealed record GmailScoredMessageResult(
|
||||||
|
GmailMessageSummary Message,
|
||||||
|
bool AlreadyImported,
|
||||||
|
int Score,
|
||||||
|
string Confidence,
|
||||||
|
IReadOnlyList<string> MatchedQueries,
|
||||||
|
IReadOnlyList<GmailMatchReason> Reasons);
|
||||||
|
|
||||||
|
public interface IGmailJobMatchingService
|
||||||
|
{
|
||||||
|
IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride);
|
||||||
|
GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported);
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class GmailJobMatchingService : IGmailJobMatchingService
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||||
|
{
|
||||||
|
var queries = new List<string>();
|
||||||
|
void Add(string? query)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
|
{
|
||||||
|
queries.Add(query.Trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Add(queryOverride);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
|
||||||
|
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
|
||||||
|
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
|
||||||
|
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
|
||||||
|
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.JobTitle))
|
||||||
|
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||||
|
|
||||||
|
foreach (var subject in job.Messages
|
||||||
|
.Select(message => message.Subject)
|
||||||
|
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(2))
|
||||||
|
{
|
||||||
|
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (queries.Count == 0)
|
||||||
|
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
|
||||||
|
|
||||||
|
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
|
||||||
|
{
|
||||||
|
var reasons = new List<GmailMatchReason>();
|
||||||
|
var score = 0;
|
||||||
|
var message = candidate.Message;
|
||||||
|
var subject = message.Subject ?? string.Empty;
|
||||||
|
var from = message.From ?? string.Empty;
|
||||||
|
var to = message.To ?? string.Empty;
|
||||||
|
var snippet = message.Snippet ?? string.Empty;
|
||||||
|
var haystack = $"{subject} {from} {to} {snippet}";
|
||||||
|
|
||||||
|
if (candidate.MatchedQueries.Count > 0)
|
||||||
|
{
|
||||||
|
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
|
||||||
|
score += queryHitPoints;
|
||||||
|
reasons.Add(new GmailMatchReason("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
|
||||||
|
{
|
||||||
|
score += 18;
|
||||||
|
reasons.Add(new GmailMatchReason("company", job.Company.Name.Trim(), 18));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
|
||||||
|
{
|
||||||
|
score += 20;
|
||||||
|
reasons.Add(new GmailMatchReason("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
|
||||||
|
{
|
||||||
|
score += 12;
|
||||||
|
reasons.Add(new GmailMatchReason("recruiter", job.Company.RecruiterName.Trim(), 12));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var token in SplitTerms(job.JobTitle).Take(4))
|
||||||
|
{
|
||||||
|
if (!ContainsValue(haystack, token)) continue;
|
||||||
|
score += 5;
|
||||||
|
reasons.Add(new GmailMatchReason("jobTitle", token, 5));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var subjectLine in job.Messages
|
||||||
|
.Select(existing => existing.Subject)
|
||||||
|
.Where(existing => !string.IsNullOrWhiteSpace(existing))
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||||
|
.Take(2))
|
||||||
|
{
|
||||||
|
if (!ContainsValue(subject, subjectLine!)) continue;
|
||||||
|
score += 8;
|
||||||
|
reasons.Add(new GmailMatchReason("existingSubject", subjectLine!.Trim(), 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.Date is { } messageDate)
|
||||||
|
{
|
||||||
|
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
|
||||||
|
if (ageDays <= 45)
|
||||||
|
{
|
||||||
|
score += 4;
|
||||||
|
reasons.Add(new GmailMatchReason("recency", "45d", 4));
|
||||||
|
}
|
||||||
|
else if (ageDays <= 180)
|
||||||
|
{
|
||||||
|
score += 2;
|
||||||
|
reasons.Add(new GmailMatchReason("recency", "180d", 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (threadAlreadyImported && !alreadyImported)
|
||||||
|
reasons.Add(new GmailMatchReason("status", "thread-already-imported", 0));
|
||||||
|
if (alreadyImported)
|
||||||
|
reasons.Add(new GmailMatchReason("status", "already-imported", 0));
|
||||||
|
|
||||||
|
reasons = reasons
|
||||||
|
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
|
||||||
|
.Select(group => group.First())
|
||||||
|
.OrderByDescending(reason => reason.Points)
|
||||||
|
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
||||||
|
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new GmailScoredMessageResult(message, alreadyImported, score, ToConfidence(score), candidate.MatchedQueries, reasons);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ContainsValue(string haystack, string? value)
|
||||||
|
=> !string.IsNullOrWhiteSpace(value) && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
private static IEnumerable<string> SplitTerms(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value)) yield break;
|
||||||
|
foreach (var token in value.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(token => token.Length >= 3)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
yield return token;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string ToConfidence(int score) => score switch
|
||||||
|
{
|
||||||
|
>= 30 => "high",
|
||||||
|
>= 16 => "medium",
|
||||||
|
_ => "low"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
+67
-35
@@ -1,44 +1,76 @@
|
|||||||
# Smart Gmail Job Correspondence Integration Progress
|
# Smart Gmail Job Correspondence Integration Progress
|
||||||
|
|
||||||
## Branch
|
## Branch
|
||||||
- feat/gmail-job-correspondence
|
- main
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
- Workstream initialized.
|
- Core Phase 1 Gmail correspondence feature is now implemented in code.
|
||||||
- Milestones planned: M006-M010.
|
- Remaining gap is deployment/runtime rollout on the live host, not missing product logic in this repo.
|
||||||
- Current focus: M006 / S01 foundation work.
|
|
||||||
|
|
||||||
## Completed so far
|
## Completed
|
||||||
- Created separate Gmail feature branch.
|
|
||||||
- Captured foundation context in `.gsd/milestones/M006/M006-CONTEXT.md`.
|
|
||||||
- Planned milestones M006-M010 for the Gmail workstream.
|
|
||||||
- Planned slice M006/S01.
|
|
||||||
- Confirmed existing architecture seams:
|
|
||||||
- Gmail OAuth/token flow already exists.
|
|
||||||
- Per-job Gmail candidate search/import/thread refresh already exists.
|
|
||||||
- Correspondence persistence already stores Gmail thread/message metadata.
|
|
||||||
- Current implementation is job-local, not global-inbox/review oriented.
|
|
||||||
- Implemented M006/S01 foundation changes:
|
|
||||||
- durable Gmail sync-state fields on `GmailConnection`
|
|
||||||
- SQLite/MySQL bootstrap support for new Gmail sync-state columns
|
|
||||||
- richer `GET /api/gmail/status` response
|
|
||||||
- per-job correspondence UI now shows sync diagnostics
|
|
||||||
- focused backend/frontend Gmail tests added and passing
|
|
||||||
- Phase 2 extension seam scaffolded with a no-op enrichment service
|
|
||||||
- Phase 1/2 design doc added at `docs/gmail-correspondence-phase1.md`
|
|
||||||
- Started M007 ingestion/storage work:
|
|
||||||
- imported correspondence now stores direction, Gmail labels JSON, and attachment metadata JSON
|
|
||||||
- Gmail message detail extraction now reads labels and attachment metadata from Gmail payloads
|
|
||||||
- focused Gmail backend/frontend tests pass against the richer import contract
|
|
||||||
|
|
||||||
## Next tasks
|
### Foundation
|
||||||
1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model.
|
- Gmail OAuth connect/disconnect/status flow preserved.
|
||||||
2. Implement M006/S01/T02: expose sync-state surfaces in UI without breaking current correspondence workflow.
|
- Durable Gmail sync-state fields added and surfaced from `GET /api/gmail/status`.
|
||||||
3. Implement M006/S01/T03: prepare Phase 2 extension seam/docs.
|
- Per-job correspondence UI shows Gmail sync diagnostics.
|
||||||
4. Verify backend + frontend Gmail focused tests.
|
|
||||||
5. Commit and push incremental progress.
|
### Ingestion and storage
|
||||||
|
- Imported Gmail correspondence stores:
|
||||||
|
- direction
|
||||||
|
- Gmail labels JSON
|
||||||
|
- attachment metadata JSON
|
||||||
|
- Gmail payload parsing extracts labels and attachment metadata.
|
||||||
|
- Message-level deduplication remains in place.
|
||||||
|
- Linked-thread refresh continues to import only new thread messages.
|
||||||
|
|
||||||
|
### Matching and routing
|
||||||
|
- Deterministic scoring extracted to `JobTrackerApi/Services/GmailJobMatchingService.cs`.
|
||||||
|
- Review queue backend exists at `GET /api/gmail/review-candidates`.
|
||||||
|
- Review decisions persist through `POST /api/gmail/review-decision`.
|
||||||
|
- Manual sync now exists at `POST /api/gmail/manual-sync`.
|
||||||
|
- Manual sync applies a bounded historical window and excludes spam/trash by default.
|
||||||
|
- High-confidence matches now auto-link during manual sync.
|
||||||
|
- Medium-confidence matches remain in review.
|
||||||
|
- Low-confidence job-like threads can be marked as suggested jobs.
|
||||||
|
- Suggested-job surfaces now exist via:
|
||||||
|
- `GET /api/gmail/suggested-jobs`
|
||||||
|
- `POST /api/gmail/create-suggested-job`
|
||||||
|
|
||||||
|
### Correspondence UX
|
||||||
|
- Global inbox exists at `/correspondence`.
|
||||||
|
- Gmail review page exists at `/correspondence/review`.
|
||||||
|
- Review page now supports:
|
||||||
|
- manual sync
|
||||||
|
- routing filters
|
||||||
|
- review notes
|
||||||
|
- link/review/reject/suggested actions
|
||||||
|
- create-job flow from suggested Gmail threads
|
||||||
|
- Per-job correspondence workspace now supports:
|
||||||
|
- linked-thread refresh
|
||||||
|
- unlink thread from current job
|
||||||
|
- move/relink thread to another existing job
|
||||||
|
- Backend relink/unlink endpoints now exist:
|
||||||
|
- `POST /api/gmail/relink-thread`
|
||||||
|
- `POST /api/gmail/unlink-thread`
|
||||||
|
|
||||||
|
### Phase 2 prep
|
||||||
|
- Future seam remains in place at `JobTrackerApi/Services/GmailCorrespondenceEnrichment.cs`.
|
||||||
|
- Design doc remains in place at `docs/gmail-correspondence-phase1.md`.
|
||||||
|
|
||||||
|
### Deployment hardening
|
||||||
|
- Added deploy smoke-check logic to `deploy/deploy.sh`.
|
||||||
|
- Deploy now fails if `${APP_PUBLIC_BASE_URL}/api/auth/config` returns HTML or non-JSON instead of backend auth config JSON.
|
||||||
|
|
||||||
|
## Verification completed
|
||||||
|
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests /p:DisableSourceControlManagerQueries=true`
|
||||||
|
- `cd job-tracker-ui && CI=true ./node_modules/.bin/react-scripts test --runInBand --watch=false src/correspondence-gmail-import.test.tsx src/gmail-review-page.test.tsx src/correspondence-inbox-page.test.tsx`
|
||||||
|
- `dotnet build './Job tracker.sln' -c Release`
|
||||||
|
|
||||||
|
## Runtime note
|
||||||
|
- Live host check shows `https://jobs.cesnimda.uk/api/auth/config` currently returns the frontend HTML shell (`x-powered-by: Express`) instead of backend JSON.
|
||||||
|
- That is a deployment/proxy mismatch outside the app code in this checkout.
|
||||||
|
- The new deploy smoke-check was added so future deploys fail fast on that condition.
|
||||||
|
|
||||||
## Resume notes
|
## Resume notes
|
||||||
- Previous CV/parsing branch work is separate and already pushed.
|
- If the live site still shows 404s for `/api/...`, the running service is not the repo’s Dockerized frontend+backend path.
|
||||||
- Local dev SQLite runtime still has missing-table drift in some unrelated surfaces (`RuleSettings`, `Companies`, etc.); avoid conflating that with the Gmail feature work.
|
- The CRA/Express-style live response and websocket attempts to `:3000/ws` suggest an old dev-style frontend process or wrong reverse-proxy target is still serving the domain.
|
||||||
- Existing per-job Gmail tests live in `JobTrackerApi.Tests/GmailControllerTests.cs` and `job-tracker-ui/src/correspondence-gmail-import.test.tsx`.
|
|
||||||
|
|||||||
+39
-2
@@ -62,9 +62,46 @@ fi
|
|||||||
|
|
||||||
ai_status="$(compose ps ai-service --format '{{.State}}' 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]')"
|
ai_status="$(compose ps ai-service --format '{{.State}}' 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]')"
|
||||||
if [ "$ai_status" != "running" ]; then
|
if [ "$ai_status" != "running" ]; then
|
||||||
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown})."
|
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown}). Continuing because AI is not a deploy gate for the core app."
|
||||||
compose logs --tail=200 ai-service || true
|
compose logs --tail=200 ai-service || true
|
||||||
exit 1
|
fi
|
||||||
|
|
||||||
|
if [ -n "${APP_PUBLIC_BASE_URL:-}" ]; then
|
||||||
|
public_base="${APP_PUBLIC_BASE_URL%/}"
|
||||||
|
auth_config_body_file="$(mktemp)"
|
||||||
|
auth_config_headers_file="$(mktemp)"
|
||||||
|
cleanup_public_check() {
|
||||||
|
rm -f "$auth_config_body_file" "$auth_config_headers_file"
|
||||||
|
}
|
||||||
|
trap cleanup_public_check EXIT
|
||||||
|
|
||||||
|
echo "Running public smoke check against ${public_base}"
|
||||||
|
if ! curl -fsS "${public_base}/" >/dev/null; then
|
||||||
|
echo "Public frontend check failed for ${public_base}/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then
|
||||||
|
echo "Public API smoke check failed for ${public_base}/api/auth/config"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/ {print $2}' "$auth_config_headers_file" | tr -d '\r' | tail -n 1)"
|
||||||
|
if [[ "$content_type" != application/json* ]]; then
|
||||||
|
echo "Public API smoke check returned unexpected content type: ${content_type:-missing}"
|
||||||
|
echo "First bytes of response:"
|
||||||
|
head -c 200 "$auth_config_body_file" || true
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! grep -q 'requireAuth' "$auth_config_body_file"; then
|
||||||
|
echo "Public API smoke check returned JSON without requireAuth."
|
||||||
|
cat "$auth_config_body_file"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
trap - EXIT
|
||||||
|
cleanup_public_check
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Clean up old legacy container name if it still exists from pre-rename deployments.
|
# Clean up old legacy container name if it still exists from pre-rename deployments.
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import AlarmIcon from "@mui/icons-material/Alarm";
|
|||||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||||
import ShieldIcon from "@mui/icons-material/Shield";
|
import ShieldIcon from "@mui/icons-material/Shield";
|
||||||
import SearchIcon from "@mui/icons-material/Search";
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||||
import MemoryIcon from "@mui/icons-material/Memory";
|
import MemoryIcon from "@mui/icons-material/Memory";
|
||||||
|
|
||||||
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||||
@@ -46,6 +47,8 @@ const ProfilePage = lazy(() => import("./pages/ProfilePage"));
|
|||||||
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
|
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
|
||||||
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
|
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
|
||||||
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
|
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
|
||||||
|
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
|
||||||
|
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
|
||||||
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
|
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
|
||||||
|
|
||||||
type AuthConfig = { requireAuth: boolean };
|
type AuthConfig = { requireAuth: boolean };
|
||||||
@@ -67,6 +70,8 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
|||||||
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
|
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
|
||||||
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
|
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
|
||||||
if (path.startsWith("/companies")) return [t("home"), t("companies")];
|
if (path.startsWith("/companies")) return [t("home"), t("companies")];
|
||||||
|
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
|
||||||
|
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
|
||||||
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
||||||
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
||||||
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
||||||
@@ -82,6 +87,8 @@ function titleFor(path: string, t: (k: any) => string): string {
|
|||||||
if (path.startsWith("/jobs")) return t("jobApplications");
|
if (path.startsWith("/jobs")) return t("jobApplications");
|
||||||
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
||||||
if (path.startsWith("/companies")) return t("companies");
|
if (path.startsWith("/companies")) return t("companies");
|
||||||
|
if (path.startsWith("/correspondence/review")) return "Gmail review queue";
|
||||||
|
if (path.startsWith("/correspondence")) return "Correspondence inbox";
|
||||||
if (path.startsWith("/trash")) return t("trash");
|
if (path.startsWith("/trash")) return t("trash");
|
||||||
if (path.startsWith("/settings")) return t("settings");
|
if (path.startsWith("/settings")) return t("settings");
|
||||||
if (path.startsWith("/profile")) return t("profile");
|
if (path.startsWith("/profile")) return t("profile");
|
||||||
@@ -154,6 +161,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
|
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
|
||||||
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
|
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
|
||||||
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
|
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
|
||||||
|
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||||
|
{ to: "/correspondence/review", label: "Gmail review", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||||
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
|
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -225,6 +234,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
<Route path="/reminders" element={<RemindersView />} />
|
<Route path="/reminders" element={<RemindersView />} />
|
||||||
<Route path="/kanban" element={<KanbanBoard />} />
|
<Route path="/kanban" element={<KanbanBoard />} />
|
||||||
<Route path="/companies" element={<CompaniesTable />} />
|
<Route path="/companies" element={<CompaniesTable />} />
|
||||||
|
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||||
|
<Route path="/correspondence/review" element={<GmailReviewPage />} />
|
||||||
<Route path="/profile" element={<ProfilePage />} />
|
<Route path="/profile" element={<ProfilePage />} />
|
||||||
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
||||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
Divider,
|
Divider,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
List,
|
List,
|
||||||
ListItemButton,
|
ListItemButton,
|
||||||
ListItemText,
|
ListItemText,
|
||||||
|
MenuItem,
|
||||||
Paper,
|
Paper,
|
||||||
|
Select,
|
||||||
Tab,
|
Tab,
|
||||||
Tabs,
|
Tabs,
|
||||||
TextField,
|
TextField,
|
||||||
@@ -35,8 +39,10 @@ import {
|
|||||||
GmailImportMessageResult,
|
GmailImportMessageResult,
|
||||||
GmailImportThreadResult,
|
GmailImportThreadResult,
|
||||||
GmailJobMatchesResponse,
|
GmailJobMatchesResponse,
|
||||||
|
GmailRelinkResult,
|
||||||
GmailStatus,
|
GmailStatus,
|
||||||
GmailThreadRefreshResult,
|
GmailThreadRefreshResult,
|
||||||
|
GmailUnlinkResult,
|
||||||
JobApplication,
|
JobApplication,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
@@ -97,6 +103,10 @@ function formatReasonLabel(label: string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PagedResult<T> {
|
||||||
|
items: T[];
|
||||||
|
}
|
||||||
|
|
||||||
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
|
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
@@ -120,6 +130,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
||||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||||
|
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
|
||||||
|
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
|
||||||
|
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
|
||||||
|
const [manageNote, setManageNote] = useState("");
|
||||||
|
const [manageSaving, setManageSaving] = useState(false);
|
||||||
const autoRefreshKeyRef = useRef<string | null>(null);
|
const autoRefreshKeyRef = useRef<string | null>(null);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
@@ -157,6 +172,15 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
}
|
}
|
||||||
}, [jobId, toast]);
|
}, [jobId, toast]);
|
||||||
|
|
||||||
|
const loadAvailableJobs = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
|
||||||
|
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
|
||||||
|
} catch {
|
||||||
|
setAvailableJobs([]);
|
||||||
|
}
|
||||||
|
}, [jobId]);
|
||||||
|
|
||||||
const linkedThreadIds = useMemo(
|
const linkedThreadIds = useMemo(
|
||||||
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
|
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
|
||||||
[messages],
|
[messages],
|
||||||
@@ -210,7 +234,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadGmailStatus();
|
void loadGmailStatus();
|
||||||
}, [loadGmailStatus]);
|
void loadAvailableJobs();
|
||||||
|
}, [loadAvailableJobs, loadGmailStatus]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
||||||
@@ -367,6 +392,55 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openManageThread = (threadId: string) => {
|
||||||
|
setManageThreadId(threadId);
|
||||||
|
setManageTargetJobId(jobId);
|
||||||
|
setManageNote("");
|
||||||
|
};
|
||||||
|
|
||||||
|
const unlinkThread = async () => {
|
||||||
|
if (!manageThreadId) return;
|
||||||
|
setManageSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<GmailUnlinkResult>("/gmail/unlink-thread", {
|
||||||
|
jobApplicationId: jobId,
|
||||||
|
threadId: manageThreadId,
|
||||||
|
note: manageNote.trim() || null,
|
||||||
|
nextDecision: "review",
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await loadGmailMatches(gmailQuery);
|
||||||
|
setManageThreadId(null);
|
||||||
|
toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
|
||||||
|
} finally {
|
||||||
|
setManageSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const relinkThread = async () => {
|
||||||
|
if (!manageThreadId || manageTargetJobId <= 0 || manageTargetJobId === jobId) return;
|
||||||
|
setManageSaving(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<GmailRelinkResult>("/gmail/relink-thread", {
|
||||||
|
jobApplicationId: manageTargetJobId,
|
||||||
|
threadId: manageThreadId,
|
||||||
|
removeFromOtherJobs: true,
|
||||||
|
note: manageNote.trim() || null,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
await loadGmailMatches(gmailQuery);
|
||||||
|
setManageThreadId(null);
|
||||||
|
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
|
||||||
|
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
|
||||||
|
} finally {
|
||||||
|
setManageSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box>
|
<Box>
|
||||||
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
|
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
|
||||||
@@ -415,6 +489,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
|
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
|
||||||
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
|
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
|
||||||
|
{linkedThreadIds.slice(0, 6).map((threadId) => (
|
||||||
|
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
|
||||||
|
Manage {threadId}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
{gmailStatus?.lastSyncStatus ? (
|
{gmailStatus?.lastSyncStatus ? (
|
||||||
<Chip
|
<Chip
|
||||||
size="small"
|
size="small"
|
||||||
@@ -457,6 +536,44 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
|||||||
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
|
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
|
||||||
|
<DialogTitle>Manage linked Gmail thread</DialogTitle>
|
||||||
|
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Unlink this thread from the current job, or move it to another existing job.
|
||||||
|
</Typography>
|
||||||
|
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
|
||||||
|
<TextField
|
||||||
|
label="Review note"
|
||||||
|
value={manageNote}
|
||||||
|
onChange={(event) => setManageNote(event.target.value)}
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
placeholder="Why this thread should stay in review or move to another job."
|
||||||
|
/>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Move to job</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={String(manageTargetJobId)}
|
||||||
|
label="Move to job"
|
||||||
|
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
|
||||||
|
>
|
||||||
|
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
|
||||||
|
{availableJobs.map((item) => (
|
||||||
|
<MenuItem key={item.id} value={String(item.id)}>
|
||||||
|
{item.company?.name || "Unknown company"} • {item.jobTitle}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
|
||||||
|
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
|
||||||
|
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
||||||
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
|
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
|
|||||||
@@ -51,6 +51,30 @@ describe("correspondence Gmail import", () => {
|
|||||||
correspondenceMessages = [];
|
correspondenceMessages = [];
|
||||||
|
|
||||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||||
|
if (url === "/jobapplications") {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 42,
|
||||||
|
jobTitle: "Backend Developer",
|
||||||
|
status: "Applied",
|
||||||
|
dateApplied: new Date().toISOString(),
|
||||||
|
daysSince: 3,
|
||||||
|
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 77,
|
||||||
|
jobTitle: "Platform Engineer",
|
||||||
|
status: "Applied",
|
||||||
|
dateApplied: new Date().toISOString(),
|
||||||
|
daysSince: 1,
|
||||||
|
company: { name: "Beta" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
if (url === "/jobapplications/42") {
|
if (url === "/jobapplications/42") {
|
||||||
return Promise.resolve({
|
return Promise.resolve({
|
||||||
data: {
|
data: {
|
||||||
@@ -142,6 +166,15 @@ describe("correspondence Gmail import", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||||
|
if (url === "/gmail/relink-thread") {
|
||||||
|
correspondenceMessages = [];
|
||||||
|
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, imported: 1, skipped: 0, unlinkedMessages: 1 } } as any);
|
||||||
|
}
|
||||||
|
if (url === "/gmail/unlink-thread") {
|
||||||
|
const removed = correspondenceMessages.filter((message) => message.externalThreadId === body.threadId).length;
|
||||||
|
correspondenceMessages = correspondenceMessages.filter((message) => message.externalThreadId !== body.threadId);
|
||||||
|
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, removedMessages: removed, decision: body.nextDecision || 'review' } } as any);
|
||||||
|
}
|
||||||
if (url === "/gmail/refresh-linked-threads") {
|
if (url === "/gmail/refresh-linked-threads") {
|
||||||
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
|
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
|
||||||
if (!hasReply && correspondenceMessages.some((message) => message.externalThreadId === "thread-1")) {
|
if (!hasReply && correspondenceMessages.some((message) => message.externalThreadId === "thread-1")) {
|
||||||
@@ -291,6 +324,72 @@ describe("correspondence Gmail import", () => {
|
|||||||
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("lets the user unlink a linked Gmail thread", async () => {
|
||||||
|
correspondenceMessages = [
|
||||||
|
{
|
||||||
|
id: 700,
|
||||||
|
jobApplicationId: 42,
|
||||||
|
from: "Company",
|
||||||
|
content: "Acme wants to schedule a call.",
|
||||||
|
subject: "Backend Developer interview",
|
||||||
|
channel: "Email",
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
externalMessageId: "msg-1",
|
||||||
|
externalThreadId: "thread-1",
|
||||||
|
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||||
|
externalTo: "user@example.test",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /unlink from this job/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/unlink-thread", expect.objectContaining({
|
||||||
|
jobApplicationId: 42,
|
||||||
|
threadId: "thread-1",
|
||||||
|
nextDecision: "review",
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByText(/no messages yet/i)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("lets the user move a linked Gmail thread to another job", async () => {
|
||||||
|
correspondenceMessages = [
|
||||||
|
{
|
||||||
|
id: 702,
|
||||||
|
jobApplicationId: 42,
|
||||||
|
from: "Company",
|
||||||
|
content: "Second import.",
|
||||||
|
subject: "Backend Developer interview",
|
||||||
|
channel: "Email",
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
externalMessageId: "msg-1",
|
||||||
|
externalThreadId: "thread-1",
|
||||||
|
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||||
|
externalTo: "user@example.test",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
||||||
|
fireEvent.mouseDown((await screen.findAllByRole("combobox")).slice(-1)[0]);
|
||||||
|
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/relink-thread", expect.objectContaining({
|
||||||
|
jobApplicationId: 77,
|
||||||
|
threadId: "thread-1",
|
||||||
|
removeFromOtherJobs: true,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
test("shows Gmail sync state diagnostics alongside linked thread continuity", async () => {
|
test("shows Gmail sync state diagnostics alongside linked thread continuity", async () => {
|
||||||
renderDialog();
|
renderDialog();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
patch: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<CorrespondenceInboxPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('CorrespondenceInboxPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.get.mockResolvedValue({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
jobApplicationId: 42,
|
||||||
|
companyName: 'Acme Systems',
|
||||||
|
jobTitle: 'Backend Engineer',
|
||||||
|
from: 'Company',
|
||||||
|
direction: 'inbound',
|
||||||
|
subject: 'Interview invite',
|
||||||
|
channel: 'Email',
|
||||||
|
date: new Date().toISOString(),
|
||||||
|
contentPreview: 'We would like to schedule an interview.',
|
||||||
|
externalThreadId: 'thread-1',
|
||||||
|
externalFrom: 'Maria Recruiter <maria@acme.test>',
|
||||||
|
externalTo: 'user@example.test',
|
||||||
|
labelCount: 2,
|
||||||
|
attachmentCount: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders correspondence inbox items and reloads with filters', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/correspondence inbox/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/1 items/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/2 labels/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/1 attachments/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } });
|
||||||
|
fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
|
||||||
|
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
|
||||||
|
params: expect.objectContaining({
|
||||||
|
q: 'Maria',
|
||||||
|
direction: 'inbound',
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import GmailReviewPage from './pages/GmailReviewPage';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(),
|
||||||
|
put: jest.fn(),
|
||||||
|
patch: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderPage() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||||
|
<GmailReviewPage />
|
||||||
|
</MemoryRouter>
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GmailReviewPage', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/gmail/review-candidates') {
|
||||||
|
return Promise.resolve({
|
||||||
|
data: {
|
||||||
|
queries: ['"Acme" "Backend Developer" newer_than:365d'],
|
||||||
|
candidateThreadCount: 2,
|
||||||
|
autoLinkThreadCount: 1,
|
||||||
|
reviewThreadCount: 1,
|
||||||
|
unmatchedThreadCount: 0,
|
||||||
|
threads: [
|
||||||
|
{
|
||||||
|
threadId: 'thread-1',
|
||||||
|
subject: 'Backend Developer interview',
|
||||||
|
latestDate: new Date().toISOString(),
|
||||||
|
messageCount: 2,
|
||||||
|
routing: 'review',
|
||||||
|
hasImportedMessages: false,
|
||||||
|
matchedQueries: ['"Acme" "Backend Developer" newer_than:365d'],
|
||||||
|
jobCandidates: [
|
||||||
|
{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] },
|
||||||
|
],
|
||||||
|
messages: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === '/gmail/suggested-jobs') {
|
||||||
|
return Promise.resolve({ data: { count: 0, items: [] } } as any);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders Gmail review queue summary and candidate threads', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/gmail review queue/i)).toBeInTheDocument();
|
||||||
|
expect(await screen.findByText(/2 candidate threads/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/backend developer interview/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/acme • backend developer \(24\)/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
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,
|
||||||
|
note: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import {
|
||||||
|
Box,
|
||||||
|
Chip,
|
||||||
|
CircularProgress,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
MenuItem,
|
||||||
|
Paper,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
Button,
|
||||||
|
} from "@mui/material";
|
||||||
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||||
|
import { api, getApiErrorMessage } from "../api";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
|
||||||
|
export type CorrespondenceInboxItem = {
|
||||||
|
id: number;
|
||||||
|
jobApplicationId: number;
|
||||||
|
companyName?: string | null;
|
||||||
|
jobTitle?: string | null;
|
||||||
|
from: string;
|
||||||
|
direction?: string | null;
|
||||||
|
subject?: string | null;
|
||||||
|
channel?: string | null;
|
||||||
|
date: string;
|
||||||
|
contentPreview: string;
|
||||||
|
externalThreadId?: string | null;
|
||||||
|
externalFrom?: string | null;
|
||||||
|
externalTo?: string | null;
|
||||||
|
labelCount: number;
|
||||||
|
attachmentCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CorrespondenceInboxPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [direction, setDirection] = useState<string>("all");
|
||||||
|
const [linkState, setLinkState] = useState<string>("all");
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
||||||
|
params: {
|
||||||
|
q: query.trim() || undefined,
|
||||||
|
direction: direction === "all" ? undefined : direction,
|
||||||
|
linkState: linkState === "all" ? undefined : linkState,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setItems(res.data ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [direction, linkState, query, toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const filteredSummary = useMemo(() => {
|
||||||
|
const linked = items.filter((item) => item.externalThreadId).length;
|
||||||
|
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||||
|
return { linked, inbound };
|
||||||
|
}, [items]);
|
||||||
|
|
||||||
|
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 }}>Correspondence inbox</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
Cross-job view of imported correspondence and Gmail-linked history.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
|
||||||
|
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
|
||||||
|
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
||||||
|
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Direction</InputLabel>
|
||||||
|
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
|
||||||
|
<MenuItem value="all">All</MenuItem>
|
||||||
|
<MenuItem value="inbound">Inbound</MenuItem>
|
||||||
|
<MenuItem value="outbound">Outbound</MenuItem>
|
||||||
|
<MenuItem value="internal">Internal</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<FormControl fullWidth>
|
||||||
|
<InputLabel>Link state</InputLabel>
|
||||||
|
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
|
||||||
|
<MenuItem value="all">All</MenuItem>
|
||||||
|
<MenuItem value="linked">Linked threads</MenuItem>
|
||||||
|
<MenuItem value="manual">Manual/internal only</MenuItem>
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||||
|
|
||||||
|
{!loading && items.length === 0 ? (
|
||||||
|
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Stack spacing={1.25}>
|
||||||
|
{items.map((item) => (
|
||||||
|
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||||
|
<Box sx={{ minWidth: 0 }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||||
|
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||||
|
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
|
||||||
|
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
|
||||||
|
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
||||||
|
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
||||||
|
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,269 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import { Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||||
|
import { api, getApiErrorMessage } from "../api";
|
||||||
|
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
export default function GmailReviewPage() {
|
||||||
|
const { toast } = useToast();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
|
||||||
|
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [syncing, setSyncing] = useState(false);
|
||||||
|
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
|
||||||
|
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
|
||||||
|
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
|
||||||
|
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const [reviewRes, suggestedRes] = await Promise.all([
|
||||||
|
api.get<GmailReviewQueueResponse>("/gmail/review-candidates"),
|
||||||
|
api.get<GmailSuggestedJobsResponse>("/gmail/suggested-jobs"),
|
||||||
|
]);
|
||||||
|
setData(reviewRes.data);
|
||||||
|
setSuggestions(suggestedRes.data);
|
||||||
|
setNotes((prev) => {
|
||||||
|
const next = { ...prev };
|
||||||
|
for (const thread of reviewRes.data.threads) {
|
||||||
|
if (next[thread.threadId] === undefined) next[thread.threadId] = thread.decisionNote || "";
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [toast]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => {
|
||||||
|
setSavingThreadId(threadId);
|
||||||
|
try {
|
||||||
|
await api.post("/gmail/review-decision", {
|
||||||
|
threadId,
|
||||||
|
decision,
|
||||||
|
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
|
||||||
|
note: notes[threadId]?.trim() || null,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
toast(
|
||||||
|
decision === "linked"
|
||||||
|
? "Thread linked and imported."
|
||||||
|
: decision === "rejected"
|
||||||
|
? "Thread rejected from review."
|
||||||
|
: decision === "suggested"
|
||||||
|
? "Thread kept as suggested job material."
|
||||||
|
: "Thread returned to review.",
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error");
|
||||||
|
} finally {
|
||||||
|
setSavingThreadId(null);
|
||||||
|
}
|
||||||
|
}, [load, notes, toast]);
|
||||||
|
|
||||||
|
const runManualSync = useCallback(async () => {
|
||||||
|
setSyncing(true);
|
||||||
|
try {
|
||||||
|
const res = await api.post<GmailManualSyncResult>("/gmail/manual-sync", {
|
||||||
|
lookbackDays: 365,
|
||||||
|
maxResultsPerQuery: 8,
|
||||||
|
autoImportHighConfidence: true,
|
||||||
|
includeSpamTrash: false,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
toast(
|
||||||
|
`Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`,
|
||||||
|
"success",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error");
|
||||||
|
} finally {
|
||||||
|
setSyncing(false);
|
||||||
|
}
|
||||||
|
}, [load, toast]);
|
||||||
|
|
||||||
|
const createSuggestedJob = useCallback(async (threadId: string) => {
|
||||||
|
const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
|
||||||
|
if (!suggestion) return;
|
||||||
|
|
||||||
|
setCreatingThreadId(threadId);
|
||||||
|
try {
|
||||||
|
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
|
||||||
|
threadId,
|
||||||
|
companyName: suggestion.companyName || "Unknown company",
|
||||||
|
jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role",
|
||||||
|
recruiterName: suggestion.recruiterName || null,
|
||||||
|
recruiterEmail: suggestion.recruiterEmail || null,
|
||||||
|
notes: notes[threadId]?.trim() || suggestion.preview || null,
|
||||||
|
status: "Applied",
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
||||||
|
navigate(`/jobs?open=${res.data.jobApplicationId}`);
|
||||||
|
} catch (error) {
|
||||||
|
toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error");
|
||||||
|
} finally {
|
||||||
|
setCreatingThreadId(null);
|
||||||
|
}
|
||||||
|
}, [load, navigate, notes, suggestions?.items, toast]);
|
||||||
|
|
||||||
|
const filteredThreads = useMemo(() => {
|
||||||
|
const threads = data?.threads ?? [];
|
||||||
|
return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter);
|
||||||
|
}, [data?.threads, routingFilter]);
|
||||||
|
|
||||||
|
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" }}>
|
||||||
|
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
|
||||||
|
{syncing ? "Syncing..." : "Run manual sync"}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||||
|
{loading ? "Loading..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||||
|
<Chip label={`Filter: ${routingFilter}`} variant="outlined" />
|
||||||
|
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
|
||||||
|
<Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
|
||||||
|
{value}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{data ? (
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||||
|
<Chip label={`${data.candidateThreadCount} candidate threads`} variant="outlined" />
|
||||||
|
<Chip label={`${data.autoLinkThreadCount} auto-link`} color="success" variant="outlined" />
|
||||||
|
<Chip label={`${data.reviewThreadCount} review`} color="warning" variant="outlined" />
|
||||||
|
<Chip label={`${data.unmatchedThreadCount} unmatched`} variant="outlined" />
|
||||||
|
{suggestions?.count ? <Chip label={`${suggestions.count} suggested jobs`} color="secondary" variant="outlined" /> : null}
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||||
|
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates match the current filter.</Typography> : null}
|
||||||
|
|
||||||
|
<Stack spacing={1.25}>
|
||||||
|
{filteredThreads.map((thread) => {
|
||||||
|
const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId);
|
||||||
|
return (
|
||||||
|
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||||
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||||
|
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
|
||||||
|
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</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.hasImportedMessages ? <Chip size="small" label="Has imported messages" color="success" variant="outlined" /> : null}
|
||||||
|
</Box>
|
||||||
|
<TextField
|
||||||
|
label="Review notes"
|
||||||
|
value={notes[thread.threadId] ?? ""}
|
||||||
|
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
|
||||||
|
size="small"
|
||||||
|
fullWidth
|
||||||
|
multiline
|
||||||
|
minRows={2}
|
||||||
|
sx={{ mt: 1.25 }}
|
||||||
|
placeholder="Why this should link, stay in review, or become a suggested job."
|
||||||
|
/>
|
||||||
|
{suggestion ? (
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
|
||||||
|
Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"}
|
||||||
|
</Typography>
|
||||||
|
) : null}
|
||||||
|
</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"}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{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="secondary"
|
||||||
|
disabled={savingThreadId === thread.threadId}
|
||||||
|
onClick={() => void saveDecision(thread.threadId, "suggested")}
|
||||||
|
>
|
||||||
|
Suggested job
|
||||||
|
</Button>
|
||||||
|
{suggestion ? (
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
disabled={creatingThreadId === thread.threadId}
|
||||||
|
onClick={() => void createSuggestedJob(thread.threadId)}
|
||||||
|
>
|
||||||
|
{creatingThreadId === thread.threadId ? "Creating..." : "Create job"}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
variant="outlined"
|
||||||
|
color="error"
|
||||||
|
disabled={savingThreadId === thread.threadId}
|
||||||
|
onClick={() => void saveDecision(thread.threadId, "rejected")}
|
||||||
|
>
|
||||||
|
Reject
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Stack>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
|||||||
@@ -299,6 +299,37 @@ export interface GmailThreadRefreshResult {
|
|||||||
threads: GmailThreadRefreshThreadResult[];
|
threads: GmailThreadRefreshThreadResult[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GmailReviewJobCandidate {
|
||||||
|
jobApplicationId: number;
|
||||||
|
jobTitle: string;
|
||||||
|
companyName: string;
|
||||||
|
score: number;
|
||||||
|
confidence: string;
|
||||||
|
reasons: GmailJobMatchReason[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailReviewThread {
|
||||||
|
threadId: string;
|
||||||
|
subject: string;
|
||||||
|
latestDate?: string;
|
||||||
|
messageCount: number;
|
||||||
|
routing: string;
|
||||||
|
hasImportedMessages: boolean;
|
||||||
|
decisionNote?: string | null;
|
||||||
|
matchedQueries: string[];
|
||||||
|
jobCandidates: GmailReviewJobCandidate[];
|
||||||
|
messages: GmailJobMatchedMessage[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailReviewQueueResponse {
|
||||||
|
queries: string[];
|
||||||
|
candidateThreadCount: number;
|
||||||
|
autoLinkThreadCount: number;
|
||||||
|
reviewThreadCount: number;
|
||||||
|
unmatchedThreadCount: number;
|
||||||
|
threads: GmailReviewThread[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface GmailStatus {
|
export interface GmailStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
gmailAddress?: string;
|
gmailAddress?: string;
|
||||||
@@ -312,6 +343,61 @@ export interface GmailStatus {
|
|||||||
lastSyncError?: string | null;
|
lastSyncError?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface GmailManualSyncResult {
|
||||||
|
queriesRun: number;
|
||||||
|
candidateThreadCount: number;
|
||||||
|
autoLinkedThreadCount: number;
|
||||||
|
reviewThreadCount: number;
|
||||||
|
unmatchedThreadCount: number;
|
||||||
|
importedMessages: number;
|
||||||
|
importedThreads: number;
|
||||||
|
skippedMessages: number;
|
||||||
|
lookbackDays: number;
|
||||||
|
includeSpamTrash: boolean;
|
||||||
|
syncedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailSuggestedJobCandidate {
|
||||||
|
threadId: string;
|
||||||
|
subject: string;
|
||||||
|
latestDate?: string | null;
|
||||||
|
companyName?: string | null;
|
||||||
|
recruiterName?: string | null;
|
||||||
|
recruiterEmail?: string | null;
|
||||||
|
suggestedJobTitle?: string | null;
|
||||||
|
routing: string;
|
||||||
|
matchedQueries: string[];
|
||||||
|
preview: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailSuggestedJobsResponse {
|
||||||
|
count: number;
|
||||||
|
items: GmailSuggestedJobCandidate[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatedSuggestedGmailJobResult {
|
||||||
|
jobApplicationId: number;
|
||||||
|
companyId: number;
|
||||||
|
threadId: string;
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailRelinkResult {
|
||||||
|
threadId: string;
|
||||||
|
jobApplicationId: number;
|
||||||
|
imported: number;
|
||||||
|
skipped: number;
|
||||||
|
unlinkedMessages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GmailUnlinkResult {
|
||||||
|
threadId: string;
|
||||||
|
jobApplicationId: number;
|
||||||
|
removedMessages: number;
|
||||||
|
decision: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GmailMessageSummary {
|
export interface GmailMessageSummary {
|
||||||
id: string;
|
id: string;
|
||||||
threadId: string;
|
threadId: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user