diff --git a/JobTrackerApi.Tests/GmailControllerTests.cs b/JobTrackerApi.Tests/GmailControllerTests.cs index c4ed730..e3fab2d 100644 --- a/JobTrackerApi.Tests/GmailControllerTests.cs +++ b/JobTrackerApi.Tests/GmailControllerTests.cs @@ -590,6 +590,316 @@ public sealed class GmailControllerTests gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny(), It.IsAny>(), It.IsAny(), It.IsAny()), 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(); + gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny())) + .ReturnsAsync(new[] + { + new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Interview invite") + }); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .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())) + .ReturnsAsync(new GmailMessageDetail( + "msg-1", + "thread-1", + "Backend Developer interview", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-1), + "Interview invite", + "Body text", + null, + new[] { "INBOX" }, + Array.Empty())); + + 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(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(); + gmail.Setup(service => service.ListJobCandidateMessagesAsync( + "user-1", + It.Is>(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())) + .ReturnsAsync(new[] + { + new GmailQueryMatchedMessage( + new GmailMessageSummary( + "msg-1", + "thread-1", + "Backend Developer interview", + "Maria Recruiter ", + "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())) + .ReturnsAsync(new[] + { + new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Invite") + }); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .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())) + .ReturnsAsync(new GmailMessageDetail( + "msg-1", + "thread-1", + "Backend Developer interview", + "Maria Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-2), + "Invite", + "Interview details", + null, + new[] { "INBOX" }, + Array.Empty())); + + 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(result.Result); + var payload = Assert.IsType(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(); + gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny>(), 6, It.IsAny())) + .ReturnsAsync(Array.Empty()); + gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-suggested", It.IsAny())) + .ReturnsAsync(new[] + { + new GmailMessageSummary("msg-s1", "thread-suggested", "Platform Engineer interview", "Nina Recruiter ", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Let's talk about the role") + }); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .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())) + .ReturnsAsync(new GmailMessageDetail( + "msg-s1", + "thread-suggested", + "Platform Engineer interview", + "Nina Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-1), + "Let's talk about the role", + "Interview details", + null, + new[] { "INBOX" }, + Array.Empty())); + + 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(), + 1, + 0, + 0, + 1, + new[] + { + new GmailController.GmailReviewThreadDto( + "thread-suggested", + "Platform Engineer interview", + DateTimeOffset.UtcNow.AddDays(-1), + 1, + "suggested", + false, + null, + Array.Empty(), + Array.Empty(), + new[] + { + new GmailController.GmailJobMatchedMessageDto( + "msg-s1", + "thread-suggested", + "Platform Engineer interview", + "Nina Recruiter ", + "user@example.test", + DateTimeOffset.UtcNow.AddDays(-1), + "Let's talk about the role", + 0, + "low", + false, + Array.Empty(), + Array.Empty()) + }) + }); + + var suggested = Assert.IsType((await controller.SuggestedJobs(CancellationToken.None)).Result); + Assert.IsType(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(create.Result); + var created = Assert.IsType(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(), "user-1"); + var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(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(); + gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny())) + .ReturnsAsync(new[] + { + new GmailMessageSummary("msg-1", "thread-1", "Interview", "Maria ", "user@example.test", DateTimeOffset.UtcNow, "Snippet") + }); + gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .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())) + .ReturnsAsync(new GmailMessageDetail( + "msg-1", + "thread-1", + "Interview", + "Maria ", + "user@example.test", + DateTimeOffset.UtcNow, + "Snippet", + "Body", + null, + Array.Empty(), + Array.Empty())); + + 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(result.Result); + var payload = Assert.IsType(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()) diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index c6dfe02..1bc9a9f 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -72,8 +72,19 @@ public sealed class GmailController : ControllerBase IReadOnlyList Threads); public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); - public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); + public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList 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 MatchedQueries, string Preview); + public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList 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())) .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 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> 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(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> 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> 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> 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> 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 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 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 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") diff --git a/SMART_GMAIL_PROGRESS.md b/SMART_GMAIL_PROGRESS.md index 65fa595..7148c62 100644 --- a/SMART_GMAIL_PROGRESS.md +++ b/SMART_GMAIL_PROGRESS.md @@ -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 repo’s 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. diff --git a/deploy/deploy.sh b/deploy/deploy.sh index 495e440..078098f 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -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 diff --git a/job-tracker-ui/src/components/Correspondence.tsx b/job-tracker-ui/src/components/Correspondence.tsx index c51c795..ea230e4 100644 --- a/job-tracker-ui/src/components/Correspondence.tsx +++ b/job-tracker-ui/src/components/Correspondence.tsx @@ -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 { + 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(null); const [importingThreadId, setImportingThreadId] = useState(null); + const [availableJobs, setAvailableJobs] = useState([]); + const [manageThreadId, setManageThreadId] = useState(null); + const [manageTargetJobId, setManageTargetJobId] = useState(jobId); + const [manageNote, setManageNote] = useState(""); + const [manageSaving, setManageSaving] = useState(false); const autoRefreshKeyRef = useRef(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>("/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("/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("/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 ( @@ -415,6 +489,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} /> + {linkedThreadIds.slice(0, 6).map((threadId) => ( + + ))} {gmailStatus?.lastSyncStatus ? ( {t("correspondenceAdd")} + setManageThreadId(null)} fullWidth maxWidth="sm"> + Manage linked Gmail thread + + + Unlink this thread from the current job, or move it to another existing job. + + {manageThreadId ? : null} + setManageNote(event.target.value)} + multiline + minRows={2} + placeholder="Why this thread should stay in review or move to another job." + /> + + Move to job + + + + + + + + + + setImportOpen(false)} fullWidth maxWidth="md"> {t("correspondenceImportTitle")} diff --git a/job-tracker-ui/src/correspondence-gmail-import.test.tsx b/job-tracker-ui/src/correspondence-gmail-import.test.tsx index fa63a22..3b3f1a2 100644 --- a/job-tracker-ui/src/correspondence-gmail-import.test.tsx +++ b/job-tracker-ui/src/correspondence-gmail-import.test.tsx @@ -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 ", + 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 ", + 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(); diff --git a/job-tracker-ui/src/correspondence-inbox-page.test.tsx b/job-tracker-ui/src/correspondence-inbox-page.test.tsx index 3185f5b..c098e6b 100644 --- a/job-tracker-ui/src/correspondence-inbox-page.test.tsx +++ b/job-tracker-ui/src/correspondence-inbox-page.test.tsx @@ -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({ diff --git a/job-tracker-ui/src/gmail-review-page.test.tsx b/job-tracker-ui/src/gmail-review-page.test.tsx index c71f77d..f39c6ce 100644 --- a/job-tracker-ui/src/gmail-review-page.test.tsx +++ b/job-tracker-ui/src/gmail-review-page.test.tsx @@ -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, }); }); }); diff --git a/job-tracker-ui/src/pages/GmailReviewPage.tsx b/job-tracker-ui/src/pages/GmailReviewPage.tsx index 5c9594d..ea6fc33 100644 --- a/job-tracker-ui/src/pages/GmailReviewPage.tsx +++ b/job-tracker-ui/src/pages/GmailReviewPage.tsx @@ -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(null); + const [suggestions, setSuggestions] = useState(null); const [loading, setLoading] = useState(false); + const [syncing, setSyncing] = useState(false); const [savingThreadId, setSavingThreadId] = useState(null); + const [creatingThreadId, setCreatingThreadId] = useState(null); + const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all"); + const [notes, setNotes] = useState>({}); const load = useCallback(async () => { setLoading(true); try { - const res = await api.get("/gmail/review-candidates"); - setData(res.data); + const [reviewRes, suggestedRes] = await Promise.all([ + api.get("/gmail/review-candidates"), + api.get("/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("/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("/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 ( Gmail review queue - 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. - + + + + + + + + + {(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => ( + + ))} {data ? ( @@ -72,73 +156,113 @@ export default function GmailReviewPage() { + {suggestions?.count ? : null} ) : null} {loading ? : null} - {!loading && data && data.threads.length === 0 ? No Gmail review candidates right now. : null} + {!loading && data && filteredThreads.length === 0 ? No Gmail review candidates match the current filter. : null} - {data?.threads.map((thread) => ( - - - - {thread.subject} - - {thread.messageCount} messages · {thread.routing} - - - {thread.matchedQueries.slice(0, 3).map((query) => ( - - ))} - - - - {thread.jobCandidates.slice(0, 2).map((candidate) => ( - { + const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId); + return ( + + + + {thread.subject} + + {thread.messageCount} messages · {thread.routing} + + + {thread.matchedQueries.slice(0, 3).map((query) => ( + + ))} + {thread.hasImportedMessages ? : null} + + 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] ? ( - - ) : null} - {thread.jobCandidates[0] ? ( + {suggestion ? ( + + Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"} + + ) : null} + + + {thread.jobCandidates.slice(0, 2).map((candidate) => ( + + ))} + {thread.jobCandidates[0] ? ( + + ) : null} + {thread.jobCandidates[0] ? ( + + ) : null} - ) : null} - - + + {suggestion ? ( + + ) : null} + + - - - ))} + + ); + })} ); diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index d44df15..bc02b2c 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -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;