Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b4fd5e2f96 | |||
| 37ea1f98bb | |||
| ab79072e52 | |||
| abe23b799a | |||
| 6a43227315 | |||
| 9b21d5c65d |
@@ -0,0 +1,111 @@
|
|||||||
|
using JobTrackerApi.Controllers;
|
||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using JobTrackerApi.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Moq;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
||||||
|
// Attachment rows (backlog Wave 3), not manually settable. These tests exercise the single
|
||||||
|
// place they're written: AttachmentsController's Purpose-change and Delete paths.
|
||||||
|
public sealed class AttachmentFlagsRecomputeTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Changing_purpose_to_resume_sets_HasResume()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "other");
|
||||||
|
var controller = CreateController(db);
|
||||||
|
|
||||||
|
var result = await controller.Rename(attachment.Id, new AttachmentsController.UpdateAttachmentRequest(null, "resume", null), CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||||
|
Assert.True(updated.HasResume);
|
||||||
|
Assert.True(updated.HasOtherAttachment == false);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Deleting_the_only_resume_attachment_clears_HasResume()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "resume");
|
||||||
|
var controller = CreateController(db);
|
||||||
|
|
||||||
|
var result = await controller.Delete(attachment.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NoContentResult>(result);
|
||||||
|
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||||
|
Assert.False(updated.HasResume);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Attachment_with_case_study_purpose_counts_as_other()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var (job, attachment) = await SeedJobWithAttachmentAsync(db, purpose: "resume");
|
||||||
|
var controller = CreateController(db);
|
||||||
|
|
||||||
|
await controller.Rename(attachment.Id, new AttachmentsController.UpdateAttachmentRequest(null, "case-study", null), CancellationToken.None);
|
||||||
|
|
||||||
|
var updated = await db.JobApplications.SingleAsync(j => j.Id == job.Id);
|
||||||
|
Assert.False(updated.HasResume);
|
||||||
|
Assert.True(updated.HasOtherAttachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<(JobApplication Job, Attachment Attachment)> SeedJobWithAttachmentAsync(JobTrackerContext db, string purpose)
|
||||||
|
{
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var attachment = new Attachment
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
FileName = "file.pdf",
|
||||||
|
FilePath = Path.Combine(Path.GetTempPath(), $"jobtracker-attachment-test-{Guid.NewGuid():N}.pdf"),
|
||||||
|
FileType = "application/pdf",
|
||||||
|
FileSize = 100,
|
||||||
|
Purpose = purpose,
|
||||||
|
};
|
||||||
|
db.Attachments.Add(attachment);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
job.HasResume = purpose == "resume";
|
||||||
|
job.HasOtherAttachment = purpose is not ("resume" or "cover-letter" or "portfolio");
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
return (job, attachment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static AttachmentsController CreateController(JobTrackerContext db)
|
||||||
|
{
|
||||||
|
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-attachments-tests-{Guid.NewGuid():N}");
|
||||||
|
Directory.CreateDirectory(tempRoot);
|
||||||
|
|
||||||
|
var config = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = tempRoot })
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var env = new Mock<IHostEnvironment>();
|
||||||
|
env.SetupGet(x => x.ContentRootPath).Returns(tempRoot);
|
||||||
|
var paths = new AppPaths(config, env.Object);
|
||||||
|
|
||||||
|
return new AttachmentsController(paths, db)
|
||||||
|
{
|
||||||
|
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -202,11 +202,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
CoverLetterText: null,
|
CoverLetterText: null,
|
||||||
JobUrl: null,
|
JobUrl: null,
|
||||||
DateApplied: null,
|
DateApplied: null,
|
||||||
FeedbackRequestedAt: null,
|
FeedbackRequestedAt: null);
|
||||||
HasResume: null,
|
|
||||||
HasCoverLetter: null,
|
|
||||||
HasPortfolio: null,
|
|
||||||
HasOtherAttachment: null);
|
|
||||||
|
|
||||||
var result = await controller.Create(request, CancellationToken.None);
|
var result = await controller.Create(request, CancellationToken.None);
|
||||||
|
|
||||||
@@ -255,10 +251,6 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
SalaryPeriod: "fortnight",
|
SalaryPeriod: "fortnight",
|
||||||
NextAction: null,
|
NextAction: null,
|
||||||
FollowUpAt: null,
|
FollowUpAt: null,
|
||||||
HasResume: null,
|
|
||||||
HasCoverLetter: null,
|
|
||||||
HasPortfolio: null,
|
|
||||||
HasOtherAttachment: null,
|
|
||||||
Notes: null,
|
Notes: null,
|
||||||
Description: null,
|
Description: null,
|
||||||
TranslatedDescription: null,
|
TranslatedDescription: null,
|
||||||
|
|||||||
@@ -59,6 +59,26 @@ namespace JobTrackerApi.Controllers
|
|||||||
return "other";
|
return "other";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// JobApplication.HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived
|
||||||
|
// from actual Attachment rows, not manually settable -- this is the single place they're
|
||||||
|
// written, called after every attachment mutation (upload/delete/purpose change) so they
|
||||||
|
// can never drift from what's actually attached.
|
||||||
|
private async Task RecomputeAttachmentFlagsAsync(int jobId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == jobId, cancellationToken);
|
||||||
|
if (job is null) return;
|
||||||
|
|
||||||
|
var purposes = await _db.Attachments
|
||||||
|
.Where(a => a.JobApplicationId == jobId)
|
||||||
|
.Select(a => a.Purpose)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
job.HasResume = purposes.Any(p => p == "resume");
|
||||||
|
job.HasCoverLetter = purposes.Any(p => p == "cover-letter");
|
||||||
|
job.HasPortfolio = purposes.Any(p => p == "portfolio");
|
||||||
|
job.HasOtherAttachment = purposes.Any(p => p is not ("resume" or "cover-letter" or "portfolio"));
|
||||||
|
}
|
||||||
|
|
||||||
[HttpGet("{jobId:int}")]
|
[HttpGet("{jobId:int}")]
|
||||||
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
public async Task<ActionResult<List<AttachmentDto>>> ListForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -102,15 +122,23 @@ namespace JobTrackerApi.Controllers
|
|||||||
att.UseForAi = request.UseForAi.Value;
|
att.UseForAi = request.UseForAi.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Purpose))
|
var purposeChanged = !string.IsNullOrWhiteSpace(request.Purpose);
|
||||||
|
if (purposeChanged)
|
||||||
{
|
{
|
||||||
att.Purpose = request.Purpose.Trim().ToLowerInvariant();
|
att.Purpose = request.Purpose!.Trim().ToLowerInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
var rawName = (request.FileName ?? string.Empty).Trim();
|
var rawName = (request.FileName ?? string.Empty).Trim();
|
||||||
if (rawName.Length == 0)
|
if (rawName.Length == 0)
|
||||||
{
|
{
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (purposeChanged)
|
||||||
|
{
|
||||||
|
// Recompute needs the Purpose change committed first -- a fresh query
|
||||||
|
// wouldn't see the pending change yet.
|
||||||
|
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,6 +158,11 @@ namespace JobTrackerApi.Controllers
|
|||||||
att.FileName = name;
|
att.FileName = name;
|
||||||
att.FilePath = newPath;
|
att.FilePath = newPath;
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
if (purposeChanged)
|
||||||
|
{
|
||||||
|
await RecomputeAttachmentFlagsAsync(att.JobApplicationId, cancellationToken);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
@@ -141,8 +174,11 @@ namespace JobTrackerApi.Controllers
|
|||||||
if (att is null) return NotFound();
|
if (att is null) return NotFound();
|
||||||
|
|
||||||
var path = att.FilePath;
|
var path = att.FilePath;
|
||||||
|
var jobId = att.JobApplicationId;
|
||||||
_db.Attachments.Remove(att);
|
_db.Attachments.Remove(att);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -200,6 +236,8 @@ namespace JobTrackerApi.Controllers
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
await RecomputeAttachmentFlagsAsync(jobId, cancellationToken);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok();
|
return Ok();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ namespace JobTrackerApi.Controllers;
|
|||||||
[ApiController]
|
[ApiController]
|
||||||
[Route("api/gmail")]
|
[Route("api/gmail")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
public sealed class GmailController : ControllerBase
|
public sealed partial class GmailController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IGmailOAuthService _gmail;
|
private readonly IGmailOAuthService _gmail;
|
||||||
private readonly IGmailJobMatchingService _matching;
|
private readonly IGmailJobMatchingService _matching;
|
||||||
@@ -37,77 +37,6 @@ public sealed class GmailController : ControllerBase
|
|||||||
private IEmailProvider Email => _providers.Get("gmail")
|
private IEmailProvider Email => _providers.Get("gmail")
|
||||||
?? throw new InvalidOperationException("Gmail email provider is not registered.");
|
?? throw new InvalidOperationException("Gmail email provider is not registered.");
|
||||||
|
|
||||||
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")]
|
[HttpGet("status")]
|
||||||
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -671,7 +600,9 @@ public sealed class GmailController : ControllerBase
|
|||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
|
|
||||||
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
|
var suggestedJobReviewDecision = await _db.GmailReviewDecisions
|
||||||
|
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
|
||||||
|
UpsertReviewDecision(suggestedJobReviewDecision, ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
|
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
|
||||||
}
|
}
|
||||||
@@ -725,8 +656,9 @@ public sealed class GmailController : ControllerBase
|
|||||||
imported++;
|
imported++;
|
||||||
}
|
}
|
||||||
|
|
||||||
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
var reviewDecision = await _db.GmailReviewDecisions
|
||||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
|
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
|
||||||
|
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, "linked", job.Id, request.Note);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
|
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
|
||||||
}
|
}
|
||||||
@@ -752,10 +684,11 @@ public sealed class GmailController : ControllerBase
|
|||||||
_db.Correspondences.RemoveRange(messages);
|
_db.Correspondences.RemoveRange(messages);
|
||||||
}
|
}
|
||||||
|
|
||||||
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
|
var reviewDecision = await _db.GmailReviewDecisions
|
||||||
|
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
|
||||||
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
|
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
|
||||||
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
|
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
|
||||||
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
|
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, nextDecision, null, request.Note);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
|
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
|
||||||
}
|
}
|
||||||
@@ -1012,40 +945,6 @@ public sealed class GmailController : ControllerBase
|
|||||||
return _matching.BuildJobQueries(job, 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(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||||
{
|
{
|
||||||
if (!decisions.TryGetValue(threadId, out var existing))
|
if (!decisions.TryGetValue(threadId, out var existing))
|
||||||
@@ -1065,9 +964,11 @@ public sealed class GmailController : ControllerBase
|
|||||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
// Single-thread upsert: callers acting on exactly one ThreadId should load just that row
|
||||||
|
// (see the FirstOrDefaultAsync call sites below) rather than every review decision for the
|
||||||
|
// owner just to scan for one match.
|
||||||
|
private void UpsertReviewDecision(GmailReviewDecision? existing, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||||
{
|
{
|
||||||
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
|
|
||||||
if (existing is null)
|
if (existing is null)
|
||||||
{
|
{
|
||||||
existing = new GmailReviewDecision
|
existing = new GmailReviewDecision
|
||||||
@@ -1075,7 +976,6 @@ public sealed class GmailController : ControllerBase
|
|||||||
OwnerUserId = ownerUserId,
|
OwnerUserId = ownerUserId,
|
||||||
ThreadId = threadId,
|
ThreadId = threadId,
|
||||||
};
|
};
|
||||||
decisions.Add(existing);
|
|
||||||
_db.GmailReviewDecisions.Add(existing);
|
_db.GmailReviewDecisions.Add(existing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1085,54 +985,6 @@ public sealed class GmailController : ControllerBase
|
|||||||
existing.UpdatedAt = DateTimeOffset.UtcNow;
|
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()
|
private string GetRequiredOwnerUserId()
|
||||||
{
|
{
|
||||||
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
|
||||||
@@ -1167,29 +1019,4 @@ public sealed class GmailController : ControllerBase
|
|||||||
return $"{Request.Scheme}://{Request.Host}/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>";
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
using JobTrackerApi.Models;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
|
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
|
||||||
|
// change; these were previously nested inline in the controller file).
|
||||||
|
public partial class GmailController
|
||||||
|
{
|
||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
using JobTrackerApi.Services;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Controllers;
|
||||||
|
|
||||||
|
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
|
||||||
|
// refactor -- no behaviour change). All are static and side-effect free.
|
||||||
|
public sealed partial class GmailController
|
||||||
|
{
|
||||||
|
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 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 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>";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1376,11 +1376,7 @@ Canonical profile:
|
|||||||
string? CoverLetterText,
|
string? CoverLetterText,
|
||||||
string? JobUrl,
|
string? JobUrl,
|
||||||
DateTime? DateApplied,
|
DateTime? DateApplied,
|
||||||
DateTime? FeedbackRequestedAt,
|
DateTime? FeedbackRequestedAt
|
||||||
bool? HasResume,
|
|
||||||
bool? HasCoverLetter,
|
|
||||||
bool? HasPortfolio,
|
|
||||||
bool? HasOtherAttachment
|
|
||||||
);
|
);
|
||||||
|
|
||||||
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
||||||
@@ -1422,10 +1418,9 @@ Canonical profile:
|
|||||||
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
|
NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim(),
|
||||||
FollowUpAt = request.FollowUpAt,
|
FollowUpAt = request.FollowUpAt,
|
||||||
FeedbackRequestedAt = request.FeedbackRequestedAt,
|
FeedbackRequestedAt = request.FeedbackRequestedAt,
|
||||||
HasResume = request.HasResume ?? false,
|
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
||||||
HasCoverLetter = request.HasCoverLetter ?? false,
|
// Attachment rows (see AttachmentsController.RecomputeAttachmentFlagsAsync), not
|
||||||
HasPortfolio = request.HasPortfolio ?? false,
|
// settable here -- they start false and get set correctly once files are uploaded.
|
||||||
HasOtherAttachment = request.HasOtherAttachment ?? false,
|
|
||||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes,
|
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes,
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description,
|
||||||
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
|
TranslatedDescription = string.IsNullOrWhiteSpace(request.TranslatedDescription) ? null : request.TranslatedDescription,
|
||||||
@@ -1486,10 +1481,6 @@ Canonical profile:
|
|||||||
string? SalaryPeriod,
|
string? SalaryPeriod,
|
||||||
string? NextAction,
|
string? NextAction,
|
||||||
DateTime? FollowUpAt,
|
DateTime? FollowUpAt,
|
||||||
bool? HasResume,
|
|
||||||
bool? HasCoverLetter,
|
|
||||||
bool? HasPortfolio,
|
|
||||||
bool? HasOtherAttachment,
|
|
||||||
string? Notes,
|
string? Notes,
|
||||||
string? Description,
|
string? Description,
|
||||||
string? TranslatedDescription,
|
string? TranslatedDescription,
|
||||||
@@ -1529,10 +1520,8 @@ Canonical profile:
|
|||||||
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
job.NextAction = string.IsNullOrWhiteSpace(request.NextAction) ? null : request.NextAction.Trim();
|
||||||
job.FollowUpAt = request.FollowUpAt;
|
job.FollowUpAt = request.FollowUpAt;
|
||||||
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
job.FeedbackRequestedAt = request.FeedbackRequestedAt;
|
||||||
if (request.HasResume is not null) job.HasResume = request.HasResume.Value;
|
// HasResume/HasCoverLetter/HasPortfolio/HasOtherAttachment are derived from
|
||||||
if (request.HasCoverLetter is not null) job.HasCoverLetter = request.HasCoverLetter.Value;
|
// Attachment rows, not settable here -- see AttachmentsController.RecomputeAttachmentFlagsAsync.
|
||||||
if (request.HasPortfolio is not null) job.HasPortfolio = request.HasPortfolio.Value;
|
|
||||||
if (request.HasOtherAttachment is not null) job.HasOtherAttachment = request.HasOtherAttachment.Value;
|
|
||||||
job.Notes = request.Notes;
|
job.Notes = request.Notes;
|
||||||
job.Description = request.Description;
|
job.Description = request.Description;
|
||||||
job.TranslatedDescription = request.TranslatedDescription;
|
job.TranslatedDescription = request.TranslatedDescription;
|
||||||
|
|||||||
@@ -24,7 +24,9 @@ public class JobApplication
|
|||||||
public DateTime? FeedbackRequestedAt { get; set; }
|
public DateTime? FeedbackRequestedAt { get; set; }
|
||||||
public string? RecruiterMessageDraft { get; set; }
|
public string? RecruiterMessageDraft { get; set; }
|
||||||
|
|
||||||
// Attachment checklist
|
// Attachment checklist. Derived from Attachment rows, not directly settable by API
|
||||||
|
// consumers -- see AttachmentsController.RecomputeAttachmentFlagsAsync, the single place
|
||||||
|
// these are written, so they can't drift from what's actually attached.
|
||||||
public bool HasResume { get; set; } = false;
|
public bool HasResume { get; set; } = false;
|
||||||
public bool HasCoverLetter { get; set; } = false;
|
public bool HasCoverLetter { get; set; } = false;
|
||||||
public bool HasPortfolio { get; set; } = false;
|
public bool HasPortfolio { get; set; } = false;
|
||||||
|
|||||||
@@ -327,10 +327,6 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
|
|||||||
notes,
|
notes,
|
||||||
coverLetterText: null,
|
coverLetterText: null,
|
||||||
dateApplied,
|
dateApplied,
|
||||||
hasResume: attachments.resume.length > 0,
|
|
||||||
hasCoverLetter: attachments.coverLetter.length > 0,
|
|
||||||
hasPortfolio: attachments.portfolio.length > 0,
|
|
||||||
hasOtherAttachment: attachments.other.length > 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.data?.id && attachmentCount > 0) {
|
if (response.data?.id && attachmentCount > 0) {
|
||||||
|
|||||||
@@ -158,10 +158,6 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
salaryPeriod: salaryPeriod || null,
|
salaryPeriod: salaryPeriod || null,
|
||||||
nextAction: nextAction.trim() || null,
|
nextAction: nextAction.trim() || null,
|
||||||
followUpAt: followUpAt || null,
|
followUpAt: followUpAt || null,
|
||||||
hasResume,
|
|
||||||
hasCoverLetter,
|
|
||||||
hasPortfolio,
|
|
||||||
hasOtherAttachment,
|
|
||||||
notes: notes || null,
|
notes: notes || null,
|
||||||
description: description || null,
|
description: description || null,
|
||||||
translatedDescription: translatedDescription || null,
|
translatedDescription: translatedDescription || null,
|
||||||
@@ -243,16 +239,13 @@ export default function EditJobDialog({ open, jobId, onClose, onSaved }: Props)
|
|||||||
|
|
||||||
<Paper variant="outlined" sx={{ p: 2 }}>
|
<Paper variant="outlined" sx={{ p: 2 }}>
|
||||||
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobAttachmentsChecklist")}</Typography>
|
<Typography variant="overline" sx={{ color: "text.secondary" }}>{t("editJobAttachmentsChecklist")}</Typography>
|
||||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1, mb: 1.5 }}>
|
{/* Derived from actual uploaded attachments (see the Attachments panel) -- not
|
||||||
|
manually editable, so this can never drift from what's really attached. */}
|
||||||
|
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mt: 1 }}>
|
||||||
<Chip size="small" label={hasResume ? t("editJobResumeReady") : t("editJobResumeMissing")} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
|
<Chip size="small" label={hasResume ? t("editJobResumeReady") : t("editJobResumeMissing")} color={hasResume ? "success" : "default"} variant={hasResume ? "filled" : "outlined"} />
|
||||||
<Chip size="small" label={hasCoverLetter ? t("editJobCoverLetterReady") : t("editJobCoverLetterMissing")} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
|
<Chip size="small" label={hasCoverLetter ? t("editJobCoverLetterReady") : t("editJobCoverLetterMissing")} color={hasCoverLetter ? "success" : "default"} variant={hasCoverLetter ? "filled" : "outlined"} />
|
||||||
<Chip size="small" label={hasPortfolio ? t("editJobPortfolioReady") : t("editJobPortfolioOptional")} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
|
<Chip size="small" label={hasPortfolio ? t("editJobPortfolioReady") : t("editJobPortfolioOptional")} color={hasPortfolio ? "success" : "default"} variant={hasPortfolio ? "filled" : "outlined"} />
|
||||||
</Box>
|
{hasOtherAttachment && <Chip size="small" label={t("editJobOtherAttachment")} color="success" variant="filled" />}
|
||||||
<Box sx={{ display: "flex", gap: 2, flexWrap: "wrap", mt: 1 }}>
|
|
||||||
<FormControlLabel control={<Checkbox checked={hasResume} onChange={(e) => setHasResume(e.target.checked)} />} label={t("editJobResume")} />
|
|
||||||
<FormControlLabel control={<Checkbox checked={hasCoverLetter} onChange={(e) => setHasCoverLetter(e.target.checked)} />} label={t("editJobCoverLetter")} />
|
|
||||||
<FormControlLabel control={<Checkbox checked={hasPortfolio} onChange={(e) => setHasPortfolio(e.target.checked)} />} label={t("editJobPortfolio")} />
|
|
||||||
<FormControlLabel control={<Checkbox checked={hasOtherAttachment} onChange={(e) => setHasOtherAttachment(e.target.checked)} />} label={t("editJobOtherAttachment")} />
|
|
||||||
</Box>
|
</Box>
|
||||||
</Paper>
|
</Paper>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user