feat: add smart gmail thread import and search suggestions
This commit is contained in:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user