feat: add smart gmail thread import and search suggestions

This commit is contained in:
cesnimda
2026-03-22 14:43:30 +01:00
parent 779ba9fc6d
commit 57d93cf234
2 changed files with 206 additions and 245 deletions
+80 -42
View File
@@ -24,6 +24,10 @@ public sealed class GmailController : ControllerBase
_cfg = cfg;
}
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
[HttpGet("status")]
public async Task<IActionResult> Status(CancellationToken cancellationToken)
{
@@ -93,8 +97,6 @@ public sealed class GmailController : ControllerBase
return Ok(items);
}
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
[HttpPost("import")]
public async Task<IActionResult> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
{
@@ -115,47 +117,9 @@ public sealed class GmailController : ControllerBase
return Ok(existing);
}
var detail = await _gmail.GetMessageAsync(ownerUserId, request.MessageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
var message = new Correspondence
{
JobApplicationId = request.JobApplicationId,
From = isMe ? "Me" : "Company",
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email",
ExternalMessageId = detail.Id,
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
_db.Correspondences.Add(message);
if (job.Company is not null)
{
job.Company.LastContactedAt = messageDate;
}
if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value))
{
var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}";
job.ResponseReceived = true;
job.ResponseDate = messageDate;
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "ReplyReceived",
OldValue = oldResponse,
NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}",
Note = detail.Subject,
At = messageDate
});
}
var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken);
await _db.SaveChangesAsync(cancellationToken);
return Ok(message);
return Ok(created);
}
catch (Exception ex)
{
@@ -163,6 +127,80 @@ public sealed class GmailController : ControllerBase
}
}
[HttpPost("import-thread")]
public async Task<ActionResult<GmailImportResultDto>> ImportThread([FromBody] ImportGmailThreadRequest request, CancellationToken cancellationToken)
{
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
if (request.MessageIds is null || request.MessageIds.Length == 0) return BadRequest("At least one messageId is required.");
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications.Include(x => x.Company).FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var distinctIds = request.MessageIds.Where(x => !string.IsNullOrWhiteSpace(x)).Select(x => x.Trim()).Distinct(StringComparer.Ordinal).ToList();
var existingIds = await _db.Correspondences
.Where(x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId != null && distinctIds.Contains(x.ExternalMessageId))
.Select(x => x.ExternalMessageId!)
.ToListAsync(cancellationToken);
var skipped = existingIds.Count;
var imported = 0;
foreach (var messageId in distinctIds)
{
if (existingIds.Contains(messageId, StringComparer.Ordinal)) continue;
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
imported++;
}
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailImportResultDto(imported, skipped, request.ThreadId));
}
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
{
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
var message = new Correspondence
{
JobApplicationId = job.Id,
From = isMe ? "Me" : "Company",
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
Channel = "Email",
ExternalMessageId = detail.Id,
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
_db.Correspondences.Add(message);
if (job.Company is not null)
{
job.Company.LastContactedAt = messageDate;
}
if (!isMe && (!job.ResponseReceived || job.ResponseDate is null || messageDate < job.ResponseDate.Value))
{
var oldResponse = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}";
job.ResponseReceived = true;
job.ResponseDate = messageDate;
_db.JobEvents.Add(new JobEvent
{
JobApplicationId = job.Id,
Type = "ReplyReceived",
OldValue = oldResponse,
NewValue = $"{job.ResponseReceived}:{job.ResponseDate?.ToString("o")}",
Note = detail.Subject,
At = messageDate
});
}
return message;
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")