1127 lines
55 KiB
C#
1127 lines
55 KiB
C#
using System.Security.Claims;
|
||
using System.Text.Json;
|
||
using JobTrackerApi.Data;
|
||
using JobTrackerApi.Models;
|
||
using JobTrackerApi.Services;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace JobTrackerApi.Controllers;
|
||
|
||
[ApiController]
|
||
[Route("api/gmail")]
|
||
[Authorize]
|
||
public sealed class GmailController : ControllerBase
|
||
{
|
||
private readonly IGmailOAuthService _gmail;
|
||
private readonly IGmailJobMatchingService _matching;
|
||
private readonly JobTrackerContext _db;
|
||
private readonly IConfiguration _cfg;
|
||
|
||
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
|
||
{
|
||
_gmail = gmail;
|
||
_matching = matching;
|
||
_db = db;
|
||
_cfg = cfg;
|
||
}
|
||
|
||
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
|
||
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
|
||
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
|
||
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
|
||
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
|
||
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
|
||
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
|
||
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
|
||
public sealed record GmailJobMatchedMessageDto(
|
||
string Id,
|
||
string ThreadId,
|
||
string Subject,
|
||
string From,
|
||
string To,
|
||
DateTimeOffset? Date,
|
||
string Snippet,
|
||
int Score,
|
||
string Confidence,
|
||
bool AlreadyImported,
|
||
IReadOnlyList<string> MatchedQueries,
|
||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
|
||
public sealed record GmailJobMatchedThreadDto(
|
||
string ThreadId,
|
||
string Subject,
|
||
int Score,
|
||
string Confidence,
|
||
bool HasImportedMessages,
|
||
int ImportedMessageCount,
|
||
int MessageCount,
|
||
DateTimeOffset? LatestDate,
|
||
IReadOnlyList<string> MatchedQueries,
|
||
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
|
||
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
|
||
public sealed record GmailJobMatchesResponseDto(
|
||
int JobApplicationId,
|
||
string JobTitle,
|
||
string CompanyName,
|
||
string? RecruiterName,
|
||
string? RecruiterEmail,
|
||
IReadOnlyList<string> Queries,
|
||
int CandidateMessageCount,
|
||
int CandidateThreadCount,
|
||
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, 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,
|
||
string? GmailAddress,
|
||
DateTimeOffset? ConnectedAt,
|
||
DateTimeOffset? LastSyncedAt,
|
||
DateTimeOffset? LastSyncAttemptedAt,
|
||
DateTimeOffset? LastSyncSucceededAt,
|
||
string? LastSyncMode,
|
||
string? LastSyncSource,
|
||
string? LastSyncStatus,
|
||
string? LastSyncError);
|
||
|
||
[HttpGet("status")]
|
||
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||
{
|
||
var ownerUserId = GetRequiredOwnerUserId();
|
||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||
return Ok(new GmailConnectionStatusDto(
|
||
connection is not null,
|
||
connection?.GmailAddress,
|
||
connection?.ConnectedAt,
|
||
connection?.LastSyncedAt,
|
||
connection?.LastSyncAttemptedAt,
|
||
connection?.LastSyncSucceededAt,
|
||
connection?.LastSyncMode,
|
||
connection?.LastSyncSource,
|
||
connection?.LastSyncStatus,
|
||
connection?.LastSyncError));
|
||
}
|
||
|
||
[HttpGet("connect-url")]
|
||
public IActionResult ConnectUrl()
|
||
{
|
||
var ownerUserId = GetRequiredOwnerUserId();
|
||
var url = _gmail.BuildAuthorizationUrl(ownerUserId, GetRedirectUri());
|
||
return Ok(new { url });
|
||
}
|
||
|
||
[HttpGet("job-candidates")]
|
||
public async Task<ActionResult<GmailJobMatchesResponseDto>> JobCandidates(
|
||
[FromQuery] int jobApplicationId,
|
||
[FromQuery] string? queryOverride,
|
||
[FromQuery] int maxResultsPerQuery = 6,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
if (jobApplicationId <= 0) return BadRequest("Valid jobApplicationId 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 == jobApplicationId, cancellationToken);
|
||
if (job is null) return NotFound("Job application not found.");
|
||
|
||
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)))
|
||
.Where(result => result.Score > 0 || result.AlreadyImported)
|
||
.OrderByDescending(result => result.Score)
|
||
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
|
||
.ToList();
|
||
|
||
var threads = rankedMessages
|
||
.GroupBy(result => string.IsNullOrWhiteSpace(result.Message.ThreadId) ? result.Message.Id : result.Message.ThreadId, StringComparer.Ordinal)
|
||
.Select(group =>
|
||
{
|
||
var ordered = group
|
||
.OrderByDescending(item => item.Score)
|
||
.ThenByDescending(item => item.Message.Date ?? DateTimeOffset.MinValue)
|
||
.ToList();
|
||
var latestDate = ordered
|
||
.Select(item => item.Message.Date)
|
||
.OrderByDescending(item => item ?? DateTimeOffset.MinValue)
|
||
.FirstOrDefault();
|
||
var combinedReasons = ordered
|
||
.SelectMany(item => item.Reasons)
|
||
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
|
||
.Select(reason => reason.First())
|
||
.OrderByDescending(reason => reason.Points)
|
||
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
||
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
||
.Take(8)
|
||
.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points))
|
||
.ToList();
|
||
var matchedQueries = ordered
|
||
.SelectMany(item => item.MatchedQueries)
|
||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||
.OrderBy(query => query, StringComparer.OrdinalIgnoreCase)
|
||
.ToList();
|
||
var hasImportedMessages = ordered.Any(item => item.AlreadyImported);
|
||
var importedMessageCount = ordered.Count(item => item.AlreadyImported);
|
||
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
|
||
var representative = ordered[0].Message;
|
||
|
||
return new GmailJobMatchedThreadDto(
|
||
group.Key,
|
||
string.IsNullOrWhiteSpace(representative.Subject) ? "(no subject)" : representative.Subject,
|
||
threadScore,
|
||
ToConfidence(threadScore),
|
||
hasImportedMessages,
|
||
importedMessageCount,
|
||
ordered.Count,
|
||
latestDate,
|
||
matchedQueries,
|
||
combinedReasons,
|
||
ordered.Select(item => new GmailJobMatchedMessageDto(
|
||
item.Message.Id,
|
||
item.Message.ThreadId,
|
||
item.Message.Subject,
|
||
item.Message.From,
|
||
item.Message.To,
|
||
item.Message.Date,
|
||
item.Message.Snippet,
|
||
item.Score,
|
||
ToConfidence(item.Score),
|
||
item.AlreadyImported,
|
||
item.MatchedQueries,
|
||
item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList());
|
||
})
|
||
.OrderByDescending(thread => thread.Score)
|
||
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||
.ToList();
|
||
|
||
return Ok(new GmailJobMatchesResponseDto(
|
||
job.Id,
|
||
job.JobTitle,
|
||
job.Company?.Name ?? string.Empty,
|
||
job.Company?.RecruiterName,
|
||
job.Company?.RecruiterEmail,
|
||
queries,
|
||
rankedMessages.Count,
|
||
threads.Count,
|
||
threads));
|
||
}
|
||
|
||
[HttpGet("review-candidates")]
|
||
public async Task<ActionResult<GmailReviewQueueResponseDto>> ReviewCandidates(
|
||
[FromQuery] string? queryOverride,
|
||
[FromQuery] int maxResultsPerQuery = 6,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
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 GmailReviewQueueResponseDto(Array.Empty<string>(), 0, 0, 0, 0, Array.Empty<GmailReviewThreadDto>()));
|
||
}
|
||
|
||
var querySet = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var job in jobs)
|
||
{
|
||
foreach (var query in _matching.BuildJobQueries(job, queryOverride))
|
||
{
|
||
querySet.Add(query);
|
||
}
|
||
}
|
||
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))
|
||
.Select(message => message.ExternalMessageId!)
|
||
.ToHashSet(StringComparer.Ordinal);
|
||
var allImportedThreadIds = jobs.SelectMany(job => job.Messages)
|
||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
|
||
.Select(message => message.ExternalThreadId!)
|
||
.ToHashSet(StringComparer.Ordinal);
|
||
var reviewDecisions = await _db.GmailReviewDecisions
|
||
.AsNoTracking()
|
||
.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)
|
||
.Select(group =>
|
||
{
|
||
var existingDecision = reviewDecisions.FirstOrDefault(x => x.ThreadId == 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 jobCandidates = jobs
|
||
.Select(job =>
|
||
{
|
||
var best = orderedMessages
|
||
.Select(item => _matching.ScoreMessage(job, item, allImportedMessageIds.Contains(item.Message.Id), allImportedThreadIds.Contains(item.Message.ThreadId)))
|
||
.OrderByDescending(score => score.Score)
|
||
.First();
|
||
return new GmailReviewJobCandidateDto(
|
||
job.Id,
|
||
job.JobTitle,
|
||
job.Company?.Name ?? string.Empty,
|
||
best.Score,
|
||
best.Confidence,
|
||
best.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList());
|
||
})
|
||
.Where(candidate => candidate.Score > 0)
|
||
.OrderByDescending(candidate => candidate.Score)
|
||
.Take(3)
|
||
.ToList();
|
||
|
||
var topScore = jobCandidates.FirstOrDefault()?.Score ?? 0;
|
||
var secondScore = jobCandidates.Skip(1).FirstOrDefault()?.Score ?? 0;
|
||
var routing = existingDecision?.Decision switch
|
||
{
|
||
"linked" => "linked",
|
||
"rejected" => "rejected",
|
||
"suggested" => "suggested",
|
||
_ => topScore >= 30 && topScore - secondScore >= 8
|
||
? "auto-link"
|
||
: topScore >= 16
|
||
? "review"
|
||
: "unmatched"
|
||
};
|
||
|
||
var messages = orderedMessages
|
||
.Select(item => new GmailJobMatchedMessageDto(
|
||
item.Message.Id,
|
||
item.Message.ThreadId,
|
||
item.Message.Subject,
|
||
item.Message.From,
|
||
item.Message.To,
|
||
item.Message.Date,
|
||
item.Message.Snippet,
|
||
item.MatchedQueries.Count * 4,
|
||
item.MatchedQueries.Count >= 2 ? "medium" : "low",
|
||
allImportedMessageIds.Contains(item.Message.Id),
|
||
item.MatchedQueries,
|
||
Array.Empty<GmailJobMatchReasonDto>()))
|
||
.ToList();
|
||
|
||
return new GmailReviewThreadDto(group.Key, subject, latestDate, orderedMessages.Count, routing, hasImportedMessages, existingDecision?.Note, matchedQueries, jobCandidates, messages);
|
||
})
|
||
.OrderByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||
.Take(100)
|
||
.ToList();
|
||
|
||
return Ok(new GmailReviewQueueResponseDto(
|
||
queries,
|
||
groupedThreads.Count,
|
||
groupedThreads.Count(thread => thread.Routing == "auto-link"),
|
||
groupedThreads.Count(thread => thread.Routing == "review"),
|
||
groupedThreads.Count(thread => thread.Routing == "unmatched"),
|
||
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)
|
||
{
|
||
if (!string.IsNullOrWhiteSpace(error))
|
||
{
|
||
return Content(BuildPopupHtml(false, $"Google returned an error: {error}"), "text/html");
|
||
}
|
||
|
||
if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state))
|
||
{
|
||
return Content(BuildPopupHtml(false, "Missing Google OAuth code or state."), "text/html");
|
||
}
|
||
|
||
var ownerUserId = _gmail.ConsumeState(state);
|
||
if (string.IsNullOrWhiteSpace(ownerUserId))
|
||
{
|
||
return Content(BuildPopupHtml(false, "This Gmail connection request is no longer valid. Start the connection again."), "text/html");
|
||
}
|
||
|
||
try
|
||
{
|
||
var result = await _gmail.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken);
|
||
return Content(BuildPopupHtml(true, $"Connected Gmail: {result.GmailAddress}"), "text/html");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return Content(BuildPopupHtml(false, ex.Message), "text/html");
|
||
}
|
||
}
|
||
|
||
[HttpDelete("connection")]
|
||
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
|
||
{
|
||
var ownerUserId = GetRequiredOwnerUserId();
|
||
await _gmail.DisconnectAsync(ownerUserId, cancellationToken);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpGet("messages")]
|
||
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
|
||
{
|
||
var ownerUserId = GetRequiredOwnerUserId();
|
||
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
|
||
return Ok(items);
|
||
}
|
||
|
||
[HttpPost("import")]
|
||
public async Task<ActionResult<GmailImportMessageResultDto>> Import([FromBody] ImportGmailMessageRequest request, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId is required.");
|
||
if (string.IsNullOrWhiteSpace(request.MessageId)) return BadRequest("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 existing = await _db.Correspondences.FirstOrDefaultAsync(
|
||
x => x.JobApplicationId == request.JobApplicationId && x.ExternalMessageId == request.MessageId,
|
||
cancellationToken);
|
||
if (existing is not null)
|
||
{
|
||
return Ok(new GmailImportMessageResultDto(0, 1, request.MessageId, existing.ExternalThreadId, existing));
|
||
}
|
||
|
||
var created = await ImportSingleMessageAsync(ownerUserId, job, request.MessageId, cancellationToken);
|
||
await _db.SaveChangesAsync(cancellationToken);
|
||
return Ok(new GmailImportMessageResultDto(1, 0, request.MessageId, created.ExternalThreadId, created));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return BadRequest(ex.Message);
|
||
}
|
||
}
|
||
|
||
[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));
|
||
}
|
||
|
||
[HttpPost("refresh-linked-threads")]
|
||
public async Task<ActionResult<GmailThreadRefreshResultDto>> RefreshLinkedThreads([FromBody] RefreshLinkedThreadsRequest request, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
if (request.JobApplicationId <= 0) return BadRequest("Valid jobApplicationId 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 connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||
if (connection is null) return Conflict("Connect Gmail before refreshing linked threads.");
|
||
|
||
var linkedThreadIds = job.Messages
|
||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
|
||
.Select(message => message.ExternalThreadId!.Trim())
|
||
.Distinct(StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
if (linkedThreadIds.Count == 0)
|
||
{
|
||
return Ok(new GmailThreadRefreshResultDto(job.Id, 0, 0, 0, false, DateTimeOffset.UtcNow, Array.Empty<GmailThreadRefreshThreadDto>()));
|
||
}
|
||
|
||
var existingMessageIds = job.Messages
|
||
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
|
||
.Select(message => message.ExternalMessageId!)
|
||
.ToHashSet(StringComparer.Ordinal);
|
||
|
||
var refreshedThreads = new List<GmailThreadRefreshThreadDto>(linkedThreadIds.Count);
|
||
var imported = 0;
|
||
var skipped = 0;
|
||
|
||
foreach (var threadId in linkedThreadIds)
|
||
{
|
||
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
|
||
var distinctThreadMessages = threadMessages
|
||
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
|
||
.GroupBy(message => message.Id, StringComparer.Ordinal)
|
||
.Select(group => group.First())
|
||
.OrderBy(message => message.Date ?? DateTimeOffset.MinValue)
|
||
.ToList();
|
||
|
||
var threadImported = 0;
|
||
var threadSkipped = 0;
|
||
foreach (var message in distinctThreadMessages)
|
||
{
|
||
if (existingMessageIds.Contains(message.Id))
|
||
{
|
||
threadSkipped++;
|
||
skipped++;
|
||
continue;
|
||
}
|
||
|
||
var created = await ImportSingleMessageAsync(ownerUserId, job, message.Id, cancellationToken);
|
||
existingMessageIds.Add(created.ExternalMessageId ?? message.Id);
|
||
threadImported++;
|
||
imported++;
|
||
}
|
||
|
||
var latestMessageDate = distinctThreadMessages
|
||
.Select(message => message.Date)
|
||
.OrderByDescending(message => message ?? DateTimeOffset.MinValue)
|
||
.FirstOrDefault();
|
||
var status = threadImported > 0 ? "imported-new-messages" : "already-current";
|
||
refreshedThreads.Add(new GmailThreadRefreshThreadDto(threadId, threadImported, threadSkipped, distinctThreadMessages.Count, status, latestMessageDate));
|
||
}
|
||
|
||
await _db.SaveChangesAsync(cancellationToken);
|
||
return Ok(new GmailThreadRefreshResultDto(job.Id, refreshedThreads.Count, imported, skipped, true, DateTimeOffset.UtcNow, refreshedThreads));
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
return BadRequest(ex.Message);
|
||
}
|
||
}
|
||
|
||
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",
|
||
Direction = isMe ? "outbound" : "inbound",
|
||
Subject = string.IsNullOrWhiteSpace(detail.Subject) ? null : detail.Subject.Trim(),
|
||
Channel = "Email",
|
||
ExternalMessageId = detail.Id,
|
||
ExternalThreadId = string.IsNullOrWhiteSpace(detail.ThreadId) ? null : detail.ThreadId.Trim(),
|
||
ExternalFrom = string.IsNullOrWhiteSpace(detail.From) ? null : detail.From.Trim(),
|
||
ExternalTo = string.IsNullOrWhiteSpace(detail.To) ? null : detail.To.Trim(),
|
||
ExternalLabelsJson = detail.Labels.Count == 0 ? null : JsonSerializer.Serialize(detail.Labels),
|
||
AttachmentMetadataJson = detail.Attachments.Count == 0 ? null : JsonSerializer.Serialize(detail.Attachments.Select(attachment => new CorrespondenceAttachmentMetadata
|
||
{
|
||
FileName = attachment.FileName,
|
||
MimeType = attachment.MimeType,
|
||
SizeBytes = attachment.SizeBytes,
|
||
GmailAttachmentId = attachment.GmailAttachmentId,
|
||
Inline = attachment.Inline,
|
||
})),
|
||
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 IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||
{
|
||
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
|
||
{
|
||
>= 30 => "high",
|
||
>= 16 => "medium",
|
||
_ => "low"
|
||
};
|
||
}
|
||
|
||
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")
|
||
?? throw new InvalidOperationException("Authenticated user id is missing.");
|
||
}
|
||
|
||
private string GetRedirectUri()
|
||
{
|
||
var configured = (_cfg["Google:GmailRedirectUri"] ?? _cfg["Google:RedirectUri"] ?? "").Trim();
|
||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||
|
||
var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/');
|
||
if (!string.IsNullOrWhiteSpace(publicBaseUrl))
|
||
{
|
||
return $"{publicBaseUrl}/api/gmail/oauth/callback";
|
||
}
|
||
|
||
return $"{Request.Scheme}://{Request.Host}/api/gmail/oauth/callback";
|
||
}
|
||
|
||
private static string BuildPopupHtml(bool success, string message)
|
||
{
|
||
var escaped = System.Net.WebUtility.HtmlEncode(message);
|
||
var status = success ? "connected" : "error";
|
||
var title = success ? "Gmail connected" : "Gmail connection failed";
|
||
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
|
||
return $@"<!doctype html>
|
||
<html>
|
||
<head>
|
||
<meta charset=""utf-8"" />
|
||
<title>Gmail connection</title>
|
||
</head>
|
||
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
|
||
<h2>{title}</h2>
|
||
<p>{escaped}</p>
|
||
<p>You can close this window.</p>
|
||
<script>
|
||
if (window.opener) {{
|
||
window.opener.postMessage({{ source: 'jobtracker-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
|
||
}}
|
||
window.close();
|
||
</script>
|
||
</body>
|
||
</html>";
|
||
}
|
||
}
|