Tighten Gmail and export hot paths

This commit is contained in:
2026-04-11 12:10:49 +02:00
parent 33ac4b963b
commit ce26325682
2 changed files with 81 additions and 50 deletions
+60 -33
View File
@@ -137,23 +137,25 @@ public sealed class GmailController : ControllerBase
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var importedMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToListAsync(cancellationToken);
var importedThreadIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToListAsync(cancellationToken);
var importedMessageIdSet = importedMessageIds.ToHashSet(StringComparer.Ordinal);
var importedThreadIdSet = importedThreadIds.ToHashSet(StringComparer.Ordinal);
var queries = BuildJobQueries(job, queryOverride);
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var importedMessageIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var importedThreadIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var rankedMessages = candidateMessages
.Select(message => _matching.ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
.Select(message => _matching.ScoreMessage(job, message, importedMessageIdSet.Contains(message.Message.Id), importedThreadIdSet.Contains(message.Message.ThreadId)))
.Where(result => result.Score > 0 || result.AlreadyImported)
.OrderByDescending(result => result.Score)
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
@@ -244,9 +246,9 @@ public sealed class GmailController : ControllerBase
return GmailNotConnectedResult();
}
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
.Include(x => x.Company)
.Include(x => x.Messages)
.OrderByDescending(x => x.DateApplied)
.Take(100)
.ToListAsync(cancellationToken);
@@ -266,35 +268,39 @@ public sealed class GmailController : ControllerBase
var queries = querySet.Take(18).ToList();
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var allImportedMessageIds = jobs.SelectMany(job => job.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
var allImportedMessageIds = await _db.Correspondences
.AsNoTracking()
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIds = jobs.SelectMany(job => job.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.ToListAsync(cancellationToken);
var allImportedThreadIds = await _db.Correspondences
.AsNoTracking()
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
.ToListAsync(cancellationToken);
var allImportedMessageIdSet = allImportedMessageIds.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIdSet = allImportedThreadIds.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.AsNoTracking()
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
.ToDictionaryAsync(decision => decision.ThreadId, StringComparer.Ordinal, cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
.Select(group =>
{
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == group.Key);
var existingDecision = reviewDecisions.GetValueOrDefault(group.Key);
var orderedMessages = group.OrderByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue).ToList();
var latestDate = orderedMessages.Max(item => item.Message.Date ?? DateTimeOffset.MinValue);
var subject = orderedMessages.FirstOrDefault(item => !string.IsNullOrWhiteSpace(item.Message.Subject))?.Message.Subject ?? "(no subject)";
var matchedQueries = orderedMessages.SelectMany(item => item.MatchedQueries).Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList();
var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIds.Contains(item.Message.Id) || allImportedThreadIds.Contains(item.Message.ThreadId));
var hasImportedMessages = orderedMessages.Any(item => allImportedMessageIdSet.Contains(item.Message.Id) || allImportedThreadIdSet.Contains(item.Message.ThreadId));
var jobCandidates = jobs
.Select(job =>
{
var best = orderedMessages
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIdSet.Contains(item.Message.Id), allImportedThreadIdSet.Contains(item.Message.ThreadId)))
.OrderByDescending(score => score.Score)
.First();
return new GmailReviewJobCandidateDto(
@@ -335,7 +341,7 @@ public sealed class GmailController : ControllerBase
item.Message.Snippet,
item.MatchedQueries.Count * 4,
item.MatchedQueries.Count >= 2 ? "medium" : "low",
allImportedMessageIds.Contains(item.Message.Id),
allImportedMessageIdSet.Contains(item.Message.Id),
item.MatchedQueries,
Array.Empty<GmailJobMatchReasonDto>()))
.ToList();
@@ -435,9 +441,9 @@ public sealed class GmailController : ControllerBase
return GmailNotConnectedResult();
}
var jobs = await _db.JobApplications
.AsNoTracking()
.Where(x => x.OwnerUserId == ownerUserId && !x.IsDeleted)
.Include(x => x.Company)
.Include(x => x.Messages)
.OrderByDescending(x => x.DateApplied)
.Take(100)
.ToListAsync(cancellationToken);
@@ -462,17 +468,19 @@ public sealed class GmailController : ControllerBase
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))
var allImportedMessageIds = await _db.Correspondences
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIds = jobs.SelectMany(jobItem => jobItem.Messages)
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.ToListAsync(cancellationToken);
var allImportedThreadIds = await _db.Correspondences
.Where(message => message.JobApplication.OwnerUserId == ownerUserId && !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
.ToListAsync(cancellationToken);
var allImportedMessageIdSet = allImportedMessageIds.ToHashSet(StringComparer.Ordinal);
var allImportedThreadIdSet = allImportedThreadIds.ToHashSet(StringComparer.Ordinal);
var reviewDecisions = await _db.GmailReviewDecisions
.Where(decision => decision.OwnerUserId == ownerUserId)
.ToListAsync(cancellationToken);
.ToDictionaryAsync(decision => decision.ThreadId, StringComparer.Ordinal, cancellationToken);
var groupedThreads = candidateMessages
.GroupBy(message => string.IsNullOrWhiteSpace(message.Message.ThreadId) ? message.Message.Id : message.Message.ThreadId, StringComparer.Ordinal)
@@ -488,7 +496,7 @@ public sealed class GmailController : ControllerBase
foreach (var threadGroup in groupedThreads)
{
var threadId = threadGroup.Key;
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == threadId);
var existingDecision = reviewDecisions.GetValueOrDefault(threadId);
if (string.Equals(existingDecision?.Decision, "rejected", StringComparison.OrdinalIgnoreCase))
{
unmatchedCount++;
@@ -500,7 +508,7 @@ public sealed class GmailController : ControllerBase
.Select(jobItem =>
{
var best = orderedMessages
.Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
.Select(item => _matching.ScoreMessage(jobItem, item, allImportedMessageIdSet.Contains(item.Message.Id), allImportedThreadIdSet.Contains(item.Message.ThreadId)))
.OrderByDescending(score => score.Score)
.First();
return new { Job = jobItem, Best = best };
@@ -528,7 +536,7 @@ public sealed class GmailController : ControllerBase
}
await ImportSingleMessageAsync(ownerUserId, top.Job, messageId, cancellationToken);
allImportedMessageIds.Add(messageId);
allImportedMessageIdSet.Add(messageId);
importedMessages++;
}
@@ -1022,6 +1030,25 @@ public sealed class GmailController : ControllerBase
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
if (!decisions.TryGetValue(threadId, out var existing))
{
existing = new GmailReviewDecision
{
OwnerUserId = ownerUserId,
ThreadId = threadId,
};
decisions[threadId] = existing;
_db.GmailReviewDecisions.Add(existing);
}
existing.Decision = decision;
existing.JobApplicationId = jobApplicationId;
if (!string.IsNullOrWhiteSpace(note)) existing.Note = note.Trim();
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
@@ -75,22 +75,22 @@ namespace JobTrackerApi.Services
using var scope = _sp.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var companies = await db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(ct);
var correspondence = await db.Correspondences.AsNoTracking().OrderBy(c => c.Date).ToListAsync(ct);
var attachments = await db.Attachments.AsNoTracking().OrderBy(a => a.UploadDate).ToListAsync(ct);
var events = await db.JobEvents.AsNoTracking().OrderBy(e => e.At).ToListAsync(ct);
var rules = await db.RuleSettings.AsNoTracking().FirstOrDefaultAsync(ct);
// If multi-user ownership is present, write one export per owner.
var owners = jobs
.Select(j => j.OwnerUserId)
var owners = await db.JobApplications
.AsNoTracking()
.OrderByDescending(job => job.DateApplied)
.Select(job => job.OwnerUserId)
.Distinct()
.ToList();
.ToListAsync(ct);
if (owners.Count <= 1)
{
var companies = await db.Companies.AsNoTracking().OrderBy(c => c.Name).ToListAsync(ct);
var jobs = await db.JobApplications.AsNoTracking().OrderByDescending(j => j.DateApplied).ToListAsync(ct);
var correspondence = await db.Correspondences.AsNoTracking().OrderBy(c => c.Date).ToListAsync(ct);
var attachments = await db.Attachments.AsNoTracking().OrderBy(a => a.UploadDate).ToListAsync(ct);
var events = await db.JobEvents.AsNoTracking().OrderBy(e => e.At).ToListAsync(ct);
var export = new
{
Version = "dailyexport.v1",
@@ -114,19 +114,23 @@ namespace JobTrackerApi.Services
foreach (var owner in owners)
{
var ownerKey = string.IsNullOrWhiteSpace(owner) ? "_unassigned" : owner;
var ownerJobs = jobs.Where(j => j.OwnerUserId == owner).ToList();
var ownerJobIds = ownerJobs.Select(j => j.Id).ToHashSet();
var ownerJobs = await db.JobApplications
.AsNoTracking()
.Where(job => job.OwnerUserId == owner)
.OrderByDescending(job => job.DateApplied)
.ToListAsync(ct);
var ownerJobIds = ownerJobs.Select(job => job.Id).ToList();
var export = new
{
Version = "dailyexport.v2",
CreatedAt = DateTime.Now,
OwnerUserId = owner,
Companies = companies.Where(c => c.OwnerUserId == owner).ToList(),
Companies = await db.Companies.AsNoTracking().Where(company => company.OwnerUserId == owner).OrderBy(company => company.Name).ToListAsync(ct),
JobApplications = ownerJobs,
Correspondence = correspondence.Where(c => ownerJobIds.Contains(c.JobApplicationId)).ToList(),
Attachments = attachments.Where(a => ownerJobIds.Contains(a.JobApplicationId)).ToList(),
Events = events.Where(e => ownerJobIds.Contains(e.JobApplicationId)).ToList(),
Correspondence = await db.Correspondences.AsNoTracking().Where(message => ownerJobIds.Contains(message.JobApplicationId)).OrderBy(message => message.Date).ToListAsync(ct),
Attachments = await db.Attachments.AsNoTracking().Where(attachment => ownerJobIds.Contains(attachment.JobApplicationId)).OrderBy(attachment => attachment.UploadDate).ToListAsync(ct),
Events = await db.JobEvents.AsNoTracking().Where(jobEvent => ownerJobIds.Contains(jobEvent.JobApplicationId)).OrderBy(jobEvent => jobEvent.At).ToListAsync(ct),
Rules = rules
};