From ab79072e523bdc68a0fc95674b5148f06f6cea0b Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sat, 11 Jul 2026 20:45:48 +0200 Subject: [PATCH] refactor(gmail): extract DTOs and static helpers from GmailController Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction, no behaviour change: - GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so every existing GmailController.XyzDto reference (tests included) keeps working unchanged. - GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary, LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/ CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach. GmailController.cs: 1200 -> 1022 lines. 169/169 green. Co-Authored-By: Claude Opus 4.8 --- JobTrackerApi/Controllers/GmailController.cs | 180 +------------------ JobTrackerApi/Controllers/GmailDtos.cs | 79 ++++++++ JobTrackerApi/Controllers/GmailParsing.cs | 116 ++++++++++++ 3 files changed, 196 insertions(+), 179 deletions(-) create mode 100644 JobTrackerApi/Controllers/GmailDtos.cs create mode 100644 JobTrackerApi/Controllers/GmailParsing.cs diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index ba05598..e29efc9 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -13,7 +13,7 @@ namespace JobTrackerApi.Controllers; [ApiController] [Route("api/gmail")] [Authorize] -public sealed class GmailController : ControllerBase +public sealed partial class GmailController : ControllerBase { private readonly IGmailOAuthService _gmail; private readonly IGmailJobMatchingService _matching; @@ -37,77 +37,6 @@ public sealed class GmailController : ControllerBase private IEmailProvider Email => _providers.Get("gmail") ?? throw new InvalidOperationException("Gmail email provider is not registered."); - public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId); - public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message); - public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId); - public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); - public sealed record RefreshLinkedThreadsRequest(int JobApplicationId); - public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate); - public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList Threads); - public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points); - 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 MatchedQueries, - IReadOnlyList MatchReasons); - public sealed record GmailJobMatchedThreadDto( - string ThreadId, - string Subject, - int Score, - string Confidence, - bool HasImportedMessages, - int ImportedMessageCount, - int MessageCount, - DateTimeOffset? LatestDate, - IReadOnlyList MatchedQueries, - IReadOnlyList MatchReasons, - IReadOnlyList Messages); - public sealed record GmailJobMatchesResponseDto( - int JobApplicationId, - string JobTitle, - string CompanyName, - string? RecruiterName, - string? RecruiterEmail, - IReadOnlyList Queries, - int CandidateMessageCount, - int CandidateThreadCount, - IReadOnlyList Threads); - - public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); - public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); - public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList Threads); - public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note); - public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash); - public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt); - public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList MatchedQueries, string Preview); - public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList Items); - public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status); - public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped); - public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note); - public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages); - public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision); - public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision); - - public sealed record GmailConnectionStatusDto( - bool Connected, - string? GmailAddress, - DateTimeOffset? ConnectedAt, - DateTimeOffset? LastSyncedAt, - DateTimeOffset? LastSyncAttemptedAt, - DateTimeOffset? LastSyncSucceededAt, - string? LastSyncMode, - string? LastSyncSource, - string? LastSyncStatus, - string? LastSyncError); - [HttpGet("status")] public async Task> Status(CancellationToken cancellationToken) { @@ -1016,40 +945,6 @@ public sealed class GmailController : ControllerBase return _matching.BuildJobQueries(job, queryOverride); } - private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash) - { - var bounded = (query ?? string.Empty).Trim(); - if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase)) - { - bounded = string.IsNullOrWhiteSpace(bounded) - ? $"newer_than:{lookbackDays}d" - : $"{bounded} newer_than:{lookbackDays}d"; - } - - if (!includeSpamTrash) - { - if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam"; - if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash"; - } - - return bounded.Trim(); - } - - private static bool LooksLikeJobRelatedThread(IReadOnlyList orderedMessages) - { - var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value))))); - if (string.IsNullOrWhiteSpace(sample)) return false; - return sample.Contains("interview", StringComparison.OrdinalIgnoreCase) - || sample.Contains("application", StringComparison.OrdinalIgnoreCase) - || sample.Contains("recruit", StringComparison.OrdinalIgnoreCase) - || sample.Contains("role", StringComparison.OrdinalIgnoreCase) - || sample.Contains("position", StringComparison.OrdinalIgnoreCase) - || sample.Contains("offer", StringComparison.OrdinalIgnoreCase) - || sample.Contains("follow up", StringComparison.OrdinalIgnoreCase) - || sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase) - || sample.Contains("rejection", StringComparison.OrdinalIgnoreCase); - } - private void UpsertReviewDecision(IDictionary decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note) { if (!decisions.TryGetValue(threadId, out var existing)) @@ -1090,54 +985,6 @@ public sealed class GmailController : ControllerBase existing.UpdatedAt = DateTimeOffset.UtcNow; } - private static string ToConfidence(int score) - { - return score switch - { - >= 30 => "high", - >= 16 => "medium", - _ => "low" - }; - } - - private static string? ExtractFirstEmail(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase); - return match.Success ? match.Value : null; - } - - private static string? ExtractRecruiterName(string? value) - { - if (string.IsNullOrWhiteSpace(value)) return null; - var trimmed = value.Split('<')[0].Trim().Trim('"'); - return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed; - } - - private static string? ExtractCompanyName(string? from, string? subject) - { - var subjectText = (subject ?? string.Empty).Trim(); - if (!string.IsNullOrWhiteSpace(subjectText)) - { - var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); - if (parts.Length >= 2) return parts[0]; - } - - var recruiterName = ExtractRecruiterName(from); - return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null; - } - - private static string? ExtractRoleFromSubject(string? subject) - { - if (string.IsNullOrWhiteSpace(subject)) return null; - var trimmed = subject.Trim(); - if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase)) - { - return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':'); - } - return trimmed.Length <= 120 ? trimmed : trimmed[..120]; - } - private string GetRequiredOwnerUserId() { return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") @@ -1172,29 +1019,4 @@ public sealed class GmailController : ControllerBase return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback"; } - private static string BuildPopupHtml(bool success, string message) - { - var escaped = System.Net.WebUtility.HtmlEncode(message); - var status = success ? "connected" : "error"; - var title = success ? "Gmail connected" : "Gmail connection failed"; - var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message); - return $@" - - - - Gmail connection - - -

