Complete Gmail correspondence workflow
This commit is contained in:
@@ -72,8 +72,19 @@ public sealed class GmailController : ControllerBase
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||||
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
|
||||
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
|
||||
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
|
||||
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
|
||||
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
|
||||
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
|
||||
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
|
||||
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
|
||||
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
|
||||
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
|
||||
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
|
||||
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
|
||||
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
@@ -325,7 +336,7 @@ public sealed class GmailController : ControllerBase
|
||||
Array.Empty<GmailJobMatchReasonDto>()))
|
||||
.ToList();
|
||||
|
||||
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, matchedQueries, jobCandidates, messages);
|
||||
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
|
||||
})
|
||||
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||||
.Take(100)
|
||||
@@ -340,6 +351,380 @@ public sealed class GmailController : ControllerBase
|
||||
groupedThreads));
|
||||
}
|
||||
|
||||
[HttpPost("review-decision")]
|
||||
public async Task<IActionResult> SaveReviewDecision([FromBody] SaveGmailReviewDecisionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||
var decision = (request.Decision ?? string.Empty).Trim().ToLowerInvariant();
|
||||
if (decision is not ("linked" or "rejected" or "review" or "suggested"))
|
||||
{
|
||||
return BadRequest("Decision must be linked, rejected, review, or suggested.");
|
||||
}
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
JobApplication? job = null;
|
||||
if (decision == "linked")
|
||||
{
|
||||
if (request.JobApplicationId is null or <= 0) return BadRequest("jobApplicationId is required when linking a thread.");
|
||||
job = await _db.JobApplications
|
||||
.Where(x => x.OwnerUserId == ownerUserId)
|
||||
.Include(x => x.Company)
|
||||
.Include(x => x.Messages)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
|
||||
if (job is null) return NotFound("Job application not found.");
|
||||
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var distinctMessageIds = threadMessages
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
|
||||
.Select(message => message.Id)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
var existingMessageIds = await _db.Correspondences
|
||||
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var messageId in distinctMessageIds)
|
||||
{
|
||||
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal)) continue;
|
||||
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
var existing = await _db.GmailReviewDecisions.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new GmailReviewDecision
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
ThreadId = request.ThreadId.Trim(),
|
||||
};
|
||||
_db.GmailReviewDecisions.Add(existing);
|
||||
}
|
||||
|
||||
existing.Decision = decision switch
|
||||
{
|
||||
"review" => "review",
|
||||
_ => decision,
|
||||
};
|
||||
existing.JobApplicationId = decision == "linked" ? request.JobApplicationId : null;
|
||||
existing.Note = string.IsNullOrWhiteSpace(request.Note) ? null : request.Note.Trim();
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
existing.ThreadId,
|
||||
existing.Decision,
|
||||
existing.JobApplicationId,
|
||||
existing.Note,
|
||||
existing.UpdatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("manual-sync")]
|
||||
public async Task<ActionResult<GmailManualSyncResultDto>> ManualSync([FromBody] GmailManualSyncRequest? request, CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var jobs = await _db.JobApplications
|
||||
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
|
||||
.Include(x => x.Company)
|
||||
.Include(x => x.Messages)
|
||||
.OrderByDescending(x => x.DateApplied)
|
||||
.Take(100)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (jobs.Count == 0)
|
||||
{
|
||||
return Ok(new GmailManualSyncResultDto(0, 0, 0, 0, 0, 0, 0, 0, 365, false, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
var lookbackDays = Math.Clamp(request?.LookbackDays ?? 365, 30, 365);
|
||||
var maxResultsPerQuery = Math.Clamp(request?.MaxResultsPerQuery ?? 8, 1, 25);
|
||||
var includeSpamTrash = request?.IncludeSpamTrash ?? false;
|
||||
var autoImportHighConfidence = request?.AutoImportHighConfidence ?? true;
|
||||
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var jobItem in jobs)
|
||||
{
|
||||
foreach (var query in _matching.BuildJobQueries(jobItem, null))
|
||||
{
|
||||
var bounded = ApplySyncBoundary(query, lookbackDays, includeSpamTrash);
|
||||
querySet.Add(bounded);
|
||||
}
|
||||
}
|
||||
|
||||
var queries = querySet.Take(24).ToList();
|
||||
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
|
||||
var allImportedMessageIds = jobs.SelectMany(jobItem => jobItem.Messages)
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var allImportedThreadIds = jobs.SelectMany(jobItem => jobItem.Messages)
|
||||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
|
||||
.Select(message => message.ExternalThreadId!)
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
var reviewDecisions = await _db.GmailReviewDecisions
|
||||
.Where(decision => decision.OwnerUserId == ownerUserId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var groupedThreads = candidateMessages
|
||||
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var autoLinked = 0;
|
||||
var reviewCount = 0;
|
||||
var unmatchedCount = 0;
|
||||
var importedMessages = 0;
|
||||
var importedThreads = 0;
|
||||
var skippedMessages = 0;
|
||||
|
||||
foreach (var threadGroup in groupedThreads)
|
||||
{
|
||||
var threadId = threadGroup.Key;
|
||||
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == threadId);
|
||||
if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
unmatchedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var orderedMessages = threadGroup.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
|
||||
var candidates = jobs
|
||||
.Select(jobItem =>
|
||||
{
|
||||
var best = orderedMessages
|
||||
.Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
|
||||
.OrderByDescending(score => score.Score)
|
||||
.First();
|
||||
return new { Job = jobItem, Best = best };
|
||||
})
|
||||
.Where(x => x.Best.Score > 0)
|
||||
.OrderByDescending(x => x.Best.Score)
|
||||
.Take(3)
|
||||
.ToList();
|
||||
|
||||
var top = candidates.FirstOrDefault();
|
||||
var secondScore = candidates.Skip(1).FirstOrDefault()?.Best.Score ?? 0;
|
||||
if (top is not null && autoImportHighConfidence && top.Best.Score >= 30 && top.Best.Score - secondScore >= 8)
|
||||
{
|
||||
var distinctMessageIds = orderedMessages.Select(item => item.Message.Id).Distinct(StringComparer.Ordinal).ToList();
|
||||
var existingIds = await _db.Correspondences
|
||||
.Where(message => message.JobApplicationId == top.Job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToListAsync(cancellationToken);
|
||||
foreach (var messageId in distinctMessageIds)
|
||||
{
|
||||
if (existingIds.Contains(messageId, StringComparer.Ordinal))
|
||||
{
|
||||
skippedMessages++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken);
|
||||
allImportedMessageIds.Add(messageId);
|
||||
importedMessages++;
|
||||
}
|
||||
|
||||
importedThreads++;
|
||||
autoLinked++;
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", top.Job.Id, existingDecision?.Note);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (top is not null && top.Best.Score >= 16)
|
||||
{
|
||||
reviewCount++;
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, existingDecision?.Decision == "suggested" ? "suggested" : "review", null, existingDecision?.Note);
|
||||
continue;
|
||||
}
|
||||
|
||||
unmatchedCount++;
|
||||
if (LooksLikeJobRelatedThread(orderedMessages))
|
||||
{
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "suggested", null, existingDecision?.Note);
|
||||
}
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new GmailManualSyncResultDto(queries.Count, groupedThreads.Count, autoLinked, reviewCount, unmatchedCount, importedMessages, importedThreads, skippedMessages, lookbackDays, includeSpamTrash, DateTimeOffset.UtcNow));
|
||||
}
|
||||
|
||||
[HttpGet("suggested-jobs")]
|
||||
public async Task<ActionResult<GmailSuggestedJobsResponseDto>> SuggestedJobs(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var reviewThreads = await ReviewCandidates(null, 6, cancellationToken);
|
||||
if (reviewThreads.Result is not OkObjectResult ok || ok.Value is not GmailReviewQueueResponseDto payload)
|
||||
{
|
||||
return BadRequest("Unable to compute Gmail suggested jobs.");
|
||||
}
|
||||
|
||||
var items = payload.Threads
|
||||
.Where(thread => thread.Routing is "unmatched" or "suggested")
|
||||
.Select(thread => new GmailSuggestedJobCandidateDto(
|
||||
thread.ThreadId,
|
||||
thread.Subject,
|
||||
thread.LatestDate,
|
||||
ExtractCompanyName(thread.Messages.FirstOrDefault()?.From, thread.Subject),
|
||||
ExtractRecruiterName(thread.Messages.FirstOrDefault()?.From),
|
||||
ExtractFirstEmail(thread.Messages.FirstOrDefault()?.From),
|
||||
ExtractRoleFromSubject(thread.Subject),
|
||||
thread.Routing,
|
||||
thread.MatchedQueries,
|
||||
thread.Messages.FirstOrDefault()?.Snippet ?? string.Empty))
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item.CompanyName) || !string.IsNullOrWhiteSpace(item.SuggestedJobTitle))
|
||||
.Take(50)
|
||||
.ToList();
|
||||
|
||||
return Ok(new GmailSuggestedJobsResponseDto(items.Count, items));
|
||||
}
|
||||
|
||||
[HttpPost("create-suggested-job")]
|
||||
public async Task<ActionResult<CreatedSuggestedGmailJobDto>> CreateSuggestedJob([FromBody] CreateSuggestedGmailJobRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.CompanyName)) return BadRequest("CompanyName is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.JobTitle)) return BadRequest("JobTitle is required.");
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var companyName = request.CompanyName.Trim();
|
||||
var jobTitle = request.JobTitle.Trim();
|
||||
var company = await _db.Companies.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.Name.ToLower() == companyName.ToLower(), cancellationToken);
|
||||
if (company is null)
|
||||
{
|
||||
company = new Company
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
Name = companyName,
|
||||
RecruiterName = string.IsNullOrWhiteSpace(request.RecruiterName) ? null : request.RecruiterName.Trim(),
|
||||
RecruiterEmail = string.IsNullOrWhiteSpace(request.RecruiterEmail) ? null : request.RecruiterEmail.Trim(),
|
||||
};
|
||||
_db.Companies.Add(company);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(company.RecruiterName) && !string.IsNullOrWhiteSpace(request.RecruiterName)) company.RecruiterName = request.RecruiterName.Trim();
|
||||
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
|
||||
}
|
||||
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
CompanyId = company.Id,
|
||||
JobTitle = jobTitle,
|
||||
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
|
||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
||||
DateApplied = DateTime.UtcNow,
|
||||
};
|
||||
_db.JobApplications.Add(job);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
|
||||
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
var imported = 0;
|
||||
var skipped = 0;
|
||||
foreach (var messageId in distinctMessageIds)
|
||||
{
|
||||
var existing = await _db.Correspondences.AnyAsync(message => message.JobApplicationId == job.Id && message.ExternalMessageId == messageId, cancellationToken);
|
||||
if (existing)
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
|
||||
imported++;
|
||||
}
|
||||
|
||||
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
|
||||
}
|
||||
|
||||
[HttpPost("relink-thread")]
|
||||
public async Task<ActionResult<GmailRelinkResultDto>> RelinkThread([FromBody] RelinkGmailThreadRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId 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 threadId = request.ThreadId.Trim();
|
||||
var unlinkedMessages = 0;
|
||||
if (request.RemoveFromOtherJobs)
|
||||
{
|
||||
var otherMessages = await _db.Correspondences
|
||||
.Include(message => message.JobApplication)
|
||||
.Where(message => message.ExternalThreadId == threadId && message.JobApplicationId != job.Id && message.JobApplication.OwnerUserId == ownerUserId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (otherMessages.Count > 0)
|
||||
{
|
||||
_db.Correspondences.RemoveRange(otherMessages);
|
||||
unlinkedMessages = otherMessages.Count;
|
||||
}
|
||||
}
|
||||
|
||||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||||
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
|
||||
var existingMessageIds = await _db.Correspondences
|
||||
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
|
||||
.Select(message => message.ExternalMessageId!)
|
||||
.ToListAsync(cancellationToken);
|
||||
var imported = 0;
|
||||
var skipped = 0;
|
||||
foreach (var messageId in distinctMessageIds)
|
||||
{
|
||||
if (existingMessageIds.Contains(messageId, StringComparer.Ordinal))
|
||||
{
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
await ImportSingleMessageAsync(ownerUserId, job, messageId, cancellationToken);
|
||||
imported++;
|
||||
}
|
||||
|
||||
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
|
||||
}
|
||||
|
||||
[HttpPost("unlink-thread")]
|
||||
public async Task<ActionResult<GmailUnlinkResultDto>> UnlinkThread([FromBody] UnlinkGmailThreadRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||||
if (string.IsNullOrWhiteSpace(request.ThreadId)) return BadRequest("ThreadId is required.");
|
||||
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var job = await _db.JobApplications
|
||||
.Where(x => x.OwnerUserId == ownerUserId)
|
||||
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId, cancellationToken);
|
||||
if (job is null) return NotFound("Job application not found.");
|
||||
|
||||
var threadId = request.ThreadId.Trim();
|
||||
var messages = await _db.Correspondences
|
||||
.Where(message => message.JobApplicationId == job.Id && message.ExternalThreadId == threadId)
|
||||
.ToListAsync(cancellationToken);
|
||||
if (messages.Count > 0)
|
||||
{
|
||||
_db.Correspondences.RemoveRange(messages);
|
||||
}
|
||||
|
||||
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
||||
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
|
||||
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
|
||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
|
||||
}
|
||||
|
||||
[AllowAnonymous]
|
||||
[HttpGet("oauth/callback")]
|
||||
public async Task<IActionResult> Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken)
|
||||
@@ -591,6 +976,60 @@ public sealed class GmailController : ControllerBase
|
||||
return _matching.BuildJobQueries(job, queryOverride);
|
||||
}
|
||||
|
||||
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||
{
|
||||
var bounded = (query ?? string.Empty).Trim();
|
||||
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bounded = string.IsNullOrWhiteSpace(bounded)
|
||||
? $"newer_than:{lookbackDays}d"
|
||||
: $"{bounded} newer_than:{lookbackDays}d";
|
||||
}
|
||||
|
||||
if (!includeSpamTrash)
|
||||
{
|
||||
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
|
||||
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
|
||||
}
|
||||
|
||||
return bounded.Trim();
|
||||
}
|
||||
|
||||
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||
{
|
||||
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
|
||||
if (string.IsNullOrWhiteSpace(sample)) return false;
|
||||
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|
||||
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||
{
|
||||
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
|
||||
if (existing is null)
|
||||
{
|
||||
existing = new GmailReviewDecision
|
||||
{
|
||||
OwnerUserId = ownerUserId,
|
||||
ThreadId = threadId,
|
||||
};
|
||||
decisions.Add(existing);
|
||||
_db.GmailReviewDecisions.Add(existing);
|
||||
}
|
||||
|
||||
existing.Decision = decision;
|
||||
existing.JobApplicationId = jobApplicationId;
|
||||
if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
|
||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||
}
|
||||
|
||||
private static string ToConfidence(int score)
|
||||
{
|
||||
return score switch
|
||||
@@ -601,6 +1040,44 @@ public sealed class GmailController : ControllerBase
|
||||
};
|
||||
}
|
||||
|
||||
private static string? ExtractFirstEmail(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
return match.Success ? match.Value : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRecruiterName(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var trimmed = value.Split('<')[0].Trim().Trim('"');
|
||||
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? ExtractCompanyName(string? from, string? subject)
|
||||
{
|
||||
var subjectText = (subject ?? string.Empty).Trim();
|
||||
if (!string.IsNullOrWhiteSpace(subjectText))
|
||||
{
|
||||
var parts = subjectText.Split(new[] { '-', '–', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length >= 2) return parts[0];
|
||||
}
|
||||
|
||||
var recruiterName = ExtractRecruiterName(from);
|
||||
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
|
||||
}
|
||||
|
||||
private static string? ExtractRoleFromSubject(string? subject)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(subject)) return null;
|
||||
var trimmed = subject.Trim();
|
||||
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
|
||||
}
|
||||
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
|
||||
}
|
||||
|
||||
private string GetRequiredOwnerUserId()
|
||||
{
|
||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||
|
||||
Reference in New Issue
Block a user