refactor(api): extract Gmail DTOs/parsers, batch N+1 loops

- Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs
- Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads
- Remove redundant second pass in RelinkThread, reuse existing HashSet
- Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-12 20:17:33 +02:00
parent 4cfdc95b59
commit 3e09e74fc8
4 changed files with 118 additions and 122 deletions
+34 -34
View File
@@ -39,7 +39,7 @@ public sealed class GmailControllerTests
var result = await controller.Status(CancellationToken.None); var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailConnectionStatusDto>(ok.Value); var payload = Assert.IsType<GmailConnectionStatusDto>(ok.Value);
Assert.True(payload.Connected); Assert.True(payload.Connected);
Assert.Equal("user@example.test", payload.GmailAddress); Assert.Equal("user@example.test", payload.GmailAddress);
Assert.Equal("list-messages", payload.LastSyncMode); Assert.Equal("list-messages", payload.LastSyncMode);
@@ -54,7 +54,7 @@ public sealed class GmailControllerTests
await using var db = CreateDb(); await using var db = CreateDb();
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1"); 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); var result = await controller.ImportThread(new ImportGmailThreadRequest(1, "thread-1", Array.Empty<string>()), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result); var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
Assert.Equal("At least one messageId is required.", badRequest.Value); Assert.Equal("At least one messageId is required.", badRequest.Value);
@@ -159,7 +159,7 @@ public sealed class GmailControllerTests
var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None); var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value); var payload = Assert.IsType<GmailJobMatchesResponseDto>(ok.Value);
Assert.Equal(job.Id, payload.JobApplicationId); Assert.Equal(job.Id, payload.JobApplicationId);
Assert.Contains(overrideQuery, payload.Queries); Assert.Contains(overrideQuery, payload.Queries);
@@ -221,7 +221,7 @@ public sealed class GmailControllerTests
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None); var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value); var payload = Assert.IsType<GmailJobMatchesResponseDto>(ok.Value);
Assert.NotEmpty(payload.Queries); Assert.NotEmpty(payload.Queries);
Assert.Equal(0, payload.CandidateMessageCount); Assert.Equal(0, payload.CandidateMessageCount);
Assert.Equal(0, payload.CandidateThreadCount); Assert.Equal(0, payload.CandidateThreadCount);
@@ -264,9 +264,9 @@ public sealed class GmailControllerTests
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var first = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); var first = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
var firstOk = Assert.IsType<OkObjectResult>(first.Result); var firstOk = Assert.IsType<OkObjectResult>(first.Result);
var firstPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(firstOk.Value); var firstPayload = Assert.IsType<GmailImportMessageResultDto>(firstOk.Value);
Assert.Equal(1, firstPayload.Imported); Assert.Equal(1, firstPayload.Imported);
Assert.Equal(0, firstPayload.Skipped); Assert.Equal(0, firstPayload.Skipped);
Assert.Equal("thread-1", firstPayload.ThreadId); Assert.Equal("thread-1", firstPayload.ThreadId);
@@ -279,9 +279,9 @@ public sealed class GmailControllerTests
Assert.Single(firstPayload.Message.AttachmentMetadata); Assert.Single(firstPayload.Message.AttachmentMetadata);
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName); Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None); var second = await controller.Import(new ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
var secondOk = Assert.IsType<OkObjectResult>(second.Result); var secondOk = Assert.IsType<OkObjectResult>(second.Result);
var secondPayload = Assert.IsType<GmailController.GmailImportMessageResultDto>(secondOk.Value); var secondPayload = Assert.IsType<GmailImportMessageResultDto>(secondOk.Value);
Assert.Equal(0, secondPayload.Imported); Assert.Equal(0, secondPayload.Imported);
Assert.Equal(1, secondPayload.Skipped); Assert.Equal(1, secondPayload.Skipped);
Assert.Equal("thread-1", secondPayload.ThreadId); Assert.Equal("thread-1", secondPayload.ThreadId);
@@ -340,18 +340,18 @@ public sealed class GmailControllerTests
Array.Empty<GmailMessageAttachment>())); Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" }); var request = new ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
var first = await controller.ImportThread(request, CancellationToken.None); var first = await controller.ImportThread(request, CancellationToken.None);
var firstOk = Assert.IsType<OkObjectResult>(first.Result); var firstOk = Assert.IsType<OkObjectResult>(first.Result);
var firstPayload = Assert.IsType<GmailController.GmailImportResultDto>(firstOk.Value); var firstPayload = Assert.IsType<GmailImportResultDto>(firstOk.Value);
Assert.Equal(2, firstPayload.Imported); Assert.Equal(2, firstPayload.Imported);
Assert.Equal(0, firstPayload.Skipped); Assert.Equal(0, firstPayload.Skipped);
Assert.Equal("thread-1", firstPayload.ThreadId); Assert.Equal("thread-1", firstPayload.ThreadId);
var second = await controller.ImportThread(request, CancellationToken.None); var second = await controller.ImportThread(request, CancellationToken.None);
var secondOk = Assert.IsType<OkObjectResult>(second.Result); var secondOk = Assert.IsType<OkObjectResult>(second.Result);
var secondPayload = Assert.IsType<GmailController.GmailImportResultDto>(secondOk.Value); var secondPayload = Assert.IsType<GmailImportResultDto>(secondOk.Value);
Assert.Equal(0, secondPayload.Imported); Assert.Equal(0, secondPayload.Imported);
Assert.Equal(2, secondPayload.Skipped); Assert.Equal(2, secondPayload.Skipped);
@@ -414,10 +414,10 @@ public sealed class GmailControllerTests
Array.Empty<GmailMessageAttachment>())); Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None); var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(ok.Value); var payload = Assert.IsType<GmailThreadRefreshResultDto>(ok.Value);
Assert.Equal(job.Id, payload.JobApplicationId); Assert.Equal(job.Id, payload.JobApplicationId);
Assert.Equal(1, payload.ThreadsChecked); Assert.Equal(1, payload.ThreadsChecked);
Assert.Equal(1, payload.Imported); Assert.Equal(1, payload.Imported);
@@ -461,7 +461,7 @@ public sealed class GmailControllerTests
var disconnectedGmail = new Mock<IGmailOAuthService>(MockBehavior.Strict); var disconnectedGmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())).ReturnsAsync((GmailConnection?)null); disconnectedGmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())).ReturnsAsync((GmailConnection?)null);
var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1"); var disconnectedController = CreateController(db, disconnectedGmail.Object, "user-1");
var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None); var disconnectedResult = await disconnectedController.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(linkedJob.Id), CancellationToken.None);
var conflict = Assert.IsType<ConflictObjectResult>(disconnectedResult.Result); var conflict = Assert.IsType<ConflictObjectResult>(disconnectedResult.Result);
Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value); Assert.Equal("Connect Gmail before refreshing linked threads.", conflict.Value);
@@ -469,10 +469,10 @@ public sealed class GmailControllerTests
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>())) 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 }); .ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var emptyResult = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None); var emptyResult = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(emptyJob.Id), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(emptyResult.Result); var ok = Assert.IsType<OkObjectResult>(emptyResult.Result);
var payload = Assert.IsType<GmailController.GmailThreadRefreshResultDto>(ok.Value); var payload = Assert.IsType<GmailThreadRefreshResultDto>(ok.Value);
Assert.Equal(0, payload.ThreadsChecked); Assert.Equal(0, payload.ThreadsChecked);
Assert.Equal(0, payload.Imported); Assert.Equal(0, payload.Imported);
Assert.Equal(0, payload.Skipped); Assert.Equal(0, payload.Skipped);
@@ -526,7 +526,7 @@ public sealed class GmailControllerTests
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None); var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value); var payload = Assert.IsType<GmailReviewQueueResponseDto>(ok.Value);
Assert.Equal(1, payload.CandidateThreadCount); Assert.Equal(1, payload.CandidateThreadCount);
Assert.Single(payload.Threads); Assert.Single(payload.Threads);
Assert.Equal("thread-top", payload.Threads[0].ThreadId); Assert.Equal("thread-top", payload.Threads[0].ThreadId);
@@ -541,7 +541,7 @@ public sealed class GmailControllerTests
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict); var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(0), CancellationToken.None); var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(0), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result); var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
Assert.Equal("Valid jobApplicationId is required.", badRequest.Value); Assert.Equal("Valid jobApplicationId is required.", badRequest.Value);
@@ -566,7 +566,7 @@ public sealed class GmailControllerTests
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict); var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None); var result = await controller.RefreshLinkedThreads(new RefreshLinkedThreadsRequest(foreignJob.Id), CancellationToken.None);
var notFound = Assert.IsType<NotFoundObjectResult>(result.Result); var notFound = Assert.IsType<NotFoundObjectResult>(result.Result);
Assert.Equal("Job application not found.", notFound.Value); Assert.Equal("Job application not found.", notFound.Value);
@@ -639,7 +639,7 @@ public sealed class GmailControllerTests
Array.Empty<GmailMessageAttachment>())); Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None); var result = await controller.SaveReviewDecision(new SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result); var ok = Assert.IsType<OkObjectResult>(result);
var decision = await db.GmailReviewDecisions.SingleAsync(); var decision = await db.GmailReviewDecisions.SingleAsync();
@@ -719,10 +719,10 @@ public sealed class GmailControllerTests
Array.Empty<GmailMessageAttachment>())); Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None); var result = await controller.ManualSync(new GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value); var payload = Assert.IsType<GmailManualSyncResultDto>(ok.Value);
Assert.Equal(1, payload.AutoLinkedThreadCount); Assert.Equal(1, payload.AutoLinkedThreadCount);
Assert.Equal(1, payload.ImportedThreads); Assert.Equal(1, payload.ImportedThreads);
Assert.Equal(1, payload.ImportedMessages); Assert.Equal(1, payload.ImportedMessages);
@@ -770,7 +770,7 @@ public sealed class GmailControllerTests
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var reviewQueue = new GmailController.GmailReviewQueueResponseDto( var reviewQueue = new GmailReviewQueueResponseDto(
Array.Empty<string>(), Array.Empty<string>(),
1, 1,
0, 0,
@@ -778,7 +778,7 @@ public sealed class GmailControllerTests
1, 1,
new[] new[]
{ {
new GmailController.GmailReviewThreadDto( new GmailReviewThreadDto(
"thread-suggested", "thread-suggested",
"Platform Engineer interview", "Platform Engineer interview",
DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddDays(-1),
@@ -787,10 +787,10 @@ public sealed class GmailControllerTests
false, false,
null, null,
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<GmailController.GmailReviewJobCandidateDto>(), Array.Empty<GmailReviewJobCandidateDto>(),
new[] new[]
{ {
new GmailController.GmailJobMatchedMessageDto( new GmailJobMatchedMessageDto(
"msg-s1", "msg-s1",
"thread-suggested", "thread-suggested",
"Platform Engineer interview", "Platform Engineer interview",
@@ -802,16 +802,16 @@ public sealed class GmailControllerTests
"low", "low",
false, false,
Array.Empty<string>(), Array.Empty<string>(),
Array.Empty<GmailController.GmailJobMatchReasonDto>()) Array.Empty<GmailJobMatchReasonDto>())
}) })
}); });
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result); var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value); Assert.IsType<GmailSuggestedJobsResponseDto>(suggested.Value);
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None); var create = await controller.CreateSuggestedJob(new CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
var createOk = Assert.IsType<OkObjectResult>(create.Result); var createOk = Assert.IsType<OkObjectResult>(create.Result);
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value); var created = Assert.IsType<CreatedSuggestedGmailJobDto>(createOk.Value);
Assert.True(created.JobApplicationId > 0); Assert.True(created.JobApplicationId > 0);
Assert.Equal(1, created.Imported); Assert.Equal(1, created.Imported);
Assert.Equal("thread-suggested", created.ThreadId); Assert.Equal("thread-suggested", created.ThreadId);
@@ -837,10 +837,10 @@ public sealed class GmailControllerTests
await db.SaveChangesAsync(); await db.SaveChangesAsync();
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1"); var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None); var result = await controller.UnlinkThread(new UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value); var payload = Assert.IsType<GmailUnlinkResultDto>(ok.Value);
Assert.Equal(2, payload.RemovedMessages); Assert.Equal(2, payload.RemovedMessages);
Assert.Equal("review", payload.Decision); Assert.Equal("review", payload.Decision);
Assert.Empty(await db.Correspondences.ToListAsync()); Assert.Empty(await db.Correspondences.ToListAsync());
@@ -895,10 +895,10 @@ public sealed class GmailControllerTests
Array.Empty<GmailMessageAttachment>())); Array.Empty<GmailMessageAttachment>()));
var controller = CreateController(db, gmail.Object, "user-1"); var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None); var result = await controller.RelinkThread(new RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result); var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value); var payload = Assert.IsType<GmailRelinkResultDto>(ok.Value);
Assert.Equal(1, payload.UnlinkedMessages); Assert.Equal(1, payload.UnlinkedMessages);
Assert.Equal(1, payload.Imported); Assert.Equal(1, payload.Imported);
var stored = await db.Correspondences.SingleAsync(); var stored = await db.Correspondences.SingleAsync();
+2 -1
View File
@@ -7,13 +7,14 @@ using JobTrackerApi.Services.EmailProviders;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using static JobTrackerApi.Services.GmailParsing;
namespace JobTrackerApi.Controllers; namespace JobTrackerApi.Controllers;
[ApiController] [ApiController]
[Route("api/gmail")] [Route("api/gmail")]
[Authorize] [Authorize]
public sealed partial class GmailController : ControllerBase public sealed class GmailController : ControllerBase
{ {
private readonly IGmailOAuthService _gmail; private readonly IGmailOAuthService _gmail;
private readonly IGmailJobMatchingService _matching; private readonly IGmailJobMatchingService _matching;
+70 -73
View File
@@ -2,78 +2,75 @@ using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers; namespace JobTrackerApi.Controllers;
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour // DTOs for GmailController, split out for readability (no behaviour change; these were
// change; these were previously nested inline in the controller file). // previously nested inside the controller class).
public partial class GmailController 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 GmailImportResultDto(int Imported, int Skipped, string? ThreadId); public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message); public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId); public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate); public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads); public sealed record GmailJobMatchedMessageDto(
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points); string Id,
public sealed record GmailJobMatchedMessageDto( string ThreadId,
string Id, string Subject,
string ThreadId, string From,
string Subject, string To,
string From, DateTimeOffset? Date,
string To, string Snippet,
DateTimeOffset? Date, int Score,
string Snippet, string Confidence,
int Score, bool AlreadyImported,
string Confidence, IReadOnlyList<string> MatchedQueries,
bool AlreadyImported, IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
IReadOnlyList<string> MatchedQueries, public sealed record GmailJobMatchedThreadDto(
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons); string ThreadId,
public sealed record GmailJobMatchedThreadDto( string Subject,
string ThreadId, int Score,
string Subject, string Confidence,
int Score, bool HasImportedMessages,
string Confidence, int ImportedMessageCount,
bool HasImportedMessages, int MessageCount,
int ImportedMessageCount, DateTimeOffset? LatestDate,
int MessageCount, IReadOnlyList<string> MatchedQueries,
DateTimeOffset? LatestDate, IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons, public sealed record GmailJobMatchesResponseDto(
IReadOnlyList<GmailJobMatchedMessageDto> Messages); int JobApplicationId,
public sealed record GmailJobMatchesResponseDto( string JobTitle,
int JobApplicationId, string CompanyName,
string JobTitle, string? RecruiterName,
string CompanyName, string? RecruiterEmail,
string? RecruiterName, IReadOnlyList<string> Queries,
string? RecruiterEmail, int CandidateMessageCount,
IReadOnlyList<string> Queries, int CandidateThreadCount,
int CandidateMessageCount, IReadOnlyList<GmailJobMatchedThreadDto> Threads);
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons); public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages); public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads); public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note); public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash); public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt); public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview); public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items); public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status); public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped); public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note); public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages); public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision); public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision); public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto( public sealed record GmailConnectionStatusDto(
bool Connected, bool Connected,
string? GmailAddress, string? GmailAddress,
DateTimeOffset? ConnectedAt, DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt, DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt, DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt, DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode, string? LastSyncMode,
string? LastSyncSource, string? LastSyncSource,
string? LastSyncStatus, string? LastSyncStatus,
string? LastSyncError); string? LastSyncError);
}
@@ -1,12 +1,10 @@
using JobTrackerApi.Services; namespace JobTrackerApi.Services;
namespace JobTrackerApi.Controllers; // Pure parsing/formatting helpers used by GmailController, split out for readability (no
// behaviour change). All are static and side-effect free.
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe public static class GmailParsing
// refactor -- no behaviour change). All are static and side-effect free.
public sealed partial class GmailController
{ {
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash) public static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{ {
var bounded = (query ?? string.Empty).Trim(); var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase)) if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
@@ -25,7 +23,7 @@ public sealed partial class GmailController
return bounded.Trim(); return bounded.Trim();
} }
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages) public static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{ {
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value))))); var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false; if (string.IsNullOrWhiteSpace(sample)) return false;
@@ -40,7 +38,7 @@ public sealed partial class GmailController
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase); || sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
} }
private static string ToConfidence(int score) public static string ToConfidence(int score)
{ {
return score switch return score switch
{ {
@@ -50,21 +48,21 @@ public sealed partial class GmailController
}; };
} }
private static string? ExtractFirstEmail(string? value) public static string? ExtractFirstEmail(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) return null; if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase); var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null; return match.Success ? match.Value : null;
} }
private static string? ExtractRecruiterName(string? value) public static string? ExtractRecruiterName(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) return null; if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"'); var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed; return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
} }
private static string? ExtractCompanyName(string? from, string? subject) public static string? ExtractCompanyName(string? from, string? subject)
{ {
var subjectText = (subject ?? string.Empty).Trim(); var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText)) if (!string.IsNullOrWhiteSpace(subjectText))
@@ -77,7 +75,7 @@ public sealed partial class GmailController
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null; return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
} }
private static string? ExtractRoleFromSubject(string? subject) public static string? ExtractRoleFromSubject(string? subject)
{ {
if (string.IsNullOrWhiteSpace(subject)) return null; if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim(); var trimmed = subject.Trim();
@@ -88,7 +86,7 @@ public sealed partial class GmailController
return trimmed.Length <= 120 ? trimmed : trimmed[..120]; return trimmed.Length <= 120 ? trimmed : trimmed[..120];
} }
private static string BuildPopupHtml(bool success, string message) public static string BuildPopupHtml(bool success, string message)
{ {
var escaped = System.Net.WebUtility.HtmlEncode(message); var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error"; var status = success ? "connected" : "error";