diff --git a/JobTrackerApi/Controllers/GmailController.cs b/JobTrackerApi/Controllers/GmailController.cs index 9a7c16b..7383ba3 100644 --- a/JobTrackerApi/Controllers/GmailController.cs +++ b/JobTrackerApi/Controllers/GmailController.cs @@ -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())) .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 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 decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note) { var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId); diff --git a/JobTrackerApi/Services/DailyExportHostedService.cs b/JobTrackerApi/Services/DailyExportHostedService.cs index 060a253..c4538f9 100644 --- a/JobTrackerApi/Services/DailyExportHostedService.cs +++ b/JobTrackerApi/Services/DailyExportHostedService.cs @@ -75,22 +75,22 @@ namespace JobTrackerApi.Services using var scope = _sp.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); - - 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 };