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);