From 9febc2b22fcc77cf915361ce5c32c77e1282d517 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Sun, 5 Jul 2026 21:38:45 +0200 Subject: [PATCH] refactor(gmail): route controller read paths through IEmailProvider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GmailController now resolves the "gmail" provider from IEmailProviderRegistry and uses the provider-neutral seam for its read paths — message search (SearchAsync) and thread listing (ListThreadMessagesAsync) across ImportThread, RelinkThread, CreateSuggestedJob, RefreshLinkedThreads and the messages endpoint. OAuth (connect/callback), connection status and Gmail-specific candidate ranking stay on IGmailOAuthService until they are generalised. An optional constructor param keeps direct construction (tests) working via a fallback single-Gmail registry, so the mocked Gmail service is exercised through GmailProvider. Behaviour is preserved (neutral DTOs mirror the Gmail shapes). This makes the seam a real consumer and sets up MicrosoftGraphProvider / ImapProvider / a manual free-text provider to slot in next. Build clean; backend suite 135/135 green. Co-Authored-By: Claude Opus 4.8 --- JobTrackerApi/Controllers/GmailController.cs | 22 ++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 57ac4f9..980887e 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -3,6 +3,7 @@ using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; +using JobTrackerApi.Services.EmailProviders; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,15 +19,24 @@ public sealed class GmailController : ControllerBase private readonly IGmailJobMatchingService _matching; private readonly JobTrackerContext _db; private readonly IConfiguration _cfg; + private readonly IEmailProviderRegistry _providers; - public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg) + public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null) { _gmail = gmail; _matching = matching; _db = db; _cfg = cfg; + // Fall back to a single-Gmail registry so direct construction (tests) keeps working. + _providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) }); } + // The email provider backing this controller's read paths (search + thread listing), + // via the provider-neutral seam. OAuth and Gmail-specific candidate ranking still use + // IGmailOAuthService directly until they are generalised. + 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); @@ -383,7 +393,7 @@ public sealed class GmailController : ControllerBase .FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken); if (job is null) return NotFound("Job application not found."); - var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken); + var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken); var distinctMessageIds = threadMessages .Where(message => !string.IsNullOrWhiteSpace(message.Id)) .Select(message => message.Id) @@ -639,7 +649,7 @@ public sealed class GmailController : ControllerBase _db.JobApplications.Add(job); await _db.SaveChangesAsync(cancellationToken); - var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken); + var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken); var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList(); // Batch the "already imported?" check with a single query instead of one // AnyAsync per message (N+1), mirroring RelinkThread below. @@ -695,7 +705,7 @@ public sealed class GmailController : ControllerBase } } - var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); + var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList(); var existingMessageIds = await _db.Correspondences .Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId)) @@ -793,7 +803,7 @@ public sealed class GmailController : ControllerBase public async Task Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default) { var ownerUserId = GetRequiredOwnerUserId(); - var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken); + var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken); return Ok(items); } @@ -897,7 +907,7 @@ public sealed class GmailController : ControllerBase foreach (var threadId in linkedThreadIds) { - var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); + var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); var distinctThreadMessages = threadMessages .Where(message => !string.IsNullOrWhiteSpace(message.Id)) .GroupBy(message => message.Id, StringComparer.Ordinal)