{title}

-

{escaped}

-

You can close this window.

- - -"; - } } diff --git a/JobTrackerApi/Controllers/GmailDtos.cs b/JobTrackerApi/Controllers/GmailDtos.cs new file mode 100644 index 0000000..4910747 --- /dev/null +++ b/JobTrackerApi/Controllers/GmailDtos.cs @@ -0,0 +1,79 @@ +using JobTrackerApi.Models; + +namespace JobTrackerApi.Controllers; + +// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour +// change; these were previously nested inline in the controller file). +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 ImportGmailMessageRequest(int JobApplicationId, string MessageId); + public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds); + public sealed record RefreshLinkedThreadsRequest(int JobApplicationId); + public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate); + public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList Threads); + public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points); + 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 MatchedQueries, + IReadOnlyList MatchReasons); + public sealed record GmailJobMatchedThreadDto( + string ThreadId, + string Subject, + int Score, + string Confidence, + bool HasImportedMessages, + int ImportedMessageCount, + int MessageCount, + DateTimeOffset? LatestDate, + IReadOnlyList MatchedQueries, + IReadOnlyList MatchReasons, + IReadOnlyList Messages); + public sealed record GmailJobMatchesResponseDto( + int JobApplicationId, + string JobTitle, + string CompanyName, + string? RecruiterName, + string? RecruiterEmail, + IReadOnlyList Queries, + int CandidateMessageCount, + int CandidateThreadCount, + IReadOnlyList Threads); + + public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList Reasons); + public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList MatchedQueries, IReadOnlyList JobCandidates, IReadOnlyList Messages); + public sealed record GmailReviewQueueResponseDto(IReadOnlyList Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList Threads); + public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note); + public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash); + public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt); + public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList MatchedQueries, string Preview); + public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList Items); + public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status); + public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped); + public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note); + public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages); + public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision); + public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision); + + public sealed record GmailConnectionStatusDto( + bool Connected, + string? GmailAddress, + DateTimeOffset? ConnectedAt, + DateTimeOffset? LastSyncedAt, + DateTimeOffset? LastSyncAttemptedAt, + DateTimeOffset? LastSyncSucceededAt, + string? LastSyncMode, + string? LastSyncSource, + string? LastSyncStatus, + string? LastSyncError); +} diff --git a/JobTrackerApi/Controllers/GmailParsing.cs b/JobTrackerApi/Controllers/GmailParsing.cs new file mode 100644 index 0000000..5a80827 --- /dev/null +++ b/JobTrackerApi/Controllers/GmailParsing.cs @@ -0,0 +1,116 @@ +using JobTrackerApi.Services; + +namespace JobTrackerApi.Controllers; + +// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe +// 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) + { + var bounded = (query ?? string.Empty).Trim(); + if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase)) + { + bounded = string.IsNullOrWhiteSpace(bounded) + ? $"newer_than:{lookbackDays}d" + : $"{bounded} newer_than:{lookbackDays}d"; + } + + if (!includeSpamTrash) + { + if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam"; + if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash"; + } + + return bounded.Trim(); + } + + private static bool LooksLikeJobRelatedThread(IReadOnlyList orderedMessages) + { + var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value))))); + if (string.IsNullOrWhiteSpace(sample)) return false; + return sample.Contains("interview", StringComparison.OrdinalIgnoreCase) + || sample.Contains("application", StringComparison.OrdinalIgnoreCase) + || sample.Contains("recruit", StringComparison.OrdinalIgnoreCase) + || sample.Contains("role", StringComparison.OrdinalIgnoreCase) + || sample.Contains("position", StringComparison.OrdinalIgnoreCase) + || sample.Contains("offer", StringComparison.OrdinalIgnoreCase) + || sample.Contains("follow up", StringComparison.OrdinalIgnoreCase) + || sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase) + || sample.Contains("rejection", StringComparison.OrdinalIgnoreCase); + } + + private static string ToConfidence(int score) + { + return score switch + { + >= 30 => "high", + >= 16 => "medium", + _ => "low" + }; + } + + private static string? ExtractFirstEmail(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase); + return match.Success ? match.Value : null; + } + + private static string? ExtractRecruiterName(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return null; + var trimmed = value.Split('<')[0].Trim().Trim('"'); + return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed; + } + + private static string? ExtractCompanyName(string? from, string? subject) + { + var subjectText = (subject ?? string.Empty).Trim(); + if (!string.IsNullOrWhiteSpace(subjectText)) + { + var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (parts.Length >= 2) return parts[0]; + } + + var recruiterName = ExtractRecruiterName(from); + return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null; + } + + private static string? ExtractRoleFromSubject(string? subject) + { + if (string.IsNullOrWhiteSpace(subject)) return null; + var trimmed = subject.Trim(); + if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase)) + { + return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':'); + } + return trimmed.Length <= 120 ? trimmed : trimmed[..120]; + } + + private static string BuildPopupHtml(bool success, string message) + { + var escaped = System.Net.WebUtility.HtmlEncode(message); + var status = success ? "connected" : "error"; + var title = success ? "Gmail connected" : "Gmail connection failed"; + var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message); + return $@" + + + + Gmail connection + + +

{title}

+

{escaped}

+

You can close this window.

+ + +"; + } +}