Complete S01 Gmail matching and import workflow
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@ The product must let one person run a real job search without losing the thread:
|
||||
|
||||
## Current State
|
||||
|
||||
A substantial brownfield app already exists. The repo has a React frontend, an ASP.NET Core API, and a local FastAPI AI service. Current capabilities already include job tracking, companies, attachments, correspondence, reminders, job import preview, Gmail connection/import, profile CV upload/parsing/rewrite flows, AI-assisted tailored CV and cover-letter generation, candidate-fit/focus-plan/interview-prep/readiness endpoints, and dashboard/system surfaces. The next phase is not greenfield feature invention; it is turning existing capability into a more coherent, more trustworthy daily workflow.
|
||||
A substantial brownfield app already exists. The repo has a React frontend, an ASP.NET Core API, and a local FastAPI AI service. Current capabilities already include job tracking, companies, attachments, correspondence, reminders, job import preview, Gmail connection/import, profile CV upload/parsing/rewrite flows, AI-assisted tailored CV and cover-letter generation, candidate-fit/focus-plan/interview-prep/readiness endpoints, and dashboard/system surfaces. S01 has now moved Gmail import from a generic search surface to a job-aware workspace flow: the backend ranks likely Gmail threads/messages for a specific job, imports report duplicate state explicitly, correspondence persists Gmail thread and sender/recipient metadata, and the job workspace renders those ranked suggestions directly. The next phase is not greenfield feature invention; it is continuing to turn existing capability into a more coherent, more trustworthy daily workflow.
|
||||
|
||||
## Architecture / Key Patterns
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ This milestone is complete only when all are true:
|
||||
|
||||
## Slices
|
||||
|
||||
- [ ] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]`
|
||||
- [x] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]`
|
||||
> After this: User can connect Gmail, review likely messages or threads for a job, and import correspondence with much better matching confidence and less manual cleanup.
|
||||
|
||||
- [ ] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]`
|
||||
|
||||
@@ -38,19 +38,19 @@ S01 directly advances active requirements **R001** and **R002**, and it also lay
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ] **T01: Add a job-aware Gmail matching contract and backend ranking tests** `est:3h`
|
||||
- [x] **T01: Add a job-aware Gmail matching contract and backend ranking tests** `est:3h`
|
||||
- Why: The slice risk sits in matching quality, so the backend needs to own candidate discovery, ranking, and duplicate awareness before the UI can present trustworthy suggestions.
|
||||
- Files: `JobTrackerApi/Controllers/GmailController.cs`, `JobTrackerApi/Services/GmailOAuthService.cs`, `JobTrackerApi.Tests/GmailControllerTests.cs`
|
||||
- Do: Add a job-scoped Gmail candidate endpoint that loads the owned job plus company context, builds multiple Gmail queries from recruiter/company/title/correspondence signals, dedupes messages and groups them by thread, and returns ranked suggestions with match reasons, confidence inputs, and already-imported flags; cover the contract with focused controller/service tests instead of leaving ranking in `Correspondence.tsx`.
|
||||
- Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests`
|
||||
- Done when: the API can return ranked Gmail candidates for one job with explicit reasons and duplicate state, and backend tests prove owned-job lookup plus ranking/dedupe behavior.
|
||||
- [ ] **T02: Persist Gmail thread metadata and harden import continuity** `est:3h`
|
||||
- [x] **T02: Persist Gmail thread metadata and harden import continuity** `est:3h`
|
||||
- Why: Smarter matching is not enough if imported correspondence still loses thread identity or forces downstream slices to re-derive sender/thread information later.
|
||||
- Files: `Models/Correspondence.cs`, `JobTrackerApi/Controllers/GmailController.cs`, `JobTrackerApi/Controllers/CorrespondenceController.cs`, `JobTrackerApi/Program.cs`, `JobTrackerApi.Tests/GmailControllerTests.cs`
|
||||
- Do: Extend correspondence persistence and import logic so Gmail imports store thread/message identity plus sender/recipient metadata, update import responses and duplicate handling to reflect imported vs skipped outcomes clearly, and update startup schema guards so older SQLite/MySQL dev databases remain bootable when the new fields land.
|
||||
- Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests`
|
||||
- Done when: imported correspondence keeps enough Gmail metadata for thread continuity and dedupe, compatibility guards are planned with the schema change, and tests prove single-message/thread imports behave correctly on repeat imports.
|
||||
- [ ] **T03: Wire ranked Gmail suggestions into the job workspace UI** `est:3h`
|
||||
- [x] **T03: Wire ranked Gmail suggestions into the job workspace UI** `est:3h`
|
||||
- Why: The slice is only complete when the user can act on the smarter backend contract inside the actual job workspace rather than through a generic inbox search UI.
|
||||
- Files: `job-tracker-ui/src/components/JobDetailsDialog.tsx`, `job-tracker-ui/src/components/Correspondence.tsx`, `job-tracker-ui/src/types.ts`, `job-tracker-ui/src/correspondence-gmail-import.test.tsx`
|
||||
- Do: Pass job context from `JobDetailsDialog` into `Correspondence`, replace client-side primary ranking with the server-provided candidate contract, show match reasons/confidence/import state and thread actions, keep manual query/search as a fallback override, and add a React test that proves ranked suggestions and import refresh behavior in the dialog.
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
using System.Security.Claims;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
@@ -12,19 +17,8 @@ public sealed class GmailControllerTests
|
||||
[Fact]
|
||||
public async Task Import_thread_rejects_missing_message_ids()
|
||||
{
|
||||
var controller = new GmailController(Mock.Of<IGmailOAuthService>(), null!, BuildConfig())
|
||||
{
|
||||
ControllerContext = new Microsoft.AspNetCore.Mvc.ControllerContext
|
||||
{
|
||||
HttpContext = new Microsoft.AspNetCore.Http.DefaultHttpContext
|
||||
{
|
||||
User = new System.Security.Claims.ClaimsPrincipal(new System.Security.Claims.ClaimsIdentity(new[]
|
||||
{
|
||||
new System.Security.Claims.Claim(System.Security.Claims.ClaimTypes.NameIdentifier, "user-1")
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
await using var db = CreateDb();
|
||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||
|
||||
var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
||||
|
||||
@@ -32,6 +26,271 @@ public sealed class GmailControllerTests
|
||||
Assert.Equal("At least one messageId is required.", badRequest.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Job_candidates_returns_ranked_threads_with_match_reasons_and_import_flags()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company
|
||||
{
|
||||
Name = "Acme",
|
||||
RecruiterName = "Maria Recruiter",
|
||||
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();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = job.Id,
|
||||
From = "Company",
|
||||
Subject = "Backend Developer interview",
|
||||
ExternalMessageId = "msg-imported",
|
||||
Content = "Earlier imported thread"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListMessagesForQueriesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary(
|
||||
"msg-top",
|
||||
"thread-top",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Acme wants to schedule a backend developer interview."),
|
||||
new GmailMessageSummary(
|
||||
"msg-imported",
|
||||
"thread-top",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-4),
|
||||
"Already imported message."),
|
||||
new GmailMessageSummary(
|
||||
"msg-low",
|
||||
"thread-low",
|
||||
"Backend update",
|
||||
"alerts@example.test",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-90),
|
||||
"A generic backend role update without recruiter context.")
|
||||
});
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
|
||||
|
||||
Assert.Equal(job.Id, payload.JobApplicationId);
|
||||
Assert.NotEmpty(payload.Queries);
|
||||
Assert.Equal(2, payload.Threads.Count);
|
||||
|
||||
var topThread = payload.Threads[0];
|
||||
Assert.Equal("thread-top", topThread.ThreadId);
|
||||
Assert.Equal("high", topThread.Confidence);
|
||||
Assert.True(topThread.HasImportedMessages);
|
||||
Assert.Equal(2, topThread.MessageCount);
|
||||
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "company" && reason.Value == "Acme");
|
||||
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "recruiterEmail" && reason.Value == "maria@acme.test");
|
||||
Assert.Contains(topThread.Messages, message => message.Id == "msg-imported" && message.AlreadyImported);
|
||||
Assert.Contains(topThread.Messages, message => message.Id == "msg-top" && !message.AlreadyImported);
|
||||
|
||||
var lowThread = payload.Threads[1];
|
||||
Assert.Equal("thread-low", lowThread.ThreadId);
|
||||
Assert.Equal("low", lowThread.Confidence);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_returns_message_metadata_and_skips_repeat_message_imports()
|
||||
{
|
||||
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.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 update",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Snippet",
|
||||
"Body text",
|
||||
null));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
||||
var firstPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(firstOk.Value);
|
||||
Assert.Equal(1, firstPayload.Imported);
|
||||
Assert.Equal(0, firstPayload.Skipped);
|
||||
Assert.Equal("thread-1", firstPayload.ThreadId);
|
||||
Assert.NotNull(firstPayload.Message);
|
||||
Assert.Equal("thread-1", firstPayload.Message!.ExternalThreadId);
|
||||
Assert.Equal("Maria Recruiter <maria@acme.test>", firstPayload.Message.ExternalFrom);
|
||||
Assert.Equal("user@example.test", firstPayload.Message.ExternalTo);
|
||||
|
||||
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||
var secondPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(secondOk.Value);
|
||||
Assert.Equal(0, secondPayload.Imported);
|
||||
Assert.Equal(1, secondPayload.Skipped);
|
||||
Assert.Equal("thread-1", secondPayload.ThreadId);
|
||||
|
||||
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
|
||||
Assert.Single(storedMessages);
|
||||
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_thread_reports_imported_and_skipped_counts_for_repeat_calls()
|
||||
{
|
||||
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.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 update",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Snippet 1",
|
||||
"Body text 1",
|
||||
null));
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-2",
|
||||
"thread-1",
|
||||
"Interview follow-up",
|
||||
"user@example.test",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Snippet 2",
|
||||
"Body text 2",
|
||||
null));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
||||
|
||||
var first = await controller.ImportThread(request, CancellationToken.None);
|
||||
var firstOk = Assert.IsType<OkObjectResult>(first.Result);
|
||||
var firstPayload = Assert.IsType<GmailController.GmailImportResultDto>(firstOk.Value);
|
||||
Assert.Equal(2, firstPayload.Imported);
|
||||
Assert.Equal(0, firstPayload.Skipped);
|
||||
Assert.Equal("thread-1", firstPayload.ThreadId);
|
||||
|
||||
var second = await controller.ImportThread(request, CancellationToken.None);
|
||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||
var secondPayload = Assert.IsType<GmailController.GmailImportResultDto>(secondOk.Value);
|
||||
Assert.Equal(0, secondPayload.Imported);
|
||||
Assert.Equal(2, secondPayload.Skipped);
|
||||
|
||||
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).OrderBy(message => message.ExternalMessageId).ToListAsync();
|
||||
Assert.Equal(2, storedMessages.Count);
|
||||
Assert.All(storedMessages, message => Assert.Equal("thread-1", message.ExternalThreadId));
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task Job_candidates_respects_owned_job_scope()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "OtherCo", OwnerUserId = "user-2" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var foreignJob = new JobApplication
|
||||
{
|
||||
JobTitle = "Hidden job",
|
||||
CompanyId = company.Id,
|
||||
OwnerUserId = "user-2"
|
||||
};
|
||||
db.JobApplications.Add(foreignJob);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.JobCandidates(foreignJob.Id, null, 6, CancellationToken.None);
|
||||
|
||||
var notFound = Assert.IsType<NotFoundObjectResult>(result.Result);
|
||||
Assert.Equal("Job application not found.", notFound.Value);
|
||||
gmail.Verify(service => service.ListMessagesForQueriesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
|
||||
{
|
||||
var controller = new GmailController(gmail, db, BuildConfig())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext
|
||||
{
|
||||
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
|
||||
{
|
||||
new Claim(ClaimTypes.NameIdentifier, userId)
|
||||
}, "test"))
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb()
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options;
|
||||
var currentUser = new Mock<ICurrentUserService>();
|
||||
currentUser.SetupGet(service => service.UserId).Returns("user-1");
|
||||
return new JobTrackerContext(options, currentUser.Object);
|
||||
}
|
||||
|
||||
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
|
||||
{
|
||||
return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||||
|
||||
@@ -47,7 +47,11 @@ namespace JobTrackerApi.Controllers
|
||||
string Content,
|
||||
string? Subject,
|
||||
string? Channel,
|
||||
DateTime? Date
|
||||
DateTime? Date,
|
||||
string? ExternalMessageId,
|
||||
string? ExternalThreadId,
|
||||
string? ExternalFrom,
|
||||
string? ExternalTo
|
||||
);
|
||||
|
||||
// POST new message
|
||||
@@ -67,6 +71,10 @@ namespace JobTrackerApi.Controllers
|
||||
From = request.From.Trim(),
|
||||
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
|
||||
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
|
||||
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
|
||||
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
|
||||
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
|
||||
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
|
||||
Content = request.Content,
|
||||
Date = request.Date ?? DateTime.Now,
|
||||
};
|
||||
|
||||
@@ -25,8 +25,40 @@ public sealed class GmailController : ControllerBase
|
||||
}
|
||||
|
||||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||||
public sealed record GmailJobMatchReasonDto(string Label, string Value);
|
||||
public sealed record GmailJobMatchedMessageDto(
|
||||
string Id,
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
string From,
|
||||
string To,
|
||||
DateTimeOffset? Date,
|
||||
string Snippet,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool AlreadyImported,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||||
public sealed record GmailJobMatchedThreadDto(
|
||||
string ThreadId,
|
||||
string Subject,
|
||||
int Score,
|
||||
string Confidence,
|
||||
bool HasImportedMessages,
|
||||
int MessageCount,
|
||||
DateTimeOffset? LatestDate,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailJobMatchesResponseDto(
|
||||
int JobApplicationId,
|
||||
string JobTitle,
|
||||
string CompanyName,
|
||||
string? RecruiterName,
|
||||
string? RecruiterEmail,
|
||||
IReadOnlyList<string> Queries,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
||||
@@ -50,6 +82,94 @@ public sealed class GmailController : ControllerBase
|
||||
return Ok(new { url });
|
||||
}
|
||||
|
||||
[HttpGet("job-candidates")]
|
||||
public async Task<ActionResult<GmailJobMatchesResponseDto>> JobCandidates(
|
||||
[FromQuery] int jobApplicationId,
|
||||
[FromQuery] string? queryOverride,
|
||||
[FromQuery] int maxResultsPerQuery = 6,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (jobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var job = await _db.JobApplications
|
||||
.Include(x => x.Company)
|
||||
.Include(x => x.Messages)
|
||||
.FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken);
|
||||
if (job is null) return NotFound("Job application not found.");
|
||||
|
||||
var queries = BuildJobQueries(job, queryOverride);
|
||||
var messages = await _gmail.ListMessagesForQueriesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||
var importedMessageIds = job.Messages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var rankedMessages = messages
|
||||
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Id)))
|
||||
.Where(result => result.Score > 0)
|
||||
.OrderByDescending(result => result.Score)
|
||||
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
|
||||
.ToList();
|
||||
|
||||
var threads = rankedMessages
|
||||
.GroupBy(result => string.IsNullOrWhiteSpace(result.Message.ThreadId) ? result.Message.Id : result.Message.ThreadId, StringComparer.Ordinal)
|
||||
.Select(group =>
|
||||
{
|
||||
var ordered = group
|
||||
.OrderByDescending(item => item.Score)
|
||||
.ThenByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue)
|
||||
.ToList();
|
||||
var latestDate = ordered
|
||||
.Select(item => item.Message.Date)
|
||||
.OrderByDescending(item => item ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
var combinedReasons = ordered
|
||||
.SelectMany(item => item.Reasons)
|
||||
.GroupBy(reason => new { reason.Label, reason.Value })
|
||||
.Select(reason => reason.First())
|
||||
.Take(6)
|
||||
.ToList();
|
||||
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
|
||||
var hasImportedMessages = ordered.Any(item => item.AlreadyImported);
|
||||
var representative = ordered[0].Message;
|
||||
|
||||
return new GmailJobMatchedThreadDto(
|
||||
group.Key,
|
||||
string.IsNullOrWhiteSpace(representative.Subject) ? "(no subject)" : representative.Subject,
|
||||
threadScore,
|
||||
ToConfidence(threadScore),
|
||||
hasImportedMessages,
|
||||
ordered.Count,
|
||||
latestDate,
|
||||
combinedReasons,
|
||||
ordered.Select(item => new GmailJobMatchedMessageDto(
|
||||
item.Message.Id,
|
||||
item.Message.ThreadId,
|
||||
item.Message.Subject,
|
||||
item.Message.From,
|
||||
item.Message.To,
|
||||
item.Message.Date,
|
||||
item.Message.Snippet,
|
||||
item.Score,
|
||||
ToConfidence(item.Score),
|
||||
item.AlreadyImported,
|
||||
item.Reasons)).ToList());
|
||||
})
|
||||
.OrderByDescending(thread => thread.Score)
|
||||
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||||
.ToList();
|
||||
|
||||
return Ok(new GmailJobMatchesResponseDto(
|
||||
job.Id,
|
||||
job.JobTitle,
|
||||
job.Company?.Name ?? string.Empty,
|
||||
job.Company?.RecruiterName,
|
||||
job.Company?.RecruiterEmail,
|
||||
queries,
|
||||
threads));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oauth/callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||
@@ -98,7 +218,7 @@ public sealed class GmailController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("import")]
|
||||
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<GmailImportMessageResultDto>> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -114,12 +234,12 @@ public sealed class GmailController : ControllerBase
|
||||
cancellationToken);
|
||||
if (existing is not null)
|
||||
{
|
||||
return Ok(existing);
|
||||
return Ok(new GmailImportMessageResultDto(0, 1, request.MessageId, existing.ExternalThreadId, existing));
|
||||
}
|
||||
|
||||
var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(created);
|
||||
return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -172,6 +292,9 @@ public sealed class GmailController : ControllerBase
|
||||
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
||||
Channel = "Email",
|
||||
ExternalMessageId = detail.Id,
|
||||
ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(),
|
||||
ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(),
|
||||
ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.Trim(),
|
||||
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||
Date = messageDate,
|
||||
};
|
||||
@@ -201,6 +324,164 @@ public sealed class GmailController : ControllerBase
|
||||
return message;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
void Add(string? query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
queries.Add(query.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
Add(queryOverride);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
|
||||
{
|
||||
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
|
||||
{
|
||||
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
|
||||
{
|
||||
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
|
||||
{
|
||||
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.JobTitle))
|
||||
{
|
||||
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
foreach (var subject in job.Messages
|
||||
.Select(message => message.Subject)
|
||||
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2))
|
||||
{
|
||||
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (queries.Count == 0)
|
||||
{
|
||||
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
|
||||
}
|
||||
|
||||
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailMessageSummary message, bool alreadyImported)
|
||||
{
|
||||
var reasons = new List<GmailJobMatchReasonDto>();
|
||||
var score = 0;
|
||||
var subject = message.Subject ?? string.Empty;
|
||||
var from = message.From ?? string.Empty;
|
||||
var to = message.To ?? string.Empty;
|
||||
var snippet = message.Snippet ?? string.Empty;
|
||||
var haystack = $"{subject} {from} {to} {snippet}";
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
|
||||
{
|
||||
score += 18;
|
||||
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
|
||||
{
|
||||
score += 20;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim()));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
|
||||
{
|
||||
score += 12;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim()));
|
||||
}
|
||||
|
||||
foreach (var token in SplitTerms(job.JobTitle).Take(4))
|
||||
{
|
||||
if (!ContainsValue(haystack, token)) continue;
|
||||
score += 5;
|
||||
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token));
|
||||
}
|
||||
|
||||
foreach (var subjectLine in job.Messages
|
||||
.Select(existing => existing.Subject)
|
||||
.Where(existing => !string.IsNullOrWhiteSpace(existing))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2))
|
||||
{
|
||||
if (!ContainsValue(subject, subjectLine!)) continue;
|
||||
score += 8;
|
||||
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim()));
|
||||
}
|
||||
|
||||
if (message.Date is { } messageDate)
|
||||
{
|
||||
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
|
||||
if (ageDays <= 45)
|
||||
{
|
||||
score += 4;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recency", "45d"));
|
||||
}
|
||||
else if (ageDays <= 180)
|
||||
{
|
||||
score += 2;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recency", "180d"));
|
||||
}
|
||||
}
|
||||
|
||||
if (alreadyImported)
|
||||
{
|
||||
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported"));
|
||||
}
|
||||
|
||||
reasons = reasons
|
||||
.GroupBy(reason => new { reason.Label, reason.Value })
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
|
||||
return new GmailScoredMessage(message, alreadyImported, score, reasons);
|
||||
}
|
||||
|
||||
private static bool ContainsValue(string haystack, string? value)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value)
|
||||
&& haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> SplitTerms(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) yield break;
|
||||
|
||||
foreach (var token in value
|
||||
.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(token => token.Length >= 3)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return token;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToConfidence(int score)
|
||||
{
|
||||
return score switch
|
||||
{
|
||||
>= 30 => "high",
|
||||
>= 16 => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
|
||||
private string GetRequiredOwnerUserId()
|
||||
{
|
||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||
@@ -246,4 +527,10 @@ public sealed class GmailController : ControllerBase
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private sealed record GmailScoredMessage(
|
||||
GmailMessageSummary Message,
|
||||
bool AlreadyImported,
|
||||
int Score,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
}
|
||||
|
||||
@@ -653,6 +653,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||||
}
|
||||
|
||||
// Record the migration as applied.
|
||||
@@ -677,6 +680,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||
|
||||
@@ -733,6 +739,9 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
|
||||
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
|
||||
|
||||
@@ -18,6 +18,7 @@ public interface IGmailOAuthService
|
||||
Task<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -180,6 +181,33 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25);
|
||||
var normalizedQueries = queries
|
||||
.Where(static query => !string.IsNullOrWhiteSpace(query))
|
||||
.Select(static query => query.Trim())
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
if (normalizedQueries.Count == 0)
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var combined = new List<GmailMessageSummary>();
|
||||
foreach (var query in normalizedQueries)
|
||||
{
|
||||
var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken);
|
||||
combined.AddRange(items);
|
||||
}
|
||||
|
||||
return combined
|
||||
.GroupBy(message => message.Id, StringComparer.Ordinal)
|
||||
.Select(group => group.First())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
|
||||
+15
-12
@@ -4,17 +4,20 @@ using System.Text.Json.Serialization;
|
||||
namespace JobTrackerApi.Models
|
||||
{
|
||||
public class Correspondence
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int JobApplicationId { get; set; }
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public int JobApplicationId { get; set; }
|
||||
|
||||
[JsonIgnore]
|
||||
public JobApplication JobApplication { get; set; } = null!;
|
||||
public string From { get; set; } = ""; // "Me" or "Company"
|
||||
public string? Subject { get; set; }
|
||||
public string? Channel { get; set; } // e.g. Email, Call, Note
|
||||
public string? ExternalMessageId { get; set; }
|
||||
public string Content { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.Now;
|
||||
}
|
||||
[JsonIgnore]
|
||||
public JobApplication JobApplication { get; set; } = null!;
|
||||
public string From { get; set; } = ""; // "Me" or "Company"
|
||||
public string? Subject { get; set; }
|
||||
public string? Channel { get; set; } // e.g. Email, Call, Note
|
||||
public string? ExternalMessageId { get; set; }
|
||||
public string? ExternalThreadId { get; set; }
|
||||
public string? ExternalFrom { get; set; }
|
||||
public string? ExternalTo { get; set; }
|
||||
public string Content { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,11 +25,19 @@ import { alpha, useTheme } from "@mui/material/styles";
|
||||
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import { IconButton } from "@mui/material";
|
||||
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
||||
import {
|
||||
CorrespondenceMessage,
|
||||
GmailImportMessageResult,
|
||||
GmailImportThreadResult,
|
||||
GmailJobMatchesResponse,
|
||||
GmailStatus,
|
||||
JobApplication,
|
||||
} from "../types";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
@@ -63,22 +71,32 @@ function parseRawEmail(raw: string): { subject?: string; date?: string; from?: s
|
||||
return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body };
|
||||
}
|
||||
|
||||
function scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) {
|
||||
const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase();
|
||||
const q = query.toLowerCase();
|
||||
let score = 0;
|
||||
if (q && hay.includes(q)) score += 8;
|
||||
if (/interview|application|recruit|follow up|follow-up|position|role/.test(hay)) score += 2;
|
||||
if (messages.some((m) => m.subject && message.subject && m.subject.toLowerCase() === message.subject.toLowerCase())) score -= 4;
|
||||
if (message.date) {
|
||||
const ageDays = Math.abs((Date.now() - new Date(message.date).getTime()) / 86400000);
|
||||
if (ageDays <= 30) score += 3;
|
||||
else if (ageDays <= 120) score += 1;
|
||||
}
|
||||
return score;
|
||||
function formatConfidence(value: string) {
|
||||
return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
|
||||
}
|
||||
|
||||
export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
function formatReasonLabel(label: string) {
|
||||
switch (label) {
|
||||
case "company":
|
||||
return "Company";
|
||||
case "recruiterEmail":
|
||||
return "Recruiter email";
|
||||
case "recruiter":
|
||||
return "Recruiter";
|
||||
case "jobTitle":
|
||||
return "Job title";
|
||||
case "existingSubject":
|
||||
return "Existing subject";
|
||||
case "recency":
|
||||
return "Recent";
|
||||
case "status":
|
||||
return "Status";
|
||||
default:
|
||||
return label;
|
||||
}
|
||||
}
|
||||
|
||||
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
const { t } = useI18n();
|
||||
@@ -95,8 +113,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||
const [gmailLoading, setGmailLoading] = useState(false);
|
||||
const [gmailQuery, setGmailQuery] = useState("");
|
||||
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
|
||||
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
|
||||
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
|
||||
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||
|
||||
@@ -117,22 +135,23 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadGmailMessages = useCallback(async () => {
|
||||
const loadGmailMatches = useCallback(async (queryOverride?: string) => {
|
||||
try {
|
||||
setGmailMessagesLoading(true);
|
||||
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
|
||||
setGmailMatchesLoading(true);
|
||||
const res = await api.get<GmailJobMatchesResponse>("/gmail/job-candidates", {
|
||||
params: {
|
||||
query: gmailQuery.trim() || undefined,
|
||||
maxResults: 12,
|
||||
jobApplicationId: jobId,
|
||||
queryOverride: queryOverride?.trim() || undefined,
|
||||
maxResultsPerQuery: 6,
|
||||
},
|
||||
});
|
||||
setGmailMessages(res.data);
|
||||
setGmailMatches(res.data);
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error");
|
||||
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
|
||||
} finally {
|
||||
setGmailMessagesLoading(false);
|
||||
setGmailMatchesLoading(false);
|
||||
}
|
||||
}, [gmailQuery, toast]);
|
||||
}, [jobId, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
@@ -151,8 +170,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
||||
void loadGmailMessages();
|
||||
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
|
||||
void loadGmailMatches(gmailQuery);
|
||||
}, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]);
|
||||
|
||||
useEffect(() => {
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
@@ -162,7 +181,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
toast(data.message || t("googleLinkedSuccess"), "success");
|
||||
void loadGmailStatus();
|
||||
setImportTab(1);
|
||||
void loadGmailMessages();
|
||||
void loadGmailMatches(gmailQuery);
|
||||
} else {
|
||||
toast(data.message || t("googleAuthFailed"), "error");
|
||||
}
|
||||
@@ -170,36 +189,24 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [loadGmailMessages, loadGmailStatus, t, toast]);
|
||||
}, [gmailQuery, loadGmailMatches, loadGmailStatus, t, toast]);
|
||||
|
||||
const canSend = useMemo(() => text.trim().length > 0, [text]);
|
||||
|
||||
const suggestedQueries = useMemo(() => {
|
||||
const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[];
|
||||
const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2);
|
||||
const companyTerms = Array.from(new Set(messages.filter((m) => m.from === "Company").map((m) => m.subject).filter(Boolean) as string[])).slice(0, 2);
|
||||
const fromSubjects = messages.map((m) => m.subject).filter(Boolean) as string[];
|
||||
const uniqueSubjects = Array.from(new Set(fromSubjects)).slice(0, 2);
|
||||
const companyName = job?.company?.name?.trim();
|
||||
const recruiterEmail = job?.company?.recruiterEmail?.trim();
|
||||
const jobTitle = job?.jobTitle?.trim();
|
||||
|
||||
return [
|
||||
{ label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" },
|
||||
{ label: "Inbox only", value: "label:inbox newer_than:120d" },
|
||||
{ label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" },
|
||||
...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })),
|
||||
...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })),
|
||||
].slice(0, 6);
|
||||
}, [messages]);
|
||||
|
||||
const rankedMessages = useMemo(() => {
|
||||
return [...gmailMessages].sort((a, b) => scoreMessage(b, gmailQuery, messages) - scoreMessage(a, gmailQuery, messages));
|
||||
}, [gmailMessages, gmailQuery, messages]);
|
||||
|
||||
const groupedByThread = useMemo(() => {
|
||||
const map = new Map<string, GmailMessageSummary[]>();
|
||||
for (const message of rankedMessages) {
|
||||
const key = message.threadId || message.id;
|
||||
map.set(key, [...(map.get(key) ?? []), message]);
|
||||
}
|
||||
return Array.from(map.entries()).map(([threadId, items]) => ({ threadId, items }));
|
||||
}, [rankedMessages]);
|
||||
recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
|
||||
companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
|
||||
companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
|
||||
...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
|
||||
].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>;
|
||||
}, [job?.company?.name, job?.company?.recruiterEmail, job?.jobTitle, messages]);
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend) return;
|
||||
@@ -227,6 +234,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
subject: parsed.subject ?? null,
|
||||
content: parsed.body || rawEmail,
|
||||
date: parsed.date ?? null,
|
||||
externalFrom: parsed.from ?? null,
|
||||
externalTo: parsed.to ?? null,
|
||||
});
|
||||
setImportOpen(false);
|
||||
setRawEmail("");
|
||||
@@ -251,7 +260,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
try {
|
||||
await api.delete("/gmail/connection");
|
||||
setGmailStatus({ connected: false });
|
||||
setGmailMessages([]);
|
||||
setGmailMatches(null);
|
||||
toast(t("googleUnlinked"), "success");
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
||||
@@ -272,9 +281,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const importGmailMessage = async (messageId: string) => {
|
||||
try {
|
||||
setImportingMessageId(messageId);
|
||||
await api.post("/gmail/import", { jobApplicationId: jobId, messageId });
|
||||
const res = await api.post<GmailImportMessageResult>("/gmail/import", { jobApplicationId: jobId, messageId });
|
||||
await load();
|
||||
toast(t("correspondenceImportEmail"), "success");
|
||||
await loadGmailMatches(gmailQuery);
|
||||
if (res.data.imported > 0) {
|
||||
toast(t("correspondenceImportEmail"), "success");
|
||||
} else {
|
||||
toast("This Gmail message is already linked to the job.", "success");
|
||||
}
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
|
||||
} finally {
|
||||
@@ -285,8 +299,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
const importGmailThread = async (threadId: string, messageIds: string[]) => {
|
||||
try {
|
||||
setImportingThreadId(threadId);
|
||||
const res = await api.post<{ imported: number; skipped: number; threadId?: string }>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
||||
const res = await api.post<GmailImportThreadResult>("/gmail/import-thread", { jobApplicationId: jobId, threadId, messageIds });
|
||||
await load();
|
||||
await loadGmailMatches(gmailQuery);
|
||||
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
|
||||
@@ -310,6 +325,13 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
|
||||
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
||||
{(m.externalThreadId || m.externalFrom || m.externalTo) ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
||||
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
|
||||
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
|
||||
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
||||
@@ -360,11 +382,16 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
|
||||
</Typography>
|
||||
{job ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 0.5 }}>
|
||||
Matching against {job.company?.name || "this company"} / {job.jobTitle}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{gmailStatus?.connected ? (
|
||||
<>
|
||||
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceRefresh")}</Button>
|
||||
<Button variant="outlined" onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceRefresh")}</Button>
|
||||
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
|
||||
</>
|
||||
) : (
|
||||
@@ -382,36 +409,65 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<TextField label={t("correspondenceSearchGmail")} value={gmailQuery} onChange={(e) => setGmailQuery(e.target.value)} placeholder={t("correspondenceSearchGmailPlaceholder")} size="small" fullWidth />
|
||||
<Button variant="outlined" onClick={() => void loadGmailMessages()} disabled={gmailMessagesLoading}>{t("correspondenceSearch")}</Button>
|
||||
<Button variant="outlined" startIcon={<SearchIcon />} onClick={() => void loadGmailMatches(gmailQuery)} disabled={gmailMatchesLoading}>{t("correspondenceSearch")}</Button>
|
||||
</Box>
|
||||
{gmailMatches?.queries?.length ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
{gmailMatches.queries.slice(0, 4).map((query) => (
|
||||
<Chip key={query} size="small" label={query} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
) : null}
|
||||
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
||||
{gmailMessagesLoading ? (
|
||||
{gmailMatchesLoading ? (
|
||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||
) : groupedByThread.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography>
|
||||
) : !gmailMatches || gmailMatches.threads.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", p: 2 }}>
|
||||
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
|
||||
</Typography>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{groupedByThread.map(({ threadId, items }, threadIndex) => (
|
||||
<React.Fragment key={threadId}>
|
||||
{gmailMatches.threads.map((thread, threadIndex) => (
|
||||
<React.Fragment key={thread.threadId}>
|
||||
{threadIndex > 0 ? <Divider /> : null}
|
||||
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</Typography>
|
||||
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 0.75 }}>
|
||||
<Chip size="small" label={`${formatConfidence(thread.confidence)} · score ${thread.score}`} color={thread.confidence === "high" ? "success" : thread.confidence === "medium" ? "warning" : "default"} />
|
||||
<Chip size="small" label={`${thread.messageCount} message${thread.messageCount === 1 ? "" : "s"}`} variant="outlined" />
|
||||
{thread.hasImportedMessages ? <Chip size="small" label="Already linked" variant="outlined" color="success" /> : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||
{thread.matchReasons.map((reason) => (
|
||||
<Chip key={`${thread.threadId}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
|
||||
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === thread.threadId} onClick={() => void importGmailThread(thread.threadId, thread.messages.map((x) => x.id))}>
|
||||
{importingThreadId === thread.threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
||||
</Button>
|
||||
</Box>
|
||||
{items.map((message, index) => (
|
||||
{thread.messages.map((message, index) => (
|
||||
<React.Fragment key={message.id}>
|
||||
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
||||
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
|
||||
<ListItemText
|
||||
secondaryTypographyProps={{ component: "div" }}
|
||||
primary={<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap" }}><Typography sx={{ fontWeight: 700 }}>{message.subject || t("correspondenceNoSubject")}</Typography><Typography variant="caption" sx={{ color: "text.secondary" }}>{message.date ? new Date(message.date).toLocaleString() : ""}</Typography></Box>}
|
||||
secondary={<Box sx={{ mt: 0.5 }}><Typography variant="body2" sx={{ color: "text.primary" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography><Typography variant="body2" sx={{ color: "text.secondary", mt: 0.25 }}>{message.snippet}</Typography></Box>}
|
||||
secondary={<Box component="span" sx={{ mt: 0.5, display: "block" }}>
|
||||
<Typography component="span" variant="body2" sx={{ color: "text.primary", display: "block" }}>{t("correspondenceFromLabel", { value: message.from || t("correspondenceUnknown") })}</Typography>
|
||||
<Typography component="span" variant="body2" sx={{ color: "text.secondary", mt: 0.25, display: "block" }}>{message.snippet}</Typography>
|
||||
<Box component="span" sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||
<Chip size="small" label={`${formatConfidence(message.confidence)} · score ${message.score}`} variant="outlined" />
|
||||
{message.alreadyImported ? <Chip size="small" label="Already linked" color="success" variant="outlined" /> : null}
|
||||
{message.matchReasons.map((reason) => (
|
||||
<Chip key={`${message.id}-${reason.label}-${reason.value}`} size="small" label={`${formatReasonLabel(reason.label)}: ${reason.value}`} variant="outlined" />
|
||||
))}
|
||||
</Box>
|
||||
</Box>}
|
||||
/>
|
||||
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
|
||||
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
|
||||
|
||||
@@ -357,7 +357,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
|
||||
{tab === 1 && jobId && <Correspondence jobId={jobId} job={job} />}
|
||||
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
||||
|
||||
{tab === 3 && (
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import React from "react";
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { ConfirmProvider } from "./confirm";
|
||||
import { PromptProvider } from "./prompt";
|
||||
import { ToastProvider } from "./toast";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
import JobDetailsDialog from "./components/JobDetailsDialog";
|
||||
import { api } from "./api";
|
||||
|
||||
jest.setTimeout(15000);
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderDialog() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<ConfirmProvider>
|
||||
<PromptProvider>
|
||||
<JobDetailsDialog open jobId={42} onClose={() => {}} initialTab={1} />
|
||||
</PromptProvider>
|
||||
</ConfirmProvider>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("correspondence Gmail import", () => {
|
||||
let correspondenceMessages: any[];
|
||||
|
||||
beforeEach(() => {
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: jest.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
});
|
||||
window.open = jest.fn();
|
||||
|
||||
correspondenceMessages = [];
|
||||
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === "/jobapplications/42") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
id: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
status: "Applied",
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 3,
|
||||
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
||||
tailoredCvText: "",
|
||||
shortSummary: "summary",
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/auth/me") {
|
||||
return Promise.resolve({ data: { roles: [], profileCvText: "Master CV text" } } as any);
|
||||
}
|
||||
if (url === "/jobapplications/42/history") {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
}
|
||||
if (url === "/attachments/42") {
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
}
|
||||
if (url === "/correspondence/42") {
|
||||
return Promise.resolve({ data: correspondenceMessages } as any);
|
||||
}
|
||||
if (url === "/gmail/status") {
|
||||
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString() } } as any);
|
||||
}
|
||||
if (url === "/gmail/job-candidates") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
jobApplicationId: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
companyName: "Acme",
|
||||
recruiterName: "Maria Recruiter",
|
||||
recruiterEmail: "maria@acme.test",
|
||||
queries: [config?.params?.queryOverride || '"Acme" "Backend Developer" newer_than:365d'],
|
||||
threads: [
|
||||
{
|
||||
threadId: "thread-1",
|
||||
subject: "Backend Developer interview",
|
||||
score: 42,
|
||||
confidence: "high",
|
||||
hasImportedMessages: false,
|
||||
messageCount: 2,
|
||||
latestDate: new Date().toISOString(),
|
||||
matchReasons: [
|
||||
{ label: "company", value: "Acme" },
|
||||
{ label: "recruiterEmail", value: "maria@acme.test" },
|
||||
],
|
||||
messages: [
|
||||
{
|
||||
id: "msg-1",
|
||||
threadId: "thread-1",
|
||||
subject: "Backend Developer interview",
|
||||
from: "Maria Recruiter <maria@acme.test>",
|
||||
to: "user@example.test",
|
||||
date: new Date().toISOString(),
|
||||
snippet: "Acme wants to schedule a call.",
|
||||
score: 42,
|
||||
confidence: "high",
|
||||
alreadyImported: false,
|
||||
matchReasons: [
|
||||
{ label: "company", value: "Acme" },
|
||||
{ label: "jobTitle", value: "Developer" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "msg-2",
|
||||
threadId: "thread-1",
|
||||
subject: "Backend Developer follow-up",
|
||||
from: "user@example.test",
|
||||
to: "Maria Recruiter <maria@acme.test>",
|
||||
date: new Date().toISOString(),
|
||||
snippet: "Following up on the role.",
|
||||
score: 24,
|
||||
confidence: "medium",
|
||||
alreadyImported: false,
|
||||
matchReasons: [{ label: "recency", value: "45d" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
|
||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||
if (url === "/gmail/import") {
|
||||
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: body.messageId,
|
||||
externalThreadId: "thread-1",
|
||||
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||
externalTo: "user@example.test",
|
||||
},
|
||||
];
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
imported: 1,
|
||||
skipped: 0,
|
||||
messageId: body.messageId,
|
||||
threadId: "thread-1",
|
||||
message: correspondenceMessages[0],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/gmail/import-thread") {
|
||||
return Promise.resolve({ data: { imported: 2, skipped: 0, threadId: "thread-1" } } as any);
|
||||
}
|
||||
if (url === "/correspondence") {
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
|
||||
mockedApi.put.mockResolvedValue({ data: {} } as any);
|
||||
mockedApi.delete.mockResolvedValue({ data: {} } as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test("shows ranked Gmail suggestions with reasons and refreshes correspondence after message import", async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
|
||||
expect((await screen.findAllByText(/backend developer interview/i)).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText(/high confidence · score 42/i)).length).toBeGreaterThan(0);
|
||||
expect((await screen.findAllByText(/company: acme/i)).length).toBeGreaterThan(0);
|
||||
expect(await screen.findByText(/recruiter email: maria@acme\.test/i)).toBeInTheDocument();
|
||||
|
||||
const importButtons = await screen.findAllByRole("button", { name: /^import email$/i });
|
||||
fireEvent.click(importButtons[importButtons.length - 1]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/import", { jobApplicationId: 42, messageId: "msg-2" });
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/thread thread-1/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/from maria recruiter <maria@acme\.test>/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/to user@example\.test/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
|
||||
const search = await screen.findByLabelText(/search gmail/i);
|
||||
fireEvent.change(search, { target: { value: 'subject:"Acme" newer_than:30d' } });
|
||||
fireEvent.click(screen.getByRole("button", { name: /^search$/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.get).toHaveBeenCalledWith("/gmail/job-candidates", expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
jobApplicationId: 42,
|
||||
queryOverride: 'subject:"Acme" newer_than:30d',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -125,8 +125,66 @@ export interface CorrespondenceMessage {
|
||||
subject?: string;
|
||||
channel?: string;
|
||||
date: string;
|
||||
externalMessageId?: string | null;
|
||||
externalThreadId?: string | null;
|
||||
externalFrom?: string | null;
|
||||
externalTo?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailJobMatchReason {
|
||||
label: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface GmailJobMatchedMessage {
|
||||
id: string;
|
||||
threadId: string;
|
||||
subject: string;
|
||||
from: string;
|
||||
to: string;
|
||||
date?: string;
|
||||
snippet: string;
|
||||
score: number;
|
||||
confidence: string;
|
||||
alreadyImported: boolean;
|
||||
matchReasons: GmailJobMatchReason[];
|
||||
}
|
||||
|
||||
export interface GmailJobMatchedThread {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
score: number;
|
||||
confidence: string;
|
||||
hasImportedMessages: boolean;
|
||||
messageCount: number;
|
||||
latestDate?: string;
|
||||
matchReasons: GmailJobMatchReason[];
|
||||
messages: GmailJobMatchedMessage[];
|
||||
}
|
||||
|
||||
export interface GmailJobMatchesResponse {
|
||||
jobApplicationId: number;
|
||||
jobTitle: string;
|
||||
companyName: string;
|
||||
recruiterName?: string | null;
|
||||
recruiterEmail?: string | null;
|
||||
queries: string[];
|
||||
threads: GmailJobMatchedThread[];
|
||||
}
|
||||
|
||||
export interface GmailImportMessageResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
messageId: string;
|
||||
threadId?: string | null;
|
||||
message?: CorrespondenceMessage | null;
|
||||
}
|
||||
|
||||
export interface GmailImportThreadResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
threadId?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailStatus {
|
||||
connected: boolean;
|
||||
|
||||
Reference in New Issue
Block a user