Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d61dd6310b | |||
| 3bd7b4b7e4 | |||
| 30bb6a942d | |||
| fb11469a48 | |||
| 5a9245cf74 | |||
| 2996441f52 | |||
| bd51c245d3 | |||
| a1a3736cc4 | |||
| ae3505b877 | |||
| 695fbd6d21 | |||
| 45cbc8b1ab |
@@ -0,0 +1,61 @@
|
|||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class EmailStatusClassifierTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Detects_rejection()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Your application", "Thank you for your time. Unfortunately, we have decided not to proceed with your application.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_offer()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Great news", "We are pleased to offer you the position of Backend Engineer.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Offer", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Detects_interview_invite()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Next steps", "We would like to invite you to interview next week. What is your availability for a call?");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Rejection_wins_over_interview_mention()
|
||||||
|
{
|
||||||
|
// A rejection email that references the interview the candidate had must classify as Rejected.
|
||||||
|
var s = EmailStatusClassifier.Classify(
|
||||||
|
"Update on your application",
|
||||||
|
"Thank you for taking the time to interview with us. Unfortunately, we will not be moving forward.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Rejected", s!.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Weak_interview_cue_is_low_confidence()
|
||||||
|
{
|
||||||
|
var s = EmailStatusClassifier.Classify("Coding challenge", "Please complete this take-home assessment.");
|
||||||
|
Assert.NotNull(s);
|
||||||
|
Assert.Equal("Interview", s!.SuggestedStatus);
|
||||||
|
Assert.Equal("low", s.Confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Returns_null_for_neutral_email()
|
||||||
|
{
|
||||||
|
Assert.Null(EmailStatusClassifier.Classify("Re: question", "Thanks for the info, that answers my question about the parking."));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Handles_empty_input()
|
||||||
|
=> Assert.Null(EmailStatusClassifier.Classify(null, null));
|
||||||
|
}
|
||||||
@@ -38,6 +38,42 @@ public sealed class JobApplicationsAuthorizationTests
|
|||||||
Assert.IsType<NotFoundResult>(result.Result);
|
Assert.IsType<NotFoundResult>(result.Result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetMatchScore_returns_not_found_for_other_users_job()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using var ownerDb = CreateDb(dbName, "owner-1");
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
|
||||||
|
ownerDb.Companies.Add(company);
|
||||||
|
await ownerDb.SaveChangesAsync();
|
||||||
|
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1", Description = "C# .NET" });
|
||||||
|
await ownerDb.SaveChangesAsync();
|
||||||
|
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||||
|
|
||||||
|
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||||
|
var result = await CreateController(attackerDb).GetMatchScore(jobId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NotFoundResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetStatusSuggestion_returns_not_found_for_other_users_job()
|
||||||
|
{
|
||||||
|
var dbName = Guid.NewGuid().ToString();
|
||||||
|
await using var ownerDb = CreateDb(dbName, "owner-1");
|
||||||
|
var company = new Company { Name = "Acme", OwnerUserId = "owner-1" };
|
||||||
|
ownerDb.Companies.Add(company);
|
||||||
|
await ownerDb.SaveChangesAsync();
|
||||||
|
ownerDb.JobApplications.Add(new JobApplication { JobTitle = "Secret", CompanyId = company.Id, OwnerUserId = "owner-1" });
|
||||||
|
await ownerDb.SaveChangesAsync();
|
||||||
|
var jobId = await ownerDb.JobApplications.Select(j => j.Id).FirstAsync();
|
||||||
|
|
||||||
|
await using var attackerDb = CreateDb(dbName, "other-user");
|
||||||
|
var result = await CreateController(attackerDb).GetStatusSuggestion(jobId, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.IsType<NotFoundResult>(result.Result);
|
||||||
|
}
|
||||||
|
|
||||||
private static JobTrackerContext CreateDb(string dbName, string? userId)
|
private static JobTrackerContext CreateDb(string dbName, string? userId)
|
||||||
{
|
{
|
||||||
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
var options = new DbContextOptionsBuilder<JobTrackerContext>()
|
||||||
|
|||||||
@@ -56,6 +56,68 @@ public sealed class JobApplicationsEndpointBehaviorTests
|
|||||||
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
Assert.Contains("Profile page", badRequest.Value?.ToString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_suggestion_from_latest_inbound_rejection()
|
||||||
|
{
|
||||||
|
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 = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Applied" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.Correspondences.Add(new Correspondence
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
From = "Company",
|
||||||
|
Direction = "inbound",
|
||||||
|
Subject = "Update",
|
||||||
|
Content = "Unfortunately, we have decided not to proceed.",
|
||||||
|
Date = DateTime.Now,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||||
|
Assert.True(dto.HasSuggestion);
|
||||||
|
Assert.Equal("Rejected", dto.SuggestedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Status_suggestion_suppressed_when_already_in_stage()
|
||||||
|
{
|
||||||
|
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 = "Dev", CompanyId = company.Id, OwnerUserId = "user-1", Status = "Rejected" };
|
||||||
|
db.JobApplications.Add(job);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
db.Correspondences.Add(new Correspondence
|
||||||
|
{
|
||||||
|
JobApplicationId = job.Id,
|
||||||
|
From = "Company",
|
||||||
|
Direction = "inbound",
|
||||||
|
Content = "Unfortunately, we will not be moving forward.",
|
||||||
|
Date = DateTime.Now,
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
var controller = CreateController(db, "user-1");
|
||||||
|
var result = await controller.GetStatusSuggestion(job.Id, CancellationToken.None);
|
||||||
|
|
||||||
|
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||||
|
var dto = Assert.IsType<JobApplicationsController.StatusSuggestionDto>(ok.Value);
|
||||||
|
Assert.False(dto.HasSuggestion);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Match_score_scores_job_against_profile_cv()
|
public async Task Match_score_scores_job_against_profile_cv()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
using JobTrackerApi.Services;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class StageAnalyticsTests
|
||||||
|
{
|
||||||
|
private static readonly DateTime Now = new(2026, 7, 1, 0, 0, 0, DateTimeKind.Utc);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Computes_median_days_per_active_stage()
|
||||||
|
{
|
||||||
|
var jobs = new[]
|
||||||
|
{
|
||||||
|
new StageOccupancy("Applied", Now.AddDays(-10)),
|
||||||
|
new StageOccupancy("Applied", Now.AddDays(-20)),
|
||||||
|
new StageOccupancy("Applied", Now.AddDays(-30)),
|
||||||
|
new StageOccupancy("Interview", Now.AddDays(-4)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = StageAnalytics.TimeInStage(jobs, Now);
|
||||||
|
|
||||||
|
var applied = Assert.Single(result, p => p.Stage == "Applied");
|
||||||
|
Assert.Equal(20, applied.MedianDays);
|
||||||
|
Assert.Equal(3, applied.Count);
|
||||||
|
|
||||||
|
var interview = Assert.Single(result, p => p.Stage == "Interview");
|
||||||
|
Assert.Equal(4, interview.MedianDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Excludes_closed_and_success_stages()
|
||||||
|
{
|
||||||
|
var jobs = new[]
|
||||||
|
{
|
||||||
|
new StageOccupancy("Offer", Now.AddDays(-5)),
|
||||||
|
new StageOccupancy("Rejected", Now.AddDays(-5)),
|
||||||
|
new StageOccupancy("Ghosted", Now.AddDays(-5)),
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Empty(StageAnalytics.TimeInStage(jobs, Now));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Normalizes_legacy_status_and_orders_by_pipeline()
|
||||||
|
{
|
||||||
|
var jobs = new[]
|
||||||
|
{
|
||||||
|
new StageOccupancy("Interviewing", Now.AddDays(-3)),
|
||||||
|
new StageOccupancy("Applied", Now.AddDays(-1)),
|
||||||
|
new StageOccupancy("Waiting", Now.AddDays(-2)),
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = StageAnalytics.TimeInStage(jobs, Now);
|
||||||
|
|
||||||
|
Assert.Equal(new[] { "Applied", "Waiting", "Interview" }, result.Select(p => p.Stage).ToArray());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Empty_input_returns_empty()
|
||||||
|
=> Assert.Empty(StageAnalytics.TimeInStage(Array.Empty<StageOccupancy>(), Now));
|
||||||
|
}
|
||||||
@@ -1405,9 +1405,7 @@ Canonical profile:
|
|||||||
if (title.Length == 0) return BadRequest("Job title is required.");
|
if (title.Length == 0) return BadRequest("Job title is required.");
|
||||||
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
|
if (request.CompanyId <= 0) return BadRequest("Valid companyId is required.");
|
||||||
|
|
||||||
var companyOk = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
// Scoped by the Company query filter, so this also rejects another user's companyId.
|
||||||
if (!companyOk) return BadRequest("companyId does not exist.");
|
|
||||||
|
|
||||||
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
var companyExists = await _db.Companies.AnyAsync(c => c.Id == request.CompanyId, cancellationToken);
|
||||||
if (!companyExists) return BadRequest("companyId does not exist.");
|
if (!companyExists) return BadRequest("companyId does not exist.");
|
||||||
|
|
||||||
@@ -1604,6 +1602,57 @@ Canonical profile:
|
|||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record StatusSuggestionDto(
|
||||||
|
bool HasSuggestion,
|
||||||
|
string? SuggestedStatus,
|
||||||
|
string? CurrentStatus,
|
||||||
|
string? Signal,
|
||||||
|
string? Confidence,
|
||||||
|
DateTime? MessageDate,
|
||||||
|
string? MessageSubject);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Suggests a pipeline status from the job's most recent inbound message (e.g. an interview
|
||||||
|
/// invite or rejection). Deterministic and always human-confirmed via PATCH .../status.
|
||||||
|
/// </summary>
|
||||||
|
[HttpGet("{id:int}/status-suggestion")]
|
||||||
|
public async Task<ActionResult<StatusSuggestionDto>> GetStatusSuggestion([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
|
||||||
|
if (job is null) return NotFound();
|
||||||
|
|
||||||
|
var none = new StatusSuggestionDto(false, null, job.Status, null, null, null, null);
|
||||||
|
|
||||||
|
var latestInbound = await _db.Correspondences
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(c => c.JobApplicationId == id
|
||||||
|
&& c.Direction != "outbound"
|
||||||
|
&& c.From != "Me")
|
||||||
|
.OrderByDescending(c => c.Date)
|
||||||
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
if (latestInbound is null) return Ok(none);
|
||||||
|
|
||||||
|
var suggestion = EmailStatusClassifier.Classify(latestInbound.Subject, latestInbound.Content);
|
||||||
|
if (suggestion is null) return Ok(none);
|
||||||
|
|
||||||
|
// Don't nag when the job is already in (or past) the suggested stage.
|
||||||
|
var currentOrder = JobPipeline.OrderOf(job.Status);
|
||||||
|
var suggestedOrder = JobPipeline.OrderOf(suggestion.SuggestedStatus);
|
||||||
|
if (JobPipeline.Normalize(job.Status) == suggestion.SuggestedStatus || currentOrder >= suggestedOrder)
|
||||||
|
{
|
||||||
|
return Ok(none);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Ok(new StatusSuggestionDto(
|
||||||
|
HasSuggestion: true,
|
||||||
|
SuggestedStatus: suggestion.SuggestedStatus,
|
||||||
|
CurrentStatus: job.Status,
|
||||||
|
Signal: suggestion.Signal,
|
||||||
|
Confidence: suggestion.Confidence,
|
||||||
|
MessageDate: latestInbound.Date,
|
||||||
|
MessageSubject: latestInbound.Subject));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[HttpPost("{id:int}/refresh-ai")]
|
[HttpPost("{id:int}/refresh-ai")]
|
||||||
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
public async Task<ActionResult<JobApplicationDto>> RefreshAi([FromRoute] int id, CancellationToken cancellationToken)
|
||||||
@@ -2023,13 +2072,15 @@ Canonical profile:
|
|||||||
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
|
public sealed record CompanyActivityPoint(int CompanyId, string Company, int Count, int Responses, double ResponseRate);
|
||||||
public sealed record TagTrendSeries(string Tag, List<int> Counts);
|
public sealed record TagTrendSeries(string Tag, List<int> Counts);
|
||||||
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
public sealed record TagTrendPoint(string Month, List<int> Counts);
|
||||||
|
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
|
||||||
public sealed record AnalyticsOverviewDto(
|
public sealed record AnalyticsOverviewDto(
|
||||||
List<FunnelStagePoint> Funnel,
|
List<FunnelStagePoint> Funnel,
|
||||||
List<ResponseRatePoint> ResponseRateBySource,
|
List<ResponseRatePoint> ResponseRateBySource,
|
||||||
List<CompanyActivityPoint> TopCompanies,
|
List<CompanyActivityPoint> TopCompanies,
|
||||||
double? MedianDaysToFirstResponse,
|
double? MedianDaysToFirstResponse,
|
||||||
int TotalResponses,
|
int TotalResponses,
|
||||||
int TotalActive
|
int TotalActive,
|
||||||
|
List<StageDurationDto> TimeInStage
|
||||||
);
|
);
|
||||||
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
|
public sealed record DuplicateCandidateDto(int Id, string JobTitle, string Company, string? JobUrl, string Status, DateTime DateApplied, string Reason);
|
||||||
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
|
public sealed record DuplicateCheckResult(bool HasDuplicates, List<DuplicateCandidateDto> Matches);
|
||||||
@@ -2803,16 +2854,14 @@ Candidate master CV:
|
|||||||
.Where(j => !j.IsDeleted)
|
.Where(j => !j.IsDeleted)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var funnelMap = new Dictionary<string, int>
|
// Funnel = distribution across canonical stages, driven by the pipeline (one source
|
||||||
{
|
// of truth, so it includes every stage and normalizes legacy spellings).
|
||||||
["Applied"] = activeJobs.Count(j => string.Equals(j.Status, "Applied", StringComparison.OrdinalIgnoreCase)),
|
var normalizedByStage = activeJobs
|
||||||
["Interview"] = activeJobs.Count(j => string.Equals(j.Status, "Interview", StringComparison.OrdinalIgnoreCase) || string.Equals(j.Status, "Interviewing", StringComparison.OrdinalIgnoreCase)),
|
.GroupBy(j => JobPipeline.Normalize(j.Status))
|
||||||
["Offer"] = activeJobs.Count(j => string.Equals(j.Status, "Offer", StringComparison.OrdinalIgnoreCase)),
|
.ToDictionary(g => g.Key, g => g.Count());
|
||||||
["Rejected"] = activeJobs.Count(j => string.Equals(j.Status, "Rejected", StringComparison.OrdinalIgnoreCase)),
|
var funnel = JobPipeline.Stages
|
||||||
["Ghosted"] = activeJobs.Count(j => string.Equals(j.Status, "Ghosted", StringComparison.OrdinalIgnoreCase)),
|
.Select(stage => new FunnelStagePoint(stage.Key, normalizedByStage.TryGetValue(stage.Key, out var c) ? c : 0))
|
||||||
};
|
.ToList();
|
||||||
|
|
||||||
var funnel = funnelMap.Select(x => new FunnelStagePoint(x.Key, x.Value)).ToList();
|
|
||||||
|
|
||||||
var responseRateBySource = activeJobs
|
var responseRateBySource = activeJobs
|
||||||
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
|
.GroupBy(j => string.IsNullOrWhiteSpace(j.Company?.Source) ? "Unknown source" : j.Company!.Source!.Trim())
|
||||||
@@ -2856,13 +2905,46 @@ Candidate master CV:
|
|||||||
: Math.Round(responseDays[mid], 1);
|
: Math.Round(responseDays[mid], 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Time-in-stage: for each active job, when did it enter its current stage? Use the most
|
||||||
|
// recent StatusChanged event into that stage, else its applied date.
|
||||||
|
var activeIds = activeJobs.Select(j => j.Id).ToList();
|
||||||
|
var statusChanges = await _db.JobEvents
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(e => e.Type == "StatusChanged" && activeIds.Contains(e.JobApplicationId))
|
||||||
|
.Select(e => new { e.JobApplicationId, e.NewValue, e.At })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var lastEntryByJob = statusChanges
|
||||||
|
.GroupBy(e => e.JobApplicationId)
|
||||||
|
.ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
|
var occupancy = activeJobs.Select(job =>
|
||||||
|
{
|
||||||
|
var current = JobPipeline.Normalize(job.Status);
|
||||||
|
DateTime enteredAt = job.DateApplied;
|
||||||
|
if (lastEntryByJob.TryGetValue(job.Id, out var changes))
|
||||||
|
{
|
||||||
|
var lastIntoCurrent = changes
|
||||||
|
.Where(e => JobPipeline.Normalize(e.NewValue) == current)
|
||||||
|
.OrderByDescending(e => e.At)
|
||||||
|
.FirstOrDefault();
|
||||||
|
if (lastIntoCurrent is not null) enteredAt = lastIntoCurrent.At;
|
||||||
|
}
|
||||||
|
return new StageOccupancy(current, enteredAt.ToUniversalTime());
|
||||||
|
});
|
||||||
|
|
||||||
|
var timeInStage = StageAnalytics.TimeInStage(occupancy, DateTime.UtcNow)
|
||||||
|
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
return Ok(new AnalyticsOverviewDto(
|
return Ok(new AnalyticsOverviewDto(
|
||||||
Funnel: funnel,
|
Funnel: funnel,
|
||||||
ResponseRateBySource: responseRateBySource,
|
ResponseRateBySource: responseRateBySource,
|
||||||
TopCompanies: topCompanies,
|
TopCompanies: topCompanies,
|
||||||
MedianDaysToFirstResponse: medianDays,
|
MedianDaysToFirstResponse: medianDays,
|
||||||
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
|
||||||
TotalActive: activeJobs.Count
|
TotalActive: activeJobs.Count,
|
||||||
|
TimeInStage: timeInStage
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace JobTrackerApi.Services
|
||||||
|
{
|
||||||
|
public sealed record EmailStatusSuggestion(string SuggestedStatus, string Signal, string Confidence);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Deterministic email → pipeline-status classifier. Scans subject/body for outcome signals and
|
||||||
|
/// suggests a canonical status. No AI: instant, reproducible, and safe (the user always confirms).
|
||||||
|
/// Priority matters — a rejection email often still mentions "interview", so rejection wins.
|
||||||
|
/// </summary>
|
||||||
|
public static class EmailStatusClassifier
|
||||||
|
{
|
||||||
|
// Ordered highest-priority first. Each stage lists lowercase phrases to look for.
|
||||||
|
private static readonly (string Status, string Confidence, string[] Phrases)[] Rules =
|
||||||
|
{
|
||||||
|
("Rejected", "high", new[]
|
||||||
|
{
|
||||||
|
"regret to inform", "we regret", "unfortunately, we", "not moving forward",
|
||||||
|
"not be moving forward", "decided not to proceed", "will not be proceeding",
|
||||||
|
"not to proceed", "not been selected", "will not be progressing",
|
||||||
|
"unable to offer", "position has been filled", "no longer being considered",
|
||||||
|
"decided to move forward with other", "pursue other candidates",
|
||||||
|
"not to move forward", "were not successful", "was not successful",
|
||||||
|
}),
|
||||||
|
("Offer", "high", new[]
|
||||||
|
{
|
||||||
|
"pleased to offer", "delighted to offer", "happy to offer", "offer of employment",
|
||||||
|
"job offer", "we would like to offer", "formal offer", "extend an offer",
|
||||||
|
"offer letter", "excited to offer",
|
||||||
|
}),
|
||||||
|
("Interview", "medium", new[]
|
||||||
|
{
|
||||||
|
"invite you to interview", "invite you to an interview", "schedule an interview",
|
||||||
|
"would like to invite you", "phone screen", "phone interview", "video interview",
|
||||||
|
"technical interview", "next steps in the", "your availability for a call",
|
||||||
|
"availability for an interview", "set up a call", "set up an interview",
|
||||||
|
"meet the team", "book a time", "invitation to interview", "interview invitation",
|
||||||
|
"like to speak with you", "move to the interview",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weaker single-word cues only fire when no strong phrase matched (kept low-confidence).
|
||||||
|
private static readonly string[] InterviewWeakCues = { "interview", "assessment", "coding challenge", "take-home" };
|
||||||
|
|
||||||
|
public static EmailStatusSuggestion? Classify(string? subject, string? body)
|
||||||
|
{
|
||||||
|
var text = $"{subject}\n{body}".ToLowerInvariant();
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
|
||||||
|
foreach (var (status, confidence, phrases) in Rules)
|
||||||
|
{
|
||||||
|
var hit = phrases.FirstOrDefault(p => text.Contains(p, StringComparison.Ordinal));
|
||||||
|
if (hit is not null) return new EmailStatusSuggestion(status, hit, confidence);
|
||||||
|
}
|
||||||
|
|
||||||
|
var weak = InterviewWeakCues.FirstOrDefault(c => text.Contains(c, StringComparison.Ordinal));
|
||||||
|
if (weak is not null) return new EmailStatusSuggestion("Interview", weak, "low");
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace JobTrackerApi.Services
|
||||||
|
{
|
||||||
|
public sealed record StageDurationPoint(string Stage, int Order, double MedianDays, int Count);
|
||||||
|
|
||||||
|
/// <summary>One job's position: its canonical stage and when it entered that stage.</summary>
|
||||||
|
public sealed record StageOccupancy(string Status, DateTime EnteredStageAtUtc);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Pure time-in-stage analytics: for each active pipeline stage, the median number of days
|
||||||
|
/// the jobs currently sitting there have been waiting. Closed stages (Rejected/Ghosted) and
|
||||||
|
/// the terminal success stage (Offer) are excluded — "how long has this been stuck" only
|
||||||
|
/// makes sense for stages you still act on.
|
||||||
|
/// </summary>
|
||||||
|
public static class StageAnalytics
|
||||||
|
{
|
||||||
|
public static List<StageDurationPoint> TimeInStage(IEnumerable<StageOccupancy> jobs, DateTime nowUtc)
|
||||||
|
{
|
||||||
|
var byStage = jobs
|
||||||
|
.Select(j => (Stage: JobPipeline.Normalize(j.Status), Days: Math.Max(0, (nowUtc - j.EnteredStageAtUtc).TotalDays)))
|
||||||
|
.Where(x => JobPipeline.Stages.Any(s => s.Key == x.Stage && s.Category == PipelineCategory.Active))
|
||||||
|
.GroupBy(x => x.Stage);
|
||||||
|
|
||||||
|
var points = new List<StageDurationPoint>();
|
||||||
|
foreach (var group in byStage)
|
||||||
|
{
|
||||||
|
var days = group.Select(x => x.Days).OrderBy(x => x).ToList();
|
||||||
|
points.Add(new StageDurationPoint(
|
||||||
|
Stage: group.Key,
|
||||||
|
Order: JobPipeline.OrderOf(group.Key),
|
||||||
|
MedianDays: Median(days),
|
||||||
|
Count: days.Count));
|
||||||
|
}
|
||||||
|
|
||||||
|
return points.OrderBy(p => p.Order).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static double Median(IReadOnlyList<double> sorted)
|
||||||
|
{
|
||||||
|
if (sorted.Count == 0) return 0;
|
||||||
|
var mid = sorted.Count / 2;
|
||||||
|
var median = sorted.Count % 2 == 0 ? (sorted[mid - 1] + sorted[mid]) / 2d : sorted[mid];
|
||||||
|
return Math.Round(median, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,8 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
|
|||||||
- History/event trail per application (created, status changes, follow-up set, delete/restore)
|
- History/event trail per application (created, status changes, follow-up set, delete/restore)
|
||||||
- Export jobs to JSON/CSV + daily scheduled JSON export
|
- Export jobs to JSON/CSV + daily scheduled JSON export
|
||||||
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
|
- Optional “job import” preview from supported job sites (plugins) + optional translation to English
|
||||||
|
- Quick-capture bookmarklet (Settings) + installable PWA with a mobile share-target: both open `/?add=<page url>` to pre-fill Add Job from any posting
|
||||||
|
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
|
||||||
- Optional local AI service for short/full descriptions
|
- Optional local AI service for short/full descriptions
|
||||||
- Optional Google sign-in (Google ID tokens) to protect the API
|
- Optional Google sign-in (Google ID tokens) to protect the API
|
||||||
|
|
||||||
@@ -205,6 +207,8 @@ Authentication:
|
|||||||
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
|
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
|
||||||
- `GET /api/jobapplications/{id}/match-score`
|
- `GET /api/jobapplications/{id}/match-score`
|
||||||
- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
|
- Deterministic CV↔job keyword-coverage score (0–100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
|
||||||
|
- `GET /api/jobapplications/{id}/status-suggestion`
|
||||||
|
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
|
||||||
- `DELETE /api/jobapplications/{id}`
|
- `DELETE /api/jobapplications/{id}`
|
||||||
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
|
- Soft-deletes an application (`IsDeleted=true`); records a `Deleted` event.
|
||||||
- `POST /api/jobapplications/{id}/restore`
|
- `POST /api/jobapplications/{id}/restore`
|
||||||
|
|||||||
@@ -54,6 +54,9 @@ services:
|
|||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
context: ./job-tracker-ui
|
context: ./job-tracker-ui
|
||||||
|
# fork-ts-checker (CRA's build type-checker) needs more than Docker's default
|
||||||
|
# 64MB /dev/shm; too little causes a SIGSEGV during `npm run build`.
|
||||||
|
shm_size: '1gb'
|
||||||
args:
|
args:
|
||||||
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
- REACT_APP_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
|
||||||
# Optional override; default in production is `/api`
|
# Optional override; default in production is `/api`
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
# Merge Request — Wave 0 quick wins + Tier-1/Tier-2 features
|
||||||
|
|
||||||
|
**Branch:** `chore/wave0-quick-wins` → `main`
|
||||||
|
**Scope:** 24 commits · 62 files · +3,165 / −489
|
||||||
|
**Status:** all tests green (backend 135, frontend 23 suites / 54 tests), production build compiles.
|
||||||
|
|
||||||
|
> Prepared for human review. Do **not** auto-merge. One operator action is required after merge
|
||||||
|
> (DataProtection key rotation — see *Known limitations*).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Delivers the first two roadmap tiers plus the engineering-health groundwork, developed as small
|
||||||
|
conventional commits. Two design principles run through it:
|
||||||
|
|
||||||
|
1. **Deterministic over "AI-guessy."** Match scoring, status suggestions, and pipeline logic are
|
||||||
|
pure/deterministic — instant, reproducible, and safe (the user confirms every state change). This
|
||||||
|
directly answers the market's most common complaint (hallucinated/generic AI output).
|
||||||
|
2. **One pathway, not two.** The bookmarklet and the PWA share-target feed a single `/?add=` capture
|
||||||
|
flow rather than parallel implementations.
|
||||||
|
|
||||||
|
## What's included
|
||||||
|
|
||||||
|
**Engineering health (Wave 0)**
|
||||||
|
- `security:` untracked committed DataProtection keys + daily exports; removed dead legacy controllers.
|
||||||
|
- `feat:` automated daily SQLite backups (`VACUUM INTO`, retention, startup catch-up) — prod previously
|
||||||
|
had **no** automated backup on Linux.
|
||||||
|
- `ci:` run the **entire** frontend suite (the old whitelist was hiding 3 broken suites, now fixed).
|
||||||
|
- `feat:` dev-only OpenAPI at `/openapi/v1.json`; `feat:` structured salary fields.
|
||||||
|
|
||||||
|
**Tier-1 features**
|
||||||
|
- **Match score** (`GET /jobapplications/{id}/match-score`) — deterministic CV↔job keyword coverage
|
||||||
|
(0–100) + matched/missing keywords + section coverage. Instant panel on the Candidate Fit tab.
|
||||||
|
- **Canonical pipeline** — `JobPipeline` single source of truth; status normalized on write (custom
|
||||||
|
values preserved); UI deduped across 5 files; `GET .../pipeline`.
|
||||||
|
- **Analytics v2** — time-in-stage medians (from `StatusChanged` history) + funnel driven by the
|
||||||
|
pipeline (fixes a bug that omitted the Waiting stage).
|
||||||
|
- **Status suggestions** — deterministic email→status classifier surfaced as a human-confirmed banner.
|
||||||
|
|
||||||
|
**Tier-2 features**
|
||||||
|
- **Bookmarklet** quick-capture (Settings) reusing `jobimport/preview`.
|
||||||
|
- **Installable PWA** with a mobile share-target into the same capture flow.
|
||||||
|
|
||||||
|
**Quality**
|
||||||
|
- Phase-6 security review (`docs/SECURITY_REPORT.md`): tenant isolation on new endpoints verified +
|
||||||
|
regression-tested; no injection/ReDoS; dev-only OpenAPI.
|
||||||
|
- Bug fixes: `SkillTagger` C#/.NET regex (silently missed those skills everywhere), a React
|
||||||
|
stale-closure, a duplicated DB query, and 3 pre-existing hidden test failures.
|
||||||
|
|
||||||
|
## Test coverage added
|
||||||
|
|
||||||
|
New pure/unit-tested services: `JobCvMatchService` (7), `JobPipeline` (14), `StageAnalytics` (4),
|
||||||
|
`EmailStatusClassifier` (7). New endpoint integration + authorization tests (match-score,
|
||||||
|
status-suggestion). New frontend tests: match-score panel, status-suggestion banner, pipeline,
|
||||||
|
quick-capture, capture-url resolution.
|
||||||
|
|
||||||
|
## Docs
|
||||||
|
|
||||||
|
New: `docs/SYSTEM_OVERVIEW.md`, `docs/PRODUCT_RESEARCH.md`, `docs/ROADMAP.md`,
|
||||||
|
`docs/SECURITY_REPORT.md`. README updated with the new endpoints, backup/pipeline config, and
|
||||||
|
quick-capture/PWA notes.
|
||||||
|
|
||||||
|
## Known limitations / follow-ups
|
||||||
|
|
||||||
|
- **ACTION REQUIRED (security):** the removed DataProtection key XMLs remain in git **history**.
|
||||||
|
Rotate them on the production host after merge (see `SECURITY_REPORT.md` §6).
|
||||||
|
- **Per-user custom pipeline stages** were deliberately deferred (unproven demand; large surface).
|
||||||
|
- **No offline service worker** by design — the app deploys frequently and an aggressive cache would
|
||||||
|
risk serving stale builds. The PWA is installable and share-capable without it.
|
||||||
|
- Not yet done (future branches): interview hub (M3), contacts CRM (M4), god-controller decomposition,
|
||||||
|
performance pass, Vite migration.
|
||||||
|
|
||||||
|
## Reviewer notes
|
||||||
|
|
||||||
|
- Repo quirk: controllers/services compile via the `JobTrackerBackend` library, **not** the
|
||||||
|
`JobTrackerApi` host project (see `docs/SYSTEM_OVERVIEW.md` §2).
|
||||||
|
- All AI-adjacent features are deterministic and make no model calls.
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
# SECURITY_REPORT.md — Session Change Review
|
||||||
|
|
||||||
|
> Phase 6 deliverable. Scope: security review of the changes made in this work session
|
||||||
|
> (Wave 0 + roadmap H1–H4), plus confirmation that the tenant-isolation model still holds.
|
||||||
|
> Date: 2026-07-03. Complements the prior standalone assessments in
|
||||||
|
> `docs/security-assessments/` (M013 adversarial, M014 remediation, M015 authorization replay).
|
||||||
|
|
||||||
|
This is **not** a full re-audit of the whole application — those live in `docs/security-assessments/`.
|
||||||
|
It is a focused review of the new/changed surface so nothing shipped this session introduces a regression.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Summary
|
||||||
|
|
||||||
|
No new vulnerabilities were introduced. Tenant isolation on the new endpoints is carried by the
|
||||||
|
existing `JobTrackerContext` global query filters and is now covered by regression tests. One latent
|
||||||
|
correctness issue (a routable background-service method) was closed, and leaked runtime secrets were
|
||||||
|
removed from version control (rotation recommended — see §6).
|
||||||
|
|
||||||
|
| Severity | Count | Items |
|
||||||
|
|---|---|---|
|
||||||
|
| Critical | 0 | — |
|
||||||
|
| High | 0 | — |
|
||||||
|
| Medium | 1 (mitigated) | DataProtection keys present in git history (untracked this session; rotation recommended) |
|
||||||
|
| Low / hardening | 3 | see §5 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. New/changed attack surface reviewed
|
||||||
|
|
||||||
|
| Change | Surface | Verdict |
|
||||||
|
|---|---|---|
|
||||||
|
| `GET /jobapplications/{id}/match-score` | route int id; reads own CV + job | Safe — tenant-scoped |
|
||||||
|
| `GET /jobapplications/{id}/status-suggestion` | route int id; reads own correspondence | Safe — tenant-scoped |
|
||||||
|
| `GET /jobapplications/pipeline` | none (static metadata) | Safe |
|
||||||
|
| `PATCH .../status`, Create/Update (status normalization) | user string → `JobPipeline.Normalize` | Safe — no injection, values stored parameterized |
|
||||||
|
| Structured salary fields | numeric + short strings, `NormalizeSalary` | Safe — clamps negatives, whitelists period |
|
||||||
|
| Automated DB backup (`VACUUM INTO`) | server-controlled path | Safe — see §4 |
|
||||||
|
| Dev OpenAPI (`/openapi/v1.json`) | schema | Safe — `Development` environment only |
|
||||||
|
| `EmailStatusClassifier` | reads stored correspondence text | Safe — deterministic, no eval/injection |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. OWASP-oriented checklist for the new code
|
||||||
|
|
||||||
|
- **A01 Broken Access Control** — The two new data endpoints load the job via
|
||||||
|
`_db.JobApplications.FirstOrDefaultAsync(j => j.Id == id)`, which is filtered by the global
|
||||||
|
query filter `CurrentUserId != null && OwnerUserId == CurrentUserId` (deny-on-null, hardened in
|
||||||
|
M013-2). A cross-user id returns `NotFound`, not another tenant's data. The correspondence lookup
|
||||||
|
in `status-suggestion` and the `JobEvent` lookup in analytics are likewise filtered through their
|
||||||
|
parent's owner. **Verified by `JobApplicationsAuthorizationTests` (match-score + status-suggestion).**
|
||||||
|
- **A03 Injection** — All new persistence goes through EF Core parameterized queries. The only raw
|
||||||
|
SQL added is `VACUUM INTO '<path>'` with a fully server-derived path (see §4). No string
|
||||||
|
concatenation of user input into queries.
|
||||||
|
- **A03 ReDoS** — New regexes (`JobCvMatchService.TokenPattern`, the revised `SkillTagger` C#/.NET
|
||||||
|
patterns with fixed-width look-behinds) are linear with no catastrophic backtracking.
|
||||||
|
- **A04 Insecure Design** — Status suggestions and match scoring are deterministic and
|
||||||
|
**human-confirmed** (a status only changes when the user clicks). No automated outbound actions.
|
||||||
|
- **A05 Security Misconfiguration** — OpenAPI is exposed only under `IsDevelopment()`; production
|
||||||
|
deployments (`ASPNETCORE_ENVIRONMENT=Production`) do not serve it.
|
||||||
|
- **A08 Data Integrity** — `JobPipeline.Normalize` canonicalizes status on write but preserves
|
||||||
|
unknown custom values (no silent data loss).
|
||||||
|
- **A09 Logging** — No secrets or PII added to logs by the new code.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Database backup — path handling
|
||||||
|
|
||||||
|
`SqliteDatabaseBackupRunner` runs `VACUUM INTO '<target>'`. The target is
|
||||||
|
`<Data:Root>/backups/jobtracker_backup_<UTC-timestamp>.db` — no user input reaches it — and single
|
||||||
|
quotes are escaped defensively. Backups contain the full database (sensitive) and are written to the
|
||||||
|
same data volume as the live DB, i.e. the same trust boundary; they are git-ignored. For defense in
|
||||||
|
depth, operators should ship backups off-host with transport encryption and restrict volume
|
||||||
|
permissions. **Recommendation (low):** document an off-host, encrypted backup rotation in the
|
||||||
|
deployment guide.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Low / hardening findings
|
||||||
|
|
||||||
|
1. **match-score input size (low).** `GetMatchScore` does not cap job-description length before
|
||||||
|
tokenizing. Descriptions are bounded in practice (imported/typed), and the algorithm is linear, so
|
||||||
|
this is not a DoS, but a defensive cap (e.g. 50 KB) would be prudent.
|
||||||
|
2. **New read endpoints are not rate-limited (low).** `match-score`/`status-suggestion` are cheap and
|
||||||
|
deterministic (no AI, one indexed query), and auth-gated in production, so abuse potential is low.
|
||||||
|
Consider a general authenticated-read limiter if the API is exposed publicly.
|
||||||
|
3. **status-suggestion is conservative for custom statuses (informational).** A job in a non-canonical
|
||||||
|
custom status (pipeline order = max) never receives a suggestion. This is safe (fails closed) but
|
||||||
|
slightly under-surfaces; acceptable given custom statuses are rare.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Secrets hygiene (actioned this session)
|
||||||
|
|
||||||
|
- Committed ASP.NET **DataProtection key XML files** (`keys/`, `JobTrackerApi/keys/`) and daily export
|
||||||
|
JSON were removed from tracking and added to `.gitignore`
|
||||||
|
(commit `security: untrack DataProtection keys and runtime exports…`).
|
||||||
|
- **These key files remain in git history.** DataProtection keys sign auth/session artifacts, so
|
||||||
|
**rotating them on the production host is recommended** (generate fresh keys; the app regenerates the
|
||||||
|
key ring in the persisted `keys/` directory on next start). Until rotated, anyone with history access
|
||||||
|
could read the old key material.
|
||||||
|
- Local `.env` remains git-ignored; `appsettings.Development.json` contains only `CHANGE_ME_*`
|
||||||
|
placeholders. No live secrets are tracked.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Confirmed intact from prior assessments
|
||||||
|
|
||||||
|
Spot-checked that the M013–M015 remediations are still in force after this session's changes:
|
||||||
|
|
||||||
|
- Owner query filters still deny on null `CurrentUserId` (`Data/JobTrackerContext.cs`).
|
||||||
|
- Local JWT still requires a concrete subject claim (`LocalAuthIdentity`, `Program.cs`).
|
||||||
|
- Job-import SSRF guard (DNS resolution + private-range rejection, redirects disabled) untouched.
|
||||||
|
- CSRF double-submit middleware and CORS allowlist untouched.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Retest
|
||||||
|
|
||||||
|
All backend tests pass (135), including the two new tenant-isolation tests for the new endpoints.
|
||||||
|
No fix in this report required code changes beyond what already landed; the residual **action for the
|
||||||
|
operator is DataProtection key rotation** (§6).
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
{
|
{
|
||||||
"short_name": "JobTrack",
|
"short_name": "Jobbjakt",
|
||||||
"name": "JobTrack — Job Application Tracker",
|
"name": "Jobbjakt — Job Application Tracker",
|
||||||
|
"description": "Track and manage your job applications, tailor CVs, and stay on top of follow-ups.",
|
||||||
|
"id": "/",
|
||||||
|
"scope": "/",
|
||||||
|
"start_url": ".",
|
||||||
|
"display": "standalone",
|
||||||
|
"orientation": "portrait-primary",
|
||||||
|
"categories": ["productivity", "business"],
|
||||||
|
"theme_color": "#15803d",
|
||||||
|
"background_color": "#0b1224",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "favicon.ico",
|
"src": "favicon.ico",
|
||||||
@@ -10,16 +19,22 @@
|
|||||||
{
|
{
|
||||||
"src": "logo192.png",
|
"src": "logo192.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "192x192"
|
"sizes": "192x192",
|
||||||
|
"purpose": "any maskable"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "logo512.png",
|
"src": "logo512.png",
|
||||||
"type": "image/png",
|
"type": "image/png",
|
||||||
"sizes": "512x512"
|
"sizes": "512x512",
|
||||||
|
"purpose": "any maskable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"start_url": ".",
|
"share_target": {
|
||||||
"display": "standalone",
|
"action": "/",
|
||||||
"theme_color": "#0b1224",
|
"method": "GET",
|
||||||
"background_color": "#0b1224"
|
"params": {
|
||||||
|
"url": "add",
|
||||||
|
"text": "addtext"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ import ForgotPasswordPage from "./pages/ForgotPasswordPage";
|
|||||||
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
import ResetPasswordPage from "./pages/ResetPasswordPage";
|
||||||
import RouteErrorPage from "./pages/RouteErrorPage";
|
import RouteErrorPage from "./pages/RouteErrorPage";
|
||||||
import { api } from "./api";
|
import { api } from "./api";
|
||||||
|
import { resolveCaptureUrl } from "./captureUrl";
|
||||||
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
import { clearAuthClientState, setAuthUserKey } from "./auth";
|
||||||
import AppShell, { NavItem } from "./layout/AppShell";
|
import AppShell, { NavItem } from "./layout/AppShell";
|
||||||
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
import { clearAccentColor, getAccentColor, getThemeModePref, setAccentColor, setThemeModePref, ThemeModePref } from "./themePrefs";
|
||||||
@@ -109,6 +110,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
const compactHeaderActions = useMediaQuery("(max-width:767.95px)");
|
||||||
|
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [captureUrl, setCaptureUrl] = useState<string | undefined>(undefined);
|
||||||
const [quickOpen, setQuickOpen] = useState(false);
|
const [quickOpen, setQuickOpen] = useState(false);
|
||||||
const [refreshToken, setRefreshToken] = useState(0);
|
const [refreshToken, setRefreshToken] = useState(0);
|
||||||
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
const [requireAuth, setRequireAuth] = useState<boolean | null>(null);
|
||||||
@@ -124,6 +126,19 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
api.get<AuthConfig>("/auth/config").then((r) => setRequireAuth(Boolean(r.data?.requireAuth))).catch(() => setRequireAuth(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Quick-capture target: bookmarklet (/?add=<url>) or PWA share (url in `add`, or a link
|
||||||
|
// embedded in shared `addtext`). Opens Add Job pre-filled and strips the params.
|
||||||
|
useEffect(() => {
|
||||||
|
const url = resolveCaptureUrl(location.search);
|
||||||
|
if (!url) return;
|
||||||
|
setCaptureUrl(url);
|
||||||
|
setAddOpen(true);
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
params.delete("add");
|
||||||
|
params.delete("addtext");
|
||||||
|
navigate({ pathname: location.pathname, search: params.toString() }, { replace: true });
|
||||||
|
}, [location.search, location.pathname, navigate]);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
api.get<MeResponse>("/auth/me")
|
api.get<MeResponse>("/auth/me")
|
||||||
@@ -288,7 +303,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
|
|||||||
</AppShell>
|
</AppShell>
|
||||||
|
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<AddJobModal open={addOpen} onClose={() => setAddOpen(false)} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
<AddJobModal open={addOpen} initialUrl={captureUrl} onClose={() => { setAddOpen(false); setCaptureUrl(undefined); }} onCreated={() => { setRefreshToken((t) => t + 1); }} />
|
||||||
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
|
<QuickCommandDialog open={quickOpen} onClose={() => setQuickOpen(false)} onNavigate={(to) => navigate(to)} onOpenAddJob={() => setAddOpen(true)} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { resolveCaptureUrl } from './captureUrl';
|
||||||
|
|
||||||
|
describe('resolveCaptureUrl', () => {
|
||||||
|
test('reads the bookmarklet add param', () => {
|
||||||
|
expect(resolveCaptureUrl('?add=https%3A%2F%2Fexample.com%2Fjob')).toBe('https://example.com/job');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('extracts a url embedded in shared text', () => {
|
||||||
|
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('Cool role here https://example.com/job/42 apply now')))
|
||||||
|
.toBe('https://example.com/job/42');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('prefers add over addtext', () => {
|
||||||
|
expect(resolveCaptureUrl('?add=https%3A%2F%2Fa.com&addtext=' + encodeURIComponent('https://b.com')))
|
||||||
|
.toBe('https://a.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns null when there is no url', () => {
|
||||||
|
expect(resolveCaptureUrl('')).toBeNull();
|
||||||
|
expect(resolveCaptureUrl('?addtext=' + encodeURIComponent('just some text, no link'))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
// Resolves the quick-capture URL from query params produced by the bookmarklet (`add`)
|
||||||
|
// or the PWA share-target (a link in `add`, or embedded in shared `addtext`).
|
||||||
|
export function resolveCaptureUrl(search: string): string | null {
|
||||||
|
const params = new URLSearchParams(search);
|
||||||
|
const add = params.get("add");
|
||||||
|
if (add) return add;
|
||||||
|
const addText = params.get("addtext");
|
||||||
|
if (addText) return addText.match(/https?:\/\/\S+/)?.[0] ?? null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useMemo, useState } from "react";
|
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
|
||||||
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
import { DatePicker } from "@mui/x-date-pickers/DatePicker";
|
||||||
|
|
||||||
@@ -37,6 +37,7 @@ interface Props {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onCreated: () => void;
|
onCreated: () => void;
|
||||||
|
initialUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DuplicateCandidate = {
|
type DuplicateCandidate = {
|
||||||
@@ -97,7 +98,7 @@ function normalizeLanguage(value?: string | null) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Props) {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { t, language } = useI18n();
|
const { t, language } = useI18n();
|
||||||
|
|
||||||
@@ -137,6 +138,21 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
setCompanies(cachedCompanies);
|
setCompanies(cachedCompanies);
|
||||||
}, [cachedCompanies]);
|
}, [cachedCompanies]);
|
||||||
|
|
||||||
|
// Quick-capture: when opened with a URL (from the bookmarklet), prefill and auto-import once.
|
||||||
|
const autoImportedUrlRef = useRef<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
autoImportedUrlRef.current = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const url = initialUrl?.trim();
|
||||||
|
if (!url || autoImportedUrlRef.current === url) return;
|
||||||
|
autoImportedUrlRef.current = url;
|
||||||
|
setJobUrl(url);
|
||||||
|
void importFromUrl(url);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, initialUrl]);
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
setCompany(null);
|
setCompany(null);
|
||||||
setCompanyInput("");
|
setCompanyInput("");
|
||||||
@@ -223,16 +239,17 @@ export default function AddJobModal({ open, onClose, onCreated }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const importFromUrl = async () => {
|
const importFromUrl = async (urlArg?: string) => {
|
||||||
if (importing) return;
|
if (importing) return;
|
||||||
if (!jobUrl.trim()) {
|
const url = (urlArg ?? jobUrl).trim();
|
||||||
|
if (!url) {
|
||||||
toast(t("addJobModalPasteUrlFirst"), "warning");
|
toast(t("addJobModalPasteUrlFirst"), "warning");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setImporting(true);
|
setImporting(true);
|
||||||
try {
|
try {
|
||||||
const res = await api.post<JobImportResult>("/jobimport/preview", { url: jobUrl.trim() });
|
const res = await api.post<JobImportResult>("/jobimport/preview", { url });
|
||||||
const r = res.data;
|
const r = res.data;
|
||||||
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
if (!r?.success) throw new Error(r?.error || t("addJobModalImportFailed"));
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import { api } from "../api";
|
|||||||
import ViewStateNotice from "./ViewStateNotice";
|
import ViewStateNotice from "./ViewStateNotice";
|
||||||
import { getUserKeyFromToken } from "../themePrefs";
|
import { getUserKeyFromToken } from "../themePrefs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { statusLabel } from "../pipeline";
|
||||||
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
import { buildWorkflowPath, getWorkflowAction } from "../jobWorkflowSignals";
|
||||||
import { JobApplication } from "../types";
|
import { JobApplication } from "../types";
|
||||||
import { useViewResource } from "../hooks/useViewResource";
|
import { useViewResource } from "../hooks/useViewResource";
|
||||||
@@ -49,6 +50,7 @@ type OverviewAnalytics = {
|
|||||||
medianDaysToFirstResponse?: number | null;
|
medianDaysToFirstResponse?: number | null;
|
||||||
totalResponses: number;
|
totalResponses: number;
|
||||||
totalActive: number;
|
totalActive: number;
|
||||||
|
timeInStage?: { stage: string; medianDays: number; count: number }[];
|
||||||
};
|
};
|
||||||
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
type TagTrendResponse = { months: string[]; series: { tag: string; counts: number[] }[] };
|
||||||
|
|
||||||
@@ -453,7 +455,7 @@ export default function DashboardView() {
|
|||||||
return (
|
return (
|
||||||
<Box key={item.label}>
|
<Box key={item.label}>
|
||||||
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
<Box sx={{ display: "flex", justifyContent: "space-between", mb: 0.5, gap: 1 }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{item.label}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.label)}</Typography>
|
||||||
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{item.count}</Typography>
|
||||||
</Box>
|
</Box>
|
||||||
<LinearProgress
|
<LinearProgress
|
||||||
@@ -474,6 +476,22 @@ export default function DashboardView() {
|
|||||||
})}
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|
||||||
|
{overview?.timeInStage?.length ? (
|
||||||
|
<Box sx={{ mt: 2.25 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 800, mb: 1 }}>{t("dashboardTimeInStageTitle")}</Typography>
|
||||||
|
<Stack spacing={0.75}>
|
||||||
|
{overview.timeInStage.map((item) => (
|
||||||
|
<Box key={item.stage} sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1 }}>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 700 }}>{statusLabel(t, item.stage)}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>
|
||||||
|
{t("dashboardTimeInStageValue", { days: item.medianDays, count: item.count })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
<Box sx={{ mt: 2.25, p: 1.5, borderRadius: 3, backgroundColor: alpha(theme.palette.primary.main, 0.05) }}>
|
||||||
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>{summaryView.topSource?.label ?? t("dashboardResponseSources")}</Typography>
|
||||||
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
<Typography variant="h5" sx={{ fontWeight: 950, mt: 0.5 }}>{summaryView.topSource ? `${summaryView.topSource.rate}%` : "—"}</Typography>
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ import {
|
|||||||
TextField,
|
TextField,
|
||||||
Typography,
|
Typography,
|
||||||
} from "@mui/material";
|
} from "@mui/material";
|
||||||
|
import { alpha } from "@mui/material/styles";
|
||||||
|
|
||||||
import { api, getApiErrorMessage } from "../api";
|
import { api, getApiErrorMessage } from "../api";
|
||||||
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, TailoredCvDraft } from "../types";
|
import { ApplicationPackageResponse, CandidateFit, FocusPlanResponse, FollowUpDraft, InterviewPrepResponse, JobApplication, MatchScore, ReadinessResponse, StatusSuggestion, TailoredCvDraft } from "../types";
|
||||||
|
import { statusLabel } from "../pipeline";
|
||||||
import { useToast } from "../toast";
|
import { useToast } from "../toast";
|
||||||
import { useDialogActions } from "../dialogs";
|
import { useDialogActions } from "../dialogs";
|
||||||
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
import { emptyTailoredCvDraft, joinLines, normalizeTailoredCvDraft, splitLines } from "../tailoredCvDraft";
|
||||||
@@ -172,6 +174,8 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
const [candidateFit, setCandidateFit] = useState<CandidateFit | null>(null);
|
||||||
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
const [matchScore, setMatchScore] = useState<MatchScore | null>(null);
|
||||||
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
const [loadingMatchScore, setLoadingMatchScore] = useState(false);
|
||||||
|
const [statusSuggestion, setStatusSuggestion] = useState<StatusSuggestion | null>(null);
|
||||||
|
const [applyingStatusSuggestion, setApplyingStatusSuggestion] = useState(false);
|
||||||
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
const [focusPlan, setFocusPlan] = useState<FocusPlanResponse | null>(null);
|
||||||
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
const [loadingCandidateFit, setLoadingCandidateFit] = useState(false);
|
||||||
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
const [loadingFocusPlan, setLoadingFocusPlan] = useState(false);
|
||||||
@@ -205,6 +209,7 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
setFollowUpDraft(null);
|
setFollowUpDraft(null);
|
||||||
setCandidateFit(null);
|
setCandidateFit(null);
|
||||||
setMatchScore(null);
|
setMatchScore(null);
|
||||||
|
setStatusSuggestion(null);
|
||||||
setFocusPlan(null);
|
setFocusPlan(null);
|
||||||
setInterviewPrep(null);
|
setInterviewPrep(null);
|
||||||
setReadiness(null);
|
setReadiness(null);
|
||||||
@@ -303,6 +308,31 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
}).catch(() => setMatchScore(null)).finally(() => setLoadingMatchScore(false));
|
||||||
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
}, [open, jobId, tab, matchScore, matchScoreCache]);
|
||||||
|
|
||||||
|
// Suggest a status move from the latest inbound email when the workspace opens.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !jobId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
api.get<StatusSuggestion>(`/jobapplications/${jobId}/status-suggestion`)
|
||||||
|
.then((r) => { if (!cancelled) setStatusSuggestion(r.data?.hasSuggestion ? r.data : null); })
|
||||||
|
.catch(() => { if (!cancelled) setStatusSuggestion(null); });
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [open, jobId]);
|
||||||
|
|
||||||
|
const applyStatusSuggestion = async () => {
|
||||||
|
if (!jobId || !statusSuggestion?.suggestedStatus) return;
|
||||||
|
setApplyingStatusSuggestion(true);
|
||||||
|
try {
|
||||||
|
await api.patch(`/jobapplications/${jobId}/status`, { status: statusSuggestion.suggestedStatus });
|
||||||
|
setJob((prev) => prev ? { ...prev, status: statusSuggestion.suggestedStatus! } : prev);
|
||||||
|
setStatusSuggestion(null);
|
||||||
|
toast(t("statusSuggestionApplied"), "success");
|
||||||
|
} catch (error: any) {
|
||||||
|
toast(getApiErrorMessage(error, t("statusSuggestionFailed")), "error");
|
||||||
|
} finally {
|
||||||
|
setApplyingStatusSuggestion(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
if (!open || !jobId || tab !== 6 || focusPlan) return;
|
||||||
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
const cacheKey = `${jobId}:focus-plan:${selectedAttachmentCsv || "none"}`;
|
||||||
@@ -621,6 +651,25 @@ export default function JobDetailsDialog({ open, jobId, onClose, initialTab = 0,
|
|||||||
|
|
||||||
{attachmentPicker}
|
{attachmentPicker}
|
||||||
|
|
||||||
|
{statusSuggestion?.hasSuggestion ? (
|
||||||
|
<Box sx={{ p: 1.5, mb: 2, borderRadius: 3, border: "1px solid", borderColor: "warning.main", backgroundColor: (theme) => alpha(theme.palette.warning.main, 0.08), display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1.5, flexWrap: "wrap" }}>
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" sx={{ fontWeight: 800 }}>
|
||||||
|
{t("statusSuggestionTitle", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" sx={{ color: "text.secondary" }}>
|
||||||
|
{t("statusSuggestionReason", { signal: statusSuggestion.signal ?? "", current: statusLabel(t, statusSuggestion.currentStatus ?? "") })}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box sx={{ display: "flex", gap: 1 }}>
|
||||||
|
<Button size="small" variant="contained" color="warning" disabled={applyingStatusSuggestion} onClick={() => void applyStatusSuggestion()}>
|
||||||
|
{t("statusSuggestionApply", { status: statusLabel(t, statusSuggestion.suggestedStatus ?? "") })}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" variant="text" onClick={() => setStatusSuggestion(null)}>{t("statusSuggestionDismiss")}</Button>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{tab === 0 && (
|
{tab === 0 && (
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 2 }}>
|
||||||
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
<Box sx={{ gridColumn: "1 / -1", display: "flex", justifyContent: "space-between", alignItems: "center", gap: 1, flexWrap: "wrap" }}>
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import React, { useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
import { Box, Paper, TextField, Typography } from "@mui/material";
|
||||||
|
|
||||||
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
|
import { useToast } from "../toast";
|
||||||
|
|
||||||
|
/** The bookmarklet opens the app at /?add=<current page url>, which triggers quick-capture. */
|
||||||
|
function buildBookmarklet(origin: string): string {
|
||||||
|
// Kept as a single minified expression; opens a small popup so the user's tab is undisturbed.
|
||||||
|
return `javascript:void(window.open('${origin}/?add='+encodeURIComponent(location.href),'jobbjakt','width=520,height=720'))`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function QuickCaptureCard() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const linkRef = useRef<HTMLAnchorElement>(null);
|
||||||
|
const origin = typeof window !== "undefined" ? window.location.origin : "";
|
||||||
|
const bookmarklet = buildBookmarklet(origin);
|
||||||
|
|
||||||
|
// React refuses to render javascript: hrefs, so set it directly on the DOM node.
|
||||||
|
useEffect(() => {
|
||||||
|
if (linkRef.current) linkRef.current.setAttribute("href", bookmarklet);
|
||||||
|
}, [bookmarklet]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Paper sx={{ p: 2 }}>
|
||||||
|
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>{t("settingsQuickCaptureTitle")}</Typography>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary", mb: 1.5 }}>{t("settingsQuickCaptureSubtitle")}</Typography>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", alignItems: "center", gap: 1.5, flexWrap: "wrap", mb: 1.5 }}>
|
||||||
|
<Box
|
||||||
|
component="a"
|
||||||
|
ref={linkRef}
|
||||||
|
onClick={(e: React.MouseEvent) => {
|
||||||
|
// Clicking (vs dragging) shouldn't navigate; the value is meant to be dragged to the bar.
|
||||||
|
e.preventDefault();
|
||||||
|
toast(t("settingsQuickCaptureDragHint"), "info");
|
||||||
|
}}
|
||||||
|
sx={{
|
||||||
|
display: "inline-block",
|
||||||
|
px: 2,
|
||||||
|
py: 1,
|
||||||
|
borderRadius: 2,
|
||||||
|
border: "1px solid",
|
||||||
|
borderColor: "primary.main",
|
||||||
|
color: "primary.main",
|
||||||
|
fontWeight: 800,
|
||||||
|
textDecoration: "none",
|
||||||
|
cursor: "grab",
|
||||||
|
userSelect: "none",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("settingsQuickCaptureButton")}
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" sx={{ color: "text.secondary" }}>{t("settingsQuickCaptureDragHint")}</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
label={t("settingsQuickCaptureManual")}
|
||||||
|
value={bookmarklet}
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
InputProps={{ readOnly: true }}
|
||||||
|
onFocus={(e) => e.target.select()}
|
||||||
|
/>
|
||||||
|
</Paper>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import ImportExportJobs from "./ImportExportJobs";
|
|||||||
import GoogleAuthCard from "./GoogleAuthCard";
|
import GoogleAuthCard from "./GoogleAuthCard";
|
||||||
import RulesSettingsCard from "./RulesSettingsCard";
|
import RulesSettingsCard from "./RulesSettingsCard";
|
||||||
import BackupCard from "./BackupCard";
|
import BackupCard from "./BackupCard";
|
||||||
|
import QuickCaptureCard from "./QuickCaptureCard";
|
||||||
import AuthStatusCard from "./AuthStatusCard";
|
import AuthStatusCard from "./AuthStatusCard";
|
||||||
import { ThemeModePref } from "../themePrefs";
|
import { ThemeModePref } from "../themePrefs";
|
||||||
import { useI18n } from "../i18n/I18nProvider";
|
import { useI18n } from "../i18n/I18nProvider";
|
||||||
@@ -297,6 +298,8 @@ export default function SettingsView({
|
|||||||
|
|
||||||
<ImportExportJobs />
|
<ImportExportJobs />
|
||||||
</Paper>
|
</Paper>
|
||||||
|
|
||||||
|
<QuickCaptureCard />
|
||||||
</Box>
|
</Box>
|
||||||
</TabPanel>
|
</TabPanel>
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ export const translations = {
|
|||||||
settingsOpenReminderInbox: "Open reminders",
|
settingsOpenReminderInbox: "Open reminders",
|
||||||
settingsReviewJobs: "Review jobs",
|
settingsReviewJobs: "Review jobs",
|
||||||
settingsNotificationsTitle: "Notification settings",
|
settingsNotificationsTitle: "Notification settings",
|
||||||
|
settingsQuickCaptureTitle: "Quick capture bookmarklet",
|
||||||
|
settingsQuickCaptureSubtitle: "Drag this button to your bookmarks bar. On any job posting, click it to open Add Job pre-filled from that page.",
|
||||||
|
settingsQuickCaptureButton: "+ Save to Jobbjakt",
|
||||||
|
settingsQuickCaptureDragHint: "Drag me to your bookmarks bar",
|
||||||
|
settingsQuickCaptureManual: "Or copy the bookmarklet code",
|
||||||
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
|
settingsNotificationsBody: "Choose which reminders should show up in your workflow. SMTP delivery can be checked from the system page.",
|
||||||
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
|
settingsNotificationsDelivery: "SMTP delivery and test mail live under Admin → System → Settings.",
|
||||||
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
settingsNotificationsFollowUpReminders: "Email reminders for follow-ups",
|
||||||
@@ -335,6 +340,8 @@ export const translations = {
|
|||||||
dashboardApplicationActivity: "Application activity",
|
dashboardApplicationActivity: "Application activity",
|
||||||
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
|
dashboardMonthlyApplicationsResponses: "Monthly applications versus responses.",
|
||||||
dashboardConversionFunnelTitle: "Conversion funnel",
|
dashboardConversionFunnelTitle: "Conversion funnel",
|
||||||
|
dashboardTimeInStageTitle: "Median time in stage",
|
||||||
|
dashboardTimeInStageValue: "{days}d · {count} active",
|
||||||
dashboardResponseSources: "Response sources",
|
dashboardResponseSources: "Response sources",
|
||||||
dashboardTopCompaniesByActivity: "Top companies by activity",
|
dashboardTopCompaniesByActivity: "Top companies by activity",
|
||||||
dashboardTopSkills: "Top skills",
|
dashboardTopSkills: "Top skills",
|
||||||
@@ -779,6 +786,12 @@ export const translations = {
|
|||||||
jobDetailsTabFocusPlan: "Focus plan",
|
jobDetailsTabFocusPlan: "Focus plan",
|
||||||
jobDetailsTabInterviewPrep: "Interview prep",
|
jobDetailsTabInterviewPrep: "Interview prep",
|
||||||
jobDetailsTabHistory: "History",
|
jobDetailsTabHistory: "History",
|
||||||
|
statusSuggestionTitle: "This email looks like a move to {status}",
|
||||||
|
statusSuggestionReason: "Matched \"{signal}\" · currently {current}",
|
||||||
|
statusSuggestionApply: "Move to {status}",
|
||||||
|
statusSuggestionDismiss: "Dismiss",
|
||||||
|
statusSuggestionApplied: "Status updated.",
|
||||||
|
statusSuggestionFailed: "Could not update status.",
|
||||||
jobDetailsTailoredCvMode: "Generation mode",
|
jobDetailsTailoredCvMode: "Generation mode",
|
||||||
jobDetailsGenerationDefault: "Balanced",
|
jobDetailsGenerationDefault: "Balanced",
|
||||||
jobDetailsGenerationConcise: "Concise",
|
jobDetailsGenerationConcise: "Concise",
|
||||||
@@ -1088,6 +1101,11 @@ export const translations = {
|
|||||||
settingsOpenReminderInbox: "Åpne påminnelser",
|
settingsOpenReminderInbox: "Åpne påminnelser",
|
||||||
settingsReviewJobs: "Gå til jobber",
|
settingsReviewJobs: "Gå til jobber",
|
||||||
settingsNotificationsTitle: "Varslingsinnstillinger",
|
settingsNotificationsTitle: "Varslingsinnstillinger",
|
||||||
|
settingsQuickCaptureTitle: "Hurtiglagring (bokmerke)",
|
||||||
|
settingsQuickCaptureButton: "+ Lagre til Jobbjakt",
|
||||||
|
settingsQuickCaptureSubtitle: "Dra denne knappen til bokmerkelinjen. På en stillingsannonse klikker du på den for å åpne Legg til jobb forhåndsutfylt fra siden.",
|
||||||
|
settingsQuickCaptureDragHint: "Dra meg til bokmerkelinjen",
|
||||||
|
settingsQuickCaptureManual: "Eller kopier bokmerkekoden",
|
||||||
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
|
settingsNotificationsBody: "Velg hvilke påminnelser som skal vises i arbeidsflyten din. SMTP-levering kan kontrolleres fra systemsiden.",
|
||||||
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
|
settingsNotificationsDelivery: "SMTP-levering og test-epost ligger under Admin → System → Innstillinger.",
|
||||||
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
settingsNotificationsFollowUpReminders: "E-postpåminnelser for oppfølginger",
|
||||||
@@ -1266,6 +1284,8 @@ export const translations = {
|
|||||||
dashboardApplicationActivity: "Søknadsaktivitet",
|
dashboardApplicationActivity: "Søknadsaktivitet",
|
||||||
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
|
dashboardMonthlyApplicationsResponses: "Månedlige søknader versus svar.",
|
||||||
dashboardConversionFunnelTitle: "Konverteringstrakt",
|
dashboardConversionFunnelTitle: "Konverteringstrakt",
|
||||||
|
dashboardTimeInStageTitle: "Median tid i fase",
|
||||||
|
dashboardTimeInStageValue: "{days}d · {count} aktive",
|
||||||
dashboardResponseSources: "Svar etter kilde",
|
dashboardResponseSources: "Svar etter kilde",
|
||||||
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
|
dashboardTopCompaniesByActivity: "Topp selskaper etter aktivitet",
|
||||||
dashboardTopSkills: "Topp ferdigheter",
|
dashboardTopSkills: "Topp ferdigheter",
|
||||||
@@ -1710,6 +1730,12 @@ export const translations = {
|
|||||||
jobDetailsTabFocusPlan: "Fokusplan",
|
jobDetailsTabFocusPlan: "Fokusplan",
|
||||||
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
|
jobDetailsTabInterviewPrep: "Intervjuforberedelse",
|
||||||
jobDetailsTabHistory: "Historikk",
|
jobDetailsTabHistory: "Historikk",
|
||||||
|
statusSuggestionTitle: "Denne e-posten ser ut som en overgang til {status}",
|
||||||
|
statusSuggestionReason: "Traff \"{signal}\" · nå {current}",
|
||||||
|
statusSuggestionApply: "Flytt til {status}",
|
||||||
|
statusSuggestionDismiss: "Avvis",
|
||||||
|
statusSuggestionApplied: "Status oppdatert.",
|
||||||
|
statusSuggestionFailed: "Kunne ikke oppdatere status.",
|
||||||
jobDetailsTailoredCvMode: "Genereringsmodus",
|
jobDetailsTailoredCvMode: "Genereringsmodus",
|
||||||
jobDetailsGenerationDefault: "Balansert",
|
jobDetailsGenerationDefault: "Balansert",
|
||||||
jobDetailsGenerationConcise: "Kortfattet",
|
jobDetailsGenerationConcise: "Kortfattet",
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
// Avoid pulling the date-fns v4 ESM adapter into Jest; the picker isn't under test here.
|
||||||
|
jest.mock('@mui/x-date-pickers/DatePicker', () => ({
|
||||||
|
DatePicker: ({ label }: any) => <div>{label}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// eslint-disable-next-line import/first
|
||||||
|
import AddJobModal from './components/AddJobModal';
|
||||||
|
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(() => Promise.resolve({ data: [] })),
|
||||||
|
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: jest.fn(() => 'error'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderModal(initialUrl?: string) {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<AddJobModal open initialUrl={initialUrl} onClose={() => {}} onCreated={() => {}} />
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.get.mockResolvedValue({ data: [] } as any);
|
||||||
|
mockedApi.post.mockImplementation((url: string) => {
|
||||||
|
if (url === '/jobimport/preview') {
|
||||||
|
return Promise.resolve({ data: { success: true, title: 'Imported Backend Role', company: 'Acme', location: 'Oslo', description: 'desc', tags: ['C#'] } } as any);
|
||||||
|
}
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
|
test('auto-imports from initialUrl and prefills the form', async () => {
|
||||||
|
renderModal('https://example.com/jobs/123');
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.post).toHaveBeenCalledWith('/jobimport/preview', { url: 'https://example.com/jobs/123' });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await screen.findByDisplayValue('Imported Backend Role')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('does not auto-import when no initialUrl is given', async () => {
|
||||||
|
renderModal(undefined);
|
||||||
|
|
||||||
|
// Wait for the modal to render, then confirm no import was triggered.
|
||||||
|
expect(await screen.findByRole('dialog')).toBeInTheDocument();
|
||||||
|
expect(mockedApi.post).not.toHaveBeenCalledWith('/jobimport/preview', expect.anything());
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import '@testing-library/jest-dom';
|
||||||
|
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { ConfirmProvider } from './confirm';
|
||||||
|
import { PromptProvider } from './prompt';
|
||||||
|
import { ToastProvider } from './toast';
|
||||||
|
import { I18nProvider } from './i18n/I18nProvider';
|
||||||
|
import JobDetailsDialog from './components/JobDetailsDialog';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
jest.setTimeout(15000);
|
||||||
|
|
||||||
|
jest.mock('./api', () => ({
|
||||||
|
api: {
|
||||||
|
get: jest.fn(),
|
||||||
|
post: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
put: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
patch: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
delete: jest.fn(() => Promise.resolve({ data: {} })),
|
||||||
|
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
|
||||||
|
},
|
||||||
|
getApiErrorMessage: jest.fn(() => 'error'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedApi = api as jest.Mocked<typeof api>;
|
||||||
|
|
||||||
|
function renderDialog() {
|
||||||
|
return render(
|
||||||
|
<ToastProvider>
|
||||||
|
<I18nProvider>
|
||||||
|
<ConfirmProvider>
|
||||||
|
<PromptProvider>
|
||||||
|
<JobDetailsDialog open jobId={42} onClose={() => {}} />
|
||||||
|
</PromptProvider>
|
||||||
|
</ConfirmProvider>
|
||||||
|
</I18nProvider>
|
||||||
|
</ToastProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/jobapplications/42') {
|
||||||
|
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/status-suggestion') {
|
||||||
|
return Promise.resolve({ data: { hasSuggestion: true, suggestedStatus: 'Interview', currentStatus: 'Applied', signal: 'schedule an interview', confidence: 'medium' } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||||
|
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||||
|
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('status suggestion banner appears and applies via PATCH', async () => {
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/looks like a move to interview/i)).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.click(await screen.findByRole('button', { name: /move to interview/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockedApi.patch).toHaveBeenCalledWith('/jobapplications/42/status', { status: 'Interview' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no banner when there is no suggestion', async () => {
|
||||||
|
mockedApi.get.mockImplementation((url: string) => {
|
||||||
|
if (url === '/jobapplications/42') {
|
||||||
|
return Promise.resolve({ data: { id: 42, jobTitle: 'Backend Developer', status: 'Applied', dateApplied: new Date().toISOString(), daysSince: 3, company: { name: 'Acme' } } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/jobapplications/42/status-suggestion') {
|
||||||
|
return Promise.resolve({ data: { hasSuggestion: false } } as any);
|
||||||
|
}
|
||||||
|
if (url === '/auth/me') return Promise.resolve({ data: { roles: [] } } as any);
|
||||||
|
if (url === '/jobapplications/42/history') return Promise.resolve({ data: [] } as any);
|
||||||
|
if (url === '/attachments/42') return Promise.resolve({ data: [] } as any);
|
||||||
|
return Promise.resolve({ data: {} } as any);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderDialog();
|
||||||
|
|
||||||
|
expect(await screen.findByText(/backend developer/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText(/looks like a move to/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
@@ -138,6 +138,16 @@ export interface MatchScoreSectionCoverage {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatusSuggestion {
|
||||||
|
hasSuggestion: boolean;
|
||||||
|
suggestedStatus?: string | null;
|
||||||
|
currentStatus?: string | null;
|
||||||
|
signal?: string | null;
|
||||||
|
confidence?: string | null;
|
||||||
|
messageDate?: string | null;
|
||||||
|
messageSubject?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface MatchScore {
|
export interface MatchScore {
|
||||||
score: number;
|
score: number;
|
||||||
band: string;
|
band: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user