Tighten Gmail and export hot paths
This commit is contained in:
@@ -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
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user