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
|
## 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
|
## Architecture / Key Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ This milestone is complete only when all are true:
|
|||||||
|
|
||||||
## Slices
|
## 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.
|
> 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]`
|
- [ ] **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
|
## 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.
|
- 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`
|
- 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`.
|
- 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`
|
- 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.
|
- 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.
|
- 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`
|
- 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.
|
- 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`
|
- 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.
|
- 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.
|
- 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`
|
- 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.
|
- 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.Controllers;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
using Moq;
|
using Moq;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
@@ -12,19 +17,8 @@ public sealed class GmailControllerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Import_thread_rejects_missing_message_ids()
|
public async Task Import_thread_rejects_missing_message_ids()
|
||||||
{
|
{
|
||||||
var controller = new GmailController(Mock.Of<IGmailOAuthService>(), null!, BuildConfig())
|
await using var db = CreateDb();
|
||||||
{
|
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||||
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"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
var result = await controller.ImportThread(new GmailController.ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
|
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);
|
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()
|
private static Microsoft.Extensions.Configuration.IConfiguration BuildConfig()
|
||||||
{
|
{
|
||||||
return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
return new Microsoft.Extensions.Configuration.ConfigurationBuilder()
|
||||||
|
|||||||
@@ -47,7 +47,11 @@ namespace JobTrackerApi.Controllers
|
|||||||
string Content,
|
string Content,
|
||||||
string? Subject,
|
string? Subject,
|
||||||
string? Channel,
|
string? Channel,
|
||||||
DateTime? Date
|
DateTime? Date,
|
||||||
|
string? ExternalMessageId,
|
||||||
|
string? ExternalThreadId,
|
||||||
|
string? ExternalFrom,
|
||||||
|
string? ExternalTo
|
||||||
);
|
);
|
||||||
|
|
||||||
// POST new message
|
// POST new message
|
||||||
@@ -67,6 +71,10 @@ namespace JobTrackerApi.Controllers
|
|||||||
From = request.From.Trim(),
|
From = request.From.Trim(),
|
||||||
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
|
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
|
||||||
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.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,
|
Content = request.Content,
|
||||||
Date = request.Date ?? DateTime.Now,
|
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 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 ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||||||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
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")]
|
[HttpGet("status")]
|
||||||
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
||||||
@@ -50,6 +82,94 @@ public sealed class GmailController : ControllerBase
|
|||||||
return Ok(new { url });
|
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]
|
[AllowAnonymous]
|
||||||
[HttpGet("oauth/callback")]
|
[HttpGet("oauth/callback")]
|
||||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||||
@@ -98,7 +218,7 @@ public sealed class GmailController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("import")]
|
[HttpPost("import")]
|
||||||
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
public async Task<ActionResult<GmailImportMessageResultDto>> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -114,12 +234,12 @@ public sealed class GmailController : ControllerBase
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (existing is not null)
|
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);
|
var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(created);
|
return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created));
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -172,6 +292,9 @@ public sealed class GmailController : ControllerBase
|
|||||||
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
||||||
Channel = "Email",
|
Channel = "Email",
|
||||||
ExternalMessageId = detail.Id,
|
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,
|
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
|
||||||
Date = messageDate,
|
Date = messageDate,
|
||||||
};
|
};
|
||||||
@@ -201,6 +324,164 @@ public sealed class GmailController : ControllerBase
|
|||||||
return message;
|
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()
|
private string GetRequiredOwnerUserId()
|
||||||
{
|
{
|
||||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||||
@@ -246,4 +527,10 @@ public sealed class GmailController : ControllerBase
|
|||||||
</body>
|
</body>
|
||||||
</html>";
|
</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", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel 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", "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.
|
// 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", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
|
||||||
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel 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", "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", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
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, "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", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
|
||||||
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime 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", "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, "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;");
|
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<GmailConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
|
||||||
Task DisconnectAsync(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>> 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);
|
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +181,33 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
|||||||
return results;
|
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)
|
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||||
|
|||||||
+15
-12
@@ -4,17 +4,20 @@ using System.Text.Json.Serialization;
|
|||||||
namespace JobTrackerApi.Models
|
namespace JobTrackerApi.Models
|
||||||
{
|
{
|
||||||
public class Correspondence
|
public class Correspondence
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
public int Id { get; set; }
|
||||||
public int JobApplicationId { get; set; }
|
public int JobApplicationId { get; set; }
|
||||||
|
|
||||||
[JsonIgnore]
|
[JsonIgnore]
|
||||||
public JobApplication JobApplication { get; set; } = null!;
|
public JobApplication JobApplication { get; set; } = null!;
|
||||||
public string From { get; set; } = ""; // "Me" or "Company"
|
public string From { get; set; } = ""; // "Me" or "Company"
|
||||||
public string? Subject { get; set; }
|
public string? Subject { get; set; }
|
||||||
public string? Channel { get; set; } // e.g. Email, Call, Note
|
public string? Channel { get; set; } // e.g. Email, Call, Note
|
||||||
public string? ExternalMessageId { get; set; }
|
public string? ExternalMessageId { get; set; }
|
||||||
public string Content { get; set; } = "";
|
public string? ExternalThreadId { get; set; }
|
||||||
public DateTime Date { get; set; } = DateTime.Now;
|
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 DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
|
||||||
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
import AutoAwesomeIcon from "@mui/icons-material/AutoAwesome";
|
||||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||||
|
import SearchIcon from "@mui/icons-material/Search";
|
||||||
import { IconButton } from "@mui/material";
|
import { IconButton } from "@mui/material";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { CorrespondenceMessage, GmailMessageSummary, GmailStatus } from "../types";
|
import {
|
||||||
|
CorrespondenceMessage,
|
||||||
|
GmailImportMessageResult,
|
||||||
|
GmailImportThreadResult,
|
||||||
|
GmailJobMatchesResponse,
|
||||||
|
GmailStatus,
|
||||||
|
JobApplication,
|
||||||
|
} from "../types";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
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 };
|
return { subject: headers["subject"], from: headers["from"], to: headers["to"], date: iso, body };
|
||||||
}
|
}
|
||||||
|
|
||||||
function scoreMessage(message: GmailMessageSummary, query: string, messages: CorrespondenceMessage[]) {
|
function formatConfidence(value: string) {
|
||||||
const hay = `${message.subject} ${message.from} ${message.to} ${message.snippet}`.toLowerCase();
|
return value ? `${value[0].toUpperCase()}${value.slice(1)} confidence` : "Confidence unknown";
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 theme = useTheme();
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -95,8 +113,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
|
||||||
const [gmailLoading, setGmailLoading] = useState(false);
|
const [gmailLoading, setGmailLoading] = useState(false);
|
||||||
const [gmailQuery, setGmailQuery] = useState("");
|
const [gmailQuery, setGmailQuery] = useState("");
|
||||||
const [gmailMessages, setGmailMessages] = useState<GmailMessageSummary[]>([]);
|
const [gmailMatches, setGmailMatches] = useState<GmailJobMatchesResponse | null>(null);
|
||||||
const [gmailMessagesLoading, setGmailMessagesLoading] = useState(false);
|
const [gmailMatchesLoading, setGmailMatchesLoading] = useState(false);
|
||||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||||
|
|
||||||
@@ -117,22 +135,23 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadGmailMessages = useCallback(async () => {
|
const loadGmailMatches = useCallback(async (queryOverride?: string) => {
|
||||||
try {
|
try {
|
||||||
setGmailMessagesLoading(true);
|
setGmailMatchesLoading(true);
|
||||||
const res = await api.get<GmailMessageSummary[]>("/gmail/messages", {
|
const res = await api.get<GmailJobMatchesResponse>("/gmail/job-candidates", {
|
||||||
params: {
|
params: {
|
||||||
query: gmailQuery.trim() || undefined,
|
jobApplicationId: jobId,
|
||||||
maxResults: 12,
|
queryOverride: queryOverride?.trim() || undefined,
|
||||||
|
maxResultsPerQuery: 6,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
setGmailMessages(res.data);
|
setGmailMatches(res.data);
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, "Failed to load Gmail messages."), "error");
|
toast(getApiErrorMessage(error, "Failed to load Gmail suggestions."), "error");
|
||||||
} finally {
|
} finally {
|
||||||
setGmailMessagesLoading(false);
|
setGmailMatchesLoading(false);
|
||||||
}
|
}
|
||||||
}, [gmailQuery, toast]);
|
}, [jobId, toast]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load();
|
void load();
|
||||||
@@ -151,8 +170,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
if (!importOpen || importTab !== 1 || !gmailStatus?.connected) return;
|
||||||
void loadGmailMessages();
|
void loadGmailMatches(gmailQuery);
|
||||||
}, [importOpen, importTab, gmailStatus?.connected, loadGmailMessages]);
|
}, [importOpen, importTab, gmailStatus?.connected, gmailQuery, loadGmailMatches]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onMessage = (event: MessageEvent) => {
|
const onMessage = (event: MessageEvent) => {
|
||||||
@@ -162,7 +181,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
toast(data.message || t("googleLinkedSuccess"), "success");
|
toast(data.message || t("googleLinkedSuccess"), "success");
|
||||||
void loadGmailStatus();
|
void loadGmailStatus();
|
||||||
setImportTab(1);
|
setImportTab(1);
|
||||||
void loadGmailMessages();
|
void loadGmailMatches(gmailQuery);
|
||||||
} else {
|
} else {
|
||||||
toast(data.message || t("googleAuthFailed"), "error");
|
toast(data.message || t("googleAuthFailed"), "error");
|
||||||
}
|
}
|
||||||
@@ -170,36 +189,24 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
|
|
||||||
window.addEventListener("message", onMessage);
|
window.addEventListener("message", onMessage);
|
||||||
return () => window.removeEventListener("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 canSend = useMemo(() => text.trim().length > 0, [text]);
|
||||||
|
|
||||||
const suggestedQueries = useMemo(() => {
|
const suggestedQueries = useMemo(() => {
|
||||||
const subjectTerms = messages.map((m) => m.subject).filter(Boolean) as string[];
|
const fromSubjects = messages.map((m) => m.subject).filter(Boolean) as string[];
|
||||||
const uniqueSubjects = Array.from(new Set(subjectTerms)).slice(0, 2);
|
const uniqueSubjects = Array.from(new Set(fromSubjects)).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 companyName = job?.company?.name?.trim();
|
||||||
|
const recruiterEmail = job?.company?.recruiterEmail?.trim();
|
||||||
|
const jobTitle = job?.jobTitle?.trim();
|
||||||
|
|
||||||
return [
|
return [
|
||||||
{ label: "Recent recruiting mail", value: "newer_than:180d (interview OR recruiter OR application OR position)" },
|
recruiterEmail ? { label: "Recruiter mailbox", value: `(from:${recruiterEmail} OR to:${recruiterEmail}) newer_than:365d` } : null,
|
||||||
{ label: "Inbox only", value: "label:inbox newer_than:120d" },
|
companyName && jobTitle ? { label: "Company + role", value: `"${companyName}" "${jobTitle}" newer_than:365d` } : null,
|
||||||
{ label: "Sent follow-ups", value: "in:sent newer_than:180d follow up" },
|
companyName ? { label: "Company mail", value: `"${companyName}" (application OR interview OR recruiter) newer_than:365d` } : null,
|
||||||
...uniqueSubjects.map((s) => ({ label: `Subject: ${s}`, value: `subject:"${s}"` })),
|
...uniqueSubjects.map((subject) => ({ label: `Subject: ${subject}`, value: `subject:"${subject}" newer_than:365d` })),
|
||||||
...companyTerms.map((s) => ({ label: `Related topic: ${s}`, value: `"${s}" newer_than:180d` })),
|
].filter(Boolean).slice(0, 6) as Array<{ label: string; value: string }>;
|
||||||
].slice(0, 6);
|
}, [job?.company?.name, job?.company?.recruiterEmail, job?.jobTitle, messages]);
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
const send = async () => {
|
const send = async () => {
|
||||||
if (!canSend) return;
|
if (!canSend) return;
|
||||||
@@ -227,6 +234,8 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
subject: parsed.subject ?? null,
|
subject: parsed.subject ?? null,
|
||||||
content: parsed.body || rawEmail,
|
content: parsed.body || rawEmail,
|
||||||
date: parsed.date ?? null,
|
date: parsed.date ?? null,
|
||||||
|
externalFrom: parsed.from ?? null,
|
||||||
|
externalTo: parsed.to ?? null,
|
||||||
});
|
});
|
||||||
setImportOpen(false);
|
setImportOpen(false);
|
||||||
setRawEmail("");
|
setRawEmail("");
|
||||||
@@ -251,7 +260,7 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
try {
|
try {
|
||||||
await api.delete("/gmail/connection");
|
await api.delete("/gmail/connection");
|
||||||
setGmailStatus({ connected: false });
|
setGmailStatus({ connected: false });
|
||||||
setGmailMessages([]);
|
setGmailMatches(null);
|
||||||
toast(t("googleUnlinked"), "success");
|
toast(t("googleUnlinked"), "success");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
toast(getApiErrorMessage(error, t("correspondenceDisconnectFailed")), "error");
|
||||||
@@ -272,9 +281,14 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
const importGmailMessage = async (messageId: string) => {
|
const importGmailMessage = async (messageId: string) => {
|
||||||
try {
|
try {
|
||||||
setImportingMessageId(messageId);
|
setImportingMessageId(messageId);
|
||||||
await api.post("/gmail/import", { jobApplicationId: jobId, messageId });
|
const res = await api.post<GmailImportMessageResult>("/gmail/import", { jobApplicationId: jobId, messageId });
|
||||||
await load();
|
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) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
|
toast(getApiErrorMessage(error, t("correspondenceImportGmailFailed")), "error");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -285,8 +299,9 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
const importGmailThread = async (threadId: string, messageIds: string[]) => {
|
const importGmailThread = async (threadId: string, messageIds: string[]) => {
|
||||||
try {
|
try {
|
||||||
setImportingThreadId(threadId);
|
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 load();
|
||||||
|
await loadGmailMatches(gmailQuery);
|
||||||
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
|
toast(t("correspondenceImportThreadResult", { imported: res.data.imported, skippedText: res.data.skipped ? t("correspondenceImportThreadSkipped", { count: res.data.skipped }) : "" }), "success");
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast(getApiErrorMessage(error, t("correspondenceImportThreadFailed")), "error");
|
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" }}>
|
<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}
|
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
||||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
<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 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
{isMe ? t("correspondenceMe") : t("correspondenceCompany")}{m.channel ? ` - ${m.channel}` : ""}{m.date ? ` - ${new Date(m.date).toLocaleString()}` : ""}
|
{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" }}>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
|
{gmailLoading ? t("correspondenceCheckingConnection") : gmailStatus?.connected ? t("correspondenceConnectedAs", { email: gmailStatus.gmailAddress || "" }) : t("correspondenceConnectGmailHint")}
|
||||||
</Typography>
|
</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>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||||
{gmailStatus?.connected ? (
|
{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>
|
<Button variant="outlined" color="error" onClick={() => void disconnectGmail()}>{t("correspondenceDisconnect")}</Button>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
@@ -382,36 +409,65 @@ export default function Correspondence({ jobId }: { jobId: number }) {
|
|||||||
</Box>
|
</Box>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
<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 />
|
<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>
|
</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}
|
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||||
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
<Paper variant="outlined" sx={{ maxHeight: 420, overflowY: "auto" }}>
|
||||||
{gmailMessagesLoading ? (
|
{gmailMatchesLoading ? (
|
||||||
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
<Box sx={{ py: 5, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box>
|
||||||
) : groupedByThread.length === 0 ? (
|
) : !gmailMatches || gmailMatches.threads.length === 0 ? (
|
||||||
<Typography sx={{ color: "text.secondary", p: 2 }}>{t("correspondenceNoGmailMessages")}</Typography>
|
<Typography sx={{ color: "text.secondary", p: 2 }}>
|
||||||
|
{gmailQuery.trim() ? "No Gmail matches for this job and search override yet." : t("correspondenceNoGmailMessages")}
|
||||||
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<List disablePadding>
|
<List disablePadding>
|
||||||
{groupedByThread.map(({ threadId, items }, threadIndex) => (
|
{gmailMatches.threads.map((thread, threadIndex) => (
|
||||||
<React.Fragment key={threadId}>
|
<React.Fragment key={thread.threadId}>
|
||||||
{threadIndex > 0 ? <Divider /> : null}
|
{threadIndex > 0 ? <Divider /> : null}
|
||||||
<Box sx={{ p: 1.5, backgroundColor: alpha(theme.palette.primary.main, 0.04) }}>
|
<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 sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||||
<Box>
|
<Box>
|
||||||
<Typography sx={{ fontWeight: 800 }}>{items[0]?.subject || t("correspondenceNoSubject")}</Typography>
|
<Typography sx={{ fontWeight: 800 }}>{thread.subject || t("correspondenceNoSubject")}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("correspondenceMessagesInThread", { count: items.length })}</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>
|
</Box>
|
||||||
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === threadId} onClick={() => void importGmailThread(threadId, items.map((x) => x.id))}>
|
<Button startIcon={<MailOutlineIcon />} variant="outlined" size="small" disabled={importingThreadId === thread.threadId} onClick={() => void importGmailThread(thread.threadId, thread.messages.map((x) => x.id))}>
|
||||||
{importingThreadId === threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
{importingThreadId === thread.threadId ? t("correspondenceImporting") : t("correspondenceImportThread")}
|
||||||
</Button>
|
</Button>
|
||||||
</Box>
|
</Box>
|
||||||
{items.map((message, index) => (
|
{thread.messages.map((message, index) => (
|
||||||
<React.Fragment key={message.id}>
|
<React.Fragment key={message.id}>
|
||||||
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
{index > 0 ? <Divider sx={{ my: 1 }} /> : null}
|
||||||
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
|
<ListItemButton sx={{ alignItems: "flex-start", px: 0, py: 1 }}>
|
||||||
<ListItemText
|
<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>}
|
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)}>
|
<Button variant="contained" size="small" disabled={importingMessageId === message.id} onClick={() => void importGmailMessage(message.id)}>
|
||||||
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
|
{importingMessageId === message.id ? t("correspondenceImporting") : t("correspondenceImportEmail")}
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{tab === 1 && jobId && <Correspondence jobId={jobId} />}
|
{tab === 1 && jobId && <Correspondence jobId={jobId} job={job} />}
|
||||||
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
{tab === 2 && jobId && <Attachments jobId={jobId} />}
|
||||||
|
|
||||||
{tab === 3 && (
|
{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;
|
subject?: string;
|
||||||
channel?: string;
|
channel?: string;
|
||||||
date: 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 {
|
export interface GmailStatus {
|
||||||
connected: boolean;
|
connected: boolean;
|
||||||
|
|||||||
Reference in New Issue
Block a user