Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b8c91a22b6 | |||
| 170f1390a9 | |||
| a22ce08913 | |||
| f7efad7337 | |||
| 947d4eeab9 | |||
| f61da1869d | |||
| 463d4277cd | |||
| 7b9a97323e | |||
| 5cd34f17bb | |||
| 1f34eb42d2 | |||
| b87e673d38 | |||
| 161ecb4b94 | |||
| a0e823facf | |||
| 5af2c66616 | |||
| 69e78d8951 | |||
| 61c12d3479 | |||
| 3f04849fe6 | |||
| 289c2f47ad | |||
| fd3527776a | |||
| f48136f04c | |||
| e5bcf9d5ea | |||
| 068ce447c0 | |||
| 9191e4cc5b | |||
| cc55fc0cf8 | |||
| 0d65835857 | |||
| 0551a525a8 |
@@ -3,12 +3,14 @@
|
||||
# everything first and then opt back into only the source folders it needs.
|
||||
*
|
||||
!JobTrackerApi/
|
||||
!JobTrackerBackend/
|
||||
!Data/
|
||||
!Models/
|
||||
!.dockerignore
|
||||
|
||||
# Include the source trees.
|
||||
!JobTrackerApi/**
|
||||
!JobTrackerBackend/**
|
||||
!Data/**
|
||||
!Models/**
|
||||
|
||||
|
||||
@@ -89,30 +89,27 @@ jobs:
|
||||
docker compose ps
|
||||
AI_CONTAINER_ID="$(docker compose ps -q ai-service)"
|
||||
if [ -z "$AI_CONTAINER_ID" ]; then
|
||||
echo "AI service container id could not be resolved after deploy."
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ai-service || true
|
||||
exit 1
|
||||
fi
|
||||
ATTEMPTS=90
|
||||
SLEEP_SECS=2
|
||||
i=1
|
||||
while [ "$i" -le "$ATTEMPTS" ]; do
|
||||
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
|
||||
if [ "$HEALTH_STATUS" = "healthy" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
|
||||
echo "AI service became unhealthy during deploy readiness wait."
|
||||
echo "AI service container id could not be resolved after deploy. Continuing because AI is not a deploy gate for the core app."
|
||||
else
|
||||
ATTEMPTS=90
|
||||
SLEEP_SECS=2
|
||||
i=1
|
||||
while [ "$i" -le "$ATTEMPTS" ]; do
|
||||
HEALTH_STATUS="$(docker inspect -f '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$AI_CONTAINER_ID" 2>/dev/null || echo unknown)"
|
||||
if [ "$HEALTH_STATUS" = "healthy" ]; then
|
||||
break
|
||||
fi
|
||||
if [ "$HEALTH_STATUS" = "unhealthy" ]; then
|
||||
echo "AI service became unhealthy during deploy readiness wait. Continuing because AI is not a deploy gate for the core app."
|
||||
docker compose logs --tail=200 ai-service || true
|
||||
break
|
||||
fi
|
||||
sleep "$SLEEP_SECS"
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ "${HEALTH_STATUS:-unknown}" != "healthy" ]; then
|
||||
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}. Continuing because AI is not a deploy gate for the core app."
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ai-service || true
|
||||
exit 1
|
||||
fi
|
||||
sleep "$SLEEP_SECS"
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ "$HEALTH_STATUS" != "healthy" ]; then
|
||||
echo "AI service did not become healthy within $((ATTEMPTS * SLEEP_SECS)) seconds. Final status: ${HEALTH_STATUS:-unknown}"
|
||||
docker compose ps
|
||||
docker compose logs --tail=200 ai-service || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace JobTrackerApi.Data
|
||||
public DbSet<JobApplication> JobApplications => Set<JobApplication>();
|
||||
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
|
||||
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
|
||||
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
|
||||
public DbSet<Attachment> Attachments => Set<Attachment>();
|
||||
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
|
||||
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
|
||||
@@ -66,6 +67,11 @@ namespace JobTrackerApi.Data
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Entity<GmailReviewDecision>()
|
||||
.HasQueryFilter(x => CurrentUserId == null || x.OwnerUserId == CurrentUserId);
|
||||
|
||||
modelBuilder.Ignore<CorrespondenceAttachmentMetadata>();
|
||||
|
||||
modelBuilder.Entity<GmailConnection>()
|
||||
.HasIndex(x => new { x.OwnerUserId, x.GmailAddress })
|
||||
.IsUnique();
|
||||
|
||||
@@ -133,6 +133,35 @@ public sealed class AuthAndSystemControllerTests
|
||||
Assert.Equal("person@example.com", result.GoogleLink.Email);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_system_email_settings_falls_back_when_override_store_is_unavailable()
|
||||
{
|
||||
var emailSettings = new Mock<IEmailSettingsResolver>();
|
||||
emailSettings.Setup(x => x.GetAdminDtoAsync(It.IsAny<CancellationToken>())).ThrowsAsync(new InvalidOperationException("missing SystemEmailSettings"));
|
||||
|
||||
var cfg = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Email:Enabled"] = "false",
|
||||
["Email:FromName"] = "Jobbjakt"
|
||||
})
|
||||
.Build();
|
||||
|
||||
var controller = new AdminSystemController(
|
||||
cfg,
|
||||
new AppPaths(cfg, new FakeHostEnv()),
|
||||
null!,
|
||||
Mock.Of<ISummarizerService>(),
|
||||
new FakeEnv(),
|
||||
emailSettings.Object);
|
||||
|
||||
var result = await controller.GetEmailSettings(CancellationToken.None);
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var dto = Assert.IsType<EmailSettingsAdminDto>(ok.Value);
|
||||
Assert.False(dto.Enabled);
|
||||
Assert.Contains("fallback", dto.FromName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Admin_system_probe_endpoint_runs_probe_once()
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Reflection;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
@@ -29,11 +30,19 @@ public sealed class CvCorpusHarnessTests
|
||||
|| path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase)
|
||||
|| path.EndsWith(".md", StringComparison.OrdinalIgnoreCase))
|
||||
.OrderBy(path => path, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(8)
|
||||
.ToList();
|
||||
|
||||
if (files.Count == 0) return;
|
||||
|
||||
var outputRoot = ResolveOutputRoot();
|
||||
var outputsDir = Path.Combine(outputRoot, "outputs");
|
||||
var candidateFixturesDir = Path.Combine(outputRoot, "candidate-fixtures");
|
||||
var approvedFixturesDir = ResolveApprovedFixturesRoot(outputRoot);
|
||||
Directory.CreateDirectory(outputRoot);
|
||||
Directory.CreateDirectory(outputsDir);
|
||||
Directory.CreateDirectory(candidateFixturesDir);
|
||||
Directory.CreateDirectory(approvedFixturesDir);
|
||||
|
||||
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "seed" };
|
||||
var userManager = TestHostFactory.CreateUserManager();
|
||||
userManager.Setup(x => x.GetUserAsync(It.IsAny<System.Security.Claims.ClaimsPrincipal>())).ReturnsAsync(user);
|
||||
@@ -44,8 +53,8 @@ public sealed class CvCorpusHarnessTests
|
||||
aiService.Setup(x => x.SummarizeSectionAsync(It.Is<string>(instruction => instruction.Contains("Reconstruct this CV text extracted from a PDF", StringComparison.Ordinal)), It.IsAny<string>(), 2800, 900)).ReturnsAsync((string _, string text, int _, int __) => text);
|
||||
|
||||
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||
var paths = CreatePaths();
|
||||
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, NoOpCvAiClassifier.Instance)
|
||||
var paths = CreatePaths(outputRoot);
|
||||
var controller = new ProfileCvController(userManager.Object, aiService.Object, db, paths, null, NoOpCvAiClassifier.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
@@ -55,7 +64,7 @@ public sealed class CvCorpusHarnessTests
|
||||
Assert.NotNull(extractMethod);
|
||||
Assert.NotNull(buildMethod);
|
||||
|
||||
var report = new List<object>();
|
||||
var entries = new List<CvBenchmarkEntry>();
|
||||
foreach (var path in files)
|
||||
{
|
||||
await using var stream = File.OpenRead(path);
|
||||
@@ -72,31 +81,212 @@ public sealed class CvCorpusHarnessTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(text));
|
||||
|
||||
var buildTask = (Task<StructuredCvProfile>)buildMethod!.Invoke(controller, new object[] { text, CancellationToken.None })!;
|
||||
var structured = await buildTask;
|
||||
var structured = StructuredCvProfileJson.Normalize(await buildTask);
|
||||
Assert.NotNull(structured);
|
||||
|
||||
report.Add(new
|
||||
var slug = Slugify(fileName);
|
||||
var normalizedJson = StructuredCvProfileJson.Serialize(structured);
|
||||
var outputPath = Path.Combine(outputsDir, $"{slug}.json");
|
||||
await File.WriteAllTextAsync(outputPath, PrettyJson(normalizedJson));
|
||||
|
||||
var approvedPath = Path.Combine(approvedFixturesDir, $"{slug}.json");
|
||||
var candidateFixturePath = Path.Combine(candidateFixturesDir, $"{slug}.json");
|
||||
string? diffSummary = null;
|
||||
var approvedExists = File.Exists(approvedPath);
|
||||
if (approvedExists)
|
||||
{
|
||||
file = fileName,
|
||||
characters = text.Length,
|
||||
contactLocation = structured.Contact.Location,
|
||||
firstJob = structured.Jobs.FirstOrDefault()?.Title,
|
||||
firstJobLocation = structured.Jobs.FirstOrDefault()?.Location,
|
||||
firstEducation = structured.Education.FirstOrDefault()?.Qualification,
|
||||
firstEducationLocation = structured.Education.FirstOrDefault()?.Location,
|
||||
suspiciousLocations = structured.Jobs.Select(job => job.Location)
|
||||
var approvedJson = await File.ReadAllTextAsync(approvedPath);
|
||||
diffSummary = SummarizeDiff(approvedJson, normalizedJson);
|
||||
}
|
||||
else
|
||||
{
|
||||
await File.WriteAllTextAsync(candidateFixturePath, PrettyJson(normalizedJson));
|
||||
diffSummary = "No approved fixture yet — candidate fixture written.";
|
||||
}
|
||||
|
||||
entries.Add(new CvBenchmarkEntry(
|
||||
FileName: fileName,
|
||||
Slug: slug,
|
||||
Extension: extension,
|
||||
Characters: text.Length,
|
||||
OutputPath: outputPath,
|
||||
ApprovedFixturePath: approvedExists ? approvedPath : null,
|
||||
CandidateFixturePath: approvedExists ? null : candidateFixturePath,
|
||||
ContactLocation: structured.Contact.Location,
|
||||
FirstJob: structured.Jobs.FirstOrDefault()?.Title,
|
||||
FirstJobLocation: structured.Jobs.FirstOrDefault()?.Location,
|
||||
FirstEducation: structured.Education.FirstOrDefault()?.Qualification,
|
||||
FirstEducationLocation: structured.Education.FirstOrDefault()?.Location,
|
||||
QualificationLevels: structured.Education.Select(x => x.QualificationLevel).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList(),
|
||||
SuspiciousLocations: structured.Jobs.Select(job => job.Location)
|
||||
.Concat(structured.Education.Select(education => education.Location))
|
||||
.Append(structured.Contact.Location)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Cast<string>()
|
||||
.Where(LooksSuspiciousLocation)
|
||||
.ToList()
|
||||
});
|
||||
.ToList(),
|
||||
CoverageScore: ComputeCoverageScore(structured),
|
||||
ConfidenceScore: ComputeConfidenceScore(structured),
|
||||
ConsistencyScore: ComputeConsistencyScore(structured),
|
||||
DiffSummary: diffSummary
|
||||
));
|
||||
}
|
||||
|
||||
var reportPath = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{DateTime.UtcNow:yyyyMMddHHmmss}.json");
|
||||
await File.WriteAllTextAsync(reportPath, JsonSerializer.Serialize(report, new JsonSerializerOptions { WriteIndented = true }));
|
||||
var summary = new CvBenchmarkSummary(
|
||||
CorpusRoot,
|
||||
outputRoot,
|
||||
DateTimeOffset.UtcNow,
|
||||
entries.Count,
|
||||
Math.Round(entries.Average(x => x.CoverageScore), 3),
|
||||
Math.Round(entries.Average(x => x.ConfidenceScore), 3),
|
||||
Math.Round(entries.Average(x => x.ConsistencyScore), 3),
|
||||
entries.Count(x => x.SuspiciousLocations.Count > 0),
|
||||
entries.Count(x => x.ApprovedFixturePath is null),
|
||||
entries
|
||||
);
|
||||
|
||||
Assert.True(report.Count > 0);
|
||||
var indexPath = Path.Combine(outputRoot, "index.json");
|
||||
var reportPath = Path.Combine(outputRoot, "report.md");
|
||||
await File.WriteAllTextAsync(indexPath, JsonSerializer.Serialize(summary, new JsonSerializerOptions { WriteIndented = true }));
|
||||
await File.WriteAllTextAsync(reportPath, RenderMarkdownReport(summary));
|
||||
|
||||
Assert.True(entries.Count > 0);
|
||||
}
|
||||
|
||||
private sealed record CvBenchmarkEntry(
|
||||
string FileName,
|
||||
string Slug,
|
||||
string Extension,
|
||||
int Characters,
|
||||
string OutputPath,
|
||||
string? ApprovedFixturePath,
|
||||
string? CandidateFixturePath,
|
||||
string? ContactLocation,
|
||||
string? FirstJob,
|
||||
string? FirstJobLocation,
|
||||
string? FirstEducation,
|
||||
string? FirstEducationLocation,
|
||||
List<string> QualificationLevels,
|
||||
List<string> SuspiciousLocations,
|
||||
double CoverageScore,
|
||||
double ConfidenceScore,
|
||||
double ConsistencyScore,
|
||||
string? DiffSummary);
|
||||
|
||||
private sealed record CvBenchmarkSummary(
|
||||
string CorpusRoot,
|
||||
string OutputRoot,
|
||||
DateTimeOffset GeneratedAtUtc,
|
||||
int TotalFiles,
|
||||
double AverageCoverage,
|
||||
double AverageConfidence,
|
||||
double AverageConsistency,
|
||||
int FilesWithSuspiciousLocations,
|
||||
int MissingApprovedFixtures,
|
||||
List<CvBenchmarkEntry> Entries);
|
||||
|
||||
private static string ResolveOutputRoot()
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_OUTPUT_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
|
||||
return Path.Combine(Path.GetTempPath(), "jobtracker-cv-benchmark", DateTime.UtcNow.ToString("yyyyMMddHHmmss"));
|
||||
}
|
||||
|
||||
private static string ResolveApprovedFixturesRoot(string outputRoot)
|
||||
{
|
||||
var configured = Environment.GetEnvironmentVariable("CV_BENCHMARK_APPROVED_DIR");
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured.Trim();
|
||||
return Path.Combine(outputRoot, "approved-fixtures");
|
||||
}
|
||||
|
||||
private static string PrettyJson(string normalizedJson)
|
||||
{
|
||||
using var doc = JsonDocument.Parse(normalizedJson);
|
||||
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
|
||||
}
|
||||
|
||||
private static string SummarizeDiff(string approvedJson, string actualJson)
|
||||
{
|
||||
if (JsonDocument.Parse(approvedJson).RootElement.ToString() == JsonDocument.Parse(actualJson).RootElement.ToString())
|
||||
{
|
||||
return "Matches approved fixture.";
|
||||
}
|
||||
|
||||
var approvedHash = Hash(approvedJson);
|
||||
var actualHash = Hash(actualJson);
|
||||
return $"Fixture differs (approved {approvedHash[..8]}, actual {actualHash[..8]}).";
|
||||
}
|
||||
|
||||
private static string Hash(string value) => Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(value))).ToLowerInvariant();
|
||||
|
||||
private static double ComputeCoverageScore(StructuredCvProfile structured)
|
||||
{
|
||||
var signals = new[]
|
||||
{
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.FullName),
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.Email),
|
||||
!string.IsNullOrWhiteSpace(structured.Contact.Location),
|
||||
structured.Summary.Count > 0,
|
||||
structured.Skills.Count > 0,
|
||||
structured.Jobs.Count > 0,
|
||||
structured.Education.Count > 0,
|
||||
structured.Certifications.Count > 0 || structured.Projects.Count > 0 || structured.OtherSections.Count > 0,
|
||||
};
|
||||
return signals.Count(x => x) / (double)signals.Length;
|
||||
}
|
||||
|
||||
private static double ComputeConfidenceScore(StructuredCvProfile structured)
|
||||
{
|
||||
var confidences = structured.Metadata.Fields.Values.Select(x => x.Confidence).Where(x => x.HasValue).Select(x => x!.Value).ToList();
|
||||
return confidences.Count == 0 ? 0.55 : Math.Clamp(confidences.Average(), 0, 1);
|
||||
}
|
||||
|
||||
private static double ComputeConsistencyScore(StructuredCvProfile structured)
|
||||
{
|
||||
var penalties = 0;
|
||||
penalties += structured.Jobs.Count(job => LooksSuspiciousLocation(job.Location));
|
||||
penalties += structured.Education.Count(education => LooksSuspiciousLocation(education.Location));
|
||||
penalties += LooksSuspiciousLocation(structured.Contact.Location) ? 1 : 0;
|
||||
penalties += structured.Education.Count(education => string.IsNullOrWhiteSpace(education.QualificationLevel) && !string.IsNullOrWhiteSpace(education.Qualification));
|
||||
return Math.Max(0, 1 - (penalties * 0.12));
|
||||
}
|
||||
|
||||
private static string RenderMarkdownReport(CvBenchmarkSummary summary)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
"# CV benchmark report",
|
||||
string.Empty,
|
||||
$"- Generated: {summary.GeneratedAtUtc:O}",
|
||||
$"- Corpus root: `{summary.CorpusRoot}`",
|
||||
$"- Output root: `{summary.OutputRoot}`",
|
||||
$"- Files: {summary.TotalFiles}",
|
||||
$"- Average coverage: {summary.AverageCoverage:P0}",
|
||||
$"- Average confidence: {summary.AverageConfidence:P0}",
|
||||
$"- Average consistency: {summary.AverageConsistency:P0}",
|
||||
$"- Files with suspicious locations: {summary.FilesWithSuspiciousLocations}",
|
||||
$"- Missing approved fixtures: {summary.MissingApprovedFixtures}",
|
||||
string.Empty,
|
||||
"| File | Coverage | Confidence | Consistency | Suspicious locations | Fixture |",
|
||||
"|---|---:|---:|---:|---:|---|",
|
||||
};
|
||||
|
||||
lines.AddRange(summary.Entries.Select(entry =>
|
||||
$"| {entry.FileName} | {entry.CoverageScore:P0} | {entry.ConfidenceScore:P0} | {entry.ConsistencyScore:P0} | {entry.SuspiciousLocations.Count} | {entry.DiffSummary} |"));
|
||||
|
||||
lines.Add(string.Empty);
|
||||
lines.Add("## Notes");
|
||||
lines.Add("- `outputs/*.json` contains the latest normalized parser output for each CV.");
|
||||
lines.Add("- `candidate-fixtures/*.json` is created when no approved fixture exists yet.");
|
||||
lines.Add("- To build a regression baseline, review a candidate fixture and copy it into the approved-fixtures directory used by the runner.");
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
|
||||
private static string Slugify(string value)
|
||||
{
|
||||
var cleaned = new string((value ?? string.Empty).ToLowerInvariant().Select(ch => char.IsLetterOrDigit(ch) ? ch : '-').ToArray());
|
||||
while (cleaned.Contains("--", StringComparison.Ordinal)) cleaned = cleaned.Replace("--", "-", StringComparison.Ordinal);
|
||||
return cleaned.Trim('-');
|
||||
}
|
||||
|
||||
private static bool LooksSuspiciousLocation(string? value)
|
||||
@@ -119,7 +309,7 @@ public sealed class CvCorpusHarnessTests
|
||||
};
|
||||
}
|
||||
|
||||
private static AppPaths CreatePaths()
|
||||
private static AppPaths CreatePaths(string outputRoot)
|
||||
{
|
||||
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-corpus-{Guid.NewGuid():N}");
|
||||
Directory.CreateDirectory(tempRoot);
|
||||
@@ -128,7 +318,8 @@ public sealed class CvCorpusHarnessTests
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Data:Root"] = tempRoot,
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts")
|
||||
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
|
||||
["Data:CvBenchmarksRoot"] = outputRoot,
|
||||
})
|
||||
.Build();
|
||||
|
||||
|
||||
@@ -15,6 +15,39 @@ namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class GmailControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_returns_sync_state_fields_for_connected_account()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
GmailAddress = "user@example.test",
|
||||
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-3),
|
||||
LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5),
|
||||
LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10),
|
||||
LastSyncMode = "list-messages",
|
||||
LastSyncSource = "custom-query",
|
||||
LastSyncStatus = "error",
|
||||
LastSyncError = "Token refresh failed"
|
||||
});
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.Status(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailConnectionStatusDto>(ok.Value);
|
||||
Assert.True(payload.Connected);
|
||||
Assert.Equal("user@example.test", payload.GmailAddress);
|
||||
Assert.Equal("list-messages", payload.LastSyncMode);
|
||||
Assert.Equal("custom-query", payload.LastSyncSource);
|
||||
Assert.Equal("error", payload.LastSyncStatus);
|
||||
Assert.Equal("Token refresh failed", payload.LastSyncError);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Import_thread_rejects_missing_message_ids()
|
||||
{
|
||||
@@ -225,7 +258,9 @@ public sealed class GmailControllerTests
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Snippet",
|
||||
"Body text",
|
||||
null));
|
||||
null,
|
||||
new[] { "INBOX", "IMPORTANT" },
|
||||
new[] { new GmailMessageAttachment("cv.pdf", "application/pdf", 2048, "att-1", false) }));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
@@ -239,6 +274,10 @@ public sealed class GmailControllerTests
|
||||
Assert.Equal("thread-1", firstPayload.Message!.ExternalThreadId);
|
||||
Assert.Equal("Maria Recruiter <maria@acme.test>", firstPayload.Message.ExternalFrom);
|
||||
Assert.Equal("user@example.test", firstPayload.Message.ExternalTo);
|
||||
Assert.Equal("inbound", firstPayload.Message.Direction);
|
||||
Assert.Contains("IMPORTANT", firstPayload.Message.ExternalLabels);
|
||||
Assert.Single(firstPayload.Message.AttachmentMetadata);
|
||||
Assert.Equal("cv.pdf", firstPayload.Message.AttachmentMetadata[0].FileName);
|
||||
|
||||
var second = await controller.Import(new GmailController.ImportGmailMessageRequest(job.Id, "msg-1"), CancellationToken.None);
|
||||
var secondOk = Assert.IsType<OkObjectResult>(second.Result);
|
||||
@@ -282,7 +321,9 @@ public sealed class GmailControllerTests
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Snippet 1",
|
||||
"Body text 1",
|
||||
null));
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-2", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-2",
|
||||
@@ -293,7 +334,9 @@ public sealed class GmailControllerTests
|
||||
DateTimeOffset.UtcNow,
|
||||
"Snippet 2",
|
||||
"Body text 2",
|
||||
null));
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var request = new GmailController.ImportGmailThreadRequest(job.Id, "thread-1", new[] { "msg-1", "msg-2" });
|
||||
@@ -365,7 +408,9 @@ public sealed class GmailControllerTests
|
||||
DateTimeOffset.UtcNow,
|
||||
"New reply",
|
||||
"Reply body",
|
||||
null));
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.RefreshLinkedThreads(new GmailController.RefreshLinkedThreadsRequest(job.Id), CancellationToken.None);
|
||||
@@ -435,6 +480,51 @@ public sealed class GmailControllerTests
|
||||
gmail.Verify(service => service.ListThreadMessagesAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Review_candidates_returns_threads_grouped_with_routing_summary()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", RecruiterEmail = "maria@acme.test", 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 gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailQueryMatchedMessage(
|
||||
new GmailMessageSummary(
|
||||
"msg-top",
|
||||
"thread-top",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Acme wants to schedule a backend developer interview."),
|
||||
new[] { "\"Acme\" \"Backend Developer\" newer_than:365d" })
|
||||
});
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.ReviewCandidates(null, 6, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailReviewQueueResponseDto>(ok.Value);
|
||||
Assert.Equal(1, payload.CandidateThreadCount);
|
||||
Assert.Single(payload.Threads);
|
||||
Assert.Equal("thread-top", payload.Threads[0].ThreadId);
|
||||
Assert.True(payload.Threads[0].JobCandidates.Count > 0);
|
||||
Assert.Contains(payload.Threads[0].Routing, new[] { "auto-link", "review", "unmatched" });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Refresh_linked_threads_rejects_invalid_job_id()
|
||||
{
|
||||
@@ -500,9 +590,319 @@ public sealed class GmailControllerTests
|
||||
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Save_review_decision_links_thread_and_imports_messages()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
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 gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Interview invite")
|
||||
});
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Interview invite",
|
||||
"Body text",
|
||||
null,
|
||||
new[] { "INBOX" },
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.SaveReviewDecision(new GmailController.SaveGmailReviewDecisionRequest("thread-1", "linked", job.Id, "Strong recruiter match"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal("linked", decision.Decision);
|
||||
Assert.Equal(job.Id, decision.JobApplicationId);
|
||||
Assert.Equal("Strong recruiter match", decision.Note);
|
||||
var imported = await db.Correspondences.SingleAsync();
|
||||
Assert.Equal("thread-1", imported.ExternalThreadId);
|
||||
Assert.Equal("msg-1", imported.ExternalMessageId);
|
||||
Assert.NotNull(ok.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Manual_sync_auto_links_high_confidence_thread()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company
|
||||
{
|
||||
Name = "Acme",
|
||||
RecruiterEmail = "maria@acme.test",
|
||||
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 gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync(
|
||||
"user-1",
|
||||
It.Is<IEnumerable<string>>(queries => queries.Any(query => query.Contains("-in:spam")) && queries.Any(query => query.Contains("-in:trash")) && queries.All(query => query.Contains("newer_than:365d"))),
|
||||
8,
|
||||
It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailQueryMatchedMessage(
|
||||
new GmailMessageSummary(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Acme wants to schedule a backend developer interview."),
|
||||
new[]
|
||||
{
|
||||
"\"Acme\" \"Backend Developer\" newer_than:365d -in:spam -in:trash",
|
||||
"(from:maria@acme.test OR to:maria@acme.test) newer_than:365d -in:spam -in:trash"
|
||||
})
|
||||
});
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-1", "thread-1", "Backend Developer interview", "Maria Recruiter <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-2), "Invite")
|
||||
});
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Backend Developer interview",
|
||||
"Maria Recruiter <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-2),
|
||||
"Invite",
|
||||
"Interview details",
|
||||
null,
|
||||
new[] { "INBOX" },
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.ManualSync(new GmailController.GmailManualSyncRequest(365, 8, true, false), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailManualSyncResultDto>(ok.Value);
|
||||
Assert.Equal(1, payload.AutoLinkedThreadCount);
|
||||
Assert.Equal(1, payload.ImportedThreads);
|
||||
Assert.Equal(1, payload.ImportedMessages);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal("linked", decision.Decision);
|
||||
Assert.Equal(job.Id, decision.JobApplicationId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Suggested_jobs_and_create_suggested_job_create_job_and_link_thread()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-suggested", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-s1", "thread-suggested", "Platform Engineer interview", "Nina Recruiter <nina@beta.test>", "user@example.test", DateTimeOffset.UtcNow.AddDays(-1), "Let's talk about the role")
|
||||
});
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-s1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-s1",
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
"Nina Recruiter <nina@beta.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Let's talk about the role",
|
||||
"Interview details",
|
||||
null,
|
||||
new[] { "INBOX" },
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
db.GmailReviewDecisions.Add(new GmailReviewDecision
|
||||
{
|
||||
OwnerUserId = "user-1",
|
||||
ThreadId = "thread-suggested",
|
||||
Decision = "suggested",
|
||||
UpdatedAt = DateTimeOffset.UtcNow
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
|
||||
var reviewQueue = new GmailController.GmailReviewQueueResponseDto(
|
||||
Array.Empty<string>(),
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
new[]
|
||||
{
|
||||
new GmailController.GmailReviewThreadDto(
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
1,
|
||||
"suggested",
|
||||
false,
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailController.GmailReviewJobCandidateDto>(),
|
||||
new[]
|
||||
{
|
||||
new GmailController.GmailJobMatchedMessageDto(
|
||||
"msg-s1",
|
||||
"thread-suggested",
|
||||
"Platform Engineer interview",
|
||||
"Nina Recruiter <nina@beta.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow.AddDays(-1),
|
||||
"Let's talk about the role",
|
||||
0,
|
||||
"low",
|
||||
false,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailController.GmailJobMatchReasonDto>())
|
||||
})
|
||||
});
|
||||
|
||||
var suggested = Assert.IsType<OkObjectResult>((await controller.SuggestedJobs(CancellationToken.None)).Result);
|
||||
Assert.IsType<GmailController.GmailSuggestedJobsResponseDto>(suggested.Value);
|
||||
|
||||
var create = await controller.CreateSuggestedJob(new GmailController.CreateSuggestedGmailJobRequest("thread-suggested", "Beta", "Platform Engineer", "Nina Recruiter", "nina@beta.test", "Create from Gmail suggestion", "Applied"), CancellationToken.None);
|
||||
var createOk = Assert.IsType<OkObjectResult>(create.Result);
|
||||
var created = Assert.IsType<GmailController.CreatedSuggestedGmailJobDto>(createOk.Value);
|
||||
Assert.True(created.JobApplicationId > 0);
|
||||
Assert.Equal(1, created.Imported);
|
||||
Assert.Equal("thread-suggested", created.ThreadId);
|
||||
Assert.Equal(1, await db.JobApplications.CountAsync());
|
||||
Assert.Equal(1, await db.Correspondences.CountAsync());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Unlink_thread_removes_messages_and_sets_review_decision()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
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();
|
||||
|
||||
db.Correspondences.AddRange(
|
||||
new Correspondence { JobApplicationId = job.Id, From = "Company", Content = "First", ExternalMessageId = "msg-1", ExternalThreadId = "thread-1" },
|
||||
new Correspondence { JobApplicationId = job.Id, From = "Me", Content = "Second", ExternalMessageId = "msg-2", ExternalThreadId = "thread-1" });
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var controller = CreateController(db, Mock.Of<IGmailOAuthService>(), "user-1");
|
||||
var result = await controller.UnlinkThread(new GmailController.UnlinkGmailThreadRequest(job.Id, "thread-1", "Need manual review", "review"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailUnlinkResultDto>(ok.Value);
|
||||
Assert.Equal(2, payload.RemovedMessages);
|
||||
Assert.Equal("review", payload.Decision);
|
||||
Assert.Empty(await db.Correspondences.ToListAsync());
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal("review", decision.Decision);
|
||||
Assert.Equal("Need manual review", decision.Note);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Relink_thread_can_move_messages_from_other_jobs()
|
||||
{
|
||||
await using var db = CreateDb();
|
||||
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
|
||||
db.Companies.Add(company);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var sourceJob = new JobApplication { JobTitle = "Source", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
var targetJob = new JobApplication { JobTitle = "Target", CompanyId = company.Id, OwnerUserId = "user-1" };
|
||||
db.JobApplications.AddRange(sourceJob, targetJob);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
db.Correspondences.Add(new Correspondence
|
||||
{
|
||||
JobApplicationId = sourceJob.Id,
|
||||
From = "Company",
|
||||
Content = "Existing import",
|
||||
ExternalMessageId = "msg-1",
|
||||
ExternalThreadId = "thread-1"
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var gmail = new Mock<IGmailOAuthService>();
|
||||
gmail.Setup(service => service.ListThreadMessagesAsync("user-1", "thread-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new[]
|
||||
{
|
||||
new GmailMessageSummary("msg-1", "thread-1", "Interview", "Maria <maria@acme.test>", "user@example.test", DateTimeOffset.UtcNow, "Snippet")
|
||||
});
|
||||
gmail.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "user-1", GmailAddress = "user@example.test", EncryptedRefreshToken = "ignored", Scope = "scope", ConnectedAt = DateTimeOffset.UtcNow });
|
||||
gmail.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailMessageDetail(
|
||||
"msg-1",
|
||||
"thread-1",
|
||||
"Interview",
|
||||
"Maria <maria@acme.test>",
|
||||
"user@example.test",
|
||||
DateTimeOffset.UtcNow,
|
||||
"Snippet",
|
||||
"Body",
|
||||
null,
|
||||
Array.Empty<string>(),
|
||||
Array.Empty<GmailMessageAttachment>()));
|
||||
|
||||
var controller = CreateController(db, gmail.Object, "user-1");
|
||||
var result = await controller.RelinkThread(new GmailController.RelinkGmailThreadRequest(targetJob.Id, "thread-1", true, "Move to target"), CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var payload = Assert.IsType<GmailController.GmailRelinkResultDto>(ok.Value);
|
||||
Assert.Equal(1, payload.UnlinkedMessages);
|
||||
Assert.Equal(1, payload.Imported);
|
||||
var stored = await db.Correspondences.SingleAsync();
|
||||
Assert.Equal(targetJob.Id, stored.JobApplicationId);
|
||||
Assert.Equal("thread-1", stored.ExternalThreadId);
|
||||
var decision = await db.GmailReviewDecisions.SingleAsync();
|
||||
Assert.Equal(targetJob.Id, decision.JobApplicationId);
|
||||
Assert.Equal("linked", decision.Decision);
|
||||
}
|
||||
|
||||
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
|
||||
{
|
||||
var controller = new GmailController(gmail, db, BuildConfig())
|
||||
var controller = new GmailController(gmail, new GmailJobMatchingService(), db, BuildConfig())
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
|
||||
@@ -27,7 +27,7 @@ public sealed class JobApplicationsControllerTests
|
||||
Assert.NotNull(type);
|
||||
|
||||
var ctor = type!.GetConstructors().Single();
|
||||
var parameters = ctor.GetParameters().Select(x => x.Name).ToArray();
|
||||
var parameters = ctor.GetParameters().Select(x => x.Name).Where(x => x is not null).Select(x => x!).ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
Assert.Contains("coverLetterText", parameters);
|
||||
Assert.Contains("notes", parameters);
|
||||
}
|
||||
|
||||
@@ -518,7 +518,12 @@ public sealed class ProfileCvControllerTests
|
||||
var paths = CreatePaths();
|
||||
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
|
||||
|
||||
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest(null, "harvard", null, 42, "harvard"));
|
||||
var result = await controller.RewriteSection(new ProfileCvController.RewriteSectionRequest
|
||||
{
|
||||
Style = "harvard",
|
||||
JobApplicationId = JsonDocument.Parse("42").RootElement.Clone(),
|
||||
TemplateId = "harvard",
|
||||
});
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var json = JsonSerializer.Serialize(ok.Value);
|
||||
@@ -787,7 +792,7 @@ public sealed class ProfileCvControllerTests
|
||||
|
||||
private static ProfileCvController CreateController(UserManager<ApplicationUser> userManager, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null)
|
||||
{
|
||||
return new ProfileCvController(userManager, aiService, db, paths, cvAiClassifier ?? NoOpCvAiClassifier.Instance)
|
||||
return new ProfileCvController(userManager, aiService, db, paths, null, cvAiClassifier ?? NoOpCvAiClassifier.Instance)
|
||||
{
|
||||
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() }
|
||||
};
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class AdminSystemController : ControllerBase
|
||||
public sealed record DatabaseStatusDto(string Provider, bool LooksConfigured, bool CanConnect, string? Target, bool UsesFileStorage, string? Warning);
|
||||
public sealed record RuntimeStatusDto(string Framework, string OSDescription, string ProcessArchitecture, string? MachineName);
|
||||
public sealed record AuthStatusDto(bool Required, bool HasJwtKey, bool GoogleConfigured, bool GmailConfigured);
|
||||
public sealed record CvBenchmarkStatusDto(string? IndexJson, string? ReportMarkdown, string RootPath, DateTimeOffset? LastUpdatedAtUtc);
|
||||
public sealed record SystemStatusDto(
|
||||
string Environment,
|
||||
string ContentRoot,
|
||||
@@ -64,6 +65,50 @@ public sealed class AdminSystemController : ControllerBase
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
private EmailSettingsSnapshot BuildFallbackEmailSettingsSnapshot()
|
||||
{
|
||||
var host = (_cfg["Email:SmtpHost"] ?? string.Empty).Trim();
|
||||
var user = (_cfg["Email:SmtpUser"] ?? string.Empty).Trim();
|
||||
var password = (_cfg["Email:SmtpPassword"] ?? string.Empty).Trim();
|
||||
var from = (_cfg["Email:From"] ?? user).Trim();
|
||||
var fromName = (_cfg["Email:FromName"] ?? "Jobbjakt").Trim();
|
||||
var port = _cfg.GetValue("Email:SmtpPort", 587);
|
||||
if (port <= 0) port = 587;
|
||||
var enableSsl = _cfg.GetValue("Email:SmtpEnableSsl", true);
|
||||
var timeoutMs = _cfg.GetValue("Email:SmtpTimeoutMs", 15000);
|
||||
if (timeoutMs <= 0) timeoutMs = 15000;
|
||||
var enabled = _cfg.GetValue("Email:Enabled", false);
|
||||
|
||||
return new EmailSettingsSnapshot(
|
||||
Enabled: enabled,
|
||||
Host: host,
|
||||
Port: port,
|
||||
User: user,
|
||||
Password: password,
|
||||
From: from,
|
||||
FromName: fromName,
|
||||
EnableSsl: enableSsl,
|
||||
TimeoutMs: timeoutMs,
|
||||
UsesOverrides: false,
|
||||
HasPassword: !string.IsNullOrWhiteSpace(password));
|
||||
}
|
||||
|
||||
private EmailSettingsAdminDto BuildFallbackEmailSettings(string? reason = null)
|
||||
{
|
||||
var snapshot = BuildFallbackEmailSettingsSnapshot();
|
||||
return new EmailSettingsAdminDto(
|
||||
Enabled: snapshot.Enabled,
|
||||
Host: snapshot.Host,
|
||||
Port: snapshot.Port,
|
||||
User: snapshot.User,
|
||||
From: snapshot.From,
|
||||
FromName: string.IsNullOrWhiteSpace(reason) ? snapshot.FromName : $"{snapshot.FromName} (fallback)",
|
||||
EnableSsl: snapshot.EnableSsl,
|
||||
TimeoutMs: snapshot.TimeoutMs,
|
||||
UsesOverrides: snapshot.UsesOverrides,
|
||||
HasPassword: snapshot.HasPassword);
|
||||
}
|
||||
|
||||
[HttpPost("ai/probe")]
|
||||
[HttpPost("summarizer/probe")]
|
||||
public async Task<IActionResult> RunSummarizerProbe(CancellationToken cancellationToken)
|
||||
@@ -75,7 +120,14 @@ public sealed class AdminSystemController : ControllerBase
|
||||
[HttpGet("email-settings")]
|
||||
public async Task<ActionResult<EmailSettingsAdminDto>> GetEmailSettings(CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
|
||||
try
|
||||
{
|
||||
return Ok(await _emailSettings.GetAdminDtoAsync(cancellationToken));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Ok(BuildFallbackEmailSettings(ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPut("email-settings")]
|
||||
@@ -86,6 +138,22 @@ public sealed class AdminSystemController : ControllerBase
|
||||
return Ok(await _emailSettings.UpdateAsync(request, cancellationToken));
|
||||
}
|
||||
|
||||
[HttpGet("cv-benchmark")]
|
||||
public async Task<ActionResult<CvBenchmarkStatusDto>> GetCvBenchmarkStatus(CancellationToken cancellationToken)
|
||||
{
|
||||
var indexPath = Path.Combine(_paths.CvBenchmarksRoot, "index.json");
|
||||
var reportPath = Path.Combine(_paths.CvBenchmarksRoot, "report.md");
|
||||
var indexJson = System.IO.File.Exists(indexPath) ? await System.IO.File.ReadAllTextAsync(indexPath, cancellationToken) : null;
|
||||
var reportMarkdown = System.IO.File.Exists(reportPath) ? await System.IO.File.ReadAllTextAsync(reportPath, cancellationToken) : null;
|
||||
var lastUpdated = new[]
|
||||
{
|
||||
System.IO.File.Exists(indexPath) ? System.IO.File.GetLastWriteTimeUtc(indexPath) : (DateTime?)null,
|
||||
System.IO.File.Exists(reportPath) ? System.IO.File.GetLastWriteTimeUtc(reportPath) : (DateTime?)null,
|
||||
}.Where(value => value.HasValue).Select(value => value!.Value).DefaultIfEmpty().Max();
|
||||
|
||||
return Ok(new CvBenchmarkStatusDto(indexJson, reportMarkdown, _paths.CvBenchmarksRoot, lastUpdated == default ? null : new DateTimeOffset(DateTime.SpecifyKind(lastUpdated, DateTimeKind.Utc))));
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<SystemStatusDto>> Get(CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -128,6 +196,10 @@ public sealed class AdminSystemController : ControllerBase
|
||||
OllamaReachable: null,
|
||||
OllamaModel: null,
|
||||
OllamaModelAvailable: null,
|
||||
OllamaVersion: null,
|
||||
OllamaInstalledModels: Array.Empty<string>(),
|
||||
OllamaLoadedModels: Array.Empty<string>(),
|
||||
OllamaLoadedCount: 0,
|
||||
HealthLatencyMs: null,
|
||||
ProbeLatencyMs: null,
|
||||
LastProbeAt: null,
|
||||
@@ -211,7 +283,15 @@ public sealed class AdminSystemController : ControllerBase
|
||||
|
||||
var gmailConfigured = !string.IsNullOrWhiteSpace((_cfg["Google:GmailClientSecret"] ?? string.Empty).Trim())
|
||||
&& !string.IsNullOrWhiteSpace((_cfg["Google:GmailRedirectUri"] ?? string.Empty).Trim());
|
||||
var emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
|
||||
EmailSettingsSnapshot emailSettings;
|
||||
try
|
||||
{
|
||||
emailSettings = await _emailSettings.GetSnapshotAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
emailSettings = BuildFallbackEmailSettingsSnapshot();
|
||||
}
|
||||
|
||||
return Ok(new SystemStatusDto(
|
||||
Environment: _env.EnvironmentName,
|
||||
|
||||
@@ -25,6 +25,84 @@ namespace JobTrackerApi.Controllers
|
||||
.FirstOrDefaultAsync(c => c.Id == correspondenceId, cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record CorrespondenceInboxItemDto(
|
||||
int Id,
|
||||
int JobApplicationId,
|
||||
string? CompanyName,
|
||||
string? JobTitle,
|
||||
string From,
|
||||
string? Direction,
|
||||
string? Subject,
|
||||
string? Channel,
|
||||
DateTime Date,
|
||||
string ContentPreview,
|
||||
string? ExternalThreadId,
|
||||
string? ExternalFrom,
|
||||
string? ExternalTo,
|
||||
int LabelCount,
|
||||
int AttachmentCount);
|
||||
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<CorrespondenceInboxItemDto>>> GetInbox(
|
||||
[FromQuery] string? q,
|
||||
[FromQuery] string? direction,
|
||||
[FromQuery] string? linkState,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var query = _db.Correspondences
|
||||
.Include(c => c.JobApplication)
|
||||
.ThenInclude(j => j.Company)
|
||||
.AsQueryable();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(q))
|
||||
{
|
||||
var needle = q.Trim();
|
||||
query = query.Where(c =>
|
||||
(c.Subject != null && EF.Functions.Like(c.Subject, $"%{needle}%")) ||
|
||||
EF.Functions.Like(c.Content, $"%{needle}%") ||
|
||||
(c.ExternalFrom != null && EF.Functions.Like(c.ExternalFrom, $"%{needle}%")) ||
|
||||
(c.JobApplication.JobTitle != null && EF.Functions.Like(c.JobApplication.JobTitle, $"%{needle}%")) ||
|
||||
(c.JobApplication.Company.Name != null && EF.Functions.Like(c.JobApplication.Company.Name, $"%{needle}%")));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(direction) && !string.Equals(direction, "all", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(c => c.Direction == direction);
|
||||
}
|
||||
|
||||
if (string.Equals(linkState, "linked", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(c => c.ExternalThreadId != null);
|
||||
}
|
||||
else if (string.Equals(linkState, "manual", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
query = query.Where(c => c.ExternalThreadId == null);
|
||||
}
|
||||
|
||||
var items = await query
|
||||
.OrderByDescending(c => c.Date)
|
||||
.Take(200)
|
||||
.Select(c => new CorrespondenceInboxItemDto(
|
||||
c.Id,
|
||||
c.JobApplicationId,
|
||||
c.JobApplication.Company != null ? c.JobApplication.Company.Name : null,
|
||||
c.JobApplication.JobTitle,
|
||||
c.From,
|
||||
c.Direction,
|
||||
c.Subject,
|
||||
c.Channel,
|
||||
c.Date,
|
||||
c.Content.Length <= 220 ? c.Content : c.Content.Substring(0, 220),
|
||||
c.ExternalThreadId,
|
||||
c.ExternalFrom,
|
||||
c.ExternalTo,
|
||||
c.ExternalLabelsJson != null ? 1 : 0,
|
||||
c.AttachmentMetadataJson != null ? 1 : 0))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return Ok(items);
|
||||
}
|
||||
|
||||
// GET all messages for a job
|
||||
[HttpGet("{jobId:int}")]
|
||||
public async Task<ActionResult<List<Correspondence>>> GetForJob([FromRoute] int jobId, CancellationToken cancellationToken)
|
||||
@@ -48,10 +126,13 @@ namespace JobTrackerApi.Controllers
|
||||
string? Subject,
|
||||
string? Channel,
|
||||
DateTime? Date,
|
||||
string? Direction,
|
||||
string? ExternalMessageId,
|
||||
string? ExternalThreadId,
|
||||
string? ExternalFrom,
|
||||
string? ExternalTo
|
||||
string? ExternalTo,
|
||||
string? ExternalLabelsJson,
|
||||
string? AttachmentMetadataJson
|
||||
);
|
||||
|
||||
// POST new message
|
||||
@@ -71,10 +152,13 @@ namespace JobTrackerApi.Controllers
|
||||
From = request.From.Trim(),
|
||||
Subject = string.IsNullOrWhiteSpace(request.Subject) ? null : request.Subject.Trim(),
|
||||
Channel = string.IsNullOrWhiteSpace(request.Channel) ? null : request.Channel.Trim(),
|
||||
Direction = string.IsNullOrWhiteSpace(request.Direction) ? null : request.Direction.Trim(),
|
||||
ExternalMessageId = string.IsNullOrWhiteSpace(request.ExternalMessageId) ? null : request.ExternalMessageId.Trim(),
|
||||
ExternalThreadId = string.IsNullOrWhiteSpace(request.ExternalThreadId) ? null : request.ExternalThreadId.Trim(),
|
||||
ExternalFrom = string.IsNullOrWhiteSpace(request.ExternalFrom) ? null : request.ExternalFrom.Trim(),
|
||||
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
|
||||
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
|
||||
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
|
||||
Content = request.Content,
|
||||
Date = request.Date ?? DateTime.Now,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
@@ -14,12 +15,14 @@ namespace JobTrackerApi.Controllers;
|
||||
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, JobTrackerContext db, IConfiguration cfg)
|
||||
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
|
||||
{
|
||||
_gmail = gmail;
|
||||
_matching = matching;
|
||||
_db = db;
|
||||
_cfg = cfg;
|
||||
}
|
||||
@@ -68,18 +71,49 @@ public sealed class GmailController : ControllerBase
|
||||
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<IActionResult> Status(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
connected = connection is not null,
|
||||
gmailAddress = connection?.GmailAddress,
|
||||
connectedAt = connection?.ConnectedAt,
|
||||
lastSyncedAt = connection?.LastSyncedAt,
|
||||
});
|
||||
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")]
|
||||
@@ -119,7 +153,7 @@ public sealed class GmailController : ControllerBase
|
||||
.ToHashSet(StringComparer.Ordinal);
|
||||
|
||||
var rankedMessages = candidateMessages
|
||||
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
|
||||
.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)
|
||||
@@ -145,6 +179,7 @@ public sealed class GmailController : ControllerBase
|
||||
.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)
|
||||
@@ -179,7 +214,7 @@ public sealed class GmailController : ControllerBase
|
||||
ToConfidence(item.Score),
|
||||
item.AlreadyImported,
|
||||
item.MatchedQueries,
|
||||
item.Reasons)).ToList());
|
||||
item.Reasons.Select(reason => new GmailJobMatchReasonDto(reason.Label, reason.Value, reason.Points)).ToList())).ToList());
|
||||
})
|
||||
.OrderByDescending(thread => thread.Score)
|
||||
.ThenByDescending(thread => thread.LatestDate ?? DateTimeOffset.MinValue)
|
||||
@@ -197,6 +232,499 @@ public sealed class GmailController : ControllerBase
|
||||
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)
|
||||
@@ -398,12 +926,22 @@ public sealed class GmailController : ControllerBase
|
||||
{
|
||||
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,
|
||||
};
|
||||
@@ -433,168 +971,63 @@ public sealed class GmailController : ControllerBase
|
||||
return message;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
private IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
void Add(string? query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
queries.Add(query.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
Add(queryOverride);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
|
||||
{
|
||||
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
|
||||
{
|
||||
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
|
||||
{
|
||||
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
|
||||
{
|
||||
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.JobTitle))
|
||||
{
|
||||
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
foreach (var subject in job.Messages
|
||||
.Select(message => message.Subject)
|
||||
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2))
|
||||
{
|
||||
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (queries.Count == 0)
|
||||
{
|
||||
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
|
||||
}
|
||||
|
||||
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
return _matching.BuildJobQueries(job, queryOverride);
|
||||
}
|
||||
|
||||
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
|
||||
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
|
||||
{
|
||||
var reasons = new List<GmailJobMatchReasonDto>();
|
||||
var score = 0;
|
||||
var message = candidate.Message;
|
||||
var subject = message.Subject ?? string.Empty;
|
||||
var from = message.From ?? string.Empty;
|
||||
var to = message.To ?? string.Empty;
|
||||
var snippet = message.Snippet ?? string.Empty;
|
||||
var haystack = $"{subject} {from} {to} {snippet}";
|
||||
|
||||
if (candidate.MatchedQueries.Count > 0)
|
||||
var bounded = (query ?? string.Empty).Trim();
|
||||
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
|
||||
score += queryHitPoints;
|
||||
reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
|
||||
bounded = string.IsNullOrWhiteSpace(bounded)
|
||||
? $"newer_than:{lookbackDays}d"
|
||||
: $"{bounded} newer_than:{lookbackDays}d";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
|
||||
if (!includeSpamTrash)
|
||||
{
|
||||
score += 18;
|
||||
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18));
|
||||
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
|
||||
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
|
||||
{
|
||||
score += 20;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
|
||||
{
|
||||
score += 12;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12));
|
||||
}
|
||||
|
||||
foreach (var token in SplitTerms(job.JobTitle).Take(4))
|
||||
{
|
||||
if (!ContainsValue(haystack, token)) continue;
|
||||
score += 5;
|
||||
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5));
|
||||
}
|
||||
|
||||
foreach (var subjectLine in job.Messages
|
||||
.Select(existing => existing.Subject)
|
||||
.Where(existing => !string.IsNullOrWhiteSpace(existing))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2))
|
||||
{
|
||||
if (!ContainsValue(subject, subjectLine!)) continue;
|
||||
score += 8;
|
||||
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8));
|
||||
}
|
||||
|
||||
if (message.Date is { } messageDate)
|
||||
{
|
||||
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
|
||||
if (ageDays <= 45)
|
||||
{
|
||||
score += 4;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4));
|
||||
}
|
||||
else if (ageDays <= 180)
|
||||
{
|
||||
score += 2;
|
||||
reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2));
|
||||
}
|
||||
}
|
||||
|
||||
if (threadAlreadyImported && !alreadyImported)
|
||||
{
|
||||
reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0));
|
||||
}
|
||||
|
||||
if (alreadyImported)
|
||||
{
|
||||
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0));
|
||||
}
|
||||
|
||||
reasons = reasons
|
||||
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
|
||||
.Select(group => group.First())
|
||||
.OrderByDescending(reason => reason.Points)
|
||||
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
||||
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons);
|
||||
return bounded.Trim();
|
||||
}
|
||||
|
||||
private static bool ContainsValue(string haystack, string? value)
|
||||
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value)
|
||||
&& haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
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 IEnumerable<string> SplitTerms(string? value)
|
||||
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) yield break;
|
||||
|
||||
foreach (var token in value
|
||||
.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(token => token.Length >= 3)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
|
||||
if (existing is null)
|
||||
{
|
||||
yield return token;
|
||||
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)
|
||||
@@ -607,6 +1040,44 @@ public sealed class GmailController : ControllerBase
|
||||
};
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -652,11 +1123,4 @@ public sealed class GmailController : ControllerBase
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private sealed record GmailScoredMessage(
|
||||
GmailMessageSummary Message,
|
||||
bool AlreadyImported,
|
||||
int Score,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
|
||||
}
|
||||
|
||||
@@ -64,18 +64,42 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private readonly ICvAiClassifier _cvAiClassifier;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly AppPaths _paths;
|
||||
private readonly ILogger<ProfileCvController> _logger;
|
||||
private readonly ICvTemplateRenderer _cvTemplateRenderer;
|
||||
private readonly ICvPdfExporter _cvPdfExporter;
|
||||
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ICvAiClassifier? cvAiClassifier = null)
|
||||
public ProfileCvController(UserManager<ApplicationUser> users, ISummarizerService aiService, JobTrackerContext db, AppPaths paths, ILogger<ProfileCvController>? logger = null, ICvAiClassifier? cvAiClassifier = null, ICvTemplateRenderer? cvTemplateRenderer = null, ICvPdfExporter? cvPdfExporter = null)
|
||||
{
|
||||
_users = users;
|
||||
_aiService = aiService;
|
||||
_cvAiClassifier = cvAiClassifier ?? NoOpCvAiClassifier.Instance;
|
||||
_db = db;
|
||||
_paths = paths;
|
||||
_logger = logger ?? Microsoft.Extensions.Logging.Abstractions.NullLogger<ProfileCvController>.Instance;
|
||||
_cvTemplateRenderer = cvTemplateRenderer ?? new CvTemplateRenderer();
|
||||
_cvPdfExporter = cvPdfExporter ?? new ThrowingCvPdfExporter();
|
||||
}
|
||||
|
||||
public sealed record RewriteSectionRequest(string? SectionName, string? Style, string? TargetRole, int? JobApplicationId, string? TemplateId);
|
||||
private sealed class ThrowingCvPdfExporter : ICvPdfExporter
|
||||
{
|
||||
public Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
|
||||
{
|
||||
throw new InvalidOperationException("CV PDF export is not configured for this controller instance.");
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class RewriteSectionRequest
|
||||
{
|
||||
public string? SectionName { get; set; }
|
||||
public string? Style { get; set; }
|
||||
public string? TargetRole { get; set; }
|
||||
public JsonElement? JobApplicationId { get; set; }
|
||||
public string? TemplateId { get; set; }
|
||||
public string? SourceText { get; set; }
|
||||
}
|
||||
public sealed record ParseCvRequest(string? Text);
|
||||
public sealed record CvTemplateDescriptor(string Id, string Title, string Tone, string AccentColor, string PreviewTagline, string PreviewSummary, List<string> PreviewBullets);
|
||||
public sealed record ProfileCvPreviewDto(string TemplateId, string Html, string SuggestedFileName, string FullText, string RewrittenText, string? SectionName, StructuredCvProfile StructuredCv, TailoredCvDocument Document, string? TargetRole, int? JobApplicationId);
|
||||
|
||||
private sealed record ExtractionPipelineResult(string RawText, string NormalizedText, StructuredCvProfile StructuredCv);
|
||||
private sealed record ClassifiedCvBlock(int Index, string OriginalBlock, string SectionName, string Content, CvBlockClassificationResult? Classification);
|
||||
@@ -275,46 +299,63 @@ public sealed class ProfileCvController : ControllerBase
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var sourceText = string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim();
|
||||
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var sourceText = string.IsNullOrWhiteSpace(request.SourceText)
|
||||
? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim())
|
||||
: request.SourceText.Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0)
|
||||
{
|
||||
return BadRequest("Add or import CV text before rewriting your CV.");
|
||||
}
|
||||
|
||||
var sectionName = string.IsNullOrWhiteSpace(request.SectionName) ? null : request.SectionName.Trim();
|
||||
var sectionName = NormalizeRewriteSectionName(request.SectionName);
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
||||
var templateId = string.IsNullOrWhiteSpace(request.TemplateId) ? "ats-minimal" : request.TemplateId.Trim();
|
||||
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
||||
var targetRole = string.IsNullOrWhiteSpace(request.TargetRole) ? null : request.TargetRole.Trim();
|
||||
var jobContext = request.JobApplicationId.HasValue
|
||||
? await _db.JobApplications.AsNoTracking().Where(job => job.Id == request.JobApplicationId.Value && job.OwnerUserId == user.Id).Select(job => new
|
||||
{
|
||||
job.Id,
|
||||
job.JobTitle,
|
||||
job.Description,
|
||||
job.ShortSummary,
|
||||
CompanyName = job.Company != null ? job.Company.Name : null
|
||||
}).FirstOrDefaultAsync(HttpContext.RequestAborted)
|
||||
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
||||
var jobContext = jobApplicationId.HasValue
|
||||
? await _db.JobApplications
|
||||
.AsNoTracking()
|
||||
.Include(job => job.Company)
|
||||
.Where(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id)
|
||||
.Select(job => new
|
||||
{
|
||||
job.Id,
|
||||
job.JobTitle,
|
||||
job.Description,
|
||||
job.TranslatedDescription,
|
||||
job.ShortSummary,
|
||||
job.Notes,
|
||||
job.JobUrl,
|
||||
job.Status,
|
||||
CompanyName = job.Company != null ? job.Company.Name : null,
|
||||
RecruiterName = job.Company != null ? job.Company.RecruiterName : null,
|
||||
RecruiterEmail = job.Company != null ? job.Company.RecruiterEmail : null
|
||||
})
|
||||
.FirstOrDefaultAsync(HttpContext.RequestAborted)
|
||||
: null;
|
||||
|
||||
var effectiveTargetRole = targetRole ?? jobContext?.JobTitle;
|
||||
var rewriteSource = BuildRewriteSourceText(sectionName, sourceText, structuredCv);
|
||||
var templateGuidance = DescribeRewriteTemplate(templateId);
|
||||
var roleGuidance = jobContext is not null
|
||||
? $"Target this toward the saved job '{jobContext.JobTitle}' at '{jobContext.CompanyName ?? "Unknown company"}'. Use the job context below to sharpen wording without inventing facts.\nJob summary: {jobContext.ShortSummary ?? "-"}\nJob description: {jobContext.Description ?? "-"}"
|
||||
? $"Target this toward the saved job '{jobContext.JobTitle}' at '{jobContext.CompanyName ?? "Unknown company"}'. Use the full job record below to sharpen wording without inventing facts.\nJob status: {jobContext.Status}\nJob summary: {jobContext.ShortSummary ?? "-"}\nJob description: {jobContext.Description ?? "-"}\nTranslated description: {jobContext.TranslatedDescription ?? "-"}\nNotes: {jobContext.Notes ?? "-"}\nJob URL: {jobContext.JobUrl ?? "-"}\nRecruiter name: {jobContext.RecruiterName ?? "-"}\nRecruiter email: {jobContext.RecruiterEmail ?? "-"}"
|
||||
: effectiveTargetRole is not null
|
||||
? $"Target role: {effectiveTargetRole}. Keep it broadly reusable but clearly aligned to that role family."
|
||||
: "Keep it broadly reusable for future tailoring.";
|
||||
|
||||
var subject = sectionName is null ? "this CV" : $"the '{sectionName}' section of this CV";
|
||||
var instruction = $"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful.";
|
||||
var rewritten = await _aiService.SummarizeSectionAsync(
|
||||
$"Rewrite only {subject}. Preserve facts, avoid inventing employers, titles, qualifications, dates, locations, or metrics. Style guidance: {style}. Template direction: {templateGuidance}. {roleGuidance} Return only the rewritten text with clean headings and bullets when useful.",
|
||||
instruction,
|
||||
rewriteSource,
|
||||
sectionName is null ? 1800 : 900,
|
||||
sectionName is null ? 400 : 180);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(rewritten))
|
||||
{
|
||||
_logger.LogWarning("CV rewrite returned empty output. Section={SectionName} Template={TemplateId} TargetRole={TargetRole} JobApplicationId={JobApplicationId} HasSourceText={HasSourceText} StructuredSections={StructuredSectionCount}",
|
||||
sectionName ?? "<whole-cv>", templateId, effectiveTargetRole ?? "<none>", jobApplicationId, !string.IsNullOrWhiteSpace(sourceText), structuredCv.Sections.Count);
|
||||
return StatusCode(StatusCodes.Status502BadGateway, "The AI service could not rewrite your CV right now.");
|
||||
}
|
||||
|
||||
@@ -329,6 +370,73 @@ public sealed class ProfileCvController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
[HttpGet("templates")]
|
||||
public ActionResult<IEnumerable<CvTemplateDescriptor>> GetTemplates()
|
||||
{
|
||||
return Ok(GetCvTemplateDescriptors());
|
||||
}
|
||||
|
||||
[HttpPost("rewrite-preview")]
|
||||
public async Task<ActionResult<ProfileCvPreviewDto>> BuildRewritePreview([FromBody] RewriteSectionRequest request)
|
||||
{
|
||||
var user = await _users.GetUserAsync(User);
|
||||
if (user is null) return Unauthorized();
|
||||
|
||||
var structuredCv = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
|
||||
var sourceText = string.IsNullOrWhiteSpace(request.SourceText)
|
||||
? (string.IsNullOrWhiteSpace(user.ProfileCvText) ? null : user.ProfileCvText.Trim())
|
||||
: request.SourceText.Trim();
|
||||
if (string.IsNullOrWhiteSpace(sourceText) && structuredCv.Sections.Count == 0)
|
||||
{
|
||||
return BadRequest("Add or import CV text before rewriting your CV.");
|
||||
}
|
||||
|
||||
var sectionName = NormalizeRewriteSectionName(request.SectionName);
|
||||
var style = string.IsNullOrWhiteSpace(request.Style) ? "ats-minimal" : request.Style.Trim();
|
||||
var templateId = NormalizeTemplateId(request.TemplateId ?? style);
|
||||
var jobApplicationId = ParseFlexibleNullableInt(request.JobApplicationId);
|
||||
var job = jobApplicationId.HasValue
|
||||
? await _db.JobApplications.AsNoTracking().Include(job => job.Company)
|
||||
.FirstOrDefaultAsync(job => job.Id == jobApplicationId.Value && job.OwnerUserId == user.Id, HttpContext.RequestAborted)
|
||||
: null;
|
||||
var effectiveTargetRole = string.IsNullOrWhiteSpace(request.TargetRole)
|
||||
? job?.JobTitle
|
||||
: request.TargetRole.Trim();
|
||||
|
||||
var rewriteResult = await RewriteSection(request);
|
||||
if (rewriteResult is not OkObjectResult ok) return StatusCode((rewriteResult as ObjectResult)?.StatusCode ?? 500, (rewriteResult as ObjectResult)?.Value);
|
||||
|
||||
var rewrittenText = JsonDocument.Parse(JsonSerializer.Serialize(ok.Value)).RootElement.GetProperty("text").GetString()?.Trim() ?? string.Empty;
|
||||
var baseText = string.IsNullOrWhiteSpace(sourceText)
|
||||
? string.Join("\n\n", structuredCv.Sections.Select(section => $"## {section.Name}\n{section.Content}"))
|
||||
: sourceText!;
|
||||
var fullText = sectionName is null ? rewrittenText : ReplaceOrAppendCvSection(baseText, sectionName, rewrittenText);
|
||||
var previewStructured = await BuildStructuredCvAsync(fullText, HttpContext.RequestAborted);
|
||||
var document = BuildMasterCvDocument(previewStructured, templateId, effectiveTargetRole, job?.JobTitle, job?.Company?.Name);
|
||||
var rendered = RenderProfileCv(document, user, effectiveTargetRole ?? user.DisplayName ?? "General CV", job?.Company?.Name);
|
||||
|
||||
return Ok(new ProfileCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName, fullText, rewrittenText, sectionName, previewStructured, document, effectiveTargetRole, job?.Id));
|
||||
}
|
||||
|
||||
[HttpPost("export-pdf")]
|
||||
public async Task<IActionResult> ExportProfileCvPdf([FromBody] RewriteSectionRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var previewResult = await BuildRewritePreview(request);
|
||||
if (previewResult.Result is ObjectResult errorResult && errorResult.StatusCode >= 400)
|
||||
{
|
||||
return StatusCode(errorResult.StatusCode ?? 500, errorResult.Value);
|
||||
}
|
||||
|
||||
var ok = previewResult.Result as OkObjectResult;
|
||||
if (ok?.Value is not ProfileCvPreviewDto preview)
|
||||
{
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, "The CV preview could not be prepared for PDF export.");
|
||||
}
|
||||
|
||||
var artifact = await _cvPdfExporter.ExportAsync(new TailoredCvRenderResult(preview.TemplateId, preview.SuggestedFileName, preview.Html), cancellationToken);
|
||||
return File(artifact.Bytes, "application/pdf", artifact.FileName);
|
||||
}
|
||||
|
||||
[HttpPost("parse")]
|
||||
public async Task<ActionResult<object>> Parse([FromBody] ParseCvRequest? request)
|
||||
{
|
||||
@@ -400,10 +508,167 @@ public sealed class ProfileCvController : ControllerBase
|
||||
"harvard" => "Harvard template: refined, traditional, strong hierarchy, restrained and credible.",
|
||||
"auckland" => "Auckland template: modern sidebar layout, crisp highlights, confident but readable.",
|
||||
"edinburgh" => "Edinburgh template: polished editorial layout with stronger visual personality and premium spacing.",
|
||||
_ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor.",
|
||||
"monarch" => "Monarch template: executive, premium, high-contrast emphasis on summary and leadership signals.",
|
||||
"fjord" => "Fjord template: calm technical layout with clear information density and practical scanability.",
|
||||
_ => "ATS Minimal template: clean, compact, scanner-friendly, and easy to tailor."
|
||||
};
|
||||
}
|
||||
|
||||
private static string NormalizeTemplateId(string? value)
|
||||
{
|
||||
var normalized = (value ?? string.Empty).Trim().ToLowerInvariant();
|
||||
return normalized switch
|
||||
{
|
||||
"base" => "ats-minimal",
|
||||
"legacy-text" => "ats-minimal",
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
"monarch" => "monarch",
|
||||
"fjord" => "fjord",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeRewriteSectionName(string? value)
|
||||
{
|
||||
var trimmed = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmed)) return null;
|
||||
return SectionAliases.TryGetValue(trimmed, out var canonical) ? canonical : trimmed;
|
||||
}
|
||||
|
||||
private static int? ParseFlexibleNullableInt(JsonElement? value)
|
||||
{
|
||||
if (value is null) return null;
|
||||
if (value.Value.ValueKind == JsonValueKind.Number && value.Value.TryGetInt32(out var number)) return number;
|
||||
if (value.Value.ValueKind == JsonValueKind.String)
|
||||
{
|
||||
var raw = value.Value.GetString();
|
||||
if (int.TryParse(raw, out var parsed)) return parsed;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string ReplaceOrAppendCvSection(string source, string sectionName, string sectionDraft)
|
||||
{
|
||||
var trimmedSource = (source ?? string.Empty).Trim();
|
||||
var trimmedDraft = (sectionDraft ?? string.Empty).Trim();
|
||||
if (string.IsNullOrWhiteSpace(trimmedDraft)) return trimmedSource;
|
||||
if (string.IsNullOrWhiteSpace(trimmedSource)) return $"## {sectionName}\n{trimmedDraft}";
|
||||
|
||||
var normalizedHeading = sectionName.Trim().ToLowerInvariant();
|
||||
var headingPattern = new Regex(@"^(##\s+|#\s+)?(?<name>[A-Z][A-Za-z &/]+):?\s*$", RegexOptions.Multiline);
|
||||
var matches = headingPattern.Matches(trimmedSource).ToList();
|
||||
var targetIndex = matches.FindIndex(match => string.Equals(match.Groups["name"].Value.Trim(), normalizedHeading, StringComparison.OrdinalIgnoreCase));
|
||||
if (targetIndex < 0)
|
||||
{
|
||||
return $"{trimmedSource}\n\n## {sectionName}\n{trimmedDraft}".Trim();
|
||||
}
|
||||
|
||||
var start = matches[targetIndex].Index;
|
||||
var end = targetIndex + 1 < matches.Count ? matches[targetIndex + 1].Index : trimmedSource.Length;
|
||||
var before = trimmedSource[..start].TrimEnd();
|
||||
var after = trimmedSource[end..].TrimStart();
|
||||
return string.Join("\n\n", new[] { before, $"## {sectionName}\n{trimmedDraft}", after }.Where(part => !string.IsNullOrWhiteSpace(part))).Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CvTemplateDescriptor> GetCvTemplateDescriptors()
|
||||
{
|
||||
return new[]
|
||||
{
|
||||
new CvTemplateDescriptor("ats-minimal", "ATS Minimal", "Scanner-friendly", "slate", "Compact, direct, and easy to parse.", "Best for broad application flows and recruiter scanning.", new List<string> { "Tight hierarchy", "Keyword-friendly", "Low visual risk" }),
|
||||
new CvTemplateDescriptor("harvard", "Harvard", "Traditional", "brick", "Formal and restrained.", "Good for conservative hiring flows or academic-adjacent applications.", new List<string> { "Classic serif rhythm", "Strong chronology", "Credible tone" }),
|
||||
new CvTemplateDescriptor("auckland", "Auckland", "Modern sidebar", "emerald", "Sharper highlights with a contemporary cadence.", "Pulls key strengths into a faster visual scan.", new List<string> { "Sidebar details", "Compact highlights", "Modern contrast" }),
|
||||
new CvTemplateDescriptor("edinburgh", "Edinburgh", "Editorial", "plum", "More personality without losing clarity.", "Useful when the CV should feel polished and distinctive.", new List<string> { "Premium spacing", "Stronger personality", "Readable density" }),
|
||||
new CvTemplateDescriptor("monarch", "Monarch", "Executive", "#7c2d12", "High-contrast leadership emphasis.", "Works well for senior, strategic, or client-facing roles.", new List<string> { "Executive summary weight", "Premium accenting", "Decision-maker friendly" }),
|
||||
new CvTemplateDescriptor("fjord", "Fjord", "Technical", "#0f4c5c", "Calm, dense, technical layout.", "Optimized for engineering resumes with richer project and skills detail.", new List<string> { "Technical depth", "Dense but readable", "Practical hierarchy" }),
|
||||
};
|
||||
}
|
||||
|
||||
private TailoredCvRenderResult RenderProfileCv(TailoredCvDocument document, ApplicationUser user, string targetRole, string? companyName)
|
||||
{
|
||||
var candidateName = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.DisplayName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.UserName?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = user.Email?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidateName)) candidateName = "Your Name";
|
||||
return _cvTemplateRenderer.Render(document, document.TemplateId, candidateName!, targetRole, companyName, user.AvatarImageDataUrl);
|
||||
}
|
||||
|
||||
private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName)
|
||||
{
|
||||
var normalized = StructuredCvProfileJson.Normalize(structuredCv);
|
||||
var customSections = new List<TailoredCvCustomSection>();
|
||||
if (normalized.Certifications.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Certifications",
|
||||
Items = normalized.Certifications.Select(certification => string.Join(" | ", new[] { certification.Name, certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
if (normalized.Projects.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Projects",
|
||||
Items = normalized.Projects.Select(project => string.Join(" | ", new[] { project.Name, project.Role, project.Location, FormatDateRangeForSection(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
if (normalized.Languages.Count > 0)
|
||||
{
|
||||
customSections.Add(new TailoredCvCustomSection
|
||||
{
|
||||
Title = "Languages",
|
||||
Items = normalized.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
|
||||
});
|
||||
}
|
||||
customSections.AddRange(normalized.OtherSections.Select(section => new TailoredCvCustomSection { Title = section.Title, Items = section.Items }));
|
||||
|
||||
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
|
||||
{
|
||||
TemplateId = templateId,
|
||||
Headline = normalized.Contact.Headline ?? targetRole ?? fallbackHeadline ?? companyName,
|
||||
Summary = normalized.Summary,
|
||||
SelectedSkills = normalized.Skills,
|
||||
Experience = normalized.Jobs.Select(job => new TailoredCvExperienceItem
|
||||
{
|
||||
Title = job.Title,
|
||||
Company = job.Company,
|
||||
Location = job.Location,
|
||||
Start = job.Start,
|
||||
End = job.End,
|
||||
IsCurrent = job.IsCurrent,
|
||||
Bullets = job.Bullets,
|
||||
}).ToList(),
|
||||
Education = normalized.Education.Select(education => new TailoredCvEducationItem
|
||||
{
|
||||
Qualification = education.Qualification,
|
||||
QualificationLevel = education.QualificationLevel,
|
||||
Institution = education.Institution,
|
||||
Location = education.Location,
|
||||
Start = education.Start,
|
||||
End = education.End,
|
||||
Details = education.Details,
|
||||
}).ToList(),
|
||||
CustomSections = customSections,
|
||||
RenderOptions = new TailoredCvRenderOptions
|
||||
{
|
||||
ShowPhoto = true,
|
||||
AccentColor = templateId switch
|
||||
{
|
||||
"harvard" => "brick",
|
||||
"auckland" => "emerald",
|
||||
"edinburgh" => "plum",
|
||||
"monarch" => "#7c2d12",
|
||||
"fjord" => "#0f4c5c",
|
||||
_ => "slate",
|
||||
},
|
||||
SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" },
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var parseSource = NormalizeTextForStructuredParsing(text);
|
||||
@@ -601,7 +866,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
private async Task<StructuredCvProfile?> TryExtractStructuredCvAsync(string text, CancellationToken cancellationToken)
|
||||
{
|
||||
var structuredJson = await _aiService.SummarizeSectionAsync(
|
||||
"Extract this CV into structured JSON. Return only valid JSON with this exact top-level shape: { \"version\": \"1\", \"contact\": { \"fullName\": string|null, \"headline\": string|null, \"email\": string|null, \"phone\": string|null, \"location\": string|null, \"website\": string|null, \"linkedin\": string|null }, \"summary\": string[], \"jobs\": [{ \"title\": string|null, \"company\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"isCurrent\": boolean, \"bullets\": string[], \"skills\": string[] }], \"education\": [{ \"qualification\": string|null, \"institution\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"details\": string[] }], \"skills\": string[], \"languages\": [{ \"name\": string|null, \"level\": string|null, \"notes\": string|null }], \"interests\": string[], \"otherSections\": [{ \"title\": string|null, \"items\": string[] }] }. Preserve facts only. Do not invent anything. If a field is unknown, use null or an empty array. Keep wording close to the source. Put unmatched content in otherSections.",
|
||||
"Extract this CV into structured JSON. Return only valid JSON with this exact top-level shape: { \"version\": \"1\", \"contact\": { \"fullName\": string|null, \"headline\": string|null, \"email\": string|null, \"phone\": string|null, \"location\": string|null, \"website\": string|null, \"linkedin\": string|null }, \"summary\": string[], \"jobs\": [{ \"title\": string|null, \"company\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"isCurrent\": boolean, \"bullets\": string[], \"skills\": string[] }], \"education\": [{ \"qualification\": string|null, \"qualificationLevel\": \"Secondary\"|\"Diploma/Certificate\"|\"Bachelor\"|\"Master\"|\"PhD\"|\"Other\"|null, \"institution\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"details\": string[] }], \"certifications\": [{ \"name\": string|null, \"issuer\": string|null, \"location\": string|null, \"date\": string|null, \"details\": string[] }], \"projects\": [{ \"name\": string|null, \"role\": string|null, \"location\": string|null, \"start\": string|null, \"end\": string|null, \"bullets\": string[], \"skills\": string[] }], \"skills\": string[], \"languages\": [{ \"name\": string|null, \"level\": string|null, \"notes\": string|null }], \"interests\": string[], \"otherSections\": [{ \"title\": string|null, \"items\": string[] }] }. Preserve facts only. Do not invent anything. If a field is unknown, use null or an empty array. Keep wording close to the source. Profile location should only be the candidate's current/home location. Education location must be the institution location. Work location must be employer/job location. Never place skill lists such as Python or Ruby into location fields. Preserve the original qualification text in education. Set qualificationLevel to the normalized enum when you can infer it, otherwise null. Put unmatched content in otherSections.",
|
||||
text,
|
||||
3200,
|
||||
900);
|
||||
@@ -720,9 +985,20 @@ public sealed class ProfileCvController : ControllerBase
|
||||
profile.Contact.Website = NullIfWhitespace(Regex.Match(rawSource, @"\b(?:https?://)?(?:www\.)?[A-Z0-9.-]+\.[A-Z]{2,}(?:/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]*)?", RegexOptions.IgnoreCase).Value);
|
||||
profile.Contact.LinkedIn = NullIfWhitespace(Regex.Match(rawSource, @"(?:linkedin(?:\.com)?/[A-Z0-9._~:/?#\[\]@!$&'()*+,;=-]+)", RegexOptions.IgnoreCase).Value);
|
||||
profile.Contact.FullName = GuessFullName(rawSource) ?? GuessFullNameFromEmail(profile.Contact.Email);
|
||||
profile.Contact.Location = NullIfWhitespace(Regex.Match(rawSource, @"\b[A-Z][a-z]+(?:[\s-][A-Z][a-z]+)*,\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*\b").Value);
|
||||
|
||||
var sections = ParseSections(normalized);
|
||||
var contactSection = sections.FirstOrDefault(section => section.Name == "Contact");
|
||||
if (!string.IsNullOrWhiteSpace(contactSection.Content))
|
||||
{
|
||||
var contactFallback = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Contact", Content = contactSection.Content } });
|
||||
profile.Contact.Location = contactFallback.Contact.Location;
|
||||
profile.Contact.Headline ??= contactFallback.Contact.Headline;
|
||||
}
|
||||
else
|
||||
{
|
||||
profile.Contact.Location = NullIfWhitespace(Regex.Match(rawSource, @"\b[A-Z][a-z]+(?:[\s-][A-Z][a-z]+)*(?:,\s*[A-Z][a-z]+(?:\s+[A-Z][a-z]+)*){1,2}\b").Value);
|
||||
}
|
||||
|
||||
var summarySection = sections.FirstOrDefault(section => section.Name == "Professional Summary");
|
||||
var flattenedSummary = Regex.Match(
|
||||
rawSource,
|
||||
@@ -777,6 +1053,18 @@ public sealed class ProfileCvController : ControllerBase
|
||||
profile.Education = ParseEducationHeuristically(educationSection.Content);
|
||||
}
|
||||
|
||||
var certificationsSection = sections.FirstOrDefault(section => section.Name == "Certifications");
|
||||
if (!string.IsNullOrWhiteSpace(certificationsSection.Content))
|
||||
{
|
||||
profile.Certifications = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Certifications", Content = certificationsSection.Content } }).Certifications;
|
||||
}
|
||||
|
||||
var projectsSection = sections.FirstOrDefault(section => section.Name == "Projects");
|
||||
if (!string.IsNullOrWhiteSpace(projectsSection.Content))
|
||||
{
|
||||
profile.Projects = StructuredCvProfileJson.FromSections(new[] { new StructuredCvSection { Name = "Projects", Content = projectsSection.Content } }).Projects;
|
||||
}
|
||||
|
||||
var experienceSection = sections.FirstOrDefault(section => section.Name == "Work Experience");
|
||||
if (!string.IsNullOrWhiteSpace(experienceSection.Content))
|
||||
{
|
||||
@@ -861,6 +1149,7 @@ public sealed class ProfileCvController : ControllerBase
|
||||
items.Add(new StructuredCvEducation
|
||||
{
|
||||
Qualification = TitleCasePreservingAcronyms(qualificationLine),
|
||||
QualificationLevel = InferQualificationLevel(qualificationLine),
|
||||
Institution = TitleCasePreservingAcronyms(institutionLine),
|
||||
Start = dateMatch.Success ? dateMatch.Groups[1].Value : null,
|
||||
End = dateMatch.Success ? dateMatch.Groups[2].Value : null,
|
||||
@@ -913,6 +1202,18 @@ public sealed class ProfileCvController : ControllerBase
|
||||
return string.Join(" ", words);
|
||||
}
|
||||
|
||||
private static string? InferQualificationLevel(string? value)
|
||||
{
|
||||
var candidate = value?.Trim();
|
||||
if (string.IsNullOrWhiteSpace(candidate)) return null;
|
||||
if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD";
|
||||
if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng)\b", RegexOptions.IgnoreCase)) return "Master";
|
||||
if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|degree)\b", RegexOptions.IgnoreCase)) return "Bachelor";
|
||||
if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate";
|
||||
if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school)\b", RegexOptions.IgnoreCase)) return "Secondary";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
private static int CountWords(string? text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text)) return 0;
|
||||
|
||||
@@ -3,9 +3,11 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
|
||||
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
|
||||
COPY Data/ Data/
|
||||
COPY Models/ Models/
|
||||
COPY JobTrackerApi/ JobTrackerApi/
|
||||
COPY JobTrackerBackend/ JobTrackerBackend/
|
||||
|
||||
RUN dotnet publish JobTrackerApi/JobTrackerApi.csproj -c Release -o /app/publish /p:UseAppHost=false
|
||||
|
||||
|
||||
@@ -10,12 +10,7 @@
|
||||
<ItemGroup>
|
||||
<Compile Remove="Controllers\**\*.cs" />
|
||||
<Compile Remove="Services\**\*.cs" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -135,6 +135,8 @@ builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
@@ -628,10 +630,35 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
"AccessTokenExpiresAt" TEXT NULL,
|
||||
"Scope" TEXT NOT NULL,
|
||||
"ConnectedAt" TEXT NOT NULL,
|
||||
"LastSyncedAt" TEXT NULL
|
||||
"LastSyncedAt" TEXT NULL,
|
||||
"LastSyncAttemptedAt" TEXT NULL,
|
||||
"LastSyncSucceededAt" TEXT NULL,
|
||||
"LastSyncMode" TEXT NULL,
|
||||
"LastSyncSource" TEXT NULL,
|
||||
"LastSyncStatus" TEXT NULL,
|
||||
"LastSyncError" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
Exec(c, """
|
||||
CREATE TABLE IF NOT EXISTS "GmailReviewDecisions" (
|
||||
"Id" INTEGER NOT NULL CONSTRAINT "PK_GmailReviewDecisions" PRIMARY KEY AUTOINCREMENT,
|
||||
"OwnerUserId" TEXT NOT NULL,
|
||||
"ThreadId" TEXT NOT NULL,
|
||||
"JobApplicationId" INTEGER NULL,
|
||||
"Decision" TEXT NOT NULL,
|
||||
"Note" TEXT NULL,
|
||||
"UpdatedAt" TEXT NOT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
|
||||
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
|
||||
}
|
||||
@@ -739,6 +766,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
|
||||
}
|
||||
|
||||
// Record the migration as applied.
|
||||
@@ -766,6 +796,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
|
||||
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
|
||||
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
|
||||
|
||||
@@ -865,6 +898,9 @@ CREATE TABLE IF NOT EXISTS "TailoredCvDrafts" (
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
|
||||
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
|
||||
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
|
||||
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
|
||||
@@ -939,6 +975,37 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "GmailConnections"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`GmailAddress` varchar(512) NOT NULL,
|
||||
`EncryptedRefreshToken` longtext NOT NULL,
|
||||
`EncryptedAccessToken` longtext NULL,
|
||||
`AccessTokenExpiresAt` datetime(6) NULL,
|
||||
`Scope` longtext NOT NULL,
|
||||
`ConnectedAt` datetime(6) NOT NULL,
|
||||
`LastSyncedAt` datetime(6) NULL,
|
||||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||||
`LastSyncSucceededAt` datetime(6) NULL,
|
||||
`LastSyncMode` varchar(255) NULL,
|
||||
`LastSyncSource` varchar(255) NULL,
|
||||
`LastSyncStatus` varchar(255) NULL,
|
||||
`LastSyncError` longtext NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -1000,6 +1067,20 @@ CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -1123,4 +1204,3 @@ app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
app.Run();
|
||||
app.Run();
|
||||
|
||||
@@ -9,6 +9,7 @@ namespace JobTrackerApi.Services
|
||||
public string AttachmentsRoot { get; }
|
||||
public string CvArtifactsRoot { get; }
|
||||
public string CvExportsRoot { get; }
|
||||
public string CvBenchmarksRoot { get; }
|
||||
|
||||
public AppPaths(IConfiguration cfg, IHostEnvironment env)
|
||||
{
|
||||
@@ -39,6 +40,13 @@ namespace JobTrackerApi.Services
|
||||
|
||||
Directory.CreateDirectory(cvExportsRoot);
|
||||
CvExportsRoot = cvExportsRoot;
|
||||
|
||||
var cvBenchmarksRoot = (cfg["Data:CvBenchmarksRoot"] ?? "").Trim();
|
||||
if (string.IsNullOrWhiteSpace(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(DataRoot, "CvBenchmarks");
|
||||
if (!Path.IsPathRooted(cvBenchmarksRoot)) cvBenchmarksRoot = Path.Combine(env.ContentRootPath, cvBenchmarksRoot);
|
||||
|
||||
Directory.CreateDirectory(cvBenchmarksRoot);
|
||||
CvBenchmarksRoot = cvBenchmarksRoot;
|
||||
}
|
||||
|
||||
public string GetDbPath(string fileName = "jobtracker.db") => Path.Combine(DataRoot, fileName);
|
||||
|
||||
@@ -24,6 +24,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
"harvard" => RenderHarvard(normalized, candidateName, jobTitle, companyName),
|
||||
"auckland" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Auckland", roundedPhoto: false, curvedHeader: false),
|
||||
"edinburgh" => RenderSidebar(normalized, candidateName, jobTitle, companyName, photoDataUrl, "Edinburgh", roundedPhoto: true, curvedHeader: true),
|
||||
"monarch" => RenderMonarch(normalized, candidateName, jobTitle, companyName, photoDataUrl),
|
||||
"fjord" => RenderFjord(normalized, candidateName, jobTitle, companyName, photoDataUrl),
|
||||
_ => RenderAtsMinimal(normalized, candidateName, jobTitle, companyName, photoDataUrl)
|
||||
};
|
||||
return new TailoredCvRenderResult(effectiveTemplateId, suggestedFileName, html);
|
||||
@@ -39,6 +41,8 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
"harvard" => "harvard",
|
||||
"auckland" => "auckland",
|
||||
"edinburgh" => "edinburgh",
|
||||
"monarch" => "monarch",
|
||||
"fjord" => "fjord",
|
||||
_ => "ats-minimal"
|
||||
};
|
||||
}
|
||||
@@ -201,6 +205,106 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderMonarch(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var photoMarkup = showPhoto ? $"<div class=\"monarch-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<div class=\"monarch-company\">Tailored toward {Encode(companyName)}</div>";
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — Monarch</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#1c1917; --muted:#57534e; --paper:#fffdf8; --panel:#f7efe6; --line:#d6c1a8; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#efe7dc; color:var(--ink); font-family:'Times New Roman', Georgia, serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:16mm; }}
|
||||
.monarch-shell {{ border:1px solid var(--line); padding:10mm; position:relative; }}
|
||||
.monarch-shell::before {{ content:''; position:absolute; inset:6mm; border:1px solid color-mix(in srgb, var(--line) 70%, white); pointer-events:none; }}
|
||||
.monarch-header {{ display:grid; grid-template-columns:1fr auto; gap:6mm; align-items:center; margin-bottom:8mm; }}
|
||||
.monarch-kicker {{ display:inline-block; text-transform:uppercase; letter-spacing:.3em; font-size:8pt; color:var(--accent); margin-bottom:2mm; }}
|
||||
.monarch-name {{ margin:0; font-size:28pt; line-height:1.05; }}
|
||||
.monarch-headline {{ margin-top:2mm; font-size:11pt; color:var(--muted); max-width:130mm; }}
|
||||
.monarch-company {{ margin-top:2mm; font-size:9pt; color:var(--accent); text-transform:uppercase; letter-spacing:.16em; }}
|
||||
.monarch-photo {{ width:30mm; height:38mm; border:1px solid var(--line); background:var(--panel); overflow:hidden; }}
|
||||
.monarch-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
.monarch-summary {{ margin-bottom:5mm; padding:4mm 5mm; background:var(--panel); border-left:3px solid var(--accent); font-size:10pt; color:var(--muted); }}
|
||||
{BaseSectionCss(accent, "harvard")}
|
||||
.section-title {{ text-transform:uppercase; letter-spacing:.12em; font-size:10pt; }}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<section class=""monarch-shell"">
|
||||
<header class=""monarch-header"">
|
||||
<div>
|
||||
<span class=""monarch-kicker"">Executive CV</span>
|
||||
<h1 class=""monarch-name"">{Encode(candidateName)}</h1>
|
||||
<div class=""monarch-headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
{companyMarkup}
|
||||
</div>
|
||||
{photoMarkup}
|
||||
</header>
|
||||
{(!string.IsNullOrWhiteSpace(jobTitle) ? $"<div class=\"monarch-summary\">Primary role target: {Encode(jobTitle)}</div>" : string.Empty)}
|
||||
{body}
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderFjord(TailoredCvDocument document, string candidateName, string jobTitle, string? companyName, string? photoDataUrl)
|
||||
{
|
||||
var accent = ResolveAccent(document.RenderOptions.AccentColor);
|
||||
var showPhoto = document.RenderOptions.ShowPhoto && !string.IsNullOrWhiteSpace(photoDataUrl);
|
||||
var body = RenderMainSections(document, accent, headingStyle: "sidebar");
|
||||
var photoMarkup = showPhoto ? $"<div class=\"fjord-photo\"><img src=\"{EncodeAttribute(photoDataUrl)}\" alt=\"Profile photo\" /></div>" : string.Empty;
|
||||
var companyMarkup = string.IsNullOrWhiteSpace(companyName) ? string.Empty : $"<span>{Encode(companyName)}</span>";
|
||||
return $@"<!DOCTYPE html>
|
||||
<html lang=""en"">
|
||||
<head>
|
||||
<meta charset=""utf-8"" />
|
||||
<title>{Encode(candidateName)} — Fjord</title>
|
||||
<style>
|
||||
:root {{ --accent:{accent}; --ink:#102a43; --muted:#486581; --panel:#e6f1f3; --line:#9fb3c8; --paper:#fbfdff; }}
|
||||
* {{ box-sizing:border-box; }}
|
||||
body {{ margin:0; background:#d9e8ef; color:var(--ink); font-family:Arial, Helvetica, sans-serif; -webkit-print-color-adjust:exact; print-color-adjust:exact; }}
|
||||
.page {{ width:210mm; min-height:297mm; margin:0 auto; background:var(--paper); padding:0; }}
|
||||
.fjord-grid {{ display:grid; grid-template-columns:72mm 1fr; min-height:297mm; }}
|
||||
.fjord-rail {{ background:linear-gradient(180deg, var(--accent), color-mix(in srgb, var(--accent) 15%, white)); color:white; padding:16mm 8mm; }}
|
||||
.fjord-name {{ margin:0; font-size:21pt; line-height:1.08; }}
|
||||
.fjord-headline {{ margin-top:2mm; font-size:10pt; opacity:.95; }}
|
||||
.fjord-meta {{ margin-top:4mm; font-size:8.5pt; display:flex; flex-direction:column; gap:1.2mm; opacity:.9; }}
|
||||
.fjord-photo {{ width:28mm; height:28mm; border-radius:50%; overflow:hidden; border:2px solid rgba(255,255,255,.65); margin-top:6mm; }}
|
||||
.fjord-photo img {{ width:100%; height:100%; object-fit:cover; display:block; }}
|
||||
.fjord-main {{ padding:14mm 14mm 14mm 10mm; }}
|
||||
{BaseSectionCss(accent, "sidebar")}
|
||||
.section {{ margin-top:5mm; }}
|
||||
.skills {{ gap:1.5mm; }}
|
||||
.skill-pill {{ background:var(--panel); border-color:transparent; color:var(--ink); }}
|
||||
@page {{ size:A4; margin:0; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main class=""page"">
|
||||
<section class=""fjord-grid"">
|
||||
<aside class=""fjord-rail"">
|
||||
<h1 class=""fjord-name"">{Encode(candidateName)}</h1>
|
||||
<div class=""fjord-headline"">{Encode(document.Headline ?? jobTitle)}</div>
|
||||
<div class=""fjord-meta""><span>{Encode(jobTitle)}</span>{companyMarkup}<span>Template: Fjord</span></div>
|
||||
{photoMarkup}
|
||||
</aside>
|
||||
<section class=""fjord-main"">{body}</section>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>";
|
||||
}
|
||||
|
||||
private static string RenderMainSections(TailoredCvDocument document, string accent, string headingStyle)
|
||||
{
|
||||
var sectionOrder = document.RenderOptions.SectionOrder.Count == 0
|
||||
@@ -291,7 +395,10 @@ public sealed class CvTemplateRenderer : ICvTemplateRenderer
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Select(Encode));
|
||||
items.Append("<article class=\"entry\">");
|
||||
items.Append($"<div class=\"entry-title\">{Encode(entry.Qualification)}</div>");
|
||||
var title = string.IsNullOrWhiteSpace(entry.QualificationLevel)
|
||||
? entry.Qualification
|
||||
: $"{entry.Qualification} ({entry.QualificationLevel})";
|
||||
items.Append($"<div class=\"entry-title\">{Encode(title)}</div>");
|
||||
if (!string.IsNullOrWhiteSpace(subtitle)) items.Append($"<div class=\"entry-subtitle\">{subtitle}</div>");
|
||||
if (entry.Details.Count > 0) items.Append($"<ul class=\"education-list\">{string.Join(string.Empty, entry.Details.Select(detail => $"<li>{Encode(detail)}</li>"))}</ul>");
|
||||
items.Append("</article>");
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record GmailSemanticMatchCandidate(
|
||||
int? JobApplicationId,
|
||||
string? Confidence,
|
||||
string? Reason,
|
||||
IReadOnlyList<string>? ExtractedCompanies,
|
||||
IReadOnlyList<string>? ExtractedRecruiters,
|
||||
IReadOnlyList<string>? ExtractedRoles,
|
||||
IReadOnlyList<string>? ExtractedHints);
|
||||
|
||||
public interface IGmailCorrespondenceEnrichmentService
|
||||
{
|
||||
Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class NoOpGmailCorrespondenceEnrichmentService : IGmailCorrespondenceEnrichmentService
|
||||
{
|
||||
public Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<GmailSemanticMatchCandidate?>(null);
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record GmailMatchReason(string Label, string Value, int Points);
|
||||
public sealed record GmailScoredMessageResult(
|
||||
GmailMessageSummary Message,
|
||||
bool AlreadyImported,
|
||||
int Score,
|
||||
string Confidence,
|
||||
IReadOnlyList<string> MatchedQueries,
|
||||
IReadOnlyList<GmailMatchReason> Reasons);
|
||||
|
||||
public interface IGmailJobMatchingService
|
||||
{
|
||||
IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride);
|
||||
GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported);
|
||||
}
|
||||
|
||||
public sealed class GmailJobMatchingService : IGmailJobMatchingService
|
||||
{
|
||||
public IReadOnlyList<string> BuildJobQueries(JobApplication job, string? queryOverride)
|
||||
{
|
||||
var queries = new List<string>();
|
||||
void Add(string? query)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
queries.Add(query.Trim());
|
||||
}
|
||||
}
|
||||
|
||||
Add(queryOverride);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail))
|
||||
Add($"(from:{job.Company.RecruiterEmail.Trim()} OR to:{job.Company.RecruiterEmail.Trim()}) newer_than:365d");
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName))
|
||||
Add($"\"{job.Company.RecruiterName.Trim()}\" newer_than:365d");
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && !string.IsNullOrWhiteSpace(job.JobTitle))
|
||||
Add($"\"{job.Company.Name.Trim()}\" \"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name))
|
||||
Add($"\"{job.Company.Name.Trim()}\" (application OR interview OR recruiter OR role OR position) newer_than:365d");
|
||||
if (!string.IsNullOrWhiteSpace(job.JobTitle))
|
||||
Add($"subject:\"{job.JobTitle.Trim()}\" newer_than:365d");
|
||||
|
||||
foreach (var subject in job.Messages
|
||||
.Select(message => message.Subject)
|
||||
.Where(subject => !string.IsNullOrWhiteSpace(subject))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2))
|
||||
{
|
||||
Add($"subject:\"{subject!.Trim()}\" newer_than:365d");
|
||||
}
|
||||
|
||||
if (queries.Count == 0)
|
||||
Add("newer_than:365d (application OR interview OR recruiter OR role OR position)");
|
||||
|
||||
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
}
|
||||
|
||||
public GmailScoredMessageResult ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
|
||||
{
|
||||
var reasons = new List<GmailMatchReason>();
|
||||
var score = 0;
|
||||
var message = candidate.Message;
|
||||
var subject = message.Subject ?? string.Empty;
|
||||
var from = message.From ?? string.Empty;
|
||||
var to = message.To ?? string.Empty;
|
||||
var snippet = message.Snippet ?? string.Empty;
|
||||
var haystack = $"{subject} {from} {to} {snippet}";
|
||||
|
||||
if (candidate.MatchedQueries.Count > 0)
|
||||
{
|
||||
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
|
||||
score += queryHitPoints;
|
||||
reasons.Add(new GmailMatchReason("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
|
||||
{
|
||||
score += 18;
|
||||
reasons.Add(new GmailMatchReason("company", job.Company.Name.Trim(), 18));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
|
||||
{
|
||||
score += 20;
|
||||
reasons.Add(new GmailMatchReason("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
|
||||
{
|
||||
score += 12;
|
||||
reasons.Add(new GmailMatchReason("recruiter", job.Company.RecruiterName.Trim(), 12));
|
||||
}
|
||||
|
||||
foreach (var token in SplitTerms(job.JobTitle).Take(4))
|
||||
{
|
||||
if (!ContainsValue(haystack, token)) continue;
|
||||
score += 5;
|
||||
reasons.Add(new GmailMatchReason("jobTitle", token, 5));
|
||||
}
|
||||
|
||||
foreach (var subjectLine in job.Messages
|
||||
.Select(existing => existing.Subject)
|
||||
.Where(existing => !string.IsNullOrWhiteSpace(existing))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(2))
|
||||
{
|
||||
if (!ContainsValue(subject, subjectLine!)) continue;
|
||||
score += 8;
|
||||
reasons.Add(new GmailMatchReason("existingSubject", subjectLine!.Trim(), 8));
|
||||
}
|
||||
|
||||
if (message.Date is { } messageDate)
|
||||
{
|
||||
var ageDays = Math.Abs((DateTimeOffset.UtcNow - messageDate).TotalDays);
|
||||
if (ageDays <= 45)
|
||||
{
|
||||
score += 4;
|
||||
reasons.Add(new GmailMatchReason("recency", "45d", 4));
|
||||
}
|
||||
else if (ageDays <= 180)
|
||||
{
|
||||
score += 2;
|
||||
reasons.Add(new GmailMatchReason("recency", "180d", 2));
|
||||
}
|
||||
}
|
||||
|
||||
if (threadAlreadyImported && !alreadyImported)
|
||||
reasons.Add(new GmailMatchReason("status", "thread-already-imported", 0));
|
||||
if (alreadyImported)
|
||||
reasons.Add(new GmailMatchReason("status", "already-imported", 0));
|
||||
|
||||
reasons = reasons
|
||||
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
|
||||
.Select(group => group.First())
|
||||
.OrderByDescending(reason => reason.Points)
|
||||
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
|
||||
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
return new GmailScoredMessageResult(message, alreadyImported, score, ToConfidence(score), candidate.MatchedQueries, reasons);
|
||||
}
|
||||
|
||||
private static bool ContainsValue(string haystack, string? value)
|
||||
=> !string.IsNullOrWhiteSpace(value) && haystack.Contains(value.Trim(), StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
private static IEnumerable<string> SplitTerms(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) yield break;
|
||||
foreach (var token in value.Split(new[] { ' ', '/', '-', ',', '.', '(', ')', ':' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(token => token.Length >= 3)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
yield return token;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ToConfidence(int score) => score switch
|
||||
{
|
||||
>= 30 => "high",
|
||||
>= 16 => "medium",
|
||||
_ => "low"
|
||||
};
|
||||
}
|
||||
@@ -27,7 +27,8 @@ public interface IGmailOAuthService
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
|
||||
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
|
||||
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
|
||||
public sealed record GmailMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GmailAttachmentId, bool Inline);
|
||||
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<GmailMessageAttachment> Attachments);
|
||||
|
||||
internal sealed class GmailTokenResponse
|
||||
{
|
||||
@@ -116,6 +117,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
|
||||
existing.Scope = tokens.scope?.Trim() ?? Scope;
|
||||
existing.ConnectedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncStatus = "connected";
|
||||
existing.LastSyncSource = "oauth-callback";
|
||||
existing.LastSyncMode = "connect";
|
||||
existing.LastSyncError = null;
|
||||
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
|
||||
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
return new GmailOAuthExchangeResult(existing.GmailAddress);
|
||||
@@ -148,40 +155,49 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
|
||||
{
|
||||
maxResults = Math.Clamp(maxResults, 1, 25);
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
try
|
||||
{
|
||||
url += $"&q={Uri.EscapeDataString(query.Trim())}";
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
|
||||
if (!string.IsNullOrWhiteSpace(query))
|
||||
{
|
||||
url += $"&q={Uri.EscapeDataString(query.Trim())}";
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var ids = messagesElement.EnumerateArray()
|
||||
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var results = new List<GmailMessageSummary>(ids.Count);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
|
||||
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
|
||||
}
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
|
||||
var ids = messagesElement.EnumerateArray()
|
||||
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
|
||||
.Where(x => !string.IsNullOrWhiteSpace(x))
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
var results = new List<GmailMessageSummary>(ids.Count);
|
||||
foreach (var id in ids)
|
||||
{
|
||||
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
|
||||
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
|
||||
}
|
||||
|
||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
|
||||
@@ -233,93 +249,117 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
try
|
||||
{
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var results = new List<GmailMessageSummary>();
|
||||
foreach (var messageElement in messagesElement.EnumerateArray())
|
||||
{
|
||||
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
|
||||
? messageThreadIdEl.GetString() ?? threadId.Trim()
|
||||
: threadId.Trim();
|
||||
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
|
||||
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
|
||||
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
DateTimeOffset? date = null;
|
||||
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
|
||||
{
|
||||
date = parsedDate;
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
|
||||
return Array.Empty<GmailMessageSummary>();
|
||||
}
|
||||
|
||||
results.Add(new GmailMessageSummary(
|
||||
id.Trim(),
|
||||
messageThreadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
|
||||
headers.TryGetValue("from", out var from) ? from : string.Empty,
|
||||
headers.TryGetValue("to", out var to) ? to : string.Empty,
|
||||
date,
|
||||
snippet));
|
||||
}
|
||||
var results = new List<GmailMessageSummary>();
|
||||
foreach (var messageElement in messagesElement.EnumerateArray())
|
||||
{
|
||||
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
|
||||
if (string.IsNullOrWhiteSpace(id)) continue;
|
||||
|
||||
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
|
||||
return results;
|
||||
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
|
||||
? messageThreadIdEl.GetString() ?? threadId.Trim()
|
||||
: threadId.Trim();
|
||||
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
|
||||
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
|
||||
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
DateTimeOffset? date = null;
|
||||
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
|
||||
{
|
||||
date = parsedDate;
|
||||
}
|
||||
|
||||
results.Add(new GmailMessageSummary(
|
||||
id.Trim(),
|
||||
messageThreadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
|
||||
headers.TryGetValue("from", out var from) ? from : string.Empty,
|
||||
headers.TryGetValue("to", out var to) ? to : string.Empty,
|
||||
date,
|
||||
snippet));
|
||||
}
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
|
||||
return results;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
|
||||
{
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
|
||||
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
|
||||
|
||||
var payload = root.GetProperty("payload");
|
||||
var headers = ReadHeaders(payload);
|
||||
var bodyText = ExtractBody(payload, "text/plain");
|
||||
var bodyHtml = ExtractBody(payload, "text/html");
|
||||
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
|
||||
try
|
||||
{
|
||||
bodyText = StripHtml(bodyHtml);
|
||||
}
|
||||
else if (LooksLikeHtml(bodyText))
|
||||
{
|
||||
bodyText = StripHtml(bodyText);
|
||||
}
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
|
||||
return new GmailMessageDetail(
|
||||
messageId,
|
||||
threadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : "",
|
||||
headers.TryGetValue("from", out var from) ? from : "",
|
||||
headers.TryGetValue("to", out var to) ? to : "",
|
||||
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
|
||||
snippet,
|
||||
bodyText.Trim(),
|
||||
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
|
||||
);
|
||||
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
|
||||
using var response = await client.GetAsync(url, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
var root = doc.RootElement;
|
||||
|
||||
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
|
||||
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
|
||||
var labels = root.TryGetProperty("labelIds", out var labelIdsEl) && labelIdsEl.ValueKind == JsonValueKind.Array
|
||||
? labelIdsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
|
||||
: new List<string>();
|
||||
|
||||
var payload = root.GetProperty("payload");
|
||||
var headers = ReadHeaders(payload);
|
||||
var attachments = ReadAttachments(payload);
|
||||
var bodyText = ExtractBody(payload, "text/plain");
|
||||
var bodyHtml = ExtractBody(payload, "text/html");
|
||||
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
|
||||
{
|
||||
bodyText = StripHtml(bodyHtml);
|
||||
}
|
||||
else if (LooksLikeHtml(bodyText))
|
||||
{
|
||||
bodyText = StripHtml(bodyText);
|
||||
}
|
||||
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", true, null, cancellationToken);
|
||||
return new GmailMessageDetail(
|
||||
messageId,
|
||||
threadId,
|
||||
headers.TryGetValue("subject", out var subject) ? subject : "",
|
||||
headers.TryGetValue("from", out var from) ? from : "",
|
||||
headers.TryGetValue("to", out var to) ? to : "",
|
||||
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
|
||||
snippet,
|
||||
bodyText.Trim(),
|
||||
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml,
|
||||
labels,
|
||||
attachments
|
||||
);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", false, ex.Message, cancellationToken);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
@@ -435,13 +475,37 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
}
|
||||
|
||||
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
|
||||
{
|
||||
await TouchSyncStateAsync(ownerUserId, "sync", "gmail", true, null, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null) return;
|
||||
connection.LastSyncedAt = DateTimeOffset.UtcNow;
|
||||
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
connection.LastSyncAttemptedAt = now;
|
||||
connection.LastSyncMode = mode;
|
||||
connection.LastSyncSource = source;
|
||||
connection.LastSyncStatus = succeeded ? "success" : "error";
|
||||
connection.LastSyncError = succeeded ? null : TrimError(error);
|
||||
if (succeeded)
|
||||
{
|
||||
connection.LastSyncedAt = now;
|
||||
connection.LastSyncSucceededAt = now;
|
||||
}
|
||||
|
||||
await _db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private static string? TrimError(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return null;
|
||||
var trimmed = value.Trim();
|
||||
return trimmed.Length <= 300 ? trimmed : trimmed[..300];
|
||||
}
|
||||
|
||||
private string GetRequiredClientId()
|
||||
{
|
||||
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch
|
||||
@@ -481,6 +545,48 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<GmailMessageAttachment> ReadAttachments(JsonElement payload)
|
||||
{
|
||||
var results = new List<GmailMessageAttachment>();
|
||||
ReadAttachmentsRecursive(payload, results);
|
||||
return results;
|
||||
}
|
||||
|
||||
private static void ReadAttachmentsRecursive(JsonElement payload, List<GmailMessageAttachment> results)
|
||||
{
|
||||
var body = payload.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object
|
||||
? bodyEl
|
||||
: default;
|
||||
var gmailAttachmentId = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("attachmentId", out var attachmentIdEl) && attachmentIdEl.ValueKind == JsonValueKind.String
|
||||
? attachmentIdEl.GetString()
|
||||
: null;
|
||||
var filename = payload.TryGetProperty("filename", out var filenameEl) ? filenameEl.GetString() : null;
|
||||
var mimeType = payload.TryGetProperty("mimeType", out var mimeTypeEl) ? mimeTypeEl.GetString() : null;
|
||||
var sizeBytes = body.ValueKind == JsonValueKind.Object && body.TryGetProperty("size", out var sizeEl) && sizeEl.ValueKind == JsonValueKind.Number
|
||||
? sizeEl.GetInt64()
|
||||
: (long?)null;
|
||||
var disposition = payload.TryGetProperty("headers", out var headersEl) && headersEl.ValueKind == JsonValueKind.Array
|
||||
? headersEl.EnumerateArray()
|
||||
.Where(h => h.TryGetProperty("name", out var n) && string.Equals(n.GetString(), "Content-Disposition", StringComparison.OrdinalIgnoreCase))
|
||||
.Select(h => h.TryGetProperty("value", out var v) ? v.GetString() : null)
|
||||
.FirstOrDefault()
|
||||
: null;
|
||||
var isInline = !string.IsNullOrWhiteSpace(disposition) && disposition.Contains("inline", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(gmailAttachmentId) || !string.IsNullOrWhiteSpace(filename))
|
||||
{
|
||||
results.Add(new GmailMessageAttachment(filename, mimeType, sizeBytes, gmailAttachmentId, isInline));
|
||||
}
|
||||
|
||||
if (payload.TryGetProperty("parts", out var partsEl) && partsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var part in partsEl.EnumerateArray())
|
||||
{
|
||||
ReadAttachmentsRecursive(part, results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string ExtractBody(JsonElement payload, string mimeType)
|
||||
{
|
||||
if (payload.TryGetProperty("mimeType", out var mimeTypeEl) &&
|
||||
|
||||
@@ -25,6 +25,10 @@ namespace JobTrackerApi.Services
|
||||
bool? OllamaReachable,
|
||||
string? OllamaModel,
|
||||
bool? OllamaModelAvailable,
|
||||
string? OllamaVersion,
|
||||
IReadOnlyList<string>? OllamaInstalledModels,
|
||||
IReadOnlyList<string>? OllamaLoadedModels,
|
||||
int? OllamaLoadedCount,
|
||||
double? HealthLatencyMs,
|
||||
double? ProbeLatencyMs,
|
||||
DateTimeOffset? LastProbeAt,
|
||||
@@ -66,6 +70,7 @@ namespace JobTrackerApi.Services
|
||||
|
||||
public interface ISummarizerService : IAiService
|
||||
{
|
||||
new Task<string?> SummarizeSectionAsync(string instruction, string text, int maxLength = 180, int minLength = 40);
|
||||
}
|
||||
|
||||
public class SummarizerService : ISummarizerService
|
||||
@@ -318,6 +323,10 @@ namespace JobTrackerApi.Services
|
||||
bool? ollamaReachable = null;
|
||||
string? ollamaModel = null;
|
||||
bool? ollamaModelAvailable = null;
|
||||
string? ollamaVersion = null;
|
||||
List<string>? ollamaInstalledModels = null;
|
||||
List<string>? ollamaLoadedModels = null;
|
||||
int? ollamaLoadedCount = null;
|
||||
double? healthLatencyMs = null;
|
||||
var healthy = false;
|
||||
string? healthError = null;
|
||||
@@ -344,6 +353,16 @@ namespace JobTrackerApi.Services
|
||||
if (doc.RootElement.TryGetProperty("ollama_reachable", out var ollamaReachableEl) && ollamaReachableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaReachable = ollamaReachableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model", out var ollamaModelEl)) ollamaModel = ollamaModelEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_model_available", out var ollamaModelAvailableEl) && ollamaModelAvailableEl.ValueKind is JsonValueKind.True or JsonValueKind.False) ollamaModelAvailable = ollamaModelAvailableEl.GetBoolean();
|
||||
if (doc.RootElement.TryGetProperty("ollama_version", out var ollamaVersionEl)) ollamaVersion = ollamaVersionEl.GetString();
|
||||
if (doc.RootElement.TryGetProperty("ollama_installed_models", out var ollamaInstalledModelsEl) && ollamaInstalledModelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ollamaInstalledModels = ollamaInstalledModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_models", out var ollamaLoadedModelsEl) && ollamaLoadedModelsEl.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
ollamaLoadedModels = ollamaLoadedModelsEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList();
|
||||
}
|
||||
if (doc.RootElement.TryGetProperty("ollama_loaded_count", out var ollamaLoadedCountEl) && ollamaLoadedCountEl.ValueKind == JsonValueKind.Number) ollamaLoadedCount = ollamaLoadedCountEl.GetInt32();
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -406,6 +425,10 @@ namespace JobTrackerApi.Services
|
||||
OllamaReachable: ollamaReachable,
|
||||
OllamaModel: ollamaModel,
|
||||
OllamaModelAvailable: ollamaModelAvailable,
|
||||
OllamaVersion: ollamaVersion,
|
||||
OllamaInstalledModels: ollamaInstalledModels,
|
||||
OllamaLoadedModels: ollamaLoadedModels,
|
||||
OllamaLoadedCount: ollamaLoadedCount,
|
||||
HealthLatencyMs: healthLatencyMs,
|
||||
ProbeLatencyMs: probeLatencyMs,
|
||||
LastProbeAt: lastProbeAt,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace JobTrackerApi.Models
|
||||
@@ -11,13 +12,35 @@ namespace JobTrackerApi.Models
|
||||
[JsonIgnore]
|
||||
public JobApplication JobApplication { get; set; } = null!;
|
||||
public string From { get; set; } = ""; // "Me" or "Company"
|
||||
public string? Direction { get; set; } // inbound, outbound, internal, unknown
|
||||
public string? Subject { get; set; }
|
||||
public string? Channel { get; set; } // e.g. Email, Call, Note
|
||||
public string? ExternalMessageId { get; set; }
|
||||
public string? ExternalThreadId { get; set; }
|
||||
public string? ExternalFrom { get; set; }
|
||||
public string? ExternalTo { get; set; }
|
||||
public string? ExternalLabelsJson { get; set; }
|
||||
public string? AttachmentMetadataJson { get; set; }
|
||||
public string Content { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.Now;
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<string> ExternalLabels => string.IsNullOrWhiteSpace(ExternalLabelsJson)
|
||||
? Array.Empty<string>()
|
||||
: (System.Text.Json.JsonSerializer.Deserialize<List<string>>(ExternalLabelsJson) ?? new List<string>());
|
||||
|
||||
[JsonIgnore]
|
||||
public IReadOnlyList<CorrespondenceAttachmentMetadata> AttachmentMetadata => string.IsNullOrWhiteSpace(AttachmentMetadataJson)
|
||||
? Array.Empty<CorrespondenceAttachmentMetadata>()
|
||||
: (System.Text.Json.JsonSerializer.Deserialize<List<CorrespondenceAttachmentMetadata>>(AttachmentMetadataJson) ?? new List<CorrespondenceAttachmentMetadata>());
|
||||
}
|
||||
|
||||
public sealed class CorrespondenceAttachmentMetadata
|
||||
{
|
||||
public string? FileName { get; set; }
|
||||
public string? MimeType { get; set; }
|
||||
public long? SizeBytes { get; set; }
|
||||
public string? GmailAttachmentId { get; set; }
|
||||
public bool Inline { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,4 +11,10 @@ public sealed class GmailConnection
|
||||
public string Scope { get; set; } = "";
|
||||
public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
public DateTimeOffset? LastSyncedAt { get; set; }
|
||||
public DateTimeOffset? LastSyncAttemptedAt { get; set; }
|
||||
public DateTimeOffset? LastSyncSucceededAt { get; set; }
|
||||
public string? LastSyncMode { get; set; }
|
||||
public string? LastSyncSource { get; set; }
|
||||
public string? LastSyncStatus { get; set; }
|
||||
public string? LastSyncError { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed class GmailReviewDecision
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string OwnerUserId { get; set; } = "";
|
||||
public string ThreadId { get; set; } = "";
|
||||
public int? JobApplicationId { get; set; }
|
||||
public string Decision { get; set; } = "review"; // review, linked, rejected, suggested
|
||||
public string? Note { get; set; }
|
||||
public DateTimeOffset UpdatedAt { get; set; } = DateTimeOffset.UtcNow;
|
||||
}
|
||||
@@ -8,6 +8,8 @@ public sealed class StructuredCvProfile
|
||||
public List<string> Summary { get; set; } = new();
|
||||
public List<StructuredCvJob> Jobs { get; set; } = new();
|
||||
public List<StructuredCvEducation> Education { get; set; } = new();
|
||||
public List<StructuredCvCertification> Certifications { get; set; } = new();
|
||||
public List<StructuredCvProject> Projects { get; set; } = new();
|
||||
public List<string> Skills { get; set; } = new();
|
||||
public List<StructuredCvLanguage> Languages { get; set; } = new();
|
||||
public List<string> Interests { get; set; } = new();
|
||||
@@ -60,6 +62,7 @@ public sealed class StructuredCvJob
|
||||
public sealed class StructuredCvEducation
|
||||
{
|
||||
public string? Qualification { get; set; }
|
||||
public string? QualificationLevel { get; set; }
|
||||
public string? Institution { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
@@ -67,6 +70,26 @@ public sealed class StructuredCvEducation
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvCertification
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Issuer { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Date { get; set; }
|
||||
public List<string> Details { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvProject
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
public string? Role { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
public string? End { get; set; }
|
||||
public List<string> Bullets { get; set; } = new();
|
||||
public List<string> Skills { get; set; } = new();
|
||||
}
|
||||
|
||||
public sealed class StructuredCvLanguage
|
||||
{
|
||||
public string? Name { get; set; }
|
||||
|
||||
@@ -67,6 +67,8 @@ public static class StructuredCvProfileJson
|
||||
: primary.Summary.Concat(secondary.Summary).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
if (primary.Jobs.Count == 0) primary.Jobs = secondary.Jobs;
|
||||
if (primary.Education.Count == 0) primary.Education = secondary.Education;
|
||||
if (primary.Certifications.Count == 0) primary.Certifications = secondary.Certifications;
|
||||
if (primary.Projects.Count == 0) primary.Projects = secondary.Projects;
|
||||
primary.Skills = primary.Skills.Count == 0
|
||||
? secondary.Skills
|
||||
: primary.Skills.Concat(secondary.Skills).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||||
@@ -132,6 +134,14 @@ public static class StructuredCvProfileJson
|
||||
case "education":
|
||||
profile.Education = ParseEducation(section.Content);
|
||||
break;
|
||||
case "certifications":
|
||||
case "certificates":
|
||||
profile.Certifications = ParseCertifications(section.Content);
|
||||
break;
|
||||
case "projects":
|
||||
case "selected projects":
|
||||
profile.Projects = ParseProjects(section.Content);
|
||||
break;
|
||||
default:
|
||||
profile.OtherSections.Add(new StructuredCvOtherSection
|
||||
{
|
||||
@@ -165,6 +175,18 @@ public static class StructuredCvProfileJson
|
||||
|| !string.IsNullOrWhiteSpace(education.Institution)
|
||||
|| education.Details.Count > 0)
|
||||
.ToList();
|
||||
profile.Certifications = (profile.Certifications ?? new List<StructuredCvCertification>())
|
||||
.Select(NormalizeCertification)
|
||||
.Where(certification => !string.IsNullOrWhiteSpace(certification.Name)
|
||||
|| !string.IsNullOrWhiteSpace(certification.Issuer)
|
||||
|| certification.Details.Count > 0)
|
||||
.ToList();
|
||||
profile.Projects = (profile.Projects ?? new List<StructuredCvProject>())
|
||||
.Select(NormalizeProject)
|
||||
.Where(project => !string.IsNullOrWhiteSpace(project.Name)
|
||||
|| !string.IsNullOrWhiteSpace(project.Role)
|
||||
|| project.Bullets.Count > 0)
|
||||
.ToList();
|
||||
profile.Skills = CleanList(profile.Skills);
|
||||
profile.Languages = (profile.Languages ?? new List<StructuredCvLanguage>())
|
||||
.Select(NormalizeLanguage)
|
||||
@@ -299,6 +321,8 @@ public static class StructuredCvProfileJson
|
||||
if (trimmed.Any(char.IsDigit) || trimmed.Length > 80) return null;
|
||||
|
||||
var normalized = Regex.Replace(trimmed, @"\s+[A-Z](?:\s+[A-Z]){2,}(?:\b.*)?$", string.Empty).Trim();
|
||||
normalized = Regex.Replace(normalized, @"\b(?:remote|hybrid)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
normalized = Regex.Replace(normalized, @"\b(?:sales representative|developer|engineer|manager|consultant|analyst|designer|specialist|technician)\b.*$", string.Empty, RegexOptions.IgnoreCase).Trim();
|
||||
normalized = Regex.Replace(normalized, @"\s+", " ").Trim(' ', '|', ';', ':');
|
||||
var parts = normalized.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
if (parts.Length == 0 || parts.Length > 4) return null;
|
||||
@@ -421,10 +445,24 @@ public static class StructuredCvProfileJson
|
||||
return string.IsNullOrWhiteSpace(trimmed) ? null : trimmed;
|
||||
}
|
||||
|
||||
private static string? NormalizeQualificationLevel(string? explicitValue, string? qualificationText)
|
||||
{
|
||||
var candidate = TrimOrNull(explicitValue) ?? TrimOrNull(qualificationText);
|
||||
if (candidate is null) return null;
|
||||
|
||||
if (Regex.IsMatch(candidate, @"\b(phd|doctorate|dphil)\b", RegexOptions.IgnoreCase)) return "PhD";
|
||||
if (Regex.IsMatch(candidate, @"\b(master(?:'s)?|msc|m\.sc|ma|m\.a|mba|meng|meng)\b", RegexOptions.IgnoreCase)) return "Master";
|
||||
if (Regex.IsMatch(candidate, @"\b(bachelor(?:'s)?|bsc|b\.sc|ba|b\.a|beng|llb|undergraduate degree)\b", RegexOptions.IgnoreCase)) return "Bachelor";
|
||||
if (Regex.IsMatch(candidate, @"\b(diploma|certificate|certification|nvq|btec|level\s*\d+|apprenticeship|associate degree)\b", RegexOptions.IgnoreCase)) return "Diploma/Certificate";
|
||||
if (Regex.IsMatch(candidate, @"\b(gcse|a-?level|secondary|high school|gymnasium)\b", RegexOptions.IgnoreCase)) return "Secondary";
|
||||
return "Other";
|
||||
}
|
||||
|
||||
private static StructuredCvEducation NormalizeEducation(StructuredCvEducation? education)
|
||||
{
|
||||
education ??= new StructuredCvEducation();
|
||||
education.Qualification = NormalizeQualification(education.Qualification);
|
||||
education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification);
|
||||
education.Institution = NormalizeInstitution(education.Institution);
|
||||
education.Location = NormalizeLocationValue(education.Location);
|
||||
education.Start = NormalizeDateValue(education.Start);
|
||||
@@ -438,12 +476,41 @@ public static class StructuredCvProfileJson
|
||||
if (qualificationLooksInstitutional && institutionLooksQualification)
|
||||
{
|
||||
(education.Qualification, education.Institution) = (education.Institution, education.Qualification);
|
||||
education.QualificationLevel = NormalizeQualificationLevel(education.QualificationLevel, education.Qualification);
|
||||
}
|
||||
}
|
||||
|
||||
return education;
|
||||
}
|
||||
|
||||
private static StructuredCvCertification NormalizeCertification(StructuredCvCertification? certification)
|
||||
{
|
||||
certification ??= new StructuredCvCertification();
|
||||
certification.Name = NormalizeQualification(certification.Name);
|
||||
certification.Issuer = NormalizeInstitution(certification.Issuer);
|
||||
certification.Location = NormalizeLocationValue(certification.Location);
|
||||
certification.Date = NormalizeDateValue(certification.Date);
|
||||
certification.Details = CleanList(certification.Details);
|
||||
return certification;
|
||||
}
|
||||
|
||||
private static StructuredCvProject NormalizeProject(StructuredCvProject? project)
|
||||
{
|
||||
project ??= new StructuredCvProject();
|
||||
project.Name = NormalizeQualification(project.Name);
|
||||
project.Role = NormalizeJobTitle(project.Role);
|
||||
project.Location = NormalizeLocationValue(project.Location);
|
||||
project.Start = NormalizeDateValue(project.Start);
|
||||
project.End = NormalizeDateValue(project.End);
|
||||
project.Bullets = CleanList(project.Bullets)
|
||||
.Select(NormalizeBullet)
|
||||
.Where(bullet => bullet is not null)
|
||||
.Select(bullet => bullet!)
|
||||
.ToList();
|
||||
project.Skills = CleanList(project.Skills);
|
||||
return project;
|
||||
}
|
||||
|
||||
private static StructuredCvLanguage NormalizeLanguage(StructuredCvLanguage? language)
|
||||
{
|
||||
language ??= new StructuredCvLanguage();
|
||||
@@ -512,12 +579,42 @@ public static class StructuredCvProfileJson
|
||||
AddIf(lines, $"### {education.Qualification}".Trim());
|
||||
var meta = string.Join(" | ", new[] { education.Institution, education.Location, FormatDateRange(education.Start, education.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddIf(lines, meta);
|
||||
if (!string.IsNullOrWhiteSpace(education.QualificationLevel)) AddIf(lines, $"Level: {education.QualificationLevel}");
|
||||
lines.AddRange(education.Details.Select(detail => $"- {detail}"));
|
||||
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
|
||||
}
|
||||
AddSectionIfAny(sections, "Education", lines);
|
||||
}
|
||||
|
||||
if (profile.Certifications.Count > 0)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var certification in profile.Certifications)
|
||||
{
|
||||
AddIf(lines, $"### {certification.Name}".Trim());
|
||||
var meta = string.Join(" | ", new[] { certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddIf(lines, meta);
|
||||
lines.AddRange(certification.Details.Select(detail => $"- {detail}"));
|
||||
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
|
||||
}
|
||||
AddSectionIfAny(sections, "Certifications", lines);
|
||||
}
|
||||
|
||||
if (profile.Projects.Count > 0)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
foreach (var project in profile.Projects)
|
||||
{
|
||||
AddIf(lines, $"### {project.Name}".Trim());
|
||||
var meta = string.Join(" | ", new[] { project.Role, project.Location, FormatDateRange(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
AddIf(lines, meta);
|
||||
lines.AddRange(project.Bullets.Select(bullet => $"- {bullet}"));
|
||||
if (project.Skills.Count > 0) AddIf(lines, $"Skills: {string.Join(", ", project.Skills)}");
|
||||
if (lines.Count > 0 && !string.IsNullOrWhiteSpace(lines[^1])) lines.Add(string.Empty);
|
||||
}
|
||||
AddSectionIfAny(sections, "Projects", lines);
|
||||
}
|
||||
|
||||
AddSectionIfAny(sections, "Skills", profile.Skills);
|
||||
|
||||
if (profile.Languages.Count > 0)
|
||||
@@ -692,9 +789,76 @@ public static class StructuredCvProfileJson
|
||||
if (metadataWithoutDates.Count > 1) education.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
|
||||
education.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
education.QualificationLevel = NormalizeQualificationLevel(null, education.Qualification);
|
||||
return string.IsNullOrWhiteSpace(education.Qualification) && string.IsNullOrWhiteSpace(education.Institution) && education.Details.Count == 0 ? null : education;
|
||||
}
|
||||
|
||||
private static List<StructuredCvCertification> ParseCertifications(string content)
|
||||
{
|
||||
var blocks = SplitBlocks(content);
|
||||
return blocks.Select(ParseCertificationBlock).Where(certification => certification is not null).Select(certification => certification!).ToList();
|
||||
}
|
||||
|
||||
private static StructuredCvCertification? ParseCertificationBlock(string block)
|
||||
{
|
||||
var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
if (lines.Count == 0) return null;
|
||||
|
||||
var certification = new StructuredCvCertification();
|
||||
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
|
||||
certification.Name = lines[0].NullIfWhitespace();
|
||||
|
||||
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line)).ToList();
|
||||
certification.Date = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
|
||||
var metadataWithoutDates = metadata
|
||||
.Select(line => string.IsNullOrWhiteSpace(certification.Date) ? line : line.Replace(certification.Date, string.Empty))
|
||||
.Select(line => line.Trim(' ', '|', ',', '-'))
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
if (metadataWithoutDates.Count > 0) certification.Issuer = metadataWithoutDates[0].NullIfWhitespace();
|
||||
if (metadataWithoutDates.Count > 1) certification.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
certification.Details = lines.Skip(1).Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
return string.IsNullOrWhiteSpace(certification.Name) && string.IsNullOrWhiteSpace(certification.Issuer) ? null : certification;
|
||||
}
|
||||
|
||||
private static List<StructuredCvProject> ParseProjects(string content)
|
||||
{
|
||||
var blocks = SplitBlocks(content);
|
||||
return blocks.Select(ParseProjectBlock).Where(project => project is not null).Select(project => project!).ToList();
|
||||
}
|
||||
|
||||
private static StructuredCvProject? ParseProjectBlock(string block)
|
||||
{
|
||||
var lines = block.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList();
|
||||
if (lines.Count == 0) return null;
|
||||
|
||||
var project = new StructuredCvProject();
|
||||
if (lines[0].StartsWith("###", StringComparison.Ordinal)) lines[0] = lines[0].TrimStart('#', ' ');
|
||||
project.Name = lines[0].NullIfWhitespace();
|
||||
var metadata = lines.Skip(1).TakeWhile(line => !IsBullet(line) && !line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
var dateValue = metadata.Select(line => Regex.Match(line, @"(?:(?:\w+\s+)?\d{4}|Present|Current)(?:\s*[-–]\s*(?:(?:\w+\s+)?\d{4}|Present|Current))?", RegexOptions.IgnoreCase).Value.NullIfWhitespace()).FirstOrDefault(value => value is not null);
|
||||
if (!string.IsNullOrWhiteSpace(dateValue))
|
||||
{
|
||||
var parts = Regex.Split(dateValue, "\\s*[-–]\\s*");
|
||||
project.Start = parts.FirstOrDefault().NullIfWhitespace();
|
||||
project.End = parts.Skip(1).FirstOrDefault().NullIfWhitespace();
|
||||
}
|
||||
|
||||
var metadataWithoutDates = metadata
|
||||
.Select(line => string.IsNullOrWhiteSpace(dateValue) ? line : line.Replace(dateValue, string.Empty))
|
||||
.Select(line => line.Trim(' ', '|', ',', '-'))
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
if (metadataWithoutDates.Count > 0) project.Role = metadataWithoutDates[0].NullIfWhitespace();
|
||||
if (metadataWithoutDates.Count > 1) project.Location = metadataWithoutDates[1].NullIfWhitespace();
|
||||
project.Bullets = lines.Where(IsBullet).Select(line => line.Trim().TrimStart('-', '•', '*', ' ')).Where(line => !string.IsNullOrWhiteSpace(line)).ToList();
|
||||
project.Skills = lines
|
||||
.Where(line => line.StartsWith("Skills:", StringComparison.OrdinalIgnoreCase))
|
||||
.SelectMany(line => SplitList(line[(line.IndexOf(':') + 1)..]))
|
||||
.ToList();
|
||||
return string.IsNullOrWhiteSpace(project.Name) && string.IsNullOrWhiteSpace(project.Role) && project.Bullets.Count == 0 ? null : project;
|
||||
}
|
||||
|
||||
private static List<string> SplitBlocks(string content)
|
||||
{
|
||||
var normalized = content.Replace("\r\n", "\n").Trim();
|
||||
|
||||
@@ -47,6 +47,7 @@ public sealed class TailoredCvExperienceItem
|
||||
public sealed class TailoredCvEducationItem
|
||||
{
|
||||
public string? Qualification { get; set; }
|
||||
public string? QualificationLevel { get; set; }
|
||||
public string? Institution { get; set; }
|
||||
public string? Location { get; set; }
|
||||
public string? Start { get; set; }
|
||||
|
||||
@@ -128,7 +128,7 @@ public static class TailoredCvDraftJson
|
||||
var block = new List<string>();
|
||||
foreach (var item in normalized.Education)
|
||||
{
|
||||
AddLine(block, item.Qualification);
|
||||
AddLine(block, string.IsNullOrWhiteSpace(item.QualificationLevel) ? item.Qualification : $"{item.Qualification} ({item.QualificationLevel})");
|
||||
var meta = string.Join(" | ", new[]
|
||||
{
|
||||
item.Institution,
|
||||
@@ -170,6 +170,7 @@ public static class TailoredCvDraftJson
|
||||
{
|
||||
item ??= new TailoredCvEducationItem();
|
||||
item.Qualification = TrimOrNull(item.Qualification);
|
||||
item.QualificationLevel = TrimOrNull(item.QualificationLevel);
|
||||
item.Institution = TrimOrNull(item.Institution);
|
||||
item.Location = TrimOrNull(item.Location);
|
||||
item.Start = TrimOrNull(item.Start);
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
# Smart Gmail Job Correspondence Integration Progress
|
||||
|
||||
## Branch
|
||||
- main
|
||||
|
||||
## Status
|
||||
- Core Phase 1 Gmail correspondence feature is now implemented in code.
|
||||
- Remaining gap is deployment/runtime rollout on the live host, not missing product logic in this repo.
|
||||
|
||||
## Completed
|
||||
|
||||
### Foundation
|
||||
- Gmail OAuth connect/disconnect/status flow preserved.
|
||||
- Durable Gmail sync-state fields added and surfaced from `GET /api/gmail/status`.
|
||||
- Per-job correspondence UI shows Gmail sync diagnostics.
|
||||
|
||||
### Ingestion and storage
|
||||
- Imported Gmail correspondence stores:
|
||||
- direction
|
||||
- Gmail labels JSON
|
||||
- attachment metadata JSON
|
||||
- Gmail payload parsing extracts labels and attachment metadata.
|
||||
- Message-level deduplication remains in place.
|
||||
- Linked-thread refresh continues to import only new thread messages.
|
||||
|
||||
### Matching and routing
|
||||
- Deterministic scoring extracted to `JobTrackerApi/Services/GmailJobMatchingService.cs`.
|
||||
- Review queue backend exists at `GET /api/gmail/review-candidates`.
|
||||
- Review decisions persist through `POST /api/gmail/review-decision`.
|
||||
- Manual sync now exists at `POST /api/gmail/manual-sync`.
|
||||
- Manual sync applies a bounded historical window and excludes spam/trash by default.
|
||||
- High-confidence matches now auto-link during manual sync.
|
||||
- Medium-confidence matches remain in review.
|
||||
- Low-confidence job-like threads can be marked as suggested jobs.
|
||||
- Suggested-job surfaces now exist via:
|
||||
- `GET /api/gmail/suggested-jobs`
|
||||
- `POST /api/gmail/create-suggested-job`
|
||||
|
||||
### Correspondence UX
|
||||
- Global inbox exists at `/correspondence`.
|
||||
- Gmail review page exists at `/correspondence/review`.
|
||||
- Review page now supports:
|
||||
- manual sync
|
||||
- routing filters
|
||||
- review notes
|
||||
- link/review/reject/suggested actions
|
||||
- create-job flow from suggested Gmail threads
|
||||
- Per-job correspondence workspace now supports:
|
||||
- linked-thread refresh
|
||||
- unlink thread from current job
|
||||
- move/relink thread to another existing job
|
||||
- Backend relink/unlink endpoints now exist:
|
||||
- `POST /api/gmail/relink-thread`
|
||||
- `POST /api/gmail/unlink-thread`
|
||||
|
||||
### Phase 2 prep
|
||||
- Future seam remains in place at `JobTrackerApi/Services/GmailCorrespondenceEnrichment.cs`.
|
||||
- Design doc remains in place at `docs/gmail-correspondence-phase1.md`.
|
||||
|
||||
### Deployment hardening
|
||||
- Added deploy smoke-check logic to `deploy/deploy.sh`.
|
||||
- Deploy now fails if `${APP_PUBLIC_BASE_URL}/api/auth/config` returns HTML or non-JSON instead of backend auth config JSON.
|
||||
|
||||
## Verification completed
|
||||
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests /p:DisableSourceControlManagerQueries=true`
|
||||
- `cd job-tracker-ui && CI=true ./node_modules/.bin/react-scripts test --runInBand --watch=false src/correspondence-gmail-import.test.tsx src/gmail-review-page.test.tsx src/correspondence-inbox-page.test.tsx`
|
||||
- `dotnet build './Job tracker.sln' -c Release`
|
||||
|
||||
## Runtime note
|
||||
- Live host check shows `https://jobs.cesnimda.uk/api/auth/config` currently returns the frontend HTML shell (`x-powered-by: Express`) instead of backend JSON.
|
||||
- That is a deployment/proxy mismatch outside the app code in this checkout.
|
||||
- The new deploy smoke-check was added so future deploys fail fast on that condition.
|
||||
|
||||
## Resume notes
|
||||
- If the live site still shows 404s for `/api/...`, the running service is not the repo’s Dockerized frontend+backend path.
|
||||
- The CRA/Express-style live response and websocket attempts to `:3000/ws` suggest an old dev-style frontend process or wrong reverse-proxy target is still serving the domain.
|
||||
+39
-2
@@ -62,9 +62,46 @@ fi
|
||||
|
||||
ai_status="$(compose ps ai-service --format '{{.State}}' 2>/dev/null | head -n 1 | tr '[:upper:]' '[:lower:]')"
|
||||
if [ "$ai_status" != "running" ]; then
|
||||
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown})."
|
||||
echo "AI service is not healthy after deploy (state: ${ai_status:-unknown}). Continuing because AI is not a deploy gate for the core app."
|
||||
compose logs --tail=200 ai-service || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${APP_PUBLIC_BASE_URL:-}" ]; then
|
||||
public_base="${APP_PUBLIC_BASE_URL%/}"
|
||||
auth_config_body_file="$(mktemp)"
|
||||
auth_config_headers_file="$(mktemp)"
|
||||
cleanup_public_check() {
|
||||
rm -f "$auth_config_body_file" "$auth_config_headers_file"
|
||||
}
|
||||
trap cleanup_public_check EXIT
|
||||
|
||||
echo "Running public smoke check against ${public_base}"
|
||||
if ! curl -fsS "${public_base}/" >/dev/null; then
|
||||
echo "Public frontend check failed for ${public_base}/"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! curl -fsS -D "$auth_config_headers_file" -o "$auth_config_body_file" "${public_base}/api/auth/config"; then
|
||||
echo "Public API smoke check failed for ${public_base}/api/auth/config"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
content_type="$(awk 'BEGIN{IGNORECASE=1} /^content-type:/ {print $2}' "$auth_config_headers_file" | tr -d '\r' | tail -n 1)"
|
||||
if [[ "$content_type" != application/json* ]]; then
|
||||
echo "Public API smoke check returned unexpected content type: ${content_type:-missing}"
|
||||
echo "First bytes of response:"
|
||||
head -c 200 "$auth_config_body_file" || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! grep -q 'requireAuth' "$auth_config_body_file"; then
|
||||
echo "Public API smoke check returned JSON without requireAuth."
|
||||
cat "$auth_config_body_file"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
trap - EXIT
|
||||
cleanup_public_check
|
||||
fi
|
||||
|
||||
# Clean up old legacy container name if it still exists from pre-rename deployments.
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
# CV builder, parser, benchmark, and Ollama admin integration
|
||||
|
||||
## What changed
|
||||
|
||||
This branch upgrades the Profile CV flow from a text-only rewrite helper into a template-driven CV builder backed by the server-side renderer/PDF pipeline, strengthens CV normalization around location and qualification handling, adds a repeatable local corpus benchmark workflow, and expands the admin system page with richer Ollama visibility.
|
||||
|
||||
## Profile CV builder
|
||||
|
||||
### New backend capabilities
|
||||
|
||||
`JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
- Hardened `POST /api/profile-cv/rewrite-section`
|
||||
- accepts flexible `jobApplicationId` payloads (number or blank string)
|
||||
- uses richer saved-job context for tailoring
|
||||
- logs empty AI responses with useful context
|
||||
- Added `GET /api/profile-cv/templates`
|
||||
- Added `POST /api/profile-cv/rewrite-preview`
|
||||
- rewrites either the whole CV or one selected section
|
||||
- rebuilds structured CV from the rewritten full text
|
||||
- maps the result into the shared template renderer
|
||||
- returns rendered HTML, file name, rewritten text, and full replacement text
|
||||
- Added `POST /api/profile-cv/export-pdf`
|
||||
- uses the same rendered HTML and the shared Playwright exporter
|
||||
|
||||
### Frontend flow
|
||||
|
||||
`job-tracker-ui/src/pages/ProfilePage.tsx`
|
||||
|
||||
- Replaced the old rewrite draft box with a template-driven builder section.
|
||||
- Users can:
|
||||
- choose from 6 templates
|
||||
- optionally target one section
|
||||
- target by free-text role or saved job
|
||||
- inspect the rewritten content
|
||||
- inspect the actual rendered preview
|
||||
- download a PDF
|
||||
- replace the master CV with the rebuilt full-text result
|
||||
|
||||
## Templates
|
||||
|
||||
Shared renderer: `JobTrackerApi/Services/CvTemplateRenderer.cs`
|
||||
|
||||
Available templates:
|
||||
- `ats-minimal`
|
||||
- `harvard`
|
||||
- `auckland`
|
||||
- `edinburgh`
|
||||
- `monarch`
|
||||
- `fjord`
|
||||
|
||||
### Adding a new template
|
||||
|
||||
1. Add the new template id to `NormalizeTemplateId()` in:
|
||||
- `JobTrackerApi/Services/CvTemplateRenderer.cs`
|
||||
- `JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
2. Add a render branch in `CvTemplateRenderer.Render()`.
|
||||
3. Add a descriptor to `GetCvTemplateDescriptors()`.
|
||||
4. Add the matching card entry in `job-tracker-ui/src/pages/ProfilePage.tsx` if you want a custom preview card.
|
||||
|
||||
## PDF generation
|
||||
|
||||
The master CV builder now reuses the existing server-side pipeline:
|
||||
|
||||
1. rewrite full text / section
|
||||
2. rebuild structured CV
|
||||
3. map to `TailoredCvDocument`
|
||||
4. render HTML via `ICvTemplateRenderer`
|
||||
5. export PDF via `ICvPdfExporter` / Playwright
|
||||
|
||||
This keeps PDF output visually aligned with the selected template and avoids a separate client-only print implementation.
|
||||
|
||||
## Parser and structured CV changes
|
||||
|
||||
### Shared schema
|
||||
|
||||
`Models/StructuredCvProfile.cs`
|
||||
|
||||
Added:
|
||||
- `education[].qualificationLevel`
|
||||
- top-level `certifications[]`
|
||||
- top-level `projects[]`
|
||||
|
||||
`qualification` remains the original preserved text.
|
||||
|
||||
### Normalization improvements
|
||||
|
||||
`Models/StructuredCvProfileJson.cs`
|
||||
|
||||
- tighter location sanitization to avoid skill or role spillover into location fields
|
||||
- qualification level normalization to one of:
|
||||
- `Secondary`
|
||||
- `Diploma/Certificate`
|
||||
- `Bachelor`
|
||||
- `Master`
|
||||
- `PhD`
|
||||
- `Other`
|
||||
- first-class normalization for certifications and projects
|
||||
- section reconstruction now includes certifications and projects
|
||||
|
||||
### Extraction prompt improvements
|
||||
|
||||
`JobTrackerApi/Controllers/ProfileCvController.cs`
|
||||
|
||||
The LLM extraction prompt now explicitly asks for:
|
||||
- qualification level enum
|
||||
- certifications
|
||||
- projects
|
||||
- strict location separation rules
|
||||
- preservation of original qualification text
|
||||
|
||||
## Benchmark workflow
|
||||
|
||||
### Runner
|
||||
|
||||
Use:
|
||||
|
||||
```bash
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
Optional overrides:
|
||||
|
||||
```bash
|
||||
CV_BENCHMARK_OUTPUT_DIR=/absolute/output/path \
|
||||
CV_BENCHMARK_APPROVED_DIR=/absolute/approved/fixtures/path \
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
The runner scans:
|
||||
|
||||
- `/home/pi/cvs`
|
||||
|
||||
Supported corpus file types:
|
||||
- PDF
|
||||
- DOCX
|
||||
- TXT
|
||||
- MD
|
||||
|
||||
### Outputs
|
||||
|
||||
The runner writes:
|
||||
|
||||
- `index.json` — machine-readable summary
|
||||
- `report.md` — markdown overview
|
||||
- `outputs/*.json` — latest normalized structured output per CV
|
||||
- `candidate-fixtures/*.json` — created when no approved fixture exists yet
|
||||
|
||||
Approved fixtures are local by design and should be reviewed manually before being promoted into the approved fixture path you use for regression comparisons.
|
||||
|
||||
### Admin review
|
||||
|
||||
`GET /api/admin/system/cv-benchmark`
|
||||
|
||||
The admin system page surfaces:
|
||||
- benchmark root path
|
||||
- last benchmark update time
|
||||
- latest markdown summary
|
||||
|
||||
## Ollama admin visibility
|
||||
|
||||
### Python health endpoint
|
||||
|
||||
`tools/summarizer/app.py`
|
||||
|
||||
`GET /health` now returns additional Ollama metadata when configured/reachable:
|
||||
- `ollama_version`
|
||||
- `ollama_installed_models`
|
||||
- `ollama_loaded_models`
|
||||
- `ollama_loaded_count`
|
||||
|
||||
### Backend propagation
|
||||
|
||||
`JobTrackerApi/Services/SummarizerService.cs`
|
||||
|
||||
The backend metrics shape now carries those fields through to admin consumers.
|
||||
|
||||
### Admin UI
|
||||
|
||||
`job-tracker-ui/src/pages/AdminSystemPage.tsx`
|
||||
|
||||
The system page now shows:
|
||||
- Ollama version
|
||||
- loaded model count
|
||||
- installed model chips
|
||||
- loaded model chips
|
||||
- benchmark summary panel
|
||||
|
||||
## Verification used on this branch
|
||||
|
||||
### Backend
|
||||
|
||||
```bash
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter ProfileCvControllerTests
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter "ProfileCvControllerTests|AuthAndSystemControllerTests|JobApplicationsApplicationPackageTests"
|
||||
dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter CvCorpusHarnessTests
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```bash
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/admin-system-page.test.tsx
|
||||
cd job-tracker-ui && CI=true npm test -- --runInBand --watch=false src/profile-page.test.tsx src/admin-system-page.test.tsx src/job-details-generated-drafts.test.tsx
|
||||
```
|
||||
|
||||
### Benchmark runner
|
||||
|
||||
```bash
|
||||
CV_BENCHMARK_OUTPUT_DIR="$(pwd)/tmp/cv-benchmarks/latest" \
|
||||
CV_BENCHMARK_APPROVED_DIR="$(pwd)/tmp/cv-benchmarks/approved" \
|
||||
./scripts/run-cv-benchmark.sh
|
||||
```
|
||||
|
||||
### Python service tests
|
||||
|
||||
The summarizer Python unit tests were updated for the new health payload, but this machine currently lacks `pip` / `venv` support (`python3 -m venv` fails because `python3.12-venv` is not installed), so test execution is environment-blocked here. Once Python packaging is available, run:
|
||||
|
||||
```bash
|
||||
cd tools/summarizer
|
||||
python3 -m pytest -q tests/test_app.py
|
||||
```
|
||||
@@ -0,0 +1,55 @@
|
||||
# Smart Gmail Job Correspondence Integration
|
||||
|
||||
## Phase split
|
||||
|
||||
### Phase 1
|
||||
Deterministic, high-trust Gmail job correspondence integration:
|
||||
- OAuth/account connection
|
||||
- token refresh lifecycle
|
||||
- sync-state tracking
|
||||
- manual sync/backfill
|
||||
- dedup by Gmail message id
|
||||
- deterministic job-linking + confidence routing
|
||||
- review queue for medium-confidence items
|
||||
- unmatched thread suggestions
|
||||
- global inbox + per-job timeline
|
||||
|
||||
### Phase 2
|
||||
Prepared, not deeply implemented in this slice:
|
||||
- semantic Gmail-to-job disambiguation
|
||||
- richer recruiter/company/role extraction
|
||||
- stage/status hinting
|
||||
- interview/rejection/offer extraction
|
||||
- follow-up/reply suggestion generation
|
||||
|
||||
## Foundation decisions
|
||||
|
||||
- Phase 1 remains useful without AI/Ollama.
|
||||
- Deterministic evidence remains the primary truth source.
|
||||
- Future AI enrichment attaches reasons/confidence alongside deterministic evidence rather than replacing it.
|
||||
- Gmail sync state is now durable on the Gmail connection record:
|
||||
- `LastSyncAttemptedAt`
|
||||
- `LastSyncSucceededAt`
|
||||
- `LastSyncMode`
|
||||
- `LastSyncSource`
|
||||
- `LastSyncStatus`
|
||||
- `LastSyncError`
|
||||
|
||||
## Current code seams
|
||||
|
||||
- Gmail OAuth and token lifecycle: `JobTrackerApi/Services/GmailOAuthService.cs`
|
||||
- Gmail endpoints: `JobTrackerApi/Controllers/GmailController.cs`
|
||||
- Gmail connection persistence: `Models/GmailConnection.cs`
|
||||
- Correspondence persistence: `Models/Correspondence.cs`
|
||||
- Per-job correspondence UX: `job-tracker-ui/src/components/Correspondence.tsx`
|
||||
- Future Phase 2 AI seam: `JobTrackerApi/Services/GmailCorrespondenceEnrichment.cs`
|
||||
|
||||
## What Phase 2 should plug into later
|
||||
|
||||
The `IGmailCorrespondenceEnrichmentService` seam is intended to accept normalized Gmail message/thread context and return optional semantic hints:
|
||||
- probable job match
|
||||
- richer confidence rationale
|
||||
- extracted recruiter/company/role entities
|
||||
- lightweight stage hints
|
||||
|
||||
Phase 1 should never require this service to return anything useful. The default runtime implementation remains a no-op.
|
||||
@@ -14,6 +14,7 @@ import AlarmIcon from "@mui/icons-material/Alarm";
|
||||
import AccountCircleIcon from "@mui/icons-material/AccountCircle";
|
||||
import ShieldIcon from "@mui/icons-material/Shield";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import MemoryIcon from "@mui/icons-material/Memory";
|
||||
|
||||
import { Navigate, Route, Routes, useLocation, useNavigate, createBrowserRouter, RouterProvider } from "react-router-dom";
|
||||
@@ -46,6 +47,8 @@ const ProfilePage = lazy(() => import("./pages/ProfilePage"));
|
||||
const AdminAuditPage = lazy(() => import("./pages/AdminAuditPage"));
|
||||
const AdminUsersPage = lazy(() => import("./pages/AdminUsersPage"));
|
||||
const AdminSystemPage = lazy(() => import("./pages/AdminSystemPage"));
|
||||
const CorrespondenceInboxPage = lazy(() => import("./pages/CorrespondenceInboxPage"));
|
||||
const GmailReviewPage = lazy(() => import("./pages/GmailReviewPage"));
|
||||
const NotFoundPage = lazy(() => import("./pages/NotFoundPage"));
|
||||
|
||||
type AuthConfig = { requireAuth: boolean };
|
||||
@@ -67,6 +70,8 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] {
|
||||
if (path.startsWith("/reminders")) return [t("home"), t("reminders")];
|
||||
if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")];
|
||||
if (path.startsWith("/companies")) return [t("home"), t("companies")];
|
||||
if (path.startsWith("/correspondence/review")) return [t("home"), "Gmail review queue"];
|
||||
if (path.startsWith("/correspondence")) return [t("home"), "Correspondence inbox"];
|
||||
if (path.startsWith("/trash")) return [t("home"), t("trash")];
|
||||
if (path.startsWith("/settings")) return [t("home"), t("settings")];
|
||||
if (path.startsWith("/profile")) return [t("home"), t("account"), t("profile")];
|
||||
@@ -82,6 +87,8 @@ function titleFor(path: string, t: (k: any) => string): string {
|
||||
if (path.startsWith("/jobs")) return t("jobApplications");
|
||||
if (path.startsWith("/kanban")) return t("kanbanBoard");
|
||||
if (path.startsWith("/companies")) return t("companies");
|
||||
if (path.startsWith("/correspondence/review")) return "Gmail review queue";
|
||||
if (path.startsWith("/correspondence")) return "Correspondence inbox";
|
||||
if (path.startsWith("/trash")) return t("trash");
|
||||
if (path.startsWith("/settings")) return t("settings");
|
||||
if (path.startsWith("/profile")) return t("profile");
|
||||
@@ -154,6 +161,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
{ to: "/reminders", label: t("reminders"), icon: <AlarmIcon fontSize="small" />, badgeCount: notifCount, section: t("manage") },
|
||||
{ to: "/kanban", label: t("kanbanBoard"), icon: <ViewKanbanIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/companies", label: t("companies"), icon: <BusinessIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/correspondence", label: "Correspondence", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/correspondence/review", label: "Gmail review", icon: <MailOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
{ to: "/trash", label: t("trash"), icon: <DeleteOutlineIcon fontSize="small" />, section: t("manage") },
|
||||
];
|
||||
|
||||
@@ -225,6 +234,8 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
||||
<Route path="/reminders" element={<RemindersView />} />
|
||||
<Route path="/kanban" element={<KanbanBoard />} />
|
||||
<Route path="/companies" element={<CompaniesTable />} />
|
||||
<Route path="/correspondence" element={<CorrespondenceInboxPage />} />
|
||||
<Route path="/correspondence/review" element={<GmailReviewPage />} />
|
||||
<Route path="/profile" element={<ProfilePage />} />
|
||||
<Route path="/admin/audit" element={<AdminAuditPage />} />
|
||||
<Route path="/admin/users" element={<AdminUsersPage />} />
|
||||
|
||||
@@ -43,6 +43,14 @@ describe('AdminSystemPage', () => {
|
||||
gpuName: null,
|
||||
ocrAvailable: true,
|
||||
ocrLanguages: 'eng',
|
||||
ollamaConfigured: true,
|
||||
ollamaReachable: true,
|
||||
ollamaModel: 'qwen2.5:7b',
|
||||
ollamaModelAvailable: true,
|
||||
ollamaVersion: '0.7.0',
|
||||
ollamaInstalledModels: ['qwen2.5:7b', 'nomic-embed-text'],
|
||||
ollamaLoadedModels: ['qwen2.5:7b'],
|
||||
ollamaLoadedCount: 1,
|
||||
healthLatencyMs: 12.4,
|
||||
probeLatencyMs: 25.8,
|
||||
lastProbeAt: '2026-03-23T10:00:00Z',
|
||||
@@ -82,6 +90,48 @@ describe('AdminSystemPage', () => {
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/admin/system/cv-benchmark') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
rootPath: '/data/CvBenchmarks/latest',
|
||||
lastUpdatedAtUtc: '2026-03-23T10:10:00Z',
|
||||
reportMarkdown: '# CV benchmark report\n\n- Files: 4',
|
||||
indexJson: JSON.stringify({
|
||||
CorpusRoot: '/home/pi/cvs',
|
||||
OutputRoot: '/data/CvBenchmarks/latest',
|
||||
GeneratedAtUtc: '2026-03-23T10:10:00Z',
|
||||
TotalFiles: 4,
|
||||
AverageCoverage: 0.72,
|
||||
AverageConfidence: 0.66,
|
||||
AverageConsistency: 0.94,
|
||||
FilesWithSuspiciousLocations: 1,
|
||||
MissingApprovedFixtures: 4,
|
||||
Entries: [
|
||||
{
|
||||
FileName: 'cv.txt',
|
||||
Slug: 'cv-txt',
|
||||
Extension: '.txt',
|
||||
Characters: 2000,
|
||||
OutputPath: '/data/CvBenchmarks/latest/outputs/cv-txt.json',
|
||||
ApprovedFixturePath: null,
|
||||
CandidateFixturePath: '/data/CvBenchmarks/latest/candidate-fixtures/cv-txt.json',
|
||||
ContactLocation: 'San Francisco, Hobbies',
|
||||
FirstJob: '* July',
|
||||
FirstJobLocation: null,
|
||||
FirstEducation: '* September',
|
||||
FirstEducationLocation: null,
|
||||
QualificationLevels: ['Other'],
|
||||
SuspiciousLocations: [],
|
||||
CoverageScore: 0.5,
|
||||
ConfidenceScore: 0.65,
|
||||
ConsistencyScore: 0.8,
|
||||
DiffSummary: 'No approved fixture yet — candidate fixture written.',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({
|
||||
@@ -118,6 +168,11 @@ describe('AdminSystemPage', () => {
|
||||
expect(screen.getByText(/25.8 ms probe/i)).toBeTruthy();
|
||||
expect(screen.getByText('OCR eng')).toBeTruthy();
|
||||
expect(screen.getAllByText(/ollama configured/i).length).toBeGreaterThan(0);
|
||||
expect(screen.getByText(/ollama version/i)).toBeTruthy();
|
||||
expect(screen.getByText(/model · qwen2.5:7b/i)).toBeTruthy();
|
||||
expect(screen.getByText(/cv benchmark review/i)).toBeTruthy();
|
||||
expect(screen.getByText(/top parser findings/i)).toBeTruthy();
|
||||
expect(screen.getByText(/suspicious contact location: san francisco, hobbies/i)).toBeTruthy();
|
||||
expect(screen.getByText('OCR avg latency')).toBeTruthy();
|
||||
expect(screen.getByText('88.4 ms')).toBeTruthy();
|
||||
});
|
||||
|
||||
@@ -10,10 +10,14 @@ import {
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemText,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Tab,
|
||||
Tabs,
|
||||
TextField,
|
||||
@@ -35,8 +39,10 @@ import {
|
||||
GmailImportMessageResult,
|
||||
GmailImportThreadResult,
|
||||
GmailJobMatchesResponse,
|
||||
GmailRelinkResult,
|
||||
GmailStatus,
|
||||
GmailThreadRefreshResult,
|
||||
GmailUnlinkResult,
|
||||
JobApplication,
|
||||
} from "../types";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
@@ -97,6 +103,10 @@ function formatReasonLabel(label: string) {
|
||||
}
|
||||
}
|
||||
|
||||
interface PagedResult<T> {
|
||||
items: T[];
|
||||
}
|
||||
|
||||
export default function Correspondence({ jobId, job }: { jobId: number; job: JobApplication | null }) {
|
||||
const theme = useTheme();
|
||||
const { toast } = useToast();
|
||||
@@ -120,6 +130,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
const [linkedThreadRefreshLoading, setLinkedThreadRefreshLoading] = useState(false);
|
||||
const [importingMessageId, setImportingMessageId] = useState<string | null>(null);
|
||||
const [importingThreadId, setImportingThreadId] = useState<string | null>(null);
|
||||
const [availableJobs, setAvailableJobs] = useState<JobApplication[]>([]);
|
||||
const [manageThreadId, setManageThreadId] = useState<string | null>(null);
|
||||
const [manageTargetJobId, setManageTargetJobId] = useState<number>(jobId);
|
||||
const [manageNote, setManageNote] = useState("");
|
||||
const [manageSaving, setManageSaving] = useState(false);
|
||||
const autoRefreshKeyRef = useRef<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
@@ -157,6 +172,15 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
}
|
||||
}, [jobId, toast]);
|
||||
|
||||
const loadAvailableJobs = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.get<PagedResult<JobApplication>>("/jobapplications", { params: { page: 1, pageSize: 100, sortBy: "dateApplied", sortDir: "desc" } });
|
||||
setAvailableJobs((res.data?.items ?? []).filter((item) => item.id !== jobId));
|
||||
} catch {
|
||||
setAvailableJobs([]);
|
||||
}
|
||||
}, [jobId]);
|
||||
|
||||
const linkedThreadIds = useMemo(
|
||||
() => Array.from(new Set(messages.map((message) => message.externalThreadId).filter(Boolean) as string[])).sort(),
|
||||
[messages],
|
||||
@@ -210,7 +234,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
|
||||
useEffect(() => {
|
||||
void loadGmailStatus();
|
||||
}, [loadGmailStatus]);
|
||||
void loadAvailableJobs();
|
||||
}, [loadAvailableJobs, loadGmailStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!gmailStatus?.connected || linkedThreadIds.length === 0) {
|
||||
@@ -367,6 +392,55 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
}
|
||||
};
|
||||
|
||||
const openManageThread = (threadId: string) => {
|
||||
setManageThreadId(threadId);
|
||||
setManageTargetJobId(jobId);
|
||||
setManageNote("");
|
||||
};
|
||||
|
||||
const unlinkThread = async () => {
|
||||
if (!manageThreadId) return;
|
||||
setManageSaving(true);
|
||||
try {
|
||||
const res = await api.post<GmailUnlinkResult>("/gmail/unlink-thread", {
|
||||
jobApplicationId: jobId,
|
||||
threadId: manageThreadId,
|
||||
note: manageNote.trim() || null,
|
||||
nextDecision: "review",
|
||||
});
|
||||
await load();
|
||||
await loadGmailMatches(gmailQuery);
|
||||
setManageThreadId(null);
|
||||
toast(`Unlinked ${res.data.removedMessages} message${res.data.removedMessages === 1 ? "" : "s"} from this job.`, "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to unlink the Gmail thread."), "error");
|
||||
} finally {
|
||||
setManageSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const relinkThread = async () => {
|
||||
if (!manageThreadId || manageTargetJobId <= 0 || manageTargetJobId === jobId) return;
|
||||
setManageSaving(true);
|
||||
try {
|
||||
const res = await api.post<GmailRelinkResult>("/gmail/relink-thread", {
|
||||
jobApplicationId: manageTargetJobId,
|
||||
threadId: manageThreadId,
|
||||
removeFromOtherJobs: true,
|
||||
note: manageNote.trim() || null,
|
||||
});
|
||||
await load();
|
||||
await loadGmailMatches(gmailQuery);
|
||||
setManageThreadId(null);
|
||||
const targetJob = availableJobs.find((item) => item.id === manageTargetJobId);
|
||||
toast(`Moved thread to ${targetJob?.company?.name || targetJob?.jobTitle || `job ${res.data.jobApplicationId}`}.`, "success");
|
||||
} catch (error: any) {
|
||||
toast(getApiErrorMessage(error, "Failed to move the Gmail thread."), "error");
|
||||
} finally {
|
||||
setManageSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Paper ref={scrollRef} sx={{ p: 1.5, maxHeight: 360, overflowY: "auto", background: theme.palette.mode === "dark" ? "rgba(15,23,42,0.45)" : "rgba(255,255,255,0.75)", backdropFilter: "blur(8px)" }}>
|
||||
@@ -382,11 +456,13 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
<Box sx={{ maxWidth: "80%", borderRadius: 3, p: 1.25, border: `1px solid ${alpha(accent, theme.palette.mode === "dark" ? 0.32 : 0.22)}`, background: alpha(accent, theme.palette.mode === "dark" ? 0.14 : 0.1), color: "text.primary" }}>
|
||||
{m.subject ? <Typography sx={{ fontWeight: 800, mb: 0.5 }}>{m.subject}</Typography> : null}
|
||||
<Typography sx={{ whiteSpace: "pre-wrap", lineHeight: 1.35 }}>{m.content}</Typography>
|
||||
{(m.externalThreadId || m.externalFrom || m.externalTo) ? (
|
||||
{(m.externalThreadId || m.externalFrom || m.externalTo || m.externalLabelsJson || m.attachmentMetadataJson) ? (
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 1 }}>
|
||||
{m.externalThreadId ? <Chip size="small" label={`Thread ${m.externalThreadId}`} variant="outlined" /> : null}
|
||||
{m.externalFrom ? <Chip size="small" label={`From ${m.externalFrom}`} variant="outlined" /> : null}
|
||||
{m.externalTo ? <Chip size="small" label={`To ${m.externalTo}`} variant="outlined" /> : null}
|
||||
{m.externalLabelsJson ? <Chip size="small" label={`${JSON.parse(m.externalLabelsJson).length} Gmail label${JSON.parse(m.externalLabelsJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
|
||||
{m.attachmentMetadataJson ? <Chip size="small" label={`${JSON.parse(m.attachmentMetadataJson).length} attachment${JSON.parse(m.attachmentMetadataJson).length === 1 ? "" : "s"}`} variant="outlined" /> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "flex-end", mt: 0.75 }}>
|
||||
@@ -413,6 +489,21 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" color={gmailStatus?.connected ? "success" : "default"} variant="outlined" label={gmailStatus?.connected ? "Gmail connected" : "Gmail not connected"} />
|
||||
<Chip size="small" color={linkedThreadIds.length > 0 ? "success" : "default"} variant="outlined" label={linkedThreadIds.length > 0 ? `Linked threads: ${linkedThreadIds.length}` : "No linked threads yet"} />
|
||||
{linkedThreadIds.slice(0, 6).map((threadId) => (
|
||||
<Button key={threadId} size="small" variant="text" onClick={() => openManageThread(threadId)}>
|
||||
Manage {threadId}
|
||||
</Button>
|
||||
))}
|
||||
{gmailStatus?.lastSyncStatus ? (
|
||||
<Chip
|
||||
size="small"
|
||||
color={gmailStatus.lastSyncStatus === "success" ? "success" : "warning"}
|
||||
variant="outlined"
|
||||
label={gmailStatus.lastSyncStatus === "success"
|
||||
? `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} ok`
|
||||
: `Last Gmail sync ${gmailStatus.lastSyncMode || "sync"} failed`}
|
||||
/>
|
||||
) : null}
|
||||
{linkedThreadRefresh ? (
|
||||
<Chip
|
||||
size="small"
|
||||
@@ -425,6 +516,11 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
/>
|
||||
) : null}
|
||||
</Box>
|
||||
{gmailStatus?.lastSyncError ? (
|
||||
<Typography variant="body2" sx={{ color: "warning.main", mt: 1 }}>
|
||||
Latest Gmail sync issue: {gmailStatus.lastSyncError}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, alignItems: "flex-start", mt: 1.5, flexWrap: "wrap" }}>
|
||||
@@ -440,6 +536,44 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
<Button variant="contained" onClick={send} disabled={!canSend}>{t("correspondenceAdd")}</Button>
|
||||
</Box>
|
||||
|
||||
<Dialog open={Boolean(manageThreadId)} onClose={() => setManageThreadId(null)} fullWidth maxWidth="sm">
|
||||
<DialogTitle>Manage linked Gmail thread</DialogTitle>
|
||||
<DialogContent sx={{ display: "flex", flexDirection: "column", gap: 2, pt: 1 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Unlink this thread from the current job, or move it to another existing job.
|
||||
</Typography>
|
||||
{manageThreadId ? <Chip label={`Thread ${manageThreadId}`} variant="outlined" sx={{ width: "fit-content" }} /> : null}
|
||||
<TextField
|
||||
label="Review note"
|
||||
value={manageNote}
|
||||
onChange={(event) => setManageNote(event.target.value)}
|
||||
multiline
|
||||
minRows={2}
|
||||
placeholder="Why this thread should stay in review or move to another job."
|
||||
/>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Move to job</InputLabel>
|
||||
<Select
|
||||
value={String(manageTargetJobId)}
|
||||
label="Move to job"
|
||||
onChange={(event) => setManageTargetJobId(Number(event.target.value))}
|
||||
>
|
||||
<MenuItem value={String(jobId)}>Keep on current job</MenuItem>
|
||||
{availableJobs.map((item) => (
|
||||
<MenuItem key={item.id} value={String(item.id)}>
|
||||
{item.company?.name || "Unknown company"} • {item.jobTitle}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setManageThreadId(null)} disabled={manageSaving}>Close</Button>
|
||||
<Button color="warning" variant="outlined" onClick={() => void unlinkThread()} disabled={manageSaving || !manageThreadId}>Unlink from this job</Button>
|
||||
<Button variant="contained" onClick={() => void relinkThread()} disabled={manageSaving || !manageThreadId || manageTargetJobId === jobId}>Move thread</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={importOpen} onClose={() => setImportOpen(false)} fullWidth maxWidth="md">
|
||||
<DialogTitle>{t("correspondenceImportTitle")}</DialogTitle>
|
||||
<DialogContent>
|
||||
@@ -503,6 +637,8 @@ export default function Correspondence({ jobId, job }: { jobId: number; job: Job
|
||||
</Box>
|
||||
) : null}
|
||||
{gmailStatus.lastSyncedAt ? <Chip label={t("correspondenceLastSynced", { date: new Date(gmailStatus.lastSyncedAt).toLocaleString() })} size="small" /> : null}
|
||||
{gmailStatus.lastSyncAttemptedAt ? <Chip label={`Sync checked ${new Date(gmailStatus.lastSyncAttemptedAt).toLocaleString()}`} size="small" variant="outlined" /> : null}
|
||||
{gmailStatus.lastSyncStatus === "error" && gmailStatus.lastSyncError ? <Chip label={`Sync issue: ${gmailStatus.lastSyncError}`} size="small" color="warning" variant="outlined" /> : null}
|
||||
{linkedThreadIds.length > 0 ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip size="small" color="success" variant="outlined" label={`Linked threads: ${linkedThreadIds.length}`} />
|
||||
|
||||
@@ -51,6 +51,30 @@ describe("correspondence Gmail import", () => {
|
||||
correspondenceMessages = [];
|
||||
|
||||
mockedApi.get.mockImplementation((url: string, config?: any) => {
|
||||
if (url === "/jobapplications") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
id: 42,
|
||||
jobTitle: "Backend Developer",
|
||||
status: "Applied",
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 3,
|
||||
company: { name: "Acme", recruiterEmail: "maria@acme.test", recruiterName: "Maria Recruiter" },
|
||||
},
|
||||
{
|
||||
id: 77,
|
||||
jobTitle: "Platform Engineer",
|
||||
status: "Applied",
|
||||
dateApplied: new Date().toISOString(),
|
||||
daysSince: 1,
|
||||
company: { name: "Beta" },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === "/jobapplications/42") {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
@@ -78,7 +102,7 @@ describe("correspondence Gmail import", () => {
|
||||
return Promise.resolve({ data: correspondenceMessages } as any);
|
||||
}
|
||||
if (url === "/gmail/status") {
|
||||
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString() } } as any);
|
||||
return Promise.resolve({ data: { connected: true, gmailAddress: "user@example.test", lastSyncedAt: new Date().toISOString(), lastSyncAttemptedAt: new Date().toISOString(), lastSyncMode: "list-messages", lastSyncSource: "custom-query", lastSyncStatus: "error", lastSyncError: "Token refresh failed" } } as any);
|
||||
}
|
||||
if (url === "/gmail/job-candidates") {
|
||||
return Promise.resolve({
|
||||
@@ -142,6 +166,15 @@ describe("correspondence Gmail import", () => {
|
||||
});
|
||||
|
||||
mockedApi.post.mockImplementation((url: string, body?: any) => {
|
||||
if (url === "/gmail/relink-thread") {
|
||||
correspondenceMessages = [];
|
||||
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, imported: 1, skipped: 0, unlinkedMessages: 1 } } as any);
|
||||
}
|
||||
if (url === "/gmail/unlink-thread") {
|
||||
const removed = correspondenceMessages.filter((message) => message.externalThreadId === body.threadId).length;
|
||||
correspondenceMessages = correspondenceMessages.filter((message) => message.externalThreadId !== body.threadId);
|
||||
return Promise.resolve({ data: { threadId: body.threadId, jobApplicationId: body.jobApplicationId, removedMessages: removed, decision: body.nextDecision || 'review' } } as any);
|
||||
}
|
||||
if (url === "/gmail/refresh-linked-threads") {
|
||||
const hasReply = correspondenceMessages.some((message) => message.externalMessageId === "msg-2");
|
||||
if (!hasReply && correspondenceMessages.some((message) => message.externalThreadId === "thread-1")) {
|
||||
@@ -291,6 +324,82 @@ describe("correspondence Gmail import", () => {
|
||||
expect((await screen.findAllByText(/thread thread-1/i)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("lets the user unlink a linked Gmail thread", async () => {
|
||||
correspondenceMessages = [
|
||||
{
|
||||
id: 700,
|
||||
jobApplicationId: 42,
|
||||
from: "Company",
|
||||
content: "Acme wants to schedule a call.",
|
||||
subject: "Backend Developer interview",
|
||||
channel: "Email",
|
||||
date: new Date().toISOString(),
|
||||
externalMessageId: "msg-1",
|
||||
externalThreadId: "thread-1",
|
||||
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||
externalTo: "user@example.test",
|
||||
},
|
||||
];
|
||||
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
||||
fireEvent.click(await screen.findByRole("button", { name: /unlink from this job/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/unlink-thread", expect.objectContaining({
|
||||
jobApplicationId: 42,
|
||||
threadId: "thread-1",
|
||||
nextDecision: "review",
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/no messages yet/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("lets the user move a linked Gmail thread to another job", async () => {
|
||||
correspondenceMessages = [
|
||||
{
|
||||
id: 702,
|
||||
jobApplicationId: 42,
|
||||
from: "Company",
|
||||
content: "Second import.",
|
||||
subject: "Backend Developer interview",
|
||||
channel: "Email",
|
||||
date: new Date().toISOString(),
|
||||
externalMessageId: "msg-1",
|
||||
externalThreadId: "thread-1",
|
||||
externalFrom: "Maria Recruiter <maria@acme.test>",
|
||||
externalTo: "user@example.test",
|
||||
},
|
||||
];
|
||||
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /manage thread-1/i }));
|
||||
fireEvent.mouseDown((await screen.findAllByRole("combobox")).slice(-1)[0]);
|
||||
fireEvent.click(await screen.findByRole("option", { name: /beta • platform engineer/i }));
|
||||
fireEvent.click(screen.getByRole("button", { name: /move thread/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith("/gmail/relink-thread", expect.objectContaining({
|
||||
jobApplicationId: 77,
|
||||
threadId: "thread-1",
|
||||
removeFromOtherJobs: true,
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
test("shows Gmail sync state diagnostics alongside linked thread continuity", async () => {
|
||||
renderDialog();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /import email/i }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: /^google$/i }));
|
||||
|
||||
expect(await screen.findByText(/sync checked/i)).toBeInTheDocument();
|
||||
expect((await screen.findAllByText(/token refresh failed/i)).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("manual Gmail search override reloads job candidates with queryOverride", async () => {
|
||||
renderDialog();
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import CorrespondenceInboxPage from './pages/CorrespondenceInboxPage';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<CorrespondenceInboxPage />
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('CorrespondenceInboxPage', () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: 1,
|
||||
jobApplicationId: 42,
|
||||
companyName: 'Acme Systems',
|
||||
jobTitle: 'Backend Engineer',
|
||||
from: 'Company',
|
||||
direction: 'inbound',
|
||||
subject: 'Interview invite',
|
||||
channel: 'Email',
|
||||
date: new Date().toISOString(),
|
||||
contentPreview: 'We would like to schedule an interview.',
|
||||
externalThreadId: 'thread-1',
|
||||
externalFrom: 'Maria Recruiter <maria@acme.test>',
|
||||
externalTo: 'user@example.test',
|
||||
labelCount: 2,
|
||||
attachmentCount: 1,
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders correspondence inbox items and reloads with filters', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/correspondence inbox/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/1 items/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/acme systems/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/backend engineer/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/2 labels/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/1 attachments/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(screen.getByLabelText(/search/i), { target: { value: 'Maria' } });
|
||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
|
||||
fireEvent.click((await screen.findAllByRole('option', { name: /Inbound/i }))[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.get).toHaveBeenLastCalledWith('/correspondence', expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
q: 'Maria',
|
||||
direction: 'inbound',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
import React from 'react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from './toast';
|
||||
import { I18nProvider } from './i18n/I18nProvider';
|
||||
import GmailReviewPage from './pages/GmailReviewPage';
|
||||
import { api } from './api';
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
api: {
|
||||
get: jest.fn(),
|
||||
post: jest.fn(),
|
||||
put: jest.fn(),
|
||||
patch: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||
},
|
||||
getApiErrorMessage: (error: any, fallback?: string) => fallback || 'Request failed.',
|
||||
}));
|
||||
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<ToastProvider>
|
||||
<I18nProvider>
|
||||
<MemoryRouter future={{ v7_startTransition: true, v7_relativeSplatPath: true }}>
|
||||
<GmailReviewPage />
|
||||
</MemoryRouter>
|
||||
</I18nProvider>
|
||||
</ToastProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('GmailReviewPage', () => {
|
||||
beforeEach(() => {
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/gmail/review-candidates') {
|
||||
return Promise.resolve({
|
||||
data: {
|
||||
queries: ['"Acme" "Backend Developer" newer_than:365d'],
|
||||
candidateThreadCount: 2,
|
||||
autoLinkThreadCount: 1,
|
||||
reviewThreadCount: 1,
|
||||
unmatchedThreadCount: 0,
|
||||
threads: [
|
||||
{
|
||||
threadId: 'thread-1',
|
||||
subject: 'Backend Developer interview',
|
||||
latestDate: new Date().toISOString(),
|
||||
messageCount: 2,
|
||||
routing: 'review',
|
||||
hasImportedMessages: false,
|
||||
matchedQueries: ['"Acme" "Backend Developer" newer_than:365d'],
|
||||
jobCandidates: [
|
||||
{ jobApplicationId: 42, jobTitle: 'Backend Developer', companyName: 'Acme', score: 24, confidence: 'medium', reasons: [{ label: 'company', value: 'Acme', points: 18 }] },
|
||||
],
|
||||
messages: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
|
||||
if (url === '/gmail/suggested-jobs') {
|
||||
return Promise.resolve({ data: { count: 0, items: [] } } as any);
|
||||
}
|
||||
|
||||
return Promise.resolve({ data: {} } as any);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('renders Gmail review queue summary and candidate threads', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/gmail review queue/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/2 candidate threads/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/backend developer interview/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/acme • backend developer \(24\)/i)).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.get).toHaveBeenCalledWith('/gmail/review-candidates');
|
||||
});
|
||||
});
|
||||
|
||||
test('persists a review decision for the top job', async () => {
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole('button', { name: /link top job/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/gmail/review-decision', {
|
||||
threadId: 'thread-1',
|
||||
decision: 'linked',
|
||||
jobApplicationId: 42,
|
||||
note: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -26,6 +26,14 @@ type AiServiceMetrics = {
|
||||
gpuName?: string | null;
|
||||
ocrAvailable?: boolean | null;
|
||||
ocrLanguages?: string | null;
|
||||
ollamaConfigured?: boolean | null;
|
||||
ollamaReachable?: boolean | null;
|
||||
ollamaModel?: string | null;
|
||||
ollamaModelAvailable?: boolean | null;
|
||||
ollamaVersion?: string | null;
|
||||
ollamaInstalledModels?: string[] | null;
|
||||
ollamaLoadedModels?: string[] | null;
|
||||
ollamaLoadedCount?: number | null;
|
||||
healthLatencyMs?: number | null;
|
||||
probeLatencyMs?: number | null;
|
||||
lastProbeAt?: string | null;
|
||||
@@ -60,6 +68,47 @@ type EditableEmailSettings = {
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
type CvBenchmarkEntry = {
|
||||
FileName: string;
|
||||
Slug: string;
|
||||
Extension: string;
|
||||
Characters: number;
|
||||
OutputPath: string;
|
||||
ApprovedFixturePath?: string | null;
|
||||
CandidateFixturePath?: string | null;
|
||||
ContactLocation?: string | null;
|
||||
FirstJob?: string | null;
|
||||
FirstJobLocation?: string | null;
|
||||
FirstEducation?: string | null;
|
||||
FirstEducationLocation?: string | null;
|
||||
QualificationLevels: string[];
|
||||
SuspiciousLocations: string[];
|
||||
CoverageScore: number;
|
||||
ConfidenceScore: number;
|
||||
ConsistencyScore: number;
|
||||
DiffSummary?: string | null;
|
||||
};
|
||||
|
||||
type CvBenchmarkIndex = {
|
||||
CorpusRoot: string;
|
||||
OutputRoot: string;
|
||||
GeneratedAtUtc: string;
|
||||
TotalFiles: number;
|
||||
AverageCoverage: number;
|
||||
AverageConfidence: number;
|
||||
AverageConsistency: number;
|
||||
FilesWithSuspiciousLocations: number;
|
||||
MissingApprovedFixtures: number;
|
||||
Entries: CvBenchmarkEntry[];
|
||||
};
|
||||
|
||||
type CvBenchmarkStatus = {
|
||||
indexJson?: string | null;
|
||||
reportMarkdown?: string | null;
|
||||
rootPath: string;
|
||||
lastUpdatedAtUtc?: string | null;
|
||||
};
|
||||
|
||||
type SystemStatus = {
|
||||
environment: string;
|
||||
contentRoot: string;
|
||||
@@ -121,6 +170,26 @@ function formatDate(value?: string | null) {
|
||||
return value ? new Date(value).toLocaleString() : "-";
|
||||
}
|
||||
|
||||
function formatPercent(value?: number | null) {
|
||||
return typeof value === "number" ? `${Math.round(value * 100)}%` : "-";
|
||||
}
|
||||
|
||||
function parseBenchmarkIndex(indexJson?: string | null): CvBenchmarkIndex | null {
|
||||
if (!indexJson?.trim()) return null;
|
||||
try {
|
||||
return JSON.parse(indexJson) as CvBenchmarkIndex;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function benchmarkTone(value?: number | null) {
|
||||
if (typeof value !== "number") return "default" as const;
|
||||
if (value >= 0.8) return "success" as const;
|
||||
if (value >= 0.6) return "warning" as const;
|
||||
return "error" as const;
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, subtitle, tone = "default" }: { title: string; value: string; subtitle?: string; tone?: "default" | "success" | "warning" | "error" }) {
|
||||
const color = tone === "success" ? "success.main" : tone === "warning" ? "warning.main" : tone === "error" ? "error.main" : "text.primary";
|
||||
return (
|
||||
@@ -141,6 +210,7 @@ export default function AdminSystemPage() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [status, setStatus] = useState<SystemStatus | null>(null);
|
||||
const [emailSettings, setEmailSettings] = useState<EditableEmailSettings | null>(null);
|
||||
const [benchmarkStatus, setBenchmarkStatus] = useState<CvBenchmarkStatus | null>(null);
|
||||
const [smtpPassword, setSmtpPassword] = useState("");
|
||||
const [clearPassword, setClearPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -156,18 +226,21 @@ export default function AdminSystemPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [statusRes, emailRes] = await Promise.all([
|
||||
const [statusRes, emailRes, benchmarkRes] = await Promise.all([
|
||||
api.get<SystemStatus>("/admin/system"),
|
||||
api.get<EditableEmailSettings>("/admin/system/email-settings"),
|
||||
api.get<CvBenchmarkStatus>("/admin/system/cv-benchmark").catch(() => ({ data: null } as any)),
|
||||
]);
|
||||
setStatus(statusRes.data);
|
||||
setEmailSettings(emailRes.data);
|
||||
setBenchmarkStatus(benchmarkRes.data ?? null);
|
||||
setSmtpPassword("");
|
||||
setClearPassword(false);
|
||||
} catch (e: any) {
|
||||
setError(getApiErrorMessage(e, "Failed to load system status."));
|
||||
setStatus(null);
|
||||
setEmailSettings(null);
|
||||
setBenchmarkStatus(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -191,6 +264,34 @@ export default function AdminSystemPage() {
|
||||
return "success" as const;
|
||||
}, [status]);
|
||||
|
||||
const benchmarkIndex = useMemo(() => parseBenchmarkIndex(benchmarkStatus?.indexJson), [benchmarkStatus?.indexJson]);
|
||||
const weakestEntries = useMemo(() => {
|
||||
if (!benchmarkIndex) return [] as CvBenchmarkEntry[];
|
||||
return [...benchmarkIndex.Entries]
|
||||
.sort((a, b) => (a.CoverageScore + a.ConfidenceScore + a.ConsistencyScore) - (b.CoverageScore + b.ConfidenceScore + b.ConsistencyScore))
|
||||
.slice(0, 6);
|
||||
}, [benchmarkIndex]);
|
||||
|
||||
const benchmarkFindings = useMemo(() => {
|
||||
if (!benchmarkIndex) return [] as Array<{ file: string; issue: string }>;
|
||||
return benchmarkIndex.Entries.flatMap((entry) => {
|
||||
const findings: Array<{ file: string; issue: string }> = [];
|
||||
if (entry.ContactLocation && /(culture|education|arial|hobbies|cooperate|ag, ni|bold)/i.test(entry.ContactLocation)) {
|
||||
findings.push({ file: entry.FileName, issue: `Suspicious contact location: ${entry.ContactLocation}` });
|
||||
}
|
||||
if (entry.FirstEducation && entry.FirstEducation.length > 120) {
|
||||
findings.push({ file: entry.FileName, issue: "Education qualification looks over-captured." });
|
||||
}
|
||||
if ((entry.FirstJob ?? "").length > 120) {
|
||||
findings.push({ file: entry.FileName, issue: "Work title looks over-captured." });
|
||||
}
|
||||
if ((entry.QualificationLevels ?? []).includes("Other")) {
|
||||
findings.push({ file: entry.FileName, issue: "Qualification level fell back to Other." });
|
||||
}
|
||||
return findings;
|
||||
}).slice(0, 10);
|
||||
}, [benchmarkIndex]);
|
||||
|
||||
const sendTestEmail = async () => {
|
||||
setSendingTestEmail(true);
|
||||
try {
|
||||
@@ -367,6 +468,8 @@ export default function AdminSystemPage() {
|
||||
<DetailRow label={t("adminSystemOllamaReachable")} value={status?.ai.ollamaReachable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label={t("adminSystemOllamaModel")} value={status?.ai.ollamaModel || "-"} />
|
||||
<DetailRow label={t("adminSystemOllamaModelAvailable")} value={status?.ai.ollamaModelAvailable ? t("yes") : t("noWord")} />
|
||||
<DetailRow label="Ollama version" value={status?.ai.ollamaVersion || "-"} />
|
||||
<DetailRow label="Loaded models" value={status?.ai.ollamaLoadedCount ?? 0} />
|
||||
<DetailRow label={t("adminSystemHealthLatency")} value={status?.ai.healthLatencyMs != null ? `${status.ai.healthLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemProbeLatency")} value={status?.ai.probeLatencyMs != null ? `${status.ai.probeLatencyMs} ms` : "-"} />
|
||||
<DetailRow label={t("adminSystemLastProbe")} value={formatDate(status?.ai.lastProbeAt)} />
|
||||
@@ -395,8 +498,79 @@ export default function AdminSystemPage() {
|
||||
<Chip label={status?.auth.gmailConfigured ? t("adminSystemGmailReady") : t("adminSystemGmailIncomplete")} variant="outlined" size="small" />
|
||||
<Chip label={status?.ai.gpuAvailable ? t("adminSystemGpuVisible") : t("adminSystemCpuMode")} color={status?.ai.gpuAvailable ? "success" : "default"} size="small" />
|
||||
<Chip label={status?.ai.ocrAvailable ? `OCR ${status.ai.ocrLanguages || "enabled"}` : t("adminSystemOcrUnavailable")} variant="outlined" size="small" />
|
||||
{(status?.ai.ollamaInstalledModels ?? []).slice(0, 4).map((model) => (
|
||||
<Chip key={model} label={`Model · ${model}`} variant="outlined" size="small" />
|
||||
))}
|
||||
{(status?.ai.ollamaLoadedModels ?? []).slice(0, 3).map((model) => (
|
||||
<Chip key={`loaded-${model}`} label={`Loaded · ${model}`} color="primary" variant="outlined" size="small" />
|
||||
))}
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 2, borderRadius: 3 }}>
|
||||
<Typography variant="h6" sx={{ fontWeight: 900, mb: 1 }}>CV benchmark review</Typography>
|
||||
<Stack spacing={0.75}>
|
||||
<DetailRow label="Benchmark root" value={benchmarkStatus?.rootPath || "-"} />
|
||||
<DetailRow label="Last benchmark update" value={formatDate(benchmarkStatus?.lastUpdatedAtUtc)} />
|
||||
<DetailRow label="Corpus root" value={benchmarkIndex?.CorpusRoot || "-"} />
|
||||
</Stack>
|
||||
|
||||
{benchmarkIndex ? (
|
||||
<>
|
||||
<Box sx={{ mt: 2, display: "grid", gridTemplateColumns: { xs: "1fr 1fr", md: "repeat(5, 1fr)" }, gap: 1.25 }}>
|
||||
<SummaryCard title="Files" value={String(benchmarkIndex.TotalFiles)} subtitle="Corpus inputs" />
|
||||
<SummaryCard title="Coverage" value={formatPercent(benchmarkIndex.AverageCoverage)} subtitle="Structured field coverage" tone={benchmarkTone(benchmarkIndex.AverageCoverage)} />
|
||||
<SummaryCard title="Confidence" value={formatPercent(benchmarkIndex.AverageConfidence)} subtitle="Field metadata confidence" tone={benchmarkTone(benchmarkIndex.AverageConfidence)} />
|
||||
<SummaryCard title="Consistency" value={formatPercent(benchmarkIndex.AverageConsistency)} subtitle="Normalization consistency" tone={benchmarkTone(benchmarkIndex.AverageConsistency)} />
|
||||
<SummaryCard title="Missing approved" value={String(benchmarkIndex.MissingApprovedFixtures)} subtitle="Needs fixture review" tone={benchmarkIndex.MissingApprovedFixtures > 0 ? "warning" : "success"} />
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2.5, display: "grid", gridTemplateColumns: { xs: "1fr", xl: "1.1fr 0.9fr" }, gap: 2 }}>
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>Top parser findings</Typography>
|
||||
<Stack spacing={1}>
|
||||
{benchmarkFindings.length > 0 ? benchmarkFindings.map((finding) => (
|
||||
<Box key={`${finding.file}:${finding.issue}`} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary" }}>{finding.file}</Typography>
|
||||
<Typography variant="body2">{finding.issue}</Typography>
|
||||
</Box>
|
||||
)) : <Typography variant="body2" sx={{ color: "text.secondary" }}>No standout benchmark anomalies in the current run.</Typography>}
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
<Paper variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900, mb: 1 }}>Weakest files in current run</Typography>
|
||||
<Stack spacing={1}>
|
||||
{weakestEntries.map((entry) => (
|
||||
<Box key={entry.Slug} sx={{ p: 1.25, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{entry.FileName}</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
|
||||
<Chip size="small" label={`Coverage ${formatPercent(entry.CoverageScore)}`} color={benchmarkTone(entry.CoverageScore)} />
|
||||
<Chip size="small" label={`Confidence ${formatPercent(entry.ConfidenceScore)}`} color={benchmarkTone(entry.ConfidenceScore)} />
|
||||
<Chip size="small" label={`Consistency ${formatPercent(entry.ConsistencyScore)}`} color={benchmarkTone(entry.ConsistencyScore)} />
|
||||
</Box>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mt: 0.75 }}>{entry.DiffSummary || "-"}</Typography>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ mt: 2, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider", maxHeight: 280, overflow: "auto" }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>Latest markdown summary</Typography>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
|
||||
{benchmarkStatus?.reportMarkdown || "-"}
|
||||
</Typography>
|
||||
</Box>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ mt: 1.5, p: 1.5, borderRadius: 2, backgroundColor: "background.default", border: "1px solid", borderColor: "divider" }}>
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap", fontFamily: "ui-monospace, SFMono-Regular, monospace" }}>
|
||||
{benchmarkStatus?.reportMarkdown || "Run scripts/run-cv-benchmark.sh to generate the latest corpus report and fixture candidates."}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Paper>
|
||||
</>
|
||||
) : (
|
||||
<Box sx={{ display: "grid", gap: 2 }}>
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
FormControl,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
TextField,
|
||||
Typography,
|
||||
Button,
|
||||
} from "@mui/material";
|
||||
import MailOutlineIcon from "@mui/icons-material/MailOutline";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useToast } from "../toast";
|
||||
|
||||
export type CorrespondenceInboxItem = {
|
||||
id: number;
|
||||
jobApplicationId: number;
|
||||
companyName?: string | null;
|
||||
jobTitle?: string | null;
|
||||
from: string;
|
||||
direction?: string | null;
|
||||
subject?: string | null;
|
||||
channel?: string | null;
|
||||
date: string;
|
||||
contentPreview: string;
|
||||
externalThreadId?: string | null;
|
||||
externalFrom?: string | null;
|
||||
externalTo?: string | null;
|
||||
labelCount: number;
|
||||
attachmentCount: number;
|
||||
};
|
||||
|
||||
export default function CorrespondenceInboxPage() {
|
||||
const navigate = useNavigate();
|
||||
const { toast } = useToast();
|
||||
const [items, setItems] = useState<CorrespondenceInboxItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
const [direction, setDirection] = useState<string>("all");
|
||||
const [linkState, setLinkState] = useState<string>("all");
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<CorrespondenceInboxItem[]>("/correspondence", {
|
||||
params: {
|
||||
q: query.trim() || undefined,
|
||||
direction: direction === "all" ? undefined : direction,
|
||||
linkState: linkState === "all" ? undefined : linkState,
|
||||
},
|
||||
});
|
||||
setItems(res.data ?? []);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to load correspondence inbox."), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [direction, linkState, query, toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const filteredSummary = useMemo(() => {
|
||||
const linked = items.filter((item) => item.externalThreadId).length;
|
||||
const inbound = items.filter((item) => item.direction === "inbound").length;
|
||||
return { linked, inbound };
|
||||
}, [items]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>Correspondence inbox</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Cross-job view of imported correspondence and Gmail-linked history.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Chip icon={<MailOutlineIcon />} label={`${items.length} items`} variant="outlined" />
|
||||
<Chip label={`${filteredSummary.linked} linked`} variant="outlined" color={filteredSummary.linked > 0 ? "success" : "default"} />
|
||||
<Chip label={`${filteredSummary.inbound} inbound`} variant="outlined" />
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "2fr 1fr 1fr auto" }, gap: 1.25, mb: 2 }}>
|
||||
<TextField label="Search" value={query} onChange={(e) => setQuery(e.target.value)} placeholder="Company, role, recruiter, subject" />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Direction</InputLabel>
|
||||
<Select value={direction} label="Direction" onChange={(e) => setDirection(String(e.target.value))}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="inbound">Inbound</MenuItem>
|
||||
<MenuItem value="outbound">Outbound</MenuItem>
|
||||
<MenuItem value="internal">Internal</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Link state</InputLabel>
|
||||
<Select value={linkState} label="Link state" onChange={(e) => setLinkState(String(e.target.value))}>
|
||||
<MenuItem value="all">All</MenuItem>
|
||||
<MenuItem value="linked">Linked threads</MenuItem>
|
||||
<MenuItem value="manual">Manual/internal only</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<Button variant="contained" onClick={() => void load()} disabled={loading}>{loading ? "Loading..." : "Refresh"}</Button>
|
||||
</Box>
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
|
||||
{!loading && items.length === 0 ? (
|
||||
<Typography sx={{ color: "text.secondary", py: 4, textAlign: "center" }}>No correspondence matches the current filters.</Typography>
|
||||
) : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{items.map((item) => (
|
||||
<Paper key={item.id} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0 }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{item.companyName || "Unknown company"} • {item.jobTitle || "Unknown role"}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", overflowWrap: "anywhere" }}>{item.subject || item.contentPreview}</Typography>
|
||||
<Typography variant="caption" sx={{ color: "text.secondary", display: "block", mt: 0.5 }}>
|
||||
{item.externalFrom || item.from} {item.externalTo ? `→ ${item.externalTo}` : ""} · {new Date(item.date).toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
{item.direction ? <Chip size="small" label={item.direction} variant="outlined" /> : null}
|
||||
{item.externalThreadId ? <Chip size="small" label={`Thread ${item.externalThreadId}`} color="success" variant="outlined" /> : <Chip size="small" label="Manual/internal" variant="outlined" />}
|
||||
{item.labelCount > 0 ? <Chip size="small" label={`${item.labelCount} labels`} variant="outlined" /> : null}
|
||||
{item.attachmentCount > 0 ? <Chip size="small" label={`${item.attachmentCount} attachments`} variant="outlined" /> : null}
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${item.jobApplicationId}`)}>Open job</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Box, Button, Chip, CircularProgress, Paper, Stack, TextField, Typography } from "@mui/material";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { CreatedSuggestedGmailJobResult, GmailManualSyncResult, GmailReviewQueueResponse, GmailSuggestedJobsResponse } from "../types";
|
||||
import { useToast } from "../toast";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function GmailReviewPage() {
|
||||
const { toast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<GmailReviewQueueResponse | null>(null);
|
||||
const [suggestions, setSuggestions] = useState<GmailSuggestedJobsResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [savingThreadId, setSavingThreadId] = useState<string | null>(null);
|
||||
const [creatingThreadId, setCreatingThreadId] = useState<string | null>(null);
|
||||
const [routingFilter, setRoutingFilter] = useState<"all" | "auto-link" | "review" | "unmatched" | "suggested" | "linked" | "rejected">("all");
|
||||
const [notes, setNotes] = useState<Record<string, string>>({});
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [reviewRes, suggestedRes] = await Promise.all([
|
||||
api.get<GmailReviewQueueResponse>("/gmail/review-candidates"),
|
||||
api.get<GmailSuggestedJobsResponse>("/gmail/suggested-jobs"),
|
||||
]);
|
||||
setData(reviewRes.data);
|
||||
setSuggestions(suggestedRes.data);
|
||||
setNotes((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const thread of reviewRes.data.threads) {
|
||||
if (next[thread.threadId] === undefined) next[thread.threadId] = thread.decisionNote || "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to load Gmail review candidates."), "error");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [toast]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const saveDecision = useCallback(async (threadId: string, decision: "linked" | "rejected" | "review" | "suggested", jobApplicationId?: number) => {
|
||||
setSavingThreadId(threadId);
|
||||
try {
|
||||
await api.post("/gmail/review-decision", {
|
||||
threadId,
|
||||
decision,
|
||||
jobApplicationId: decision === "linked" ? jobApplicationId ?? null : null,
|
||||
note: notes[threadId]?.trim() || null,
|
||||
});
|
||||
await load();
|
||||
toast(
|
||||
decision === "linked"
|
||||
? "Thread linked and imported."
|
||||
: decision === "rejected"
|
||||
? "Thread rejected from review."
|
||||
: decision === "suggested"
|
||||
? "Thread kept as suggested job material."
|
||||
: "Thread returned to review.",
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to save Gmail review decision."), "error");
|
||||
} finally {
|
||||
setSavingThreadId(null);
|
||||
}
|
||||
}, [load, notes, toast]);
|
||||
|
||||
const runManualSync = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await api.post<GmailManualSyncResult>("/gmail/manual-sync", {
|
||||
lookbackDays: 365,
|
||||
maxResultsPerQuery: 8,
|
||||
autoImportHighConfidence: true,
|
||||
includeSpamTrash: false,
|
||||
});
|
||||
await load();
|
||||
toast(
|
||||
`Manual Gmail sync finished: ${res.data.importedThreads} threads linked, ${res.data.reviewThreadCount} review, ${res.data.unmatchedThreadCount} unmatched.`,
|
||||
"success",
|
||||
);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to run Gmail manual sync."), "error");
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [load, toast]);
|
||||
|
||||
const createSuggestedJob = useCallback(async (threadId: string) => {
|
||||
const suggestion = suggestions?.items.find((item) => item.threadId === threadId);
|
||||
if (!suggestion) return;
|
||||
|
||||
setCreatingThreadId(threadId);
|
||||
try {
|
||||
const res = await api.post<CreatedSuggestedGmailJobResult>("/gmail/create-suggested-job", {
|
||||
threadId,
|
||||
companyName: suggestion.companyName || "Unknown company",
|
||||
jobTitle: suggestion.suggestedJobTitle || suggestion.subject || "Suggested role",
|
||||
recruiterName: suggestion.recruiterName || null,
|
||||
recruiterEmail: suggestion.recruiterEmail || null,
|
||||
notes: notes[threadId]?.trim() || suggestion.preview || null,
|
||||
status: "Applied",
|
||||
});
|
||||
await load();
|
||||
toast(`Created suggested job and imported ${res.data.imported} message${res.data.imported === 1 ? "" : "s"}.`, "success");
|
||||
navigate(`/jobs?open=${res.data.jobApplicationId}`);
|
||||
} catch (error) {
|
||||
toast(getApiErrorMessage(error, "Failed to create the suggested job."), "error");
|
||||
} finally {
|
||||
setCreatingThreadId(null);
|
||||
}
|
||||
}, [load, navigate, notes, suggestions?.items, toast]);
|
||||
|
||||
const filteredThreads = useMemo(() => {
|
||||
const threads = data?.threads ?? [];
|
||||
return routingFilter === "all" ? threads : threads.filter((thread) => thread.routing === routingFilter);
|
||||
}, [data?.threads, routingFilter]);
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, alignItems: "center", flexWrap: "wrap", mb: 2 }}>
|
||||
<Box>
|
||||
<Typography variant="h5" sx={{ fontWeight: 900 }}>Gmail review queue</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
Manual sync, high-confidence auto-linking, medium-confidence review, and suggested jobs from unmatched Gmail threads.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="contained" onClick={() => void runManualSync()} disabled={syncing}>
|
||||
{syncing ? "Syncing..." : "Run manual sync"}
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={() => void load()} disabled={loading || syncing}>
|
||||
{loading ? "Loading..." : "Refresh"}
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={`Filter: ${routingFilter}`} variant="outlined" />
|
||||
{(["all", "auto-link", "review", "unmatched", "suggested", "linked", "rejected"] as const).map((value) => (
|
||||
<Button key={value} size="small" variant={routingFilter === value ? "contained" : "text"} onClick={() => setRoutingFilter(value)}>
|
||||
{value}
|
||||
</Button>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{data ? (
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", mb: 2 }}>
|
||||
<Chip label={`${data.candidateThreadCount} candidate threads`} variant="outlined" />
|
||||
<Chip label={`${data.autoLinkThreadCount} auto-link`} color="success" variant="outlined" />
|
||||
<Chip label={`${data.reviewThreadCount} review`} color="warning" variant="outlined" />
|
||||
<Chip label={`${data.unmatchedThreadCount} unmatched`} variant="outlined" />
|
||||
{suggestions?.count ? <Chip label={`${suggestions.count} suggested jobs`} color="secondary" variant="outlined" /> : null}
|
||||
</Box>
|
||||
) : null}
|
||||
|
||||
{loading ? <Box sx={{ py: 6, display: "flex", justifyContent: "center" }}><CircularProgress size={28} /></Box> : null}
|
||||
{!loading && data && filteredThreads.length === 0 ? <Typography sx={{ color: "text.secondary" }}>No Gmail review candidates match the current filter.</Typography> : null}
|
||||
|
||||
<Stack spacing={1.25}>
|
||||
{filteredThreads.map((thread) => {
|
||||
const suggestion = (suggestions?.items ?? []).find((item) => item.threadId === thread.threadId);
|
||||
return (
|
||||
<Paper key={thread.threadId} variant="outlined" sx={{ p: 1.5, borderRadius: 3 }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "flex-start" }}>
|
||||
<Box sx={{ minWidth: 0, flex: "1 1 420px" }}>
|
||||
<Typography sx={{ fontWeight: 800, overflowWrap: "anywhere" }}>{thread.subject}</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{thread.messageCount} messages · {thread.routing}
|
||||
</Typography>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", mt: 0.75 }}>
|
||||
{thread.matchedQueries.slice(0, 3).map((query) => (
|
||||
<Chip key={query} size="small" label={query} variant="outlined" />
|
||||
))}
|
||||
{thread.hasImportedMessages ? <Chip size="small" label="Has imported messages" color="success" variant="outlined" /> : null}
|
||||
</Box>
|
||||
<TextField
|
||||
label="Review notes"
|
||||
value={notes[thread.threadId] ?? ""}
|
||||
onChange={(event) => setNotes((prev) => ({ ...prev, [thread.threadId]: event.target.value }))}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
minRows={2}
|
||||
sx={{ mt: 1.25 }}
|
||||
placeholder="Why this should link, stay in review, or become a suggested job."
|
||||
/>
|
||||
{suggestion ? (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", mt: 1 }}>
|
||||
Suggested job: {suggestion.companyName || "Unknown company"} · {suggestion.suggestedJobTitle || "Unknown role"}
|
||||
</Typography>
|
||||
) : null}
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 0.75, flexWrap: "wrap", justifyContent: "flex-end" }}>
|
||||
{thread.jobCandidates.slice(0, 2).map((candidate) => (
|
||||
<Chip
|
||||
key={candidate.jobApplicationId}
|
||||
label={`${candidate.companyName} • ${candidate.jobTitle} (${candidate.score})`}
|
||||
variant="outlined"
|
||||
color={candidate.confidence === "high" ? "success" : candidate.confidence === "medium" ? "warning" : "default"}
|
||||
/>
|
||||
))}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button size="small" variant="text" onClick={() => navigate(`/jobs?open=${thread.jobCandidates[0].jobApplicationId}`)}>
|
||||
Open top job
|
||||
</Button>
|
||||
) : null}
|
||||
{thread.jobCandidates[0] ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="contained"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "linked", thread.jobCandidates[0].jobApplicationId)}
|
||||
>
|
||||
Link top job
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="warning"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "review")}
|
||||
>
|
||||
Keep in review
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "suggested")}
|
||||
>
|
||||
Suggested job
|
||||
</Button>
|
||||
{suggestion ? (
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
disabled={creatingThreadId === thread.threadId}
|
||||
onClick={() => void createSuggestedJob(thread.threadId)}
|
||||
>
|
||||
{creatingThreadId === thread.threadId ? "Creating..." : "Create job"}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="error"
|
||||
disabled={savingThreadId === thread.threadId}
|
||||
onClick={() => void saveDecision(thread.threadId, "rejected")}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ import { JobApplication } from "../types";
|
||||
|
||||
|
||||
type CvSectionOption = "" | "Professional Summary" | "Core Skills" | "Experience Highlights" | "Selected Achievements" | "Projects";
|
||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh";
|
||||
type CvSectionStyle = "ats-minimal" | "harvard" | "auckland" | "edinburgh" | "monarch" | "fjord";
|
||||
|
||||
type ExtractionRun = {
|
||||
id: number;
|
||||
@@ -60,6 +60,18 @@ type RewriteTemplateOption = {
|
||||
sampleBullets: string[];
|
||||
};
|
||||
|
||||
type CvBuilderPreview = {
|
||||
templateId: CvSectionStyle;
|
||||
html: string;
|
||||
suggestedFileName: string;
|
||||
fullText: string;
|
||||
rewrittenText: string;
|
||||
structuredCv: StructuredCvProfile;
|
||||
sectionName?: string | null;
|
||||
targetRole?: string | null;
|
||||
jobApplicationId?: number | null;
|
||||
};
|
||||
|
||||
type MeResponse = {
|
||||
provider?: "local" | "google" | "external";
|
||||
id?: string;
|
||||
@@ -122,6 +134,26 @@ const REWRITE_TEMPLATES: RewriteTemplateOption[] = [
|
||||
sampleMeta: "Premium spacing · stronger visual voice",
|
||||
sampleBullets: ["Useful when the CV should feel more distinctive.", "Still keeps wording grounded and factual."]
|
||||
},
|
||||
{
|
||||
id: "monarch",
|
||||
title: "Monarch",
|
||||
eyebrow: "Executive",
|
||||
accent: "#7c2d12",
|
||||
blurb: "High-contrast premium presentation for leadership-heavy applications.",
|
||||
sampleHeading: "Executive Profile",
|
||||
sampleMeta: "Leadership clarity · premium hierarchy",
|
||||
sampleBullets: ["Adds more top-level summary emphasis.", "Well suited to senior strategic roles."]
|
||||
},
|
||||
{
|
||||
id: "fjord",
|
||||
title: "Fjord",
|
||||
eyebrow: "Technical",
|
||||
accent: "#0f4c5c",
|
||||
blurb: "Calm, high-density layout for engineering resumes and project-heavy CVs.",
|
||||
sampleHeading: "Projects & Systems",
|
||||
sampleMeta: "Technical depth · practical readability",
|
||||
sampleBullets: ["Gives projects and skills more weight.", "Better for technical detail without chaos."]
|
||||
},
|
||||
];
|
||||
|
||||
function initialsFrom(values: Array<string | undefined>) {
|
||||
@@ -135,28 +167,6 @@ function initialsFrom(values: Array<string | undefined>) {
|
||||
return (joined[0][0] + joined[1][0]).toUpperCase();
|
||||
}
|
||||
|
||||
function replaceCvSection(source: string, sectionName: string, sectionDraft: string) {
|
||||
const trimmedSource = source.trim();
|
||||
const trimmedDraft = sectionDraft.trim();
|
||||
if (!trimmedDraft) return source;
|
||||
if (!trimmedSource) return `${sectionName}\n${trimmedDraft}`;
|
||||
|
||||
const headingPattern = /^([A-Z][A-Za-z &/]+):?\s*$/gm;
|
||||
const matches = Array.from(trimmedSource.matchAll(headingPattern));
|
||||
const normalizedTarget = sectionName.trim().toLowerCase();
|
||||
const targetIndex = matches.findIndex((match) => match[1].trim().toLowerCase() === normalizedTarget);
|
||||
|
||||
if (targetIndex === -1) {
|
||||
return `${trimmedSource}\n\n${sectionName}\n${trimmedDraft}`.trim();
|
||||
}
|
||||
|
||||
const start = matches[targetIndex].index ?? 0;
|
||||
const end = targetIndex + 1 < matches.length ? (matches[targetIndex + 1].index ?? trimmedSource.length) : trimmedSource.length;
|
||||
const before = trimmedSource.slice(0, start).trimEnd();
|
||||
const after = trimmedSource.slice(end).trimStart();
|
||||
return [before, `${sectionName}\n${trimmedDraft}`, after].filter(Boolean).join("\n\n").trim();
|
||||
}
|
||||
|
||||
function confidenceTone(confidence?: number) {
|
||||
if (typeof confidence !== "number") return { label: "Review", color: "default" as const };
|
||||
if (confidence >= 0.8) return { label: `High ${Math.round(confidence * 100)}%`, color: "success" as const };
|
||||
@@ -208,7 +218,7 @@ export default function ProfilePage() {
|
||||
const [cvSectionStyle, setCvSectionStyle] = useState<CvSectionStyle>("ats-minimal");
|
||||
const [cvSectionTargetRole, setCvSectionTargetRole] = useState("");
|
||||
const [selectedRewriteJobId, setSelectedRewriteJobId] = useState<string>("");
|
||||
const [cvSectionDraft, setCvSectionDraft] = useState("");
|
||||
const [rewritePreview, setRewritePreview] = useState<CvBuilderPreview | null>(null);
|
||||
const [rewritePreviewTemplate, setRewritePreviewTemplate] = useState<RewriteTemplateOption | null>(null);
|
||||
const [savedJobs, setSavedJobs] = useState<JobApplication[]>([]);
|
||||
const [parsingCvSections, setParsingCvSections] = useState(false);
|
||||
@@ -263,6 +273,7 @@ export default function ProfilePage() {
|
||||
const latestRun = extractionRuns[0];
|
||||
const selectedRewriteTemplate = REWRITE_TEMPLATES.find((option) => option.id === cvSectionStyle) ?? REWRITE_TEMPLATES[0];
|
||||
const selectedRewriteJob = savedJobs.find((job) => String(job.id) === selectedRewriteJobId) ?? null;
|
||||
const rewriteReady = Boolean(rewritePreview?.html && rewritePreview.fullText.trim());
|
||||
|
||||
return (
|
||||
<Paper sx={{ mt: 0, p: 2.5 }}>
|
||||
@@ -742,39 +753,22 @@ export default function ProfilePage() {
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box sx={{ mt: 2, p: 2, borderRadius: 3, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.03) 0%, rgba(15,23,42,0) 100%)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box sx={{ mt: 2, p: 2.25, borderRadius: 4, border: "1px solid", borderColor: "divider", background: "linear-gradient(180deg, rgba(15,23,42,0.04) 0%, rgba(15,23,42,0) 100%)" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.75 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 800 }}>CV style rewrite studio</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("profileCvSectionToolsHelp")}</Typography>
|
||||
<Typography variant="subtitle1" sx={{ fontWeight: 900 }}>Template-driven CV builder</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", maxWidth: 720 }}>
|
||||
Choose a template, optionally target one section, and tailor the output toward a saved job or free-text role target. The preview below renders the actual PDF layout before you apply it.
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap", alignItems: "center" }}>
|
||||
<Chip size="small" variant="outlined" label={cvSection || "Whole CV rewrite"} />
|
||||
{selectedRewriteJob ? <Chip size="small" color="primary" variant="outlined" label={`Saved job · ${selectedRewriteJob.jobTitle}`} /> : null}
|
||||
{rewriteReady ? <Chip size="small" color="success" label="Preview ready" /> : null}
|
||||
</Box>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<{ text?: string }>("/profile-cv/rewrite-section", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
});
|
||||
setCvSectionDraft(res.data?.text ?? "");
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : t("profileCvSectionRewrite")}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(4, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 1.5, mb: 2 }}>
|
||||
{REWRITE_TEMPLATES.map((option) => {
|
||||
const selected = option.id === cvSectionStyle;
|
||||
return (
|
||||
@@ -791,25 +785,27 @@ export default function ProfilePage() {
|
||||
}}
|
||||
sx={{
|
||||
p: 1.5,
|
||||
borderRadius: 3,
|
||||
borderRadius: 3.5,
|
||||
cursor: "pointer",
|
||||
border: "1px solid",
|
||||
borderColor: selected ? "primary.main" : "divider",
|
||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.15)" : "none",
|
||||
backgroundColor: selected ? "rgba(25,118,210,0.06)" : "background.paper",
|
||||
boxShadow: selected ? "0 0 0 1px rgba(25,118,210,0.18), 0 12px 30px rgba(15,23,42,0.08)" : "0 6px 18px rgba(15,23,42,0.04)",
|
||||
background: selected ? `linear-gradient(180deg, ${option.accent}12 0%, rgba(255,255,255,0.96) 100%)` : "background.paper",
|
||||
transition: "transform 120ms ease, box-shadow 120ms ease, border-color 120ms ease",
|
||||
'&:hover': { transform: 'translateY(-2px)' },
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "flex-start", gap: 1, mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 800 }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{option.title}</Typography>
|
||||
<Typography variant="overline" sx={{ color: option.accent, fontWeight: 900, letterSpacing: '0.14em' }}>{option.eyebrow}</Typography>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>{option.title}</Typography>
|
||||
</Box>
|
||||
<IconButton size="small" onClick={(event) => { event.stopPropagation(); setRewritePreviewTemplate(option); }}>
|
||||
<ZoomInOutlinedIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
<Box sx={{ p: 1.25, borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 148 }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 700, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
||||
<Box sx={{ p: 1.25, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", minHeight: 160 }}>
|
||||
<Typography variant="caption" sx={{ display: "block", color: option.accent, fontWeight: 800, mb: 0.5 }}>{option.sampleHeading}</Typography>
|
||||
<Typography variant="caption" sx={{ display: "block", color: "text.secondary", mb: 1 }}>{option.sampleMeta}</Typography>
|
||||
{option.sampleBullets.map((bullet) => (
|
||||
<Typography key={bullet} variant="caption" sx={{ display: "block", color: "text.primary", mb: 0.5 }}>• {bullet}</Typography>
|
||||
@@ -821,7 +817,7 @@ export default function ProfilePage() {
|
||||
})}
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.5 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "1fr 1fr" }, gap: 1.5, mb: 1.75 }}>
|
||||
<FormControl fullWidth size="small">
|
||||
<InputLabel>{t("profileCvSectionLabel")}</InputLabel>
|
||||
<Select value={cvSection} label={t("profileCvSectionLabel")} onChange={(e) => setCvSection(e.target.value as CvSectionOption)}>
|
||||
@@ -833,7 +829,13 @@ export default function ProfilePage() {
|
||||
<MenuItem value="Projects">{t("profileCvSectionProjects")}</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField label={t("profileCvSectionTargetRole")} value={cvSectionTargetRole} onChange={(e) => setCvSectionTargetRole(e.target.value)} fullWidth helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : undefined} />
|
||||
<TextField
|
||||
label={t("profileCvSectionTargetRole")}
|
||||
value={cvSectionTargetRole}
|
||||
onChange={(e) => setCvSectionTargetRole(e.target.value)}
|
||||
fullWidth
|
||||
helperText={selectedRewriteJob ? `Using saved job context: ${selectedRewriteJob.jobTitle}` : "Leave empty to let the selected job drive tailoring."}
|
||||
/>
|
||||
<FormControl fullWidth size="small" sx={{ gridColumn: { xs: "1 / -1", md: "1 / -1" } }}>
|
||||
<InputLabel>Saved job context</InputLabel>
|
||||
<Select value={selectedRewriteJobId} label="Saved job context" onChange={(e) => setSelectedRewriteJobId(String(e.target.value))}>
|
||||
@@ -845,27 +847,121 @@ export default function ProfilePage() {
|
||||
</FormControl>
|
||||
</Box>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Rewrite preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · {cvSection || "Whole CV"}</Typography>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 2, flexWrap: "wrap", alignItems: "center", mb: 1.5 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 900 }}>Builder output</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||
{selectedRewriteTemplate.title} · {rewritePreview?.targetRole || selectedRewriteJob?.jobTitle || cvSectionTargetRole || "General reuse"}
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box sx={{ display: "flex", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!isLocal || (!profileCvText.trim() && structuredCv.sections.length === 0) || rewritingSection || uploadingCv || improvingCv || rebuildingCv}
|
||||
onClick={async () => {
|
||||
setRewritingSection(true);
|
||||
try {
|
||||
const res = await api.post<CvBuilderPreview>("/profile-cv/rewrite-preview", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
});
|
||||
setRewritePreview(res.data);
|
||||
toast(t("profileCvSectionRewritten"), "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || t("profileCvSectionRewriteFailed")), "error");
|
||||
} finally {
|
||||
setRewritingSection(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{rewritingSection ? t("profileCvSectionRewriting") : rewriteReady ? "Refresh preview" : "Build preview"}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outlined"
|
||||
disabled={!rewriteReady}
|
||||
onClick={async () => {
|
||||
try {
|
||||
const response = await api.post("/profile-cv/export-pdf", {
|
||||
sectionName: cvSection || null,
|
||||
style: cvSectionStyle,
|
||||
templateId: cvSectionStyle,
|
||||
targetRole: cvSectionTargetRole.trim() || null,
|
||||
jobApplicationId: selectedRewriteJob ? selectedRewriteJob.id : null,
|
||||
sourceText: profileCvText.trim() || null,
|
||||
}, { responseType: "blob" });
|
||||
const blob = new Blob([response.data], { type: "application/pdf" });
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
link.download = rewritePreview?.suggestedFileName || `${cvSectionStyle}-cv.pdf`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast("CV PDF downloaded.", "success");
|
||||
} catch (e: any) {
|
||||
toast(String(e?.response?.data || e?.message || "Failed to export the CV PDF."), "error");
|
||||
}
|
||||
}}
|
||||
>
|
||||
Download PDF
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", xl: "0.9fr 1.1fr" }, gap: 1.5 }}>
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>{rewritePreview?.sectionName || "Full rewritten CV text"}</Typography>
|
||||
{rewriteReady ? <Chip size="small" color="success" label={`${(rewritePreview?.fullText || "").trim().split(/\s+/).filter(Boolean).length} words`} /> : null}
|
||||
</Box>
|
||||
{cvSectionDraft.trim() ? <Chip size="small" color="success" label="Draft ready" /> : null}
|
||||
</Box>
|
||||
<Box sx={{ minHeight: 180, borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{cvSectionDraft.trim() ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{cvSectionDraft}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a CV style, optionally aim it at a saved job, and generate a rewrite preview here.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!cvSectionDraft.trim()} onClick={() => navigator.clipboard.writeText(cvSectionDraft)}>{t("profileCopyCvText")}</Button>
|
||||
<Button variant="outlined" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? `${prev.trim()}\n\n${cvSection}\n${cvSectionDraft.trim()}`.trim() : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionAppend") : "Use as full CV"}</Button>
|
||||
<Button variant="contained" disabled={!cvSectionDraft.trim()} onClick={() => setProfileCvText((prev) => cvSection ? replaceCvSection(prev, cvSection, cvSectionDraft) : cvSectionDraft.trim())}>{cvSection ? t("profileCvSectionReplace") : "Replace full CV"}</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
<Box sx={{ minHeight: 220, maxHeight: 520, overflow: "auto", borderRadius: 2.5, backgroundColor: "background.default", border: "1px dashed", borderColor: "divider", p: 1.5 }}>
|
||||
{rewriteReady ? (
|
||||
<Typography variant="body2" sx={{ whiteSpace: "pre-wrap" }}>{rewritePreview?.sectionName ? rewritePreview?.rewrittenText : rewritePreview?.fullText}</Typography>
|
||||
) : (
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>Choose a template and generate a live preview. The builder will show rewritten content here and render the PDF layout beside it.</Typography>
|
||||
)}
|
||||
</Box>
|
||||
<Box sx={{ mt: 1.25, display: "flex", justifyContent: "flex-end", gap: 1, flexWrap: "wrap" }}>
|
||||
<Button variant="text" disabled={!rewriteReady} onClick={() => navigator.clipboard.writeText(rewritePreview?.fullText ?? "")}>{t("profileCopyCvText")}</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
disabled={!rewriteReady}
|
||||
onClick={() => {
|
||||
setProfileCvText(rewritePreview?.fullText ?? "");
|
||||
if (rewritePreview?.structuredCv) setStructuredCv(normalizeStructuredCv(rewritePreview.structuredCv));
|
||||
}}
|
||||
>
|
||||
Replace master CV
|
||||
</Button>
|
||||
</Box>
|
||||
</Paper>
|
||||
|
||||
<Paper sx={{ p: 1.5, borderRadius: 3, border: "1px solid", borderColor: "divider", backgroundColor: "background.paper" }}>
|
||||
<Box sx={{ display: "flex", justifyContent: "space-between", gap: 1, alignItems: "center", mb: 1 }}>
|
||||
<Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800 }}>Styled preview</Typography>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{selectedRewriteTemplate.title} · print-ready layout</Typography>
|
||||
</Box>
|
||||
{rewriteReady ? <Chip size="small" variant="outlined" label={rewritePreview?.suggestedFileName || "preview.pdf"} /> : null}
|
||||
</Box>
|
||||
<Box sx={{ borderRadius: 2.5, border: "1px solid", borderColor: "divider", backgroundColor: "background.default", overflow: "hidden", minHeight: 520 }}>
|
||||
{rewriteReady ? (
|
||||
<iframe title="Profile CV preview" srcDoc={rewritePreview?.html} style={{ width: "100%", minHeight: 520, border: 0, background: "white" }} />
|
||||
) : (
|
||||
<Box sx={{ minHeight: 520, display: "grid", placeItems: "center", p: 3 }}>
|
||||
<Typography variant="body2" sx={{ color: "text.secondary", textAlign: "center", maxWidth: 360 }}>
|
||||
The visual preview uses the same server-rendered HTML that the PDF exporter prints. Build a preview to inspect layout, spacing, and hierarchy before you apply it.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Paper>
|
||||
</Box>
|
||||
|
||||
<Dialog open={Boolean(rewritePreviewTemplate)} onClose={() => setRewritePreviewTemplate(null)} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{rewritePreviewTemplate?.title ?? "Template preview"}</DialogTitle>
|
||||
|
||||
@@ -146,8 +146,8 @@ beforeEach(() => {
|
||||
},
|
||||
} as any);
|
||||
}
|
||||
if (url === '/profile-cv/rewrite-section') {
|
||||
return Promise.resolve({ data: { text: 'Professional Summary\nClearer, sharper positioning for backend platform roles.' } } as any);
|
||||
if (url === '/profile-cv/rewrite-preview') {
|
||||
return Promise.resolve({ data: { templateId: 'harvard', html: '<html><body>Preview</body></html>', suggestedFileName: 'harvard-preview.pdf', fullText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', rewrittenText: 'Professional Summary\nClearer, sharper positioning for backend platform roles.', structuredCv, sectionName: null, jobApplicationId: 42, targetRole: 'Senior Backend Engineer' } } as any);
|
||||
}
|
||||
if (url === '/profile-cv/reprocess') {
|
||||
return Promise.resolve({ data: { reprocessed: true } } as any);
|
||||
@@ -228,16 +228,16 @@ test('profile page keeps raw extraction collapsed until expanded', async () => {
|
||||
test('profile page rewrite tools use selected template and saved job context', async () => {
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByText(/cv style rewrite studio/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/template-driven cv builder/i)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByText(/harvard/i));
|
||||
fireEvent.mouseDown(screen.getAllByRole('combobox')[1]);
|
||||
fireEvent.click(await screen.findByText(/senior backend engineer · acme systems/i));
|
||||
|
||||
const rewriteButton = screen.getByRole('button', { name: /rewrite section/i });
|
||||
const rewriteButton = screen.getByRole('button', { name: /build preview/i });
|
||||
fireEvent.click(rewriteButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-section', expect.objectContaining({
|
||||
expect(mockedApi.post).toHaveBeenCalledWith('/profile-cv/rewrite-preview', expect.objectContaining({
|
||||
sectionName: null,
|
||||
style: 'harvard',
|
||||
templateId: 'harvard',
|
||||
@@ -245,7 +245,7 @@ test('profile page rewrite tools use selected template and saved job context', a
|
||||
}));
|
||||
});
|
||||
|
||||
expect(await screen.findByText(/draft ready/i)).toBeInTheDocument();
|
||||
expect(await screen.findByText(/preview ready/i)).toBeInTheDocument();
|
||||
expect(screen.getByText(/clearer, sharper positioning for backend platform roles/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ export type StructuredCvJob = {
|
||||
|
||||
export type StructuredCvEducation = {
|
||||
qualification?: string;
|
||||
qualificationLevel?: string;
|
||||
institution?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
@@ -51,6 +52,24 @@ export type StructuredCvEducation = {
|
||||
details: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvCertification = {
|
||||
name?: string;
|
||||
issuer?: string;
|
||||
location?: string;
|
||||
date?: string;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvProject = {
|
||||
name?: string;
|
||||
role?: string;
|
||||
location?: string;
|
||||
start?: string;
|
||||
end?: string;
|
||||
bullets: string[];
|
||||
skills: string[];
|
||||
};
|
||||
|
||||
export type StructuredCvLanguage = {
|
||||
name?: string;
|
||||
level?: string;
|
||||
@@ -69,6 +88,8 @@ export type StructuredCvProfile = {
|
||||
summary: string[];
|
||||
jobs: StructuredCvJob[];
|
||||
education: StructuredCvEducation[];
|
||||
certifications: StructuredCvCertification[];
|
||||
projects: StructuredCvProject[];
|
||||
skills: string[];
|
||||
languages: StructuredCvLanguage[];
|
||||
interests: string[];
|
||||
@@ -95,6 +116,8 @@ export function emptyStructuredCv(): StructuredCvProfile {
|
||||
summary: [],
|
||||
jobs: [],
|
||||
education: [],
|
||||
certifications: [],
|
||||
projects: [],
|
||||
skills: [],
|
||||
languages: [],
|
||||
interests: [],
|
||||
@@ -214,6 +237,7 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
education: Array.isArray(source.education)
|
||||
? source.education.map((education: any) => ({
|
||||
qualification: normalizeString(education?.qualification),
|
||||
qualificationLevel: normalizeString(education?.qualificationLevel),
|
||||
institution: normalizeString(education?.institution),
|
||||
location: normalizeString(education?.location),
|
||||
start: normalizeString(education?.start),
|
||||
@@ -221,6 +245,26 @@ export function normalizeStructuredCv(value: unknown): StructuredCvProfile {
|
||||
details: normalizeList(education?.details),
|
||||
}))
|
||||
: [],
|
||||
certifications: Array.isArray(source.certifications)
|
||||
? source.certifications.map((certification: any) => ({
|
||||
name: normalizeString(certification?.name),
|
||||
issuer: normalizeString(certification?.issuer),
|
||||
location: normalizeString(certification?.location),
|
||||
date: normalizeString(certification?.date),
|
||||
details: normalizeList(certification?.details),
|
||||
}))
|
||||
: [],
|
||||
projects: Array.isArray(source.projects)
|
||||
? source.projects.map((project: any) => ({
|
||||
name: normalizeString(project?.name),
|
||||
role: normalizeString(project?.role),
|
||||
location: normalizeString(project?.location),
|
||||
start: normalizeString(project?.start),
|
||||
end: normalizeString(project?.end),
|
||||
bullets: normalizeList(project?.bullets),
|
||||
skills: normalizeList(project?.skills),
|
||||
}))
|
||||
: [],
|
||||
skills: normalizeList(source.skills),
|
||||
languages: Array.isArray(source.languages)
|
||||
? source.languages.map((language: any) => ({
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface TailoredCvExperienceItem {
|
||||
|
||||
export interface TailoredCvEducationItem {
|
||||
qualification?: string | null;
|
||||
qualificationLevel?: string | null;
|
||||
institution?: string | null;
|
||||
location?: string | null;
|
||||
start?: string | null;
|
||||
@@ -199,10 +200,19 @@ export interface SaveApplicationDraftsRequest {
|
||||
recruiterMessageDraft?: string | null;
|
||||
}
|
||||
|
||||
export interface CorrespondenceAttachmentMetadata {
|
||||
fileName?: string | null;
|
||||
mimeType?: string | null;
|
||||
sizeBytes?: number | null;
|
||||
gmailAttachmentId?: string | null;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
export interface CorrespondenceMessage {
|
||||
id: number;
|
||||
jobApplicationId: number;
|
||||
from: string;
|
||||
direction?: string | null;
|
||||
content: string;
|
||||
subject?: string;
|
||||
channel?: string;
|
||||
@@ -211,6 +221,8 @@ export interface CorrespondenceMessage {
|
||||
externalThreadId?: string | null;
|
||||
externalFrom?: string | null;
|
||||
externalTo?: string | null;
|
||||
externalLabelsJson?: string | null;
|
||||
attachmentMetadataJson?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailJobMatchReason {
|
||||
@@ -287,11 +299,103 @@ export interface GmailThreadRefreshResult {
|
||||
threads: GmailThreadRefreshThreadResult[];
|
||||
}
|
||||
|
||||
export interface GmailReviewJobCandidate {
|
||||
jobApplicationId: number;
|
||||
jobTitle: string;
|
||||
companyName: string;
|
||||
score: number;
|
||||
confidence: string;
|
||||
reasons: GmailJobMatchReason[];
|
||||
}
|
||||
|
||||
export interface GmailReviewThread {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
latestDate?: string;
|
||||
messageCount: number;
|
||||
routing: string;
|
||||
hasImportedMessages: boolean;
|
||||
decisionNote?: string | null;
|
||||
matchedQueries: string[];
|
||||
jobCandidates: GmailReviewJobCandidate[];
|
||||
messages: GmailJobMatchedMessage[];
|
||||
}
|
||||
|
||||
export interface GmailReviewQueueResponse {
|
||||
queries: string[];
|
||||
candidateThreadCount: number;
|
||||
autoLinkThreadCount: number;
|
||||
reviewThreadCount: number;
|
||||
unmatchedThreadCount: number;
|
||||
threads: GmailReviewThread[];
|
||||
}
|
||||
|
||||
export interface GmailStatus {
|
||||
connected: boolean;
|
||||
gmailAddress?: string;
|
||||
connectedAt?: string;
|
||||
lastSyncedAt?: string;
|
||||
lastSyncAttemptedAt?: string;
|
||||
lastSyncSucceededAt?: string;
|
||||
lastSyncMode?: string | null;
|
||||
lastSyncSource?: string | null;
|
||||
lastSyncStatus?: string | null;
|
||||
lastSyncError?: string | null;
|
||||
}
|
||||
|
||||
export interface GmailManualSyncResult {
|
||||
queriesRun: number;
|
||||
candidateThreadCount: number;
|
||||
autoLinkedThreadCount: number;
|
||||
reviewThreadCount: number;
|
||||
unmatchedThreadCount: number;
|
||||
importedMessages: number;
|
||||
importedThreads: number;
|
||||
skippedMessages: number;
|
||||
lookbackDays: number;
|
||||
includeSpamTrash: boolean;
|
||||
syncedAt: string;
|
||||
}
|
||||
|
||||
export interface GmailSuggestedJobCandidate {
|
||||
threadId: string;
|
||||
subject: string;
|
||||
latestDate?: string | null;
|
||||
companyName?: string | null;
|
||||
recruiterName?: string | null;
|
||||
recruiterEmail?: string | null;
|
||||
suggestedJobTitle?: string | null;
|
||||
routing: string;
|
||||
matchedQueries: string[];
|
||||
preview: string;
|
||||
}
|
||||
|
||||
export interface GmailSuggestedJobsResponse {
|
||||
count: number;
|
||||
items: GmailSuggestedJobCandidate[];
|
||||
}
|
||||
|
||||
export interface CreatedSuggestedGmailJobResult {
|
||||
jobApplicationId: number;
|
||||
companyId: number;
|
||||
threadId: string;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
export interface GmailRelinkResult {
|
||||
threadId: string;
|
||||
jobApplicationId: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
unlinkedMessages: number;
|
||||
}
|
||||
|
||||
export interface GmailUnlinkResult {
|
||||
threadId: string;
|
||||
jobApplicationId: number;
|
||||
removedMessages: number;
|
||||
decision: string;
|
||||
}
|
||||
|
||||
export interface GmailMessageSummary {
|
||||
|
||||
Executable
+23
@@ -0,0 +1,23 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
OUTPUT_DIR="${CV_BENCHMARK_OUTPUT_DIR:-$ROOT_DIR/tmp/cv-benchmarks/latest}"
|
||||
APPROVED_DIR="${CV_BENCHMARK_APPROVED_DIR:-$ROOT_DIR/tmp/cv-benchmarks/approved-fixtures}"
|
||||
|
||||
mkdir -p "$OUTPUT_DIR" "$APPROVED_DIR"
|
||||
|
||||
echo "CV benchmark output: $OUTPUT_DIR"
|
||||
echo "Approved fixtures: $APPROVED_DIR"
|
||||
|
||||
CV_BENCHMARK_OUTPUT_DIR="$OUTPUT_DIR" \
|
||||
CV_BENCHMARK_APPROVED_DIR="$APPROVED_DIR" \
|
||||
dotnet test "$ROOT_DIR/JobTrackerApi.Tests/JobTrackerApi.Tests.csproj" --filter CvCorpusHarnessTests
|
||||
|
||||
if [[ -f "$OUTPUT_DIR/report.md" ]]; then
|
||||
echo
|
||||
echo "Benchmark report written to: $OUTPUT_DIR/report.md"
|
||||
fi
|
||||
if [[ -f "$OUTPUT_DIR/index.json" ]]; then
|
||||
echo "Benchmark index written to: $OUTPUT_DIR/index.json"
|
||||
fi
|
||||
@@ -30,11 +30,31 @@ pip install -r requirements.txt
|
||||
python -m uvicorn app:app --host 127.0.0.1 --port 8001 --workers 1
|
||||
```
|
||||
|
||||
If the host is missing `python3-venv` or `pip`, use the bootstrap script instead:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap-and-test.sh bootstrap
|
||||
```
|
||||
|
||||
## Docker
|
||||
The Dockerfile installs Tesseract OCR so scanned PDFs and supported images can be processed inside the container.
|
||||
|
||||
## Tests
|
||||
|
||||
Run the summarizer unit tests with:
|
||||
|
||||
```bash
|
||||
./scripts/bootstrap-and-test.sh test
|
||||
```
|
||||
|
||||
The script:
|
||||
- creates `.venv` with stdlib `venv` when available
|
||||
- falls back to user-space `virtualenv` when host `venv` support is missing
|
||||
- installs `requirements-dev.txt`
|
||||
- writes pytest cache under `tmp/pytest-cache` to avoid stale root-owned `.pytest_cache` directories
|
||||
|
||||
## API
|
||||
- `GET /health` — health check and runtime capabilities
|
||||
- `GET /health` — health check and runtime capabilities, including Ollama version/model metadata when configured
|
||||
- `POST /summarize` — JSON body `{ "text": "...", "max_length": 150, "min_length": 30 }`
|
||||
- `POST /extract-text` — multipart file upload, returns extracted text and OCR metadata
|
||||
- `POST /cv/classify-block` — JSON body `{ "block": "..." }`, uses Ollama when `OLLAMA_MODEL` is configured
|
||||
|
||||
+27
-5
@@ -63,6 +63,12 @@ def _key(text: str, max_length: int, min_length: int, top_skills: int) -> str:
|
||||
return f"{h}:{max_length}:{min_length}:{top_skills}"
|
||||
|
||||
|
||||
def _ollama_json(path: str):
|
||||
req = urllib_request.Request(f"{OLLAMA_BASE_URL}{path}", method="GET")
|
||||
with urllib_request.urlopen(req, timeout=5) as response:
|
||||
return json.loads(response.read().decode("utf-8"))
|
||||
|
||||
|
||||
def _ollama_status():
|
||||
configured = bool(OLLAMA_MODEL)
|
||||
if not configured:
|
||||
@@ -71,27 +77,43 @@ def _ollama_status():
|
||||
"ollama_reachable": False,
|
||||
"ollama_model": None,
|
||||
"ollama_model_available": False,
|
||||
"ollama_version": None,
|
||||
"ollama_installed_models": [],
|
||||
"ollama_loaded_models": [],
|
||||
"ollama_loaded_count": 0,
|
||||
}
|
||||
|
||||
req = urllib_request.Request(f"{OLLAMA_BASE_URL}/api/tags", method="GET")
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=5) as response:
|
||||
body = json.loads(response.read().decode("utf-8"))
|
||||
tags_body = _ollama_json("/api/tags")
|
||||
version_body = _ollama_json("/api/version")
|
||||
try:
|
||||
ps_body = _ollama_json("/api/ps")
|
||||
except Exception:
|
||||
ps_body = {"models": []}
|
||||
except Exception:
|
||||
return {
|
||||
"ollama_configured": True,
|
||||
"ollama_reachable": False,
|
||||
"ollama_model": OLLAMA_MODEL,
|
||||
"ollama_model_available": False,
|
||||
"ollama_version": None,
|
||||
"ollama_installed_models": [],
|
||||
"ollama_loaded_models": [],
|
||||
"ollama_loaded_count": 0,
|
||||
}
|
||||
|
||||
models = body.get("models") or []
|
||||
names = {item.get("name") for item in models if isinstance(item, dict)}
|
||||
models = tags_body.get("models") or []
|
||||
names = sorted({item.get("name") for item in models if isinstance(item, dict) and item.get("name")})
|
||||
loaded_models = sorted({item.get("name") for item in (ps_body.get("models") or []) if isinstance(item, dict) and item.get("name")})
|
||||
return {
|
||||
"ollama_configured": True,
|
||||
"ollama_reachable": True,
|
||||
"ollama_model": OLLAMA_MODEL,
|
||||
"ollama_model_available": OLLAMA_MODEL in names,
|
||||
"ollama_version": version_body.get("version"),
|
||||
"ollama_installed_models": names,
|
||||
"ollama_loaded_models": loaded_models,
|
||||
"ollama_loaded_count": len(loaded_models),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
[pytest]
|
||||
cache_dir = tmp/pytest-cache
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
VENV_DIR="$ROOT_DIR/.venv"
|
||||
PYTHON_BIN="${PYTHON_BIN:-python3}"
|
||||
VIRTUALENV_BIN="${VIRTUALENV_BIN:-$HOME/.local/bin/virtualenv}"
|
||||
|
||||
ensure_user_pip() {
|
||||
if "$PYTHON_BIN" -m pip --version >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
mkdir -p /tmp/jobtracker-python-bootstrap
|
||||
cd /tmp/jobtracker-python-bootstrap
|
||||
if [[ ! -f get-pip.py ]]; then
|
||||
curl -fsSLo get-pip.py https://bootstrap.pypa.io/get-pip.py
|
||||
fi
|
||||
"$PYTHON_BIN" get-pip.py --user --break-system-packages
|
||||
}
|
||||
|
||||
ensure_venv() {
|
||||
if [[ -x "$VENV_DIR/bin/python" && -x "$VENV_DIR/bin/pip" ]]; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
if "$PYTHON_BIN" -m venv "$VENV_DIR" >/dev/null 2>&1; then
|
||||
return 0
|
||||
fi
|
||||
|
||||
ensure_user_pip
|
||||
if [[ ! -x "$VIRTUALENV_BIN" ]]; then
|
||||
"$PYTHON_BIN" -m pip install --user --break-system-packages virtualenv
|
||||
fi
|
||||
"$VIRTUALENV_BIN" "$VENV_DIR"
|
||||
}
|
||||
|
||||
install_requirements() {
|
||||
"$VENV_DIR/bin/pip" install -r "$ROOT_DIR/requirements-dev.txt"
|
||||
}
|
||||
|
||||
run_tests() {
|
||||
mkdir -p "$ROOT_DIR/tmp/pytest-cache"
|
||||
cd "$ROOT_DIR"
|
||||
AI_SERVICE_SKIP_MODEL_LOAD=1 "$VENV_DIR/bin/python" -m pytest -q -o cache_dir=tmp/pytest-cache tests/test_app.py
|
||||
}
|
||||
|
||||
case "${1:-test}" in
|
||||
bootstrap)
|
||||
ensure_venv
|
||||
install_requirements
|
||||
;;
|
||||
test)
|
||||
ensure_venv
|
||||
install_requirements
|
||||
run_tests
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [bootstrap|test]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
@@ -32,6 +32,8 @@ def test_health_reports_runtime_without_ollama(monkeypatch):
|
||||
assert payload["device"] == "cpu"
|
||||
assert payload["ollama_configured"] is False
|
||||
assert payload["ollama_model"] is None
|
||||
assert payload["ollama_installed_models"] == []
|
||||
assert payload["ollama_loaded_models"] == []
|
||||
|
||||
|
||||
def test_classify_block_returns_structured_json(monkeypatch):
|
||||
|
||||
Reference in New Issue
Block a user