chore(M001/S01): auto-commit after complete-slice
This commit is contained in:
@@ -28,6 +28,9 @@ public sealed class GmailController : ControllerBase
|
||||
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<GmailThreadRefreshThreadDto> Threads);
|
||||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||||
public sealed record GmailJobMatchedMessageDto(
|
||||
string Id,
|
||||
@@ -301,6 +304,88 @@ public sealed class GmailController : ControllerBase
|
||||
return Ok(new GmailImportResultDto(imported, skipped, request.ThreadId));
|
||||
}
|
||||
|
||||
[HttpPost("refresh-linked-threads")]
|
||||
public async Task<ActionResult<GmailThreadRefreshResultDto>> RefreshLinkedThreads([FromBody] RefreshLinkedThreadsRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var job = await _db.JobApplications
|
||||
.Where(x => x.OwnerUserId == ownerUserId)
|
||||
.Include(x => x.Company)
|
||||
.Include(x => x.Messages)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
|
||||
if (job is null) return NotFound("Job application not found.");
|
||||
|
||||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
if (connection is null) return Conflict("Connect Gmail before refreshing linked threads.");
|
||||
|
||||
var linkedThreadIds = job.Messages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
|
||||
.Select(message => message.ExternalThreadId!.Trim())
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (linkedThreadIds.Count == 0)
|
||||
{
|
||||
return Ok(new GmailThreadRefreshResultDto(job.Id, 0, 0, 0, false, DateTimeOffset.UtcNow, Array.Empty<GmailThreadRefreshThreadDto>()));
|
||||
}
|
||||
|
||||
var existingMessageIds = job.Messages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var refreshedThreads = new List<GmailThreadRefreshThreadDto>(linkedThreadIds.Count);
|
||||
var imported = 0;
|
||||
var skipped = 0;
|
||||
|
||||
foreach (var threadId in linkedThreadIds)
|
||||
{
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
var distinctThreadMessages = threadMessages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
|
||||
.GroupBy(message => message.Id, StringComparer.Ordinal)
|
||||
.Select(group => group.First())
|
||||
.OrderBy(message => message.Date ?? DateTimeOffset.MinValue)
|
||||
.ToList();
|
||||
|
||||
var threadImported = 0;
|
||||
var threadSkipped = 0;
|
||||
foreach (var message in distinctThreadMessages)
|
||||
{
|
||||
if (existingMessageIds.Contains(message.Id))
|
||||
{
|
||||
threadSkipped++;
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var created = await ImportSingleMessageAsync(ownerUserId, job, message.Id, cancellationToken);
|
||||
existingMessageIds.Add(created.ExternalMessageId ?? message.Id);
|
||||
threadImported++;
|
||||
imported++;
|
||||
}
|
||||
|
||||
var latestMessageDate = distinctThreadMessages
|
||||
.Select(message => message.Date)
|
||||
.OrderByDescending(message => message ?? DateTimeOffset.MinValue)
|
||||
.FirstOrDefault();
|
||||
var status = threadImported > 0 ? "imported-new-messages" : "already-current";
|
||||
refreshedThreads.Add(new GmailThreadRefreshThreadDto(threadId, threadImported, threadSkipped, distinctThreadMessages.Count, status, latestMessageDate));
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new GmailThreadRefreshResultDto(job.Id, refreshedThreads.Count, imported, skipped, true, DateTimeOffset.UtcNow, refreshedThreads));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return BadRequest(ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
|
||||
|
||||
@@ -20,6 +20,7 @@ public interface IGmailOAuthService
|
||||
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<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -225,6 +226,60 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(threadId))
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var results = new List<GmailMessageSummary>();
|
||||
foreach (var messageElement in messagesElement.EnumerateArray())
|
||||
{
|
||||
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
|
||||
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
|
||||
? messageThreadIdEl.GetString() ?? threadId.Trim()
|
||||
: threadId.Trim();
|
||||
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
|
||||
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
|
||||
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
DateTimeOffset? date = null;
|
||||
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
|
||||
{
|
||||
date = parsedDate;
|
||||
}
|
||||
|
||||
results.Add(new GmailMessageSummary(
|
||||
id.Trim(),
|
||||
messageThreadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
|
||||
headers.TryGetValue("from", out var from) ? from : string.Empty,
|
||||
headers.TryGetValue("to", out var to) ? to : string.Empty,
|
||||
date,
|
||||
snippet));
|
||||
}
|
||||
|
||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
|
||||
Reference in New Issue
Block a user