chore(M001/S01): auto-commit after complete-slice

This commit is contained in:
2026-03-24 12:27:04 +01:00
parent 9f03d123d0
commit 13d4e29336
22 changed files with 970 additions and 118 deletions
@@ -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);