Complete Gmail correspondence workflow

This commit is contained in:
2026-04-02 12:29:24 +02:00
parent 1f34eb42d2
commit 5cd34f17bb
10 changed files with 1390 additions and 145 deletions
+310
View File
@@ -590,6 +590,316 @@ public sealed class GmailControllerTests
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)
{
var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig())
+479 -2
View File
@@ -72,8 +72,19 @@ public sealed class GmailController : ControllerBase
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, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
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(
bool Connected,
@@ -325,7 +336,7 @@ public sealed class GmailController : ControllerBase
Array.Empty<GmailJobMatchReasonDto>()))
.ToList();
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, matchedQueries, jobCandidates, messages);
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
})
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
.Take(100)
@@ -340,6 +351,380 @@ public sealed class GmailController : ControllerBase
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]
[HttpGet("oauth/callback")]
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
@@ -591,6 +976,60 @@ public sealed class GmailController : ControllerBase
return _matching.BuildJobQueries(job, queryOverride);
}
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
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)))));
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 void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
if (existing is null)
{
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)
{
return score switch
@@ -601,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()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
+67 -53
View File
@@ -1,62 +1,76 @@
# Smart Gmail Job Correspondence Integration Progress
## Branch
- feat/gmail-job-correspondence
- main
## Status
- Workstream initialized.
- Milestones planned: M006-M010.
- Current focus: M006 / S01 foundation work.
- Core Phase 1 Gmail correspondence feature is now implemented in code.
- Remaining gap is deployment/runtime rollout on the live host, not missing product logic in this repo.
## Completed so far
- Created separate Gmail feature branch and merged the completed checkpoint into `main`.
- 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
- Added the first cross-job UX surface:
- new `/correspondence` inbox API
- new global correspondence inbox page and nav route
- focused frontend test for inbox filtering/refresh behavior
- Current next focus:
- deterministic Gmail matching logic now extracted into `JobTrackerApi/Services/GmailJobMatchingService.cs`
- next step is to build cross-job routing/review behavior on top of that reusable matching seam
- branch context has been merged into `main`; continue delivery directly on `main`
- Added a first review surface:
- backend `GET /api/gmail/review-candidates`
- frontend `/correspondence/review` page
- focused review-page frontend test
- Review queue is now actionable:
- backend `POST /api/gmail/review-decision`
- frontend actions for link/reject/keep-in-review
- focused action test and successful frontend build
- Backend release build for `JobTrackerApi` is clean again after fixing a duplicated `app.Run()` tail in `Program.cs`.
- Cleaned the new Gmail page tests to use the same React Router future flags as the app, removing warning noise from the inbox/review suites.
## Completed
## Next tasks
1. Implement M006/S01/T01: refactor Gmail connection foundation and sync-state model.
2. Implement M006/S01/T02: expose sync-state surfaces in UI without breaking current correspondence workflow.
3. Implement M006/S01/T03: prepare Phase 2 extension seam/docs.
4. Verify backend + frontend Gmail focused tests.
5. Commit and push incremental progress.
### Foundation
- Gmail OAuth connect/disconnect/status flow preserved.
- Durable Gmail sync-state fields added and surfaced from `GET /api/gmail/status`.
- Per-job correspondence UI shows Gmail sync diagnostics.
### 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
- Previous CV/parsing branch work is separate and already pushed.
- Local dev SQLite runtime still has missing-table drift in some unrelated surfaces (`RuleSettings`, `Companies`, etc.); avoid conflating that with the Gmail feature work.
- Existing per-job Gmail tests live in `JobTrackerApi.Tests/GmailControllerTests.cs` and `job-tracker-ui/src/correspondence-gmail-import.test.tsx`.
- If the live site still shows 404s for `/api/...`, the running service is not the repos Dockerized frontend+backend path.
- 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.
+38
View File
@@ -67,6 +67,44 @@ if [ "$ai_status" != "running" ]; then
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
# Clean up old legacy container name if it still exists from pre-rename deployments.
docker rm -f app-summarizer-1 2>/dev/null || true
@@ -10,10 +10,14 @@ import {
DialogContent,
DialogTitle,
Divider,
FormControl,
InputLabel,
List,
ListItemButton,
ListItemText,
MenuItem,
Paper,
Select,
Tab,
Tabs,
TextField,
@@ -35,8 +39,10 @@ import {
GmailImportMessageResult,
GmailImportThreadResult,
GmailJobMatchesResponse,
GmailRelinkResult,
GmailStatus,
GmailThreadRefreshResult,
GmailUnlinkResult,
JobApplication,
} from "../types";
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 }) {
const theme = useTheme();
const { toast } = useToast();
@@ -120,6 +130,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
const [importingMessageId, setImportingMessageId] = 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 load = useCallback(async () => {
@@ -157,6 +172,15 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
}
}, [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(
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
[messages],
@@ -210,7 +234,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
useEffect(() => {
void loadGmailStatus();
}, [loadGmailStatus]);
void loadAvailableJobs();
}, [loadAvailableJobs, loadGmailStatus]);
useEffect(() => {
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 (
<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)" }}>
@@ -415,6 +489,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
<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={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 ? (
<Chip
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>
</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">
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
<DialogContent>
@@ -51,6 +51,30 @@ describe("correspondence Gmail import", () => {
correspondenceMessages = [];
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") {
return Promise.resolve({
data: {
@@ -142,6 +166,15 @@ describe("correspondence Gmail import", () => {
});
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") {
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
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);
});
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 () => {
renderDialog();
@@ -75,7 +75,6 @@ describe('CorrespondenceInboxPage', () => {
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]);
fireEvent.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => {
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
+33 -22
View File
@@ -35,30 +35,40 @@ function renderPage() {
describe('GmailReviewPage', () => {
beforeEach(() => {
mockedApi.get.mockResolvedValue({
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 }] },
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: [],
},
],
messages: [],
},
],
},
} as any);
} as any);
}
if (url === '/gmail/suggested-jobs') {
return Promise.resolve({ data: { count: 0, items: [] } } as any);
}
return Promise.resolve({ data: {} } as any);
});
});
afterEach(() => {
@@ -88,6 +98,7 @@ describe('GmailReviewPage', () => {
threadId: 'thread-1',
decision: 'linked',
jobApplicationId: 42,
note: null,
});
});
});
+190 -66
View File
@@ -1,7 +1,7 @@
import React, { useCallback, useEffect, useState } from "react";
import { Box, Button, Chip, CircularProgress, Paper, Stack, Typography } from "@mui/material";
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 { GmailReviewQueueResponse } from "../types";
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
import { useToast } from "../toast";
import { useNavigate } from "react-router-dom";
@@ -9,14 +9,30 @@ 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 res = await api.get<GmailReviewQueueResponse>("/gmail/review-candidates");
setData(res.data);
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 {
@@ -28,21 +44,24 @@ export default function GmailReviewPage() {
void load();
}, [load]);
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review", jobApplicationId?: number) => {
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 for review."
? "Thread linked and imported."
: decision === "rejected"
? "Thread rejected from review."
: "Thread returned to review.",
: decision === "suggested"
? "Thread kept as suggested job material."
: "Thread returned to review.",
"success",
);
} catch (error) {
@@ -50,20 +69,85 @@ export default function GmailReviewPage() {
} 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" }}>
Review medium-confidence Gmail correspondence routing and unmatched job-like threads.
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
</Typography>
</Box>
<Button variant="contained" onClick={() => void load()} disabled={loading}>
{loading ? "Loading..." : "Refresh"}
</Button>
<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 ? (
@@ -72,73 +156,113 @@ export default function GmailReviewPage() {
<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 && data.threads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates right now.</Typography> : 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}>
{data?.threads.map((thread) => (
<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 }}>
<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" />
))}
</Box>
</Box>
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
{thread.jobCandidates.slice(0, 2).map((candidate) => (
<Chip
key={candidate.jobApplicationId}
label={`${candidate.companyName}${candidate.jobTitle} (${candidate.score})`}
variant="outlined"
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
{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."
/>
))}
{thread.jobCandidates[0] ? (
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
Open top job
</Button>
) : null}
{thread.jobCandidates[0] ? (
{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="contained"
variant="outlined"
color="warning"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
onClick={() => void saveDecision(thread.threadId, "review")}
>
Link top job
Keep in review
</Button>
) : null}
<Button
size="small"
variant="outlined"
color="warning"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "review")}
>
Keep in review
</Button>
<Button
size="small"
variant="outlined"
color="error"
disabled={savingThreadId === thread.threadId}
onClick={() => void saveDecision(thread.threadId, "rejected")}
>
Reject
</Button>
<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>
</Box>
</Paper>
))}
</Paper>
);
})}
</Stack>
</Paper>
);
+56
View File
@@ -315,6 +315,7 @@ export interface GmailReviewThread {
messageCount: number;
routing: string;
hasImportedMessages: boolean;
decisionNote?: string | null;
matchedQueries: string[];
jobCandidates: GmailReviewJobCandidate[];
messages: GmailJobMatchedMessage[];
@@ -342,6 +343,61 @@ export interface GmailStatus {
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 {
id: string;
threadId: string;