Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8b4c33acd | |||
| 9252e43ad4 | |||
| 1b78f712d3 | |||
| a56e377c98 | |||
| d7366f9bd7 | |||
| f8fc8d6976 | |||
| 1152e05687 | |||
| caf486dceb | |||
| b7029faaec | |||
| 19a89018a0 | |||
| e508fb9541 | |||
| 789f30b6e6 | |||
| 14c5dd58d7 | |||
| d3903961f8 |
@@ -104,6 +104,8 @@ EMAIL_FOLLOWUPREMINDERS_ENABLED=false
|
||||
EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2
|
||||
WORKER_RULES_ENABLED=false
|
||||
WORKER_FOLLOWUP_REMINDERS_ENABLED=false
|
||||
WORKER_SAVED_SEARCH_ALERTS_ENABLED=true
|
||||
SAVED_SEARCH_INTERVAL_HOURS=6
|
||||
WORKER_DAILY_EXPORT_ENABLED=false
|
||||
WORKER_JOB_ENRICHMENT_ENABLED=false
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ jobs:
|
||||
python3 scripts/test-ollama-evaluation.py
|
||||
python3 scripts/test-supply-chain.py
|
||||
|
||||
- name: Test browser extension
|
||||
run: node --test browser-extension/tests/*.test.mjs
|
||||
|
||||
- name: Scan tracked files and generate dependency SBOM
|
||||
run: |
|
||||
python3 scripts/supply-chain.py scan-secrets
|
||||
@@ -70,14 +73,22 @@ jobs:
|
||||
- name: Test document parser boundary
|
||||
working-directory: tools/summarizer
|
||||
run: |
|
||||
python3 -m venv .venv-ci
|
||||
. .venv-ci/bin/activate
|
||||
python -m pip install --upgrade pip
|
||||
python -m pip install --require-hashes \
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu \
|
||||
-r requirements-linux.lock
|
||||
python -m pip install pytest==8.3.5 httpx2==2.12.0
|
||||
AI_SERVICE_SKIP_MODEL_LOAD=1 python -m pytest -q
|
||||
# The Debian runner omits pip and python3-venv. Use pip's official
|
||||
# zipapp and install into the checkout so CI remains isolated without
|
||||
# sudo or a mutable runner-global Python environment.
|
||||
curl -fsSLo /tmp/pip.pyz https://bootstrap.pypa.io/pip/pip.pyz
|
||||
install_parser_dependencies() {
|
||||
rm -rf .python-ci
|
||||
parser_pip_cache="$(mktemp -d)"
|
||||
PIP_CACHE_DIR="$parser_pip_cache" python3 /tmp/pip.pyz install --target .python-ci --require-hashes \
|
||||
--extra-index-url https://download.pytorch.org/whl/cpu \
|
||||
-r requirements-linux.lock
|
||||
}
|
||||
install_parser_dependencies \
|
||||
|| ( echo "Parser dependency install failed ($?) — retrying with a fresh isolated cache..." \
|
||||
&& install_parser_dependencies )
|
||||
PIP_CACHE_DIR="$parser_pip_cache" python3 /tmp/pip.pyz install --upgrade --target .python-ci pytest==8.3.5 httpx2==2.12.0
|
||||
PYTHONPATH="$PWD/.python-ci" AI_SERVICE_SKIP_MODEL_LOAD=1 python3 -m pytest -q
|
||||
|
||||
- name: Restore backend
|
||||
# The runner/proxy has occasionally supplied checksum-invalid NuGet cache entries (NU3008).
|
||||
@@ -165,7 +176,23 @@ jobs:
|
||||
|
||||
- name: Lint frontend
|
||||
working-directory: job-tracker-ui
|
||||
run: npm run lint
|
||||
# Match the runner resilience used by tests/builds without masking real lint errors.
|
||||
# Exit 139 is the self-hosted runner killing Node before ESLint can report a result.
|
||||
run: |
|
||||
set +e
|
||||
npm run lint
|
||||
lint_status=$?
|
||||
set -e
|
||||
|
||||
if [ "$lint_status" -eq 0 ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$lint_status" -ne 139 ]; then
|
||||
exit "$lint_status"
|
||||
fi
|
||||
|
||||
echo "Frontend lint process crashed with SIGSEGV — retrying once..."
|
||||
npm run lint
|
||||
|
||||
- name: Test frontend
|
||||
working-directory: job-tracker-ui
|
||||
@@ -196,6 +223,10 @@ jobs:
|
||||
CI: 'false'
|
||||
GENERATE_SOURCEMAP: 'false'
|
||||
NODE_OPTIONS: --max-old-space-size=4096
|
||||
# NEXT_PUBLIC values are embedded during `next build`. The browser smoke
|
||||
# suite serves this prebuilt export, so it must target its isolated API
|
||||
# rather than the normal localhost development port.
|
||||
NEXT_PUBLIC_API_BASE_URL: http://localhost:5302/api
|
||||
# Build once, then make Playwright serve this exact export. A SIGSEGV-only retry preserves
|
||||
# the runner workaround without hiding an ordinary compiler or type-check failure.
|
||||
run: |
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class ApplicationCalendarControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Status_distinguishes_connection_from_calendar_consent()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var google = new Mock<IGmailOAuthService>();
|
||||
google.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new GmailConnection { OwnerUserId = "owner-1", Scope = GmailOAuthService.CalendarScope });
|
||||
var microsoft = new Mock<IMicrosoftGraphOAuthService>();
|
||||
microsoft.Setup(service => service.GetConnectionAsync("owner-1", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new MicrosoftGraphConnection { OwnerUserId = "owner-1", Scope = "Mail.Read" });
|
||||
var controller = CreateController(db, "owner-1", google.Object, microsoft.Object);
|
||||
|
||||
var value = Assert.IsType<ApplicationCalendarController.CalendarStatusDto>(Assert.IsType<OkObjectResult>((await controller.Status(default)).Result).Value);
|
||||
|
||||
Assert.True(value.Google.Connected);
|
||||
Assert.True(value.Google.Writable);
|
||||
Assert.True(value.Microsoft.Connected);
|
||||
Assert.False(value.Microsoft.Writable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Creates_follow_up_in_selected_connected_calendar_with_stable_job_context()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var job = await SeedJobAsync(db, "owner-1");
|
||||
ExternalCalendarEventRequest? sent = null;
|
||||
var google = new Mock<IGmailOAuthService>();
|
||||
google.Setup(service => service.CreateCalendarEventAsync("owner-1", It.IsAny<ExternalCalendarEventRequest>(), It.IsAny<CancellationToken>()))
|
||||
.Callback<string, ExternalCalendarEventRequest, CancellationToken>((_, request, _) => sent = request)
|
||||
.ReturnsAsync(new ExternalCalendarEventResult("event-1", "https://calendar.google.test/event-1"));
|
||||
var controller = CreateController(db, "owner-1", google.Object, Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
|
||||
var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default);
|
||||
|
||||
var value = Assert.IsType<ApplicationCalendarController.CalendarEventDto>(Assert.IsType<OkObjectResult>(response.Result).Value);
|
||||
Assert.Equal("google", value.Provider);
|
||||
Assert.Equal("event-1", value.Id);
|
||||
Assert.NotNull(sent);
|
||||
Assert.Equal("Follow up: Backend Engineer at Acme", sent.Summary);
|
||||
Assert.Equal(job.FollowUpAt, sent.StartsAtUtc.UtcDateTime);
|
||||
Assert.False(sent.AllDay);
|
||||
Assert.Equal(64, sent.StableId.Length);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Rejects_another_owners_job_without_calling_provider()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var job = await SeedJobAsync(db, "owner-2");
|
||||
var google = new Mock<IGmailOAuthService>(MockBehavior.Strict);
|
||||
var controller = CreateController(db, "owner-1", google.Object, Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
|
||||
var response = await controller.CreateEvent(job.Id, new("google", "follow-up"), default);
|
||||
|
||||
Assert.IsType<NotFoundResult>(response.Result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Downloads_portable_all_day_deadline_event_without_provider_credentials()
|
||||
{
|
||||
await using var db = CreateDb("owner-1");
|
||||
var job = await SeedJobAsync(db, "owner-1");
|
||||
var controller = CreateController(db, "owner-1", Mock.Of<IGmailOAuthService>(), Mock.Of<IMicrosoftGraphOAuthService>());
|
||||
|
||||
var response = Assert.IsType<FileContentResult>(await controller.DownloadEvent(job.Id, "deadline", default));
|
||||
var content = System.Text.Encoding.UTF8.GetString(response.FileContents);
|
||||
|
||||
Assert.Equal("text/calendar; charset=utf-8", response.ContentType);
|
||||
Assert.Contains("DTSTART;VALUE=DATE:20260915", content);
|
||||
Assert.Contains("DTEND;VALUE=DATE:20260916", content);
|
||||
Assert.Contains("SUMMARY:Application deadline: Backend Engineer at Acme", content);
|
||||
}
|
||||
|
||||
private static JobTrackerContext CreateDb(string owner)
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(service => service.UserId).Returns(owner);
|
||||
return new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object);
|
||||
}
|
||||
|
||||
private static async Task<JobApplication> SeedJobAsync(JobTrackerContext db, string owner)
|
||||
{
|
||||
var company = new Company { OwnerUserId = owner, Name = "Acme" };
|
||||
var job = new JobApplication
|
||||
{
|
||||
OwnerUserId = owner,
|
||||
Company = company,
|
||||
JobTitle = "Backend Engineer",
|
||||
Status = "Applied",
|
||||
FollowUpAt = new DateTime(2026, 9, 2, 10, 30, 0, DateTimeKind.Utc),
|
||||
Deadline = new DateTime(2026, 9, 15, 0, 0, 0, DateTimeKind.Utc),
|
||||
Location = "Oslo"
|
||||
};
|
||||
db.JobApplications.Add(job);
|
||||
await db.SaveChangesAsync();
|
||||
return job;
|
||||
}
|
||||
|
||||
private static ApplicationCalendarController CreateController(JobTrackerContext db, string owner, IGmailOAuthService google, IMicrosoftGraphOAuthService microsoft)
|
||||
{
|
||||
var current = new Mock<ICurrentUserService>();
|
||||
current.SetupGet(service => service.UserId).Returns(owner);
|
||||
return new ApplicationCalendarController(db, current.Object, google, microsoft);
|
||||
}
|
||||
}
|
||||
@@ -211,6 +211,23 @@ public sealed class CvBuilderTests
|
||||
Assert.DoesNotContain("transform:scale", html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Ats_themes_keep_plain_text_content_but_use_distinct_structures_and_skill_treatments()
|
||||
{
|
||||
var atsThemes = CvThemeCatalog.Themes.Where(theme => theme.AtsFriendly).ToList();
|
||||
|
||||
Assert.All(atsThemes, theme => Assert.Contains(theme.Layout, new[] { "single", "header-band" }));
|
||||
Assert.Contains(atsThemes, theme => theme.DefaultSkillsStyle == "text");
|
||||
Assert.Contains(atsThemes, theme => theme.DefaultSkillsStyle == "bullets");
|
||||
Assert.Contains(atsThemes, theme => theme.DefaultSkillsStyle == "tags");
|
||||
Assert.True(atsThemes.Select(theme => (theme.Layout, theme.HeaderStyle, theme.HeadingStyle, theme.NumberSections)).Distinct().Count() >= 5);
|
||||
|
||||
var renderer = new ThemedCvRenderer();
|
||||
var model = CvVariantResolver.Build(Rich(), new CvVariantSettings(), "F", null);
|
||||
Assert.Contains("class=\"skills-text\"", renderer.Render(model, CvThemeCatalog.Resolve("minimal"), new CvVariantSettings { ThemeId = "minimal" }).Html);
|
||||
Assert.Contains("class=\"bullets skill-bullets\"", renderer.Render(model, CvThemeCatalog.Resolve("ats-classic"), new CvVariantSettings { ThemeId = "ats-classic" }).Html);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Multi_entry_sections_flow_between_entries_instead_of_leaving_a_blank_page()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Net;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Controllers;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -25,7 +26,7 @@ public sealed class JobDiscoveryControllerTests
|
||||
var result = await controller.Search("backend", "oslo", CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value));
|
||||
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<DiscoveredJob>>(ok.Value));
|
||||
Assert.Equal("Backend Developer", job.Title);
|
||||
Assert.Equal("nav", job.Source);
|
||||
Assert.Equal("NAV Arbeidsplassen", job.SourceName);
|
||||
@@ -46,7 +47,7 @@ public sealed class JobDiscoveryControllerTests
|
||||
var result = await controller.Search(null, null, CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
||||
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value));
|
||||
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<DiscoveredJob>>(ok.Value));
|
||||
Assert.Equal("1", job.Id);
|
||||
Assert.Equal("Current title", job.Title);
|
||||
}
|
||||
@@ -71,6 +72,8 @@ public sealed class JobDiscoveryControllerTests
|
||||
Assert.IsType<NoContentResult>(await controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default));
|
||||
var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
|
||||
Assert.Equal(1, Assert.Single(listed).DismissedCount);
|
||||
var paused = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<OkObjectResult>((await controller.UpdateSavedSearch(created.Id, new(false), default)).Result).Value);
|
||||
Assert.False(paused.IsActive);
|
||||
}
|
||||
|
||||
private sealed class ClientFactory(HttpClient client) : IHttpClientFactory
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Moq;
|
||||
using Xunit;
|
||||
|
||||
namespace JobTrackerApi.Tests;
|
||||
|
||||
public sealed class SavedJobSearchAlertTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Worker_establishes_baseline_then_notifies_only_for_later_results()
|
||||
{
|
||||
var clock = new MutableTimeProvider(new DateTimeOffset(2026, 8, 31, 8, 0, 0, TimeSpan.Zero));
|
||||
var discovery = new FakeDiscovery(clock, "job-1");
|
||||
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Workers:SavedJobSearchAlertsEnabled"] = "true",
|
||||
["JobDiscovery:SavedSearchIntervalHours"] = "6"
|
||||
}).Build();
|
||||
var services = new ServiceCollection();
|
||||
var databaseName = Guid.NewGuid().ToString();
|
||||
services.AddLogging();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddSingleton<IConfiguration>(configuration);
|
||||
services.AddSingleton<TimeProvider>(clock);
|
||||
services.AddSingleton<IJobDiscoverySearchService>(discovery);
|
||||
services.AddScoped<CurrentUserService>();
|
||||
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
|
||||
services.AddDbContext<JobTrackerContext>((_, options) => options.UseInMemoryDatabase(databaseName));
|
||||
services.AddScoped<SavedJobSearchRunner>();
|
||||
services.AddSingleton<BackgroundTenantRunner>();
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
db.SavedJobSearches.Add(new SavedJobSearch
|
||||
{
|
||||
OwnerUserId = "owner-without-jobs",
|
||||
Name = "Backend Oslo",
|
||||
Query = "backend",
|
||||
Location = "Oslo",
|
||||
CreatedAtUtc = clock.GetUtcNow(),
|
||||
UpdatedAtUtc = clock.GetUtcNow()
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
}
|
||||
|
||||
var worker = new SavedJobSearchAlertHostedService(
|
||||
provider.GetRequiredService<BackgroundTenantRunner>(),
|
||||
configuration,
|
||||
NullLogger<SavedJobSearchAlertHostedService>.Instance,
|
||||
Mock.Of<IStartupReadiness>(),
|
||||
clock);
|
||||
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default));
|
||||
await using (var scope = provider.CreateAsyncScope())
|
||||
{
|
||||
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||
Assert.Empty(await db.UserNotifications.IgnoreQueryFilters().ToListAsync());
|
||||
Assert.Single(await db.SavedJobSearchResults.IgnoreQueryFilters().ToListAsync());
|
||||
}
|
||||
|
||||
clock.SetUtcNow(clock.GetUtcNow().AddHours(7));
|
||||
discovery.Ids.Add("job-2");
|
||||
Assert.Equal(new BackgroundWorkerRunResult(true, 1, 1, 0), await worker.RunOnceAsync(default));
|
||||
|
||||
await using var verification = provider.CreateAsyncScope();
|
||||
var notification = Assert.Single(await verification.ServiceProvider.GetRequiredService<JobTrackerContext>()
|
||||
.UserNotifications.IgnoreQueryFilters().AsNoTracking().ToListAsync());
|
||||
Assert.Equal("owner-without-jobs", notification.OwnerUserId);
|
||||
Assert.Equal("saved_search_results", notification.Kind);
|
||||
Assert.Equal("/discover", notification.LinkPath);
|
||||
Assert.Contains("Backend Oslo", notification.Message);
|
||||
}
|
||||
|
||||
private sealed class FakeDiscovery(MutableTimeProvider clock, params string[] ids) : IJobDiscoverySearchService
|
||||
{
|
||||
public List<string> Ids { get; } = [.. ids];
|
||||
|
||||
public Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken) =>
|
||||
Task.FromResult<IReadOnlyList<DiscoveredJob>>(Ids.Select(id => new DiscoveredJob(
|
||||
id, "Backend Developer", "Acme", "Oslo", clock.GetUtcNow(), null,
|
||||
$"https://example.test/{id}", "nav", "NAV Arbeidsplassen", "searched", clock.GetUtcNow(), "NO")).ToList());
|
||||
}
|
||||
|
||||
private sealed class MutableTimeProvider(DateTimeOffset now) : TimeProvider
|
||||
{
|
||||
private DateTimeOffset _now = now;
|
||||
public override DateTimeOffset GetUtcNow() => _now;
|
||||
public void SetUtcNow(DateTimeOffset value) => _now = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using JobTrackerApi.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/calendar")]
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class ApplicationCalendarController(
|
||||
JobTrackerContext db,
|
||||
ICurrentUserService currentUser,
|
||||
IGmailOAuthService google,
|
||||
IMicrosoftGraphOAuthService microsoft) : ControllerBase
|
||||
{
|
||||
[HttpGet("status")]
|
||||
public async Task<ActionResult<CalendarStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var owner = RequiredOwner();
|
||||
var googleConnection = await google.GetConnectionAsync(owner, cancellationToken);
|
||||
var microsoftConnection = await microsoft.GetConnectionAsync(owner, cancellationToken);
|
||||
return Ok(new CalendarStatusDto(
|
||||
new CalendarProviderStatusDto(googleConnection is not null, GmailOAuthService.HasCalendarScope(googleConnection?.Scope)),
|
||||
new CalendarProviderStatusDto(microsoftConnection is not null, MicrosoftGraphOAuthService.HasCalendarScope(microsoftConnection?.Scope))));
|
||||
}
|
||||
|
||||
[HttpPost("jobs/{jobId:int}/events")]
|
||||
public async Task<ActionResult<CalendarEventDto>> CreateEvent(int jobId, [FromBody] CreateCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var owner = RequiredOwner();
|
||||
var job = await LoadJobAsync(jobId, owner, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (!TryBuildEvent(job, owner, request.Kind, out var calendarEvent, out var validationError))
|
||||
return ValidationProblem(validationError);
|
||||
|
||||
try
|
||||
{
|
||||
var provider = request.Provider?.Trim().ToLowerInvariant();
|
||||
var result = provider switch
|
||||
{
|
||||
"google" => await google.CreateCalendarEventAsync(owner, calendarEvent!, cancellationToken),
|
||||
"microsoft" => await microsoft.CreateCalendarEventAsync(owner, calendarEvent!, cancellationToken),
|
||||
_ => throw new ArgumentException("Choose Google Calendar or Outlook Calendar.")
|
||||
};
|
||||
return Ok(new CalendarEventDto(provider!, result.Id, result.WebUrl));
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
return ValidationProblem(ex.Message);
|
||||
}
|
||||
catch (InvalidOperationException ex)
|
||||
{
|
||||
return Conflict(new ProblemDetails { Title = "Calendar permission required", Detail = ex.Message, Status = StatusCodes.Status409Conflict });
|
||||
}
|
||||
catch (HttpRequestException)
|
||||
{
|
||||
return Problem("The calendar provider is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("jobs/{jobId:int}/events.ics")]
|
||||
public async Task<IActionResult> DownloadEvent(int jobId, [FromQuery] string? kind, CancellationToken cancellationToken)
|
||||
{
|
||||
var owner = RequiredOwner();
|
||||
var job = await LoadJobAsync(jobId, owner, cancellationToken);
|
||||
if (job is null) return NotFound();
|
||||
if (!TryBuildEvent(job, owner, kind, out var calendarEvent, out var validationError))
|
||||
return ValidationProblem(validationError);
|
||||
|
||||
var value = calendarEvent!;
|
||||
var start = value.AllDay ? $"DTSTART;VALUE=DATE:{value.StartsAtUtc:yyyyMMdd}" : $"DTSTART:{value.StartsAtUtc.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}";
|
||||
var end = value.AllDay ? $"DTEND;VALUE=DATE:{value.EndsAtUtc:yyyyMMdd}" : $"DTEND:{value.EndsAtUtc.UtcDateTime:yyyyMMdd'T'HHmmss'Z'}";
|
||||
var ics = string.Join("\r\n", new[]
|
||||
{
|
||||
"BEGIN:VCALENDAR", "VERSION:2.0", "PRODID:-//Jobjakt//Application Calendar//EN", "CALSCALE:GREGORIAN",
|
||||
"BEGIN:VEVENT", $"UID:{value.StableId}@jobs.cesnimda.uk", $"DTSTAMP:{DateTime.UtcNow:yyyyMMdd'T'HHmmss'Z'}",
|
||||
start, end, $"SUMMARY:{EscapeIcs(value.Summary)}",
|
||||
$"DESCRIPTION:{EscapeIcs(value.Description)}", $"LOCATION:{EscapeIcs(value.Location)}",
|
||||
"END:VEVENT", "END:VCALENDAR", ""
|
||||
});
|
||||
return File(Encoding.UTF8.GetBytes(ics), "text/calendar; charset=utf-8", $"jobjakt-{job.Id}-{NormalizeKind(kind)}.ics");
|
||||
}
|
||||
|
||||
private Task<JobApplication?> LoadJobAsync(int jobId, string owner, CancellationToken cancellationToken) =>
|
||||
db.JobApplications.Include(job => job.Company)
|
||||
.FirstOrDefaultAsync(job => job.Id == jobId && job.OwnerUserId == owner && !job.IsDeleted, cancellationToken);
|
||||
|
||||
private static bool TryBuildEvent(JobApplication job, string owner, string? requestedKind, out ExternalCalendarEventRequest? result, out string error)
|
||||
{
|
||||
var kind = NormalizeKind(requestedKind);
|
||||
DateTimeOffset start;
|
||||
DateTimeOffset end;
|
||||
bool allDay;
|
||||
string label;
|
||||
if (kind == "follow-up" && job.FollowUpAt is { } followUp)
|
||||
{
|
||||
start = ToUtcOffset(followUp);
|
||||
end = start.AddMinutes(30);
|
||||
allDay = false;
|
||||
label = "Follow up";
|
||||
}
|
||||
else if (kind == "deadline" && job.Deadline is { } deadline)
|
||||
{
|
||||
start = new DateTimeOffset(DateTime.SpecifyKind(deadline.Date, DateTimeKind.Utc));
|
||||
end = start.AddDays(1);
|
||||
allDay = true;
|
||||
label = "Application deadline";
|
||||
}
|
||||
else
|
||||
{
|
||||
result = null;
|
||||
error = kind == "follow-up" ? "Set a follow-up date before adding it to a calendar." : "This application has no deadline to add.";
|
||||
return false;
|
||||
}
|
||||
|
||||
var company = job.Company?.Name?.Trim();
|
||||
var summary = string.IsNullOrWhiteSpace(company) ? $"{label}: {job.JobTitle}" : $"{label}: {job.JobTitle} at {company}";
|
||||
var stableId = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes($"{owner}|{job.Id}|{kind}"))).ToLowerInvariant();
|
||||
result = new ExternalCalendarEventRequest(stableId, summary, job.NextAction, job.Location, start, end, allDay);
|
||||
error = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static DateTimeOffset ToUtcOffset(DateTime value) => value.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => new DateTimeOffset(value),
|
||||
DateTimeKind.Local => value.ToUniversalTime(),
|
||||
_ => new DateTimeOffset(DateTime.SpecifyKind(value, DateTimeKind.Utc))
|
||||
};
|
||||
|
||||
private static string NormalizeKind(string? kind) => string.Equals(kind?.Trim(), "deadline", StringComparison.OrdinalIgnoreCase) ? "deadline" : "follow-up";
|
||||
private static string EscapeIcs(string? value) => (value ?? string.Empty).Replace("\\", "\\\\").Replace(";", "\\;").Replace(",", "\\,").Replace("\r\n", "\\n").Replace("\r", "\\n").Replace("\n", "\\n");
|
||||
private string RequiredOwner() => currentUser.UserId ?? throw new UnauthorizedAccessException("Authentication required.");
|
||||
|
||||
public sealed record CalendarProviderStatusDto(bool Connected, bool Writable);
|
||||
public sealed record CalendarStatusDto(CalendarProviderStatusDto Google, CalendarProviderStatusDto Microsoft);
|
||||
public sealed record CreateCalendarEventRequest(string? Provider, string? Kind);
|
||||
public sealed record CalendarEventDto(string Provider, string? Id, string? WebUrl);
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
@@ -15,10 +14,8 @@ namespace JobTrackerApi.Controllers;
|
||||
[Authorize(AuthenticationSchemes = "local")]
|
||||
public sealed class JobDiscoveryController : ControllerBase
|
||||
{
|
||||
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
|
||||
private readonly IHttpClientFactory _clients;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IJobDiscoverySearchService _discovery;
|
||||
private readonly SavedJobSearchRunner? _savedSearchRunner;
|
||||
private readonly JobTrackerContext? _db;
|
||||
private readonly ICurrentUserService? _currentUser;
|
||||
|
||||
@@ -27,11 +24,12 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
IConfiguration configuration,
|
||||
IMemoryCache cache,
|
||||
JobTrackerContext? db = null,
|
||||
ICurrentUserService? currentUser = null)
|
||||
ICurrentUserService? currentUser = null,
|
||||
IJobDiscoverySearchService? discovery = null,
|
||||
SavedJobSearchRunner? savedSearchRunner = null)
|
||||
{
|
||||
_clients = clients;
|
||||
_configuration = configuration;
|
||||
_cache = cache;
|
||||
_discovery = discovery ?? new NavJobDiscoverySearchService(clients, configuration, cache, TimeProvider.System);
|
||||
_savedSearchRunner = savedSearchRunner;
|
||||
_db = db;
|
||||
_currentUser = currentUser;
|
||||
}
|
||||
@@ -41,7 +39,7 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
return Ok(await SearchCoreAsync(q, location, cancellationToken));
|
||||
return Ok(await _discovery.SearchAsync(q, location, cancellationToken));
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
|
||||
{
|
||||
@@ -86,6 +84,19 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
[HttpPatch("saved-searches/{id:int}")]
|
||||
public async Task<ActionResult<SavedSearchDto>> UpdateSavedSearch(int id, [FromBody] UpdateSavedSearchRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!TryGetPersistence(out var db, out var owner)) return Unauthorized();
|
||||
var search = await db.SavedJobSearches.Include(x => x.Results)
|
||||
.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, cancellationToken);
|
||||
if (search is null) return NotFound();
|
||||
search.IsActive = request.IsActive;
|
||||
search.UpdatedAtUtc = DateTimeOffset.UtcNow;
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(ToDto(search));
|
||||
}
|
||||
|
||||
[HttpPost("saved-searches/{id:int}/run")]
|
||||
public async Task<ActionResult<SavedSearchRunDto>> RunSavedSearch(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -95,26 +106,10 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var jobs = await SearchCoreAsync(search.Query, search.Location, cancellationToken);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var existing = search.Results.ToDictionary(x => x.ExternalJobId, StringComparer.OrdinalIgnoreCase);
|
||||
var results = new List<SavedSearchJobDto>(jobs.Count);
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var isNew = !existing.TryGetValue(job.Id, out var state);
|
||||
if (state is null)
|
||||
{
|
||||
state = new SavedJobSearchResult { ExternalJobId = job.Id, FirstSeenAtUtc = now, LastSeenAtUtc = now };
|
||||
search.Results.Add(state);
|
||||
}
|
||||
else state.LastSeenAtUtc = now;
|
||||
results.Add(new SavedSearchJobDto(job, isNew, state.IsDismissed));
|
||||
}
|
||||
|
||||
search.LastRunAtUtc = now;
|
||||
search.UpdatedAtUtc = now;
|
||||
var runner = _savedSearchRunner ?? new SavedJobSearchRunner(db, _discovery, TimeProvider.System);
|
||||
var run = await runner.RunAsync(search, cancellationToken);
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return Ok(new SavedSearchRunDto(ToDto(search), results));
|
||||
return Ok(new SavedSearchRunDto(ToDto(search), run.Jobs.Select(item => new SavedSearchJobDto(item.Job, item.IsNew, item.IsDismissed)).ToList()));
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
|
||||
{
|
||||
@@ -141,79 +136,8 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
return db is not null && owner.Length > 0;
|
||||
}
|
||||
|
||||
private async Task<List<DiscoveredJob>> SearchCoreAsync(string? q, string? location, CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievedAt = DateTimeOffset.UtcNow;
|
||||
var token = await GetTokenAsync(cancellationToken);
|
||||
var client = _clients.CreateClient();
|
||||
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
|
||||
var next = "/api/v1/feed";
|
||||
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (page == 0) request.Headers.IfModifiedSince = DateTimeOffset.UtcNow.AddDays(-14);
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
|
||||
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
var feed = item.GetProperty("_feed_entry");
|
||||
var jobId = feed.GetProperty("uuid").GetString();
|
||||
if (string.IsNullOrWhiteSpace(jobId)) continue;
|
||||
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
|
||||
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) { entries.Remove(jobId); continue; }
|
||||
entries[jobId] = new DiscoveredJob(jobId,
|
||||
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
|
||||
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
|
||||
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
|
||||
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
|
||||
feed.TryGetProperty("applicationDue", out var due) && due.TryGetDateTimeOffset(out var deadline) ? deadline : null,
|
||||
$"https://arbeidsplassen.nav.no/stillinger/stilling/{jobId}", "nav", "NAV Arbeidsplassen", "searched", retrievedAt, "NO");
|
||||
}
|
||||
}
|
||||
var query = (q ?? "").Trim();
|
||||
var place = (location ?? "").Trim();
|
||||
return entries.Values.Where(job => Contains(job.Title, query) || Contains(job.Company, query))
|
||||
.Where(job => Contains(job.Location, place)).OrderByDescending(job => job.ModifiedAt).Take(100).ToList();
|
||||
}
|
||||
|
||||
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configured = _configuration["NavJobs:Token"]?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||||
|
||||
return await _cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
|
||||
var text = await _clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
|
||||
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
|
||||
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
|
||||
var token = text[start..].Trim();
|
||||
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
|
||||
return end < 0 ? token : token[..end];
|
||||
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string filter) =>
|
||||
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
|
||||
public sealed record DiscoveredJob(
|
||||
string Id,
|
||||
string Title,
|
||||
string? Company,
|
||||
string? Location,
|
||||
DateTimeOffset? ModifiedAt,
|
||||
DateTimeOffset? Deadline,
|
||||
string Url,
|
||||
string Source,
|
||||
string SourceName,
|
||||
string AcquisitionType,
|
||||
DateTimeOffset RetrievedAt,
|
||||
string CountryCode);
|
||||
public sealed record SavedSearchRequest(string? Name, string? Query, string? Location);
|
||||
public sealed record UpdateSavedSearchRequest(bool IsActive);
|
||||
public sealed record DismissRequest(bool IsDismissed);
|
||||
public sealed record SavedSearchDto(int Id, string Name, string Query, string Location, bool IsActive, DateTimeOffset? LastRunAtUtc, int ResultCount, int DismissedCount);
|
||||
public sealed record SavedSearchJobDto(DiscoveredJob Job, bool IsNew, bool IsDismissed);
|
||||
|
||||
@@ -53,6 +53,7 @@ public sealed class CvTheme
|
||||
public bool AtsFriendly { get; init; }
|
||||
public bool Premium { get; init; }
|
||||
public bool NumberSections { get; init; }
|
||||
public string DefaultSkillsStyle { get; init; } = "tags"; // tags | text | bullets
|
||||
|
||||
// Builder options this template intentionally exposes. The renderer remains generic; the
|
||||
// registry tells the editor which controls make sense for a selected template.
|
||||
@@ -86,7 +87,7 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "plain", HeadingStyle = "plain",
|
||||
Accent = "#111827", HeadingColor = "#111827", Muted = "#6b7280",
|
||||
HeadingFont = "'Helvetica Neue', Arial, sans-serif", BodyFont = "'Helvetica Neue', Arial, sans-serif",
|
||||
SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", AtsFriendly = true,
|
||||
SectionGapMm = 7, EntryGapMm = 5, PhotoShape = "none", AtsFriendly = true, DefaultSkillsStyle = "text",
|
||||
},
|
||||
new()
|
||||
{
|
||||
@@ -95,7 +96,7 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
|
||||
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
|
||||
HeadingFont = "Georgia, 'Times New Roman', serif", BodyFont = "Georgia, 'Times New Roman', serif",
|
||||
NameSizePt = 27, PhotoShape = "square", AtsFriendly = true,
|
||||
NameSizePt = 27, PhotoShape = "square", AtsFriendly = true, DefaultSkillsStyle = "text",
|
||||
},
|
||||
new()
|
||||
{
|
||||
@@ -104,7 +105,7 @@ public static class CvThemeCatalog
|
||||
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
|
||||
Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff",
|
||||
HeadingFont = "'Roboto', Arial, sans-serif", BodyFont = "'Roboto', Arial, sans-serif",
|
||||
BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true,
|
||||
BodySizePt = 9.5, LineHeight = 1.36, DefaultIcons = true, DefaultSkillsStyle = "bullets",
|
||||
SidebarSections = new() { "contact", "skills", "languages", "certifications" },
|
||||
},
|
||||
new()
|
||||
@@ -114,7 +115,7 @@ public static class CvThemeCatalog
|
||||
Layout = "single", HeaderStyle = "plain", HeadingStyle = "caps-rule",
|
||||
Accent = "#334155", Ink = "#111827", Muted = "#374151",
|
||||
HeadingFont = "Arial, Helvetica, sans-serif", BodyFont = "Arial, Helvetica, sans-serif",
|
||||
PhotoShape = "none", AtsFriendly = true,
|
||||
PhotoShape = "none", AtsFriendly = true, DefaultSkillsStyle = "bullets",
|
||||
},
|
||||
new()
|
||||
{
|
||||
@@ -153,7 +154,7 @@ public static class CvThemeCatalog
|
||||
HeadingFont = "'Segoe UI', Roboto, Arial, sans-serif", BodyFont = "'Segoe UI', Roboto, Arial, sans-serif",
|
||||
UtilityFont = "Consolas, 'Courier New', monospace", NameSizePt = 25.5, HeadingSizePt = 9.5,
|
||||
BodySizePt = 9.4, LineHeight = 1.43, PageMarginMm = 15.24, SectionGapMm = 5.6, EntryGapMm = 4.3,
|
||||
PhotoShape = "none", DefaultIcons = false, AtsFriendly = true, NumberSections = true,
|
||||
PhotoShape = "none", DefaultIcons = false, AtsFriendly = true, NumberSections = true, DefaultSkillsStyle = "bullets",
|
||||
SupportedSettings = new() { "accent", "typography", "spacing", "skills", "page" },
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record DiscoveredJob(
|
||||
string Id,
|
||||
string Title,
|
||||
string? Company,
|
||||
string? Location,
|
||||
DateTimeOffset? ModifiedAt,
|
||||
DateTimeOffset? Deadline,
|
||||
string Url,
|
||||
string Source,
|
||||
string SourceName,
|
||||
string AcquisitionType,
|
||||
DateTimeOffset RetrievedAt,
|
||||
string CountryCode);
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace JobTrackerApi.Models;
|
||||
|
||||
public sealed record ExternalCalendarEventRequest(
|
||||
string StableId,
|
||||
string Summary,
|
||||
string? Description,
|
||||
string? Location,
|
||||
DateTimeOffset StartsAtUtc,
|
||||
DateTimeOffset EndsAtUtc,
|
||||
bool AllDay);
|
||||
|
||||
public sealed record ExternalCalendarEventResult(string? Id, string? WebUrl);
|
||||
@@ -60,6 +60,8 @@ builder.Services.AddSingleton<IAiOperationHandler, StrategySnapshotOperationHand
|
||||
builder.Services.AddSingleton<IAiOperationHandler, CvProcessingOperationHandler>();
|
||||
builder.Services.AddSingleton<AiOperationWorker>();
|
||||
builder.Services.AddScoped<UserNotificationStore>();
|
||||
builder.Services.AddSingleton<IJobDiscoverySearchService, NavJobDiscoverySearchService>();
|
||||
builder.Services.AddScoped<SavedJobSearchRunner>();
|
||||
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
|
||||
@@ -174,6 +176,7 @@ builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>
|
||||
builder.Services.AddHostedService<DatabaseBackupHostedService>();
|
||||
builder.Services.AddHostedService<RulesHostedService>();
|
||||
builder.Services.AddHostedService<FollowUpReminderHostedService>();
|
||||
builder.Services.AddHostedService<SavedJobSearchAlertHostedService>();
|
||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||
@@ -416,6 +419,8 @@ builder.Services.AddAuthorization(options =>
|
||||
builder.Services.AddScoped<IAuthorizationHandler, ProEntitlementHandler>();
|
||||
builder.Services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProEntitlementAuthorizationResultHandler>();
|
||||
|
||||
var authLoginPermitLimit = builder.Configuration.GetValue<int?>("RateLimits:AuthLoginPermitLimit") ?? 10;
|
||||
|
||||
builder.Services.AddRateLimiter(options =>
|
||||
{
|
||||
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
|
||||
@@ -425,7 +430,7 @@ builder.Services.AddRateLimiter(options =>
|
||||
partitionKey: $"login:{context.Connection.RemoteIpAddress?.ToString() ?? "unknown"}",
|
||||
factory: _ => new FixedWindowRateLimiterOptions
|
||||
{
|
||||
PermitLimit = 10,
|
||||
PermitLimit = authLoginPermitLimit,
|
||||
Window = TimeSpan.FromMinutes(5),
|
||||
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
|
||||
QueueLimit = 0,
|
||||
|
||||
@@ -16,13 +16,28 @@ public sealed class BackgroundTenantRunner(
|
||||
string worker,
|
||||
Func<IServiceProvider, CancellationToken, Task> work,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await RunForOwnersAsync(worker, async db => await db.JobApplications.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(job => job.OwnerUserId != null).Select(job => job.OwnerUserId!).ToListAsync(cancellationToken), work, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<BackgroundWorkerRunResult> RunForSavedSearchOwnersAsync(
|
||||
string worker,
|
||||
Func<IServiceProvider, CancellationToken, Task> work,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await RunForOwnersAsync(worker, async db => await db.SavedJobSearches.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(search => search.IsActive).Select(search => search.OwnerUserId).ToListAsync(cancellationToken), work, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<BackgroundWorkerRunResult> RunForOwnersAsync(
|
||||
string worker,
|
||||
Func<JobTrackerContext, Task<List<string>>> findOwners,
|
||||
Func<IServiceProvider, CancellationToken, Task> work,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var enumerationScope = scopes.CreateAsyncScope();
|
||||
var ownerIds = await enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>()
|
||||
.JobApplications.IgnoreQueryFilters().AsNoTracking()
|
||||
.Where(job => job.OwnerUserId != null)
|
||||
.Select(job => job.OwnerUserId!)
|
||||
.ToListAsync(cancellationToken);
|
||||
var ownerIds = await findOwners(enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>());
|
||||
var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner))
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.Order(StringComparer.Ordinal)
|
||||
|
||||
@@ -25,6 +25,7 @@ public interface IGmailOAuthService
|
||||
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<GmailSendResult> SendAsync(string ownerUserId, GmailSendRequest request, CancellationToken cancellationToken);
|
||||
Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||
@@ -47,7 +48,8 @@ internal sealed class GmailTokenResponse
|
||||
public sealed class GmailOAuthService : IGmailOAuthService
|
||||
{
|
||||
public const string SendScope = "https://www.googleapis.com/auth/gmail.send";
|
||||
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope}";
|
||||
public const string CalendarScope = "https://www.googleapis.com/auth/calendar.events";
|
||||
private const string Scope = $"openid email profile https://www.googleapis.com/auth/gmail.readonly {SendScope} {CalendarScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -419,6 +421,47 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
||||
public static bool HasSendScope(string? scope) =>
|
||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(SendScope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
public async Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.GmailConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasCalendarScope(connection.Scope))
|
||||
throw new InvalidOperationException("Reconnect Google and approve calendar access before adding events.");
|
||||
|
||||
var payload = request.AllDay
|
||||
? JsonSerializer.Serialize(new
|
||||
{
|
||||
summary = request.Summary,
|
||||
description = request.Description,
|
||||
location = request.Location,
|
||||
start = new { date = request.StartsAtUtc.UtcDateTime.ToString("yyyy-MM-dd") },
|
||||
end = new { date = request.EndsAtUtc.UtcDateTime.ToString("yyyy-MM-dd") }
|
||||
})
|
||||
: JsonSerializer.Serialize(new
|
||||
{
|
||||
summary = request.Summary,
|
||||
description = request.Description,
|
||||
location = request.Location,
|
||||
start = new { dateTime = request.StartsAtUtc.ToString("O") },
|
||||
end = new { dateTime = request.EndsAtUtc.ToString("O") }
|
||||
});
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.PutAsync(
|
||||
$"https://www.googleapis.com/calendar/v3/calendars/primary/events/{Uri.EscapeDataString(request.StableId)}",
|
||||
new StringContent(payload, Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
return new ExternalCalendarEventResult(
|
||||
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
document.RootElement.TryGetProperty("htmlLink", out var link) ? link.GetString() : null);
|
||||
}
|
||||
|
||||
public static bool HasCalendarScope(string? scope) =>
|
||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(CalendarScope, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text.Json;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public interface IJobDiscoverySearchService
|
||||
{
|
||||
Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed class NavJobDiscoverySearchService(
|
||||
IHttpClientFactory clients,
|
||||
IConfiguration configuration,
|
||||
IMemoryCache cache,
|
||||
TimeProvider timeProvider) : IJobDiscoverySearchService
|
||||
{
|
||||
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
|
||||
|
||||
public async Task<IReadOnlyList<DiscoveredJob>> SearchAsync(string? query, string? location, CancellationToken cancellationToken)
|
||||
{
|
||||
var retrievedAt = timeProvider.GetUtcNow();
|
||||
var token = await GetTokenAsync(cancellationToken);
|
||||
var client = clients.CreateClient();
|
||||
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
|
||||
var next = "/api/v1/feed";
|
||||
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
if (page == 0) request.Headers.IfModifiedSince = timeProvider.GetUtcNow().AddDays(-14);
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
|
||||
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
|
||||
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
|
||||
{
|
||||
var feed = item.GetProperty("_feed_entry");
|
||||
var jobId = feed.GetProperty("uuid").GetString();
|
||||
if (string.IsNullOrWhiteSpace(jobId)) continue;
|
||||
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
|
||||
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) { entries.Remove(jobId); continue; }
|
||||
entries[jobId] = new DiscoveredJob(jobId,
|
||||
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
|
||||
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
|
||||
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
|
||||
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
|
||||
feed.TryGetProperty("applicationDue", out var due) && due.TryGetDateTimeOffset(out var deadline) ? deadline : null,
|
||||
$"https://arbeidsplassen.nav.no/stillinger/stilling/{jobId}", "nav", "NAV Arbeidsplassen", "searched", retrievedAt, "NO");
|
||||
}
|
||||
}
|
||||
|
||||
var term = (query ?? "").Trim();
|
||||
var place = (location ?? "").Trim();
|
||||
return entries.Values.Where(job => Contains(job.Title, term) || Contains(job.Company, term))
|
||||
.Where(job => Contains(job.Location, place)).OrderByDescending(job => job.ModifiedAt).Take(100).ToList();
|
||||
}
|
||||
|
||||
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var configured = configuration["NavJobs:Token"]?.Trim();
|
||||
if (!string.IsNullOrWhiteSpace(configured)) return configured;
|
||||
|
||||
return await cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
|
||||
{
|
||||
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
|
||||
var text = await clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
|
||||
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
|
||||
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
|
||||
var token = text[start..].Trim();
|
||||
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
|
||||
return end < 0 ? token : token[..end];
|
||||
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
|
||||
}
|
||||
|
||||
private static bool Contains(string? value, string filter) =>
|
||||
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ public interface IMicrosoftGraphOAuthService
|
||||
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||
Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, CancellationToken cancellationToken);
|
||||
Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
||||
@@ -47,7 +48,8 @@ internal sealed class MicrosoftGraphTokenResponse
|
||||
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
{
|
||||
public const string SendScope = "https://graph.microsoft.com/Mail.Send";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope}";
|
||||
public const string CalendarScope = "https://graph.microsoft.com/Calendars.ReadWrite";
|
||||
private const string Scope = $"openid email profile offline_access https://graph.microsoft.com/Mail.Read {SendScope} {CalendarScope}";
|
||||
private readonly IConfiguration _cfg;
|
||||
private readonly JobTrackerContext _db;
|
||||
private readonly IDataProtector _protector;
|
||||
@@ -340,6 +342,47 @@ public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||
string.Equals(value, "Mail.Send", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
public async Task<ExternalCalendarEventResult> CreateCalendarEventAsync(string ownerUserId, ExternalCalendarEventRequest request, CancellationToken cancellationToken)
|
||||
{
|
||||
var connection = await _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(item => item.OwnerUserId == ownerUserId, cancellationToken);
|
||||
if (connection is null || !HasCalendarScope(connection.Scope))
|
||||
throw new InvalidOperationException("Reconnect Outlook and approve calendar access before adding events.");
|
||||
|
||||
var startsAt = request.AllDay ? request.StartsAtUtc.UtcDateTime.Date : request.StartsAtUtc.UtcDateTime;
|
||||
var endsAt = request.AllDay ? request.EndsAtUtc.UtcDateTime.Date : request.EndsAtUtc.UtcDateTime;
|
||||
var payload = JsonSerializer.Serialize(new
|
||||
{
|
||||
subject = request.Summary,
|
||||
body = new { contentType = "Text", content = request.Description ?? string.Empty },
|
||||
location = new { displayName = request.Location ?? string.Empty },
|
||||
start = new { dateTime = startsAt.ToString("yyyy-MM-ddTHH:mm:ss"), timeZone = "UTC" },
|
||||
end = new { dateTime = endsAt.ToString("yyyy-MM-ddTHH:mm:ss"), timeZone = "UTC" },
|
||||
isAllDay = request.AllDay,
|
||||
transactionId = request.StableId
|
||||
});
|
||||
|
||||
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
|
||||
using var response = await client.PostAsync(
|
||||
"https://graph.microsoft.com/v1.0/me/events",
|
||||
new StringContent(payload, System.Text.Encoding.UTF8, "application/json"),
|
||||
cancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
using var document = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
|
||||
return new ExternalCalendarEventResult(
|
||||
document.RootElement.TryGetProperty("id", out var id) ? id.GetString() : null,
|
||||
document.RootElement.TryGetProperty("webLink", out var link) ? link.GetString() : null);
|
||||
}
|
||||
|
||||
public static bool HasCalendarScope(string? scope)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(scope)) return false;
|
||||
return scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Any(value =>
|
||||
string.Equals(value, CalendarScope, StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(value, "Calendars.ReadWrite", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed class SavedJobSearchAlertHostedService(
|
||||
BackgroundTenantRunner tenants,
|
||||
IConfiguration configuration,
|
||||
ILogger<SavedJobSearchAlertHostedService> logger,
|
||||
IStartupReadiness startupReadiness,
|
||||
TimeProvider timeProvider) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await startupReadiness.WaitUntilReadyAsync(stoppingToken);
|
||||
if (!IsEnabled())
|
||||
{
|
||||
logger.LogInformation("Saved job search alert worker disabled.");
|
||||
return;
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), timeProvider, stoppingToken);
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await RunOnceAsync(stoppingToken);
|
||||
await Task.Delay(GetInterval(), timeProvider, stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
public Task<BackgroundWorkerRunResult> RunOnceAsync(CancellationToken cancellationToken) =>
|
||||
IsEnabled()
|
||||
? tenants.RunForSavedSearchOwnersAsync("saved-job-search-alerts", ProcessOwnerAsync, cancellationToken)
|
||||
: Task.FromResult(BackgroundWorkerRunResult.Disabled);
|
||||
|
||||
private bool IsEnabled() => configuration.GetValue("Workers:SavedJobSearchAlertsEnabled", true);
|
||||
|
||||
private TimeSpan GetInterval() => TimeSpan.FromHours(Math.Clamp(
|
||||
configuration.GetValue("JobDiscovery:SavedSearchIntervalHours", 6), 1, 24));
|
||||
|
||||
private async Task ProcessOwnerAsync(IServiceProvider services, CancellationToken cancellationToken)
|
||||
{
|
||||
var db = services.GetRequiredService<JobTrackerContext>();
|
||||
var runner = services.GetRequiredService<SavedJobSearchRunner>();
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var dueBefore = now - GetInterval();
|
||||
var useNorwegian = string.Equals(
|
||||
await db.Users.AsNoTracking().Where(user => user.Id == db.CurrentUserId).Select(user => user.UiLanguage).FirstOrDefaultAsync(cancellationToken),
|
||||
"nb",
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
var searches = await db.SavedJobSearches.Include(search => search.Results)
|
||||
.Where(search => search.IsActive)
|
||||
.OrderBy(search => search.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
foreach (var search in searches.Where(search => search.LastRunAtUtc is null || search.LastRunAtUtc <= dueBefore))
|
||||
{
|
||||
var result = await runner.RunAsync(search, cancellationToken);
|
||||
// The first automatic run establishes a baseline. Alert only for vacancies
|
||||
// that appear after that baseline so existing results never look "new".
|
||||
if (!result.WasInitialRun && result.NewCount > 0)
|
||||
{
|
||||
db.UserNotifications.Add(new UserNotification
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
OwnerUserId = search.OwnerUserId,
|
||||
Kind = "saved_search_results",
|
||||
Title = useNorwegian
|
||||
? result.NewCount == 1 ? "Ny stilling funnet" : $"{result.NewCount} nye stillinger funnet"
|
||||
: result.NewCount == 1 ? "New vacancy found" : $"{result.NewCount} new vacancies found",
|
||||
Message = useNorwegian
|
||||
? result.NewCount == 1
|
||||
? $"Det lagrede søket «{search.Name}» fant en ny stilling."
|
||||
: $"Det lagrede søket «{search.Name}» fant {result.NewCount} nye stillinger."
|
||||
: result.NewCount == 1
|
||||
? $"Your saved search ‘{search.Name}’ found a new vacancy."
|
||||
: $"Your saved search ‘{search.Name}’ found {result.NewCount} new vacancies.",
|
||||
LinkPath = "/discover",
|
||||
CreatedAtUtc = now.UtcDateTime
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using JobTrackerApi.Data;
|
||||
using JobTrackerApi.Models;
|
||||
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record SavedJobSearchRunResult(
|
||||
IReadOnlyList<SavedJobSearchJobResult> Jobs,
|
||||
int NewCount,
|
||||
bool WasInitialRun);
|
||||
|
||||
public sealed record SavedJobSearchJobResult(DiscoveredJob Job, bool IsNew, bool IsDismissed);
|
||||
|
||||
public sealed class SavedJobSearchRunner(
|
||||
JobTrackerContext db,
|
||||
IJobDiscoverySearchService discovery,
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
public async Task<SavedJobSearchRunResult> RunAsync(SavedJobSearch search, CancellationToken cancellationToken)
|
||||
{
|
||||
var jobs = await discovery.SearchAsync(search.Query, search.Location, cancellationToken);
|
||||
var now = timeProvider.GetUtcNow();
|
||||
var wasInitialRun = search.LastRunAtUtc is null;
|
||||
var existing = search.Results.ToDictionary(item => item.ExternalJobId, StringComparer.OrdinalIgnoreCase);
|
||||
var results = new List<SavedJobSearchJobResult>(jobs.Count);
|
||||
var newCount = 0;
|
||||
foreach (var job in jobs)
|
||||
{
|
||||
var isNew = !existing.TryGetValue(job.Id, out var state);
|
||||
if (state is null)
|
||||
{
|
||||
state = new SavedJobSearchResult { ExternalJobId = job.Id, FirstSeenAtUtc = now, LastSeenAtUtc = now };
|
||||
search.Results.Add(state);
|
||||
db.SavedJobSearchResults.Add(state);
|
||||
newCount++;
|
||||
}
|
||||
else state.LastSeenAtUtc = now;
|
||||
results.Add(new SavedJobSearchJobResult(job, isNew, state.IsDismissed));
|
||||
}
|
||||
|
||||
search.LastRunAtUtc = now;
|
||||
search.UpdatedAtUtc = now;
|
||||
return new SavedJobSearchRunResult(results, newCount, wasInitialRun);
|
||||
}
|
||||
}
|
||||
@@ -130,13 +130,14 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
|
||||
private static string RenderSection(CvRenderSection section, CvTheme theme, CvVariantSettings? settings = null)
|
||||
{
|
||||
if (section.IsEmpty) return string.Empty;
|
||||
var skillsStyle = settings?.SkillsStyle ?? theme.DefaultSkillsStyle;
|
||||
var inner = section.Kind switch
|
||||
{
|
||||
"bullets" => $@"<ul class=""bullets"">{Items(section.Bullets)}</ul>",
|
||||
"paragraphs" => $@"<div class=""paragraphs"">{string.Join("", section.Bullets.Select(item => $@"<p>{Inline(item)}</p>"))}</div>",
|
||||
"skill-groups" => $@"<div class=""skill-groups"">{string.Join("", section.SkillGroups.Select(RenderSkillGroup))}</div>",
|
||||
"tags" when section.Key == "skills" && settings?.SkillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</ul>",
|
||||
"tags" when section.Key == "skills" && settings?.SkillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>",
|
||||
"tags" when section.Key == "skills" && skillsStyle == "bullets" => $@"<ul class=""bullets skill-bullets"">{Items(section.Tags)}</ul>",
|
||||
"tags" when section.Key == "skills" && skillsStyle == "text" => $@"<p class=""skills-text"">{string.Join(" · ", section.Tags.Select(Enc))}</p>",
|
||||
"tags" => $@"<ul class=""tags"">{string.Join("", section.Tags.Select(t => $@"<li class=""tag"">{Enc(t)}</li>"))}</ul>",
|
||||
_ => string.Join("", section.Entries.Select(RenderEntry)),
|
||||
};
|
||||
|
||||
@@ -13,10 +13,14 @@
|
||||
"Workers": {
|
||||
"RulesEnabled": false,
|
||||
"FollowUpRemindersEnabled": false,
|
||||
"SavedJobSearchAlertsEnabled": true,
|
||||
"DailyExportEnabled": false,
|
||||
"JobEnrichmentEnabled": false,
|
||||
"AiOperationsEnabled": false
|
||||
},
|
||||
"JobDiscovery": {
|
||||
"SavedSearchIntervalHours": 6
|
||||
},
|
||||
"Ai": {
|
||||
"ExternalProcessingEnabled": false,
|
||||
"ExternalProvider": "ollama",
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# Jobjakt Quick Capture
|
||||
|
||||
The Manifest V3 extension opens the current page or selected link in Jobjakt's existing reviewed import flow. It does not scrape page content, store credentials, or submit an application automatically.
|
||||
|
||||
## Install locally
|
||||
|
||||
1. Open `chrome://extensions` (or the equivalent extensions page in Edge).
|
||||
2. Enable developer mode.
|
||||
3. Choose **Load unpacked** and select this `browser-extension` folder.
|
||||
4. Open a vacancy and click the Jobjakt extension, or use **Save vacancy to Jobjakt** from the page/link context menu.
|
||||
|
||||
The Jobjakt add-job dialog opens with the vacancy URL and runs the same server-side preview, validation, duplicate checking, and user review used by normal URL imports.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
node --test browser-extension/tests/*.test.mjs
|
||||
```
|
||||
|
||||
No build or package installation is required. Zip the folder contents for store submission after adding store artwork and completing the browser-store privacy declarations.
|
||||
@@ -0,0 +1,23 @@
|
||||
import { buildCaptureUrl } from "./capture-url.mjs";
|
||||
|
||||
const menuId = "save-vacancy-to-jobjakt";
|
||||
|
||||
function openCapture(pageUrl) {
|
||||
const captureUrl = buildCaptureUrl(pageUrl);
|
||||
if (captureUrl) chrome.tabs.create({ url: captureUrl });
|
||||
}
|
||||
|
||||
chrome.runtime.onInstalled.addListener(() => {
|
||||
chrome.contextMenus.removeAll(() => {
|
||||
chrome.contextMenus.create({
|
||||
id: menuId,
|
||||
title: "Save vacancy to Jobjakt",
|
||||
contexts: ["page", "link"]
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
chrome.action.onClicked.addListener((tab) => openCapture(tab.url));
|
||||
chrome.contextMenus.onClicked.addListener((info, tab) => {
|
||||
if (info.menuItemId === menuId) openCapture(info.linkUrl || info.pageUrl || tab?.url);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
export const JOBBJAKT_CAPTURE_ORIGIN = "https://jobs.cesnimda.uk";
|
||||
|
||||
export function buildCaptureUrl(pageUrl, origin = JOBBJAKT_CAPTURE_ORIGIN) {
|
||||
let source;
|
||||
try {
|
||||
source = new URL(pageUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (source.protocol !== "http:" && source.protocol !== "https:") return null;
|
||||
|
||||
const capture = new URL("/", origin);
|
||||
capture.searchParams.set("add", source.href);
|
||||
return capture.href;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "Jobjakt Quick Capture",
|
||||
"version": "1.0.0",
|
||||
"description": "Send the current vacancy to Jobjakt's reviewed import flow.",
|
||||
"permissions": ["activeTab", "contextMenus"],
|
||||
"action": {
|
||||
"default_title": "Save vacancy to Jobjakt"
|
||||
},
|
||||
"background": {
|
||||
"service_worker": "background.mjs",
|
||||
"type": "module"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { buildCaptureUrl } from "../capture-url.mjs";
|
||||
|
||||
test("builds the existing reviewed capture route", () => {
|
||||
const result = new URL(buildCaptureUrl("https://example.test/jobs/42?from=search"));
|
||||
assert.equal(result.origin, "https://jobs.cesnimda.uk");
|
||||
assert.equal(result.pathname, "/");
|
||||
assert.equal(result.searchParams.get("add"), "https://example.test/jobs/42?from=search");
|
||||
});
|
||||
|
||||
test("rejects privileged and malformed URLs", () => {
|
||||
assert.equal(buildCaptureUrl("chrome://settings"), null);
|
||||
assert.equal(buildCaptureUrl("javascript:alert(1)"), null);
|
||||
assert.equal(buildCaptureUrl("not a url"), null);
|
||||
});
|
||||
|
||||
test("supports a local application origin during development", () => {
|
||||
assert.equal(
|
||||
buildCaptureUrl("https://arbeidsplassen.nav.no/stillinger/stilling/abc", "http://localhost:3300"),
|
||||
"http://localhost:3300/?add=https%3A%2F%2Farbeidsplassen.nav.no%2Fstillinger%2Fstilling%2Fabc"
|
||||
);
|
||||
});
|
||||
@@ -78,6 +78,8 @@ services:
|
||||
# notification/privacy/entitlement prerequisites have been explicitly rolled out.
|
||||
- Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false}
|
||||
- Workers__FollowUpRemindersEnabled=${WORKER_FOLLOWUP_REMINDERS_ENABLED:-false}
|
||||
- Workers__SavedJobSearchAlertsEnabled=${WORKER_SAVED_SEARCH_ALERTS_ENABLED:-true}
|
||||
- JobDiscovery__SavedSearchIntervalHours=${SAVED_SEARCH_INTERVAL_HOURS:-6}
|
||||
- Workers__DailyExportEnabled=${WORKER_DAILY_EXPORT_ENABLED:-false}
|
||||
- Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
|
||||
- Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_ENABLED:-false}
|
||||
|
||||
@@ -6,7 +6,7 @@ Updated: 2026-08-02
|
||||
|
||||
HTTP requests derive `JobTrackerContext.CurrentUserId` from the authenticated request. Hosted services have no HTTP context, so deny-on-null query filters intentionally return no tenant rows.
|
||||
|
||||
`BackgroundTenantRunner` is the only worker bypass for the four job-owner schedulers. It uses `IgnoreQueryFilters` only to enumerate distinct non-empty job owners, then creates a fresh scope per owner and sets `CurrentUserService` before resolving the scoped `JobTrackerContext`. All work queries run through the normal tenant filters. An owner failure is counted and isolated; logs contain worker name, exception type and aggregate counts, not owner IDs or private content. An HTTP context cannot be replaced by a background owner.
|
||||
`BackgroundTenantRunner` is the only worker bypass for the tenant schedulers. It uses `IgnoreQueryFilters` only to enumerate distinct non-empty owners from the resource a worker owns, then creates a fresh scope per owner and sets `CurrentUserService` before resolving the scoped `JobTrackerContext`. All work queries run through the normal tenant filters. An owner failure is counted and isolated; logs contain worker name, exception type and aggregate counts, not owner IDs or private content. An HTTP context cannot be replaced by a background owner.
|
||||
|
||||
This is a sequential, single-instance foundation. Generic operation leasing now exists in OPS-001A, but notifications, bounded AI handlers and multi-replica scheduling belong to OPS-001B/C and AI-001 and must precede activation that needs them.
|
||||
|
||||
@@ -16,6 +16,7 @@ This is a sequential, single-instance foundation. Generic operation leasing now
|
||||
|---|---|---|---|
|
||||
| `RulesHostedService` | owner runner + normal filters/per-user rules | changes job status | `Workers:RulesEnabled=false` by default; keep off until user-visible notification/audit behavior is ready |
|
||||
| `FollowUpReminderHostedService` | owner runner + normal filters | sends email, then marks date | both `Workers:FollowUpRemindersEnabled` and `Email:FollowUpReminders:Enabled`; keep off until persistent notification/idempotency work |
|
||||
| `SavedJobSearchAlertHostedService` | saved-search owner runner + normal filters | checks active NAV searches and creates in-app notifications | enabled by default; users can pause each search, interval is `JobDiscovery:SavedSearchIntervalHours` (default 6); first run establishes a silent baseline |
|
||||
| `DailyExportHostedService` | owner runner + normal filters; one hashed-owner atomic file | writes local JSON | both `Workers:DailyExportEnabled` and `Exports:DailyEnabled`; keep off pending retention/operator rollout |
|
||||
| `JobEnrichmentHostedService` | owner runner + normal filters | deterministic tags and AI summary | `Workers:JobEnrichmentEnabled=false`; do not enable before Pro/privacy/provider/queue gates |
|
||||
| `CvProcessingHostedService` | existing explicit run owner on every unfiltered query | user-requested CV parsing/AI | unchanged; persistent run rows recover at startup, process-local wake-up remains single-instance |
|
||||
|
||||
@@ -22,14 +22,16 @@ which is retained only for the legacy tailored-draft flow and is not used by the
|
||||
| Palette | `Accent`, `Ink`, `Muted`, `Line`, `Paper`, `SidebarBg`, `SidebarInk`, `HeadingColor` |
|
||||
| Typography | `HeadingFont`, `BodyFont`, `UtilityFont`, `NameSizePt`, `HeadingSizePt`, `BodySizePt`, `LineHeight` |
|
||||
| Spacing | `PageMarginMm`, `SectionGapMm`, `EntryGapMm` |
|
||||
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons`, `NumberSections` |
|
||||
| Styling | `HeaderStyle` (`plain`\|`band`\|`centered`\|`kicker`), `HeadingStyle` (`caps-rule`\|`underline`\|`plain`\|`bar`), `DefaultSkillsStyle` (`tags`\|`text`\|`bullets`), `PhotoShape` (`none`\|`square`\|`rounded`\|`circle`), `DefaultIcons`, `NumberSections` |
|
||||
| Capabilities | `SupportedSettings` controls which editor panels the template exposes |
|
||||
|
||||
The renderer computes CSS from these plus normalized variant overrides: printable palette, curated
|
||||
fonts, body/heading size, line height, spacing, page size, layout, sidebar width/content, header and
|
||||
heading treatments, skills presentation, photo and icons. `SidebarSections` decides which section
|
||||
keys move to the sidebar for the two-column layouts. Changing a template or override never transforms
|
||||
the structured career content.
|
||||
the structured career content. Selecting a different template clears previous structural overrides
|
||||
(`Layout`, header/heading treatment and sidebar geometry) so its own design is immediately visible;
|
||||
content and deliberate colour/typography/spacing choices remain untouched.
|
||||
|
||||
## Adding a theme
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Calendar integrations
|
||||
|
||||
Jobjakt can add an application's follow-up date or deadline to Google Calendar or Outlook Calendar. The same workspace panel always offers a portable `.ics` download, which requires no provider account.
|
||||
|
||||
## Provider configuration
|
||||
|
||||
Calendar access reuses the encrypted OAuth connections already used for Gmail and Microsoft Graph mail. No calendar tokens or credentials are stored separately.
|
||||
|
||||
### Google
|
||||
|
||||
1. Enable the Google Calendar API in the Google Cloud project used by `GMAIL_CLIENT_ID` / `GMAIL_CLIENT_SECRET`.
|
||||
2. Keep the existing Gmail OAuth callback URI registered.
|
||||
3. Reconnect Google from Settings. The consent request now includes `https://www.googleapis.com/auth/calendar.events`.
|
||||
|
||||
### Microsoft
|
||||
|
||||
1. In the Entra application used by `MICROSOFT_GRAPH_CLIENT_ID` / `MICROSOFT_GRAPH_CLIENT_SECRET`, add the delegated `Calendars.ReadWrite` permission.
|
||||
2. Keep the existing Microsoft Graph OAuth callback URI registered.
|
||||
3. Reconnect Outlook from Settings and grant the new permission.
|
||||
|
||||
Existing connections remain valid for mail. The application reports them as connected but not calendar-writable until the user reconnects and grants the new scope.
|
||||
|
||||
## Behaviour and rollback
|
||||
|
||||
- Calendar writes happen only after an explicit user action in an application workspace.
|
||||
- Jobjakt sends the job title, company, location, next-action text, and selected date; it never sends the CV or job description.
|
||||
- Google uses a deterministic event ID, and Microsoft receives a deterministic transaction ID, reducing duplicate events when a request is retried.
|
||||
- Disable or remove the provider's delegated calendar permission to stop direct writes. `.ics` download remains available.
|
||||
- No database migration is required.
|
||||
@@ -175,7 +175,7 @@ test("a saved job can be created through the reviewed UI flow", async ({ page })
|
||||
await page.goto("/jobs");
|
||||
await page.getByRole("button", { name: "Add Job" }).click();
|
||||
await page.getByRole("button", { name: "Enter details manually" }).click();
|
||||
await page.getByLabel("Company").fill(`E2E Company ${suffix}`);
|
||||
await page.getByRole("dialog").getByRole("combobox", { name: "Company" }).fill(`E2E Company ${suffix}`);
|
||||
await page.getByLabel("Job title").fill(title);
|
||||
await page.getByRole("button", { name: "Continue" }).click();
|
||||
for (let step = 0; step < 3; step += 1) {
|
||||
|
||||
@@ -39,6 +39,7 @@ export default defineConfig({
|
||||
Auth__AdminEmail: "e2e@example.test",
|
||||
Auth__AdminPassword: "E2ePassword123!",
|
||||
Auth__AllowRegistration: "false",
|
||||
RateLimits__AuthLoginPermitLimit: "100",
|
||||
App__Version: "e2e-verification",
|
||||
App__CommitSha: "e2e1234",
|
||||
Cors__Origins__0: appUrl,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import "@testing-library/jest-dom";
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { api } from "./api";
|
||||
import ApplicationCalendarActions from "./components/ApplicationCalendarActions";
|
||||
import { I18nProvider } from "./i18n/I18nProvider";
|
||||
|
||||
jest.mock("./api", () => ({
|
||||
api: { get: jest.fn(), post: jest.fn() },
|
||||
getApiErrorMessage: (_: unknown, fallback: string) => fallback,
|
||||
}));
|
||||
const mockedApi = api as jest.Mocked<typeof api>;
|
||||
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: true }, microsoft: { connected: false, writable: false } } } as any);
|
||||
mockedApi.post.mockResolvedValue({ data: { provider: "google", id: "event-1", webUrl: "https://calendar.google.test/event-1" } } as any);
|
||||
});
|
||||
afterEach(() => jest.clearAllMocks());
|
||||
|
||||
test("adds the selected application date to a writable connected calendar", async () => {
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
|
||||
|
||||
const googleButtons = await screen.findAllByRole("button", { name: "Google Calendar" });
|
||||
fireEvent.click(googleButtons[0]);
|
||||
|
||||
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/calendar/jobs/42/events", { provider: "google", kind: "follow-up" }));
|
||||
expect(await screen.findByText("The event was added to your calendar.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Open event" })).toHaveAttribute("href", "https://calendar.google.test/event-1");
|
||||
});
|
||||
|
||||
test("keeps portable calendar download available without a provider connection", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } } } as any);
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt={null} deadline="2026-09-15T00:00:00Z" /></I18nProvider>);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Download .ics" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "Google Calendar" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test("explains when an existing connection needs renewed calendar consent", async () => {
|
||||
mockedApi.get.mockResolvedValue({ data: { google: { connected: true, writable: false }, microsoft: { connected: false, writable: false } } } as any);
|
||||
render(<I18nProvider><ApplicationCalendarActions jobId={42} followUpAt="2026-09-02T10:30:00Z" /></I18nProvider>);
|
||||
|
||||
expect(await screen.findByText("Reconnect your calendar account to approve calendar access.")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "Reconnect" })).toHaveAttribute("href", "/settings");
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Alert, Box, Button, CircularProgress, Stack, Typography } from "@mui/material";
|
||||
import CalendarMonthOutlinedIcon from "@mui/icons-material/CalendarMonthOutlined";
|
||||
import DownloadOutlinedIcon from "@mui/icons-material/DownloadOutlined";
|
||||
import OpenInNewIcon from "@mui/icons-material/OpenInNew";
|
||||
import { api, getApiErrorMessage } from "../api";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
|
||||
type ProviderStatus = { connected: boolean; writable: boolean };
|
||||
type CalendarStatus = { google: ProviderStatus; microsoft: ProviderStatus };
|
||||
type EventKind = "follow-up" | "deadline";
|
||||
type CalendarEventResult = { provider: string; id?: string; webUrl?: string };
|
||||
|
||||
export default function ApplicationCalendarActions({ jobId, followUpAt, deadline }: { jobId: number; followUpAt?: string | null; deadline?: string | null }) {
|
||||
const { t } = useI18n();
|
||||
const [status, setStatus] = useState<CalendarStatus | null>(null);
|
||||
const [busy, setBusy] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [created, setCreated] = useState<CalendarEventResult | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
api.get<CalendarStatus>("/calendar/status")
|
||||
.then((response) => { if (active) setStatus(response.data); })
|
||||
.catch(() => { if (active) setStatus({ google: { connected: false, writable: false }, microsoft: { connected: false, writable: false } }); });
|
||||
return () => { active = false; };
|
||||
}, []);
|
||||
|
||||
const create = async (provider: "google" | "microsoft", kind: EventKind) => {
|
||||
setBusy(`${provider}-${kind}`); setError(""); setCreated(null);
|
||||
try {
|
||||
const response = await api.post<CalendarEventResult>(`/calendar/jobs/${jobId}/events`, { provider, kind });
|
||||
setCreated(response.data);
|
||||
} catch (err) { setError(getApiErrorMessage(err, t("calendarCreateFailed"))); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
|
||||
const download = async (kind: EventKind) => {
|
||||
setBusy(`ics-${kind}`); setError("");
|
||||
try {
|
||||
const response = await api.get(`/calendar/jobs/${jobId}/events.ics`, { params: { kind }, responseType: "blob" });
|
||||
const url = URL.createObjectURL(response.data);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url; anchor.download = `jobjakt-${jobId}-${kind}.ics`; anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (err) { setError(getApiErrorMessage(err, t("calendarDownloadFailed"))); }
|
||||
finally { setBusy(""); }
|
||||
};
|
||||
|
||||
const rows: Array<{ kind: EventKind; title: string }> = [
|
||||
...(followUpAt ? [{ kind: "follow-up" as const, title: t("calendarFollowUp") }] : []),
|
||||
...(deadline ? [{ kind: "deadline" as const, title: t("calendarDeadline") }] : []),
|
||||
];
|
||||
|
||||
if (rows.length === 0) return <Alert severity="info">{t("calendarNoDates")}</Alert>;
|
||||
if (!status) return <Stack direction="row" spacing={1} alignItems="center"><CircularProgress size={18} /><Typography variant="body2">{t("loading")}</Typography></Stack>;
|
||||
|
||||
return (
|
||||
<Stack spacing={1.5}>
|
||||
<Typography variant="body2" color="text.secondary">{t("calendarHelp")}</Typography>
|
||||
{(status.google.connected && !status.google.writable) || (status.microsoft.connected && !status.microsoft.writable) ? (
|
||||
<Alert severity="info" action={<Button color="inherit" size="small" href="/settings">{t("calendarReconnect")}</Button>}>{t("calendarPermissionNeeded")}</Alert>
|
||||
) : null}
|
||||
{error ? <Alert severity="error">{error}</Alert> : null}
|
||||
{created ? (
|
||||
<Alert severity="success" action={created.webUrl ? <Button color="inherit" size="small" href={created.webUrl} target="_blank" rel="noreferrer" endIcon={<OpenInNewIcon />}>{t("calendarOpen")}</Button> : undefined}>
|
||||
{t("calendarCreated")}
|
||||
</Alert>
|
||||
) : null}
|
||||
{rows.map((row) => (
|
||||
<Box key={row.kind} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: "divider", borderRadius: 2 }}>
|
||||
<Typography variant="body2" sx={{ fontWeight: 750, flex: "1 1 180px" }}>{row.title}</Typography>
|
||||
{status.google.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("google", row.kind)}>{t("calendarGoogle")}</Button> : null}
|
||||
{status.microsoft.writable ? <Button size="small" startIcon={<CalendarMonthOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void create("microsoft", row.kind)}>{t("calendarOutlook")}</Button> : null}
|
||||
<Button size="small" startIcon={<DownloadOutlinedIcon />} disabled={Boolean(busy)} onClick={() => void download(row.kind)}>{t("calendarDownload")}</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -172,6 +172,50 @@ test('professional editor starts with templates and combines design and layout c
|
||||
expect(screen.getByText('Check spelling and grammar')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('choosing a template restores its own structure instead of inheriting the previous template layout', async () => {
|
||||
const customizedVariant = {
|
||||
...variant,
|
||||
settings: {
|
||||
...emptyCvVariantSettings(),
|
||||
themeId: 'minimal',
|
||||
layout: 'single',
|
||||
headerStyle: 'plain',
|
||||
headingStyle: 'plain',
|
||||
sidebarWidthMm: 72,
|
||||
sidebarSections: ['skills'],
|
||||
accentColor: '#123456',
|
||||
},
|
||||
};
|
||||
mockedApi.get.mockImplementation((url: string) => {
|
||||
if (url === '/cv/variants/3') return Promise.resolve({ data: customizedVariant } as any);
|
||||
if (url === '/cv/themes') return Promise.resolve({ data: [
|
||||
{ id: 'minimal', name: 'Minimal', category: 'ATS', description: 'Quiet single column', layout: 'single', available: true, swatches: ['#111827', '#fff', '#fff'] },
|
||||
{ id: 'technical', name: 'Technical', category: 'Technical', description: 'Structured sidebar', layout: 'sidebar-left', available: true, swatches: ['#0f4c5c', '#0f4c5c', '#fff'] },
|
||||
] } as any);
|
||||
if (url === '/cv/outline') return Promise.resolve({ data: { sections: [] } } as any);
|
||||
if (url.startsWith('/cv/themes/')) return Promise.resolve({ data: { themeId: url.includes('technical') ? 'technical' : 'minimal', html: '<p>preview</p>', suggestedFileName: 'preview.pdf' } } as any);
|
||||
return Promise.resolve({ data: [] } as any);
|
||||
});
|
||||
mockedApi.put.mockResolvedValue({ data: customizedVariant } as any);
|
||||
renderAt(3);
|
||||
|
||||
await screen.findByDisplayValue('Backend CV');
|
||||
fireEvent.click(screen.getByRole('button', { name: /Technical/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Save now' }));
|
||||
|
||||
await waitFor(() => expect(mockedApi.put).toHaveBeenCalledWith('/cv/variants/3', expect.objectContaining({
|
||||
settings: expect.objectContaining({
|
||||
themeId: 'technical',
|
||||
layout: null,
|
||||
headerStyle: null,
|
||||
headingStyle: null,
|
||||
sidebarWidthMm: null,
|
||||
sidebarSections: null,
|
||||
accentColor: '#123456',
|
||||
}),
|
||||
})));
|
||||
});
|
||||
|
||||
test('editor sections, design controls and document settings render in Bokmål', async () => {
|
||||
window.localStorage.setItem('uiLanguage', 'nb');
|
||||
routeGet(() => Promise.resolve({ data: variant } as any));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
|
||||
import { applyCvTheme, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage, wrapSelection } from "./cvBuilder";
|
||||
|
||||
describe("moveItem", () => {
|
||||
test("moves an item forward", () => {
|
||||
@@ -59,4 +59,41 @@ describe("CV language compatibility", () => {
|
||||
expect(normalizeCvLanguage("nb-NO")).toBe("nb-NO");
|
||||
expect(normalizeCvLanguage("en")).toBe("en");
|
||||
});
|
||||
|
||||
test("fits the physical page to the available preview width", () => {
|
||||
expect(getCvPreviewFitZoom(500, 800)).toBeCloseTo(0.595);
|
||||
expect(getCvPreviewFitZoom(1200, 800)).toBe(1);
|
||||
expect(getCvPreviewFitZoom(120, 800)).toBe(0.32);
|
||||
});
|
||||
});
|
||||
|
||||
describe("CV template selection", () => {
|
||||
test("restores the selected template structure without discarding user content or design choices", () => {
|
||||
const settings = {
|
||||
themeId: "minimal",
|
||||
layout: "single" as const,
|
||||
headerStyle: "plain" as const,
|
||||
headingStyle: "plain" as const,
|
||||
sidebarWidthMm: 70,
|
||||
sidebarSections: ["skills"],
|
||||
accentColor: "#123456",
|
||||
showPhoto: false,
|
||||
showPageNumbers: false,
|
||||
showIcons: true,
|
||||
sections: [{ key: "experience", title: "Career" }],
|
||||
overrides: {},
|
||||
customSections: [],
|
||||
};
|
||||
|
||||
expect(applyCvTheme(settings, "technical")).toMatchObject({
|
||||
themeId: "technical",
|
||||
layout: null,
|
||||
headerStyle: null,
|
||||
headingStyle: null,
|
||||
sidebarWidthMm: null,
|
||||
sidebarSections: null,
|
||||
accentColor: "#123456",
|
||||
sections: [{ key: "experience", title: "Career" }],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -176,6 +176,27 @@ export function getCvPageCount(contentHeightPx: number, pageHeightPx: number): n
|
||||
return Math.max(1, Math.ceil(Math.max(0, contentHeightPx - 2) / pageHeightPx));
|
||||
}
|
||||
|
||||
export function getCvPreviewFitZoom(containerWidthPx: number, pageWidthPx: number, gutterPx = 24, minZoom = 0.32): number {
|
||||
if (!Number.isFinite(containerWidthPx) || !Number.isFinite(pageWidthPx) || pageWidthPx <= 0) return minZoom;
|
||||
const usableWidth = Math.max(1, containerWidthPx - gutterPx);
|
||||
return Math.min(1, Math.max(minZoom, usableWidth / pageWidthPx));
|
||||
}
|
||||
|
||||
// Selecting a template is a structural choice. Clear only the presentation overrides that can
|
||||
// flatten every template into the previous template's layout; keep content, colour, typography,
|
||||
// spacing and document settings intact.
|
||||
export function applyCvTheme(settings: CvVariantSettings, themeId: string): CvVariantSettings {
|
||||
return {
|
||||
...settings,
|
||||
themeId,
|
||||
layout: null,
|
||||
headerStyle: null,
|
||||
headingStyle: null,
|
||||
sidebarWidthMm: null,
|
||||
sidebarSections: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCvLanguage(language?: string | null): string {
|
||||
const normalized = language?.trim().toLowerCase();
|
||||
return normalized === "no" || normalized === "nb" || normalized === "nb-no" ? "nb-NO" : normalized || "en";
|
||||
|
||||
@@ -275,7 +275,23 @@ export const translations = {
|
||||
jobDiscoveryViewListing: "View listing",
|
||||
jobDiscoverySaveTracker: "Save to tracker",
|
||||
jobDiscoverySavedSearches: "Saved searches",
|
||||
jobDiscoverySavedSearchesHelp: "Run a saved NAV search to see which vacancies are genuinely new since your last check.",
|
||||
jobDiscoverySavedSearchesHelp: "Saved NAV searches are checked automatically. You will be notified when new vacancies appear.",
|
||||
jobDiscoveryAutomaticAlerts: "Automatic alerts",
|
||||
jobDiscoveryUpdateAlertsFailed: "The alert setting could not be updated. Try again.",
|
||||
calendarTitle: "Calendar",
|
||||
calendarHelp: "Add important application dates to a connected calendar, or download a portable calendar file.",
|
||||
calendarFollowUp: "Application follow-up",
|
||||
calendarDeadline: "Application deadline",
|
||||
calendarGoogle: "Google Calendar",
|
||||
calendarOutlook: "Outlook Calendar",
|
||||
calendarDownload: "Download .ics",
|
||||
calendarReconnect: "Reconnect",
|
||||
calendarPermissionNeeded: "Reconnect your calendar account to approve calendar access.",
|
||||
calendarCreated: "The event was added to your calendar.",
|
||||
calendarOpen: "Open event",
|
||||
calendarNoDates: "Set a follow-up date or application deadline to add it to a calendar.",
|
||||
calendarCreateFailed: "The calendar event could not be created.",
|
||||
calendarDownloadFailed: "The calendar file could not be downloaded.",
|
||||
jobDiscoverySavedName: "Search name",
|
||||
jobDiscoverySavedDefaultName: "Recent vacancies",
|
||||
jobDiscoverySaveSearch: "Save search",
|
||||
@@ -2675,7 +2691,23 @@ export const translations = {
|
||||
jobDiscoveryViewListing: "Vis stilling",
|
||||
jobDiscoverySaveTracker: "Lagre i oversikten",
|
||||
jobDiscoverySavedSearches: "Lagrede søk",
|
||||
jobDiscoverySavedSearchesHelp: "Kjør et lagret NAV-søk for å se hvilke stillinger som faktisk er nye siden sist.",
|
||||
jobDiscoverySavedSearchesHelp: "Lagrede NAV-søk kontrolleres automatisk. Du får et varsel når nye stillinger dukker opp.",
|
||||
jobDiscoveryAutomaticAlerts: "Automatiske varsler",
|
||||
jobDiscoveryUpdateAlertsFailed: "Varslingsinnstillingen kunne ikke oppdateres. Prøv igjen.",
|
||||
calendarTitle: "Kalender",
|
||||
calendarHelp: "Legg viktige søknadsdatoer til i en tilkoblet kalender, eller last ned en kalenderfil.",
|
||||
calendarFollowUp: "Oppfølging av søknad",
|
||||
calendarDeadline: "Søknadsfrist",
|
||||
calendarGoogle: "Google Kalender",
|
||||
calendarOutlook: "Outlook-kalender",
|
||||
calendarDownload: "Last ned .ics",
|
||||
calendarReconnect: "Koble til på nytt",
|
||||
calendarPermissionNeeded: "Koble til kalenderkontoen på nytt for å godkjenne kalendertilgang.",
|
||||
calendarCreated: "Hendelsen ble lagt til i kalenderen.",
|
||||
calendarOpen: "Åpne hendelsen",
|
||||
calendarNoDates: "Angi en oppfølgingsdato eller søknadsfrist for å legge den til i en kalender.",
|
||||
calendarCreateFailed: "Kalenderhendelsen kunne ikke opprettes.",
|
||||
calendarDownloadFailed: "Kalenderfilen kunne ikke lastes ned.",
|
||||
jobDiscoverySavedName: "Navn på søket",
|
||||
jobDiscoverySavedDefaultName: "Nylige stillinger",
|
||||
jobDiscoverySaveSearch: "Lagre søk",
|
||||
|
||||
@@ -93,3 +93,17 @@ test("runs a saved search, marks unseen vacancies, and dismisses them", async ()
|
||||
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
|
||||
expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
|
||||
});
|
||||
|
||||
test("lets the user pause automatic alerts for a saved search", async () => {
|
||||
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", isActive: true, resultCount: 1, dismissedCount: 0 };
|
||||
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
|
||||
mockedApi.patch.mockResolvedValue({ data: { ...saved, isActive: false } } as any);
|
||||
renderPage();
|
||||
|
||||
const toggle = await screen.findByRole("switch", { name: "Automatic alerts" });
|
||||
expect(toggle).toBeChecked();
|
||||
fireEvent.click(toggle);
|
||||
|
||||
await waitFor(() => expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7", { isActive: false }));
|
||||
await waitFor(() => expect(toggle).not.toBeChecked());
|
||||
});
|
||||
|
||||
@@ -22,6 +22,7 @@ import { getApiErrorMessage } from "../api";
|
||||
import Attachments from "../components/Attachments";
|
||||
import Correspondence from "../components/Correspondence";
|
||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||
import ApplicationCalendarActions from "../components/ApplicationCalendarActions";
|
||||
import {
|
||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||
} from "../components/ApplicationIntelligence";
|
||||
@@ -387,6 +388,7 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
|
||||
const panels = [
|
||||
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
||||
{ id: "calendar", title: t("calendarTitle"), content: <ApplicationCalendarActions jobId={jobId} followUpAt={overview.followUpAt} deadline={overview.deadline} /> },
|
||||
{ id: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
|
||||
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments jobId={jobId} /> },
|
||||
{ id: "communication", title: t("workspaceCommunication"), content: <Correspondence jobId={jobId} jobContext={{ companyName: overview.company, jobTitle: overview.jobTitle }} /> },
|
||||
|
||||
@@ -37,7 +37,7 @@ import { useDragReorder } from "../hooks/useDragReorder";
|
||||
import {
|
||||
CUSTOM_SECTION_PRESETS, CvCustomSectionSetting, CvItemOverride, CvOutline, CvOutlineSection, CvSectionSetting,
|
||||
CvTheme, CvVariant, CvVariantSettings, CvVariantVersionInfo, DEFAULT_SECTION_ORDER, SECTION_LABELS,
|
||||
cvBuilderApi, getCvPageCount, getCvPageMetrics, moveItem, normalizeCvLanguage,
|
||||
applyCvTheme, cvBuilderApi, getCvPageCount, getCvPageMetrics, getCvPreviewFitZoom, moveItem, normalizeCvLanguage,
|
||||
} from "../cvBuilder";
|
||||
import { useDialogActions } from "../dialogs";
|
||||
import { useI18n } from "../i18n/I18nProvider";
|
||||
@@ -84,6 +84,7 @@ export default function CvBuilderEditor() {
|
||||
const [tab, setTab] = useState(0);
|
||||
const [html, setHtml] = useState("");
|
||||
const [zoom, setZoom] = useState(0.62);
|
||||
const [autoFitPreview, setAutoFitPreview] = useState(true);
|
||||
const [previewing, setPreviewing] = useState(false);
|
||||
const [previewError, setPreviewError] = useState(false);
|
||||
const [previewRevision, setPreviewRevision] = useState(0);
|
||||
@@ -409,10 +410,24 @@ export default function CvBuilderEditor() {
|
||||
if (target && scrollRef.current) scrollRef.current.scrollTo({ top: Math.max(0, target.offsetTop - 12), behavior: "smooth" });
|
||||
};
|
||||
|
||||
const fitPreview = () => {
|
||||
const availableWidth = Math.max(1, (scrollRef.current?.clientWidth ?? pageMetrics.widthPx) - 24);
|
||||
setZoom(Math.min(1, Math.max(MIN_PREVIEW_ZOOM, availableWidth / pageMetrics.widthPx)));
|
||||
};
|
||||
const fitPreview = useCallback(() => {
|
||||
setZoom(getCvPreviewFitZoom(scrollRef.current?.clientWidth ?? pageMetrics.widthPx, pageMetrics.widthPx, 24, MIN_PREVIEW_ZOOM));
|
||||
}, [pageMetrics.widthPx]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFitPreview) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
const resize = () => fitPreview();
|
||||
resize();
|
||||
window.addEventListener("resize", resize);
|
||||
const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(resize);
|
||||
observer?.observe(container);
|
||||
return () => {
|
||||
window.removeEventListener("resize", resize);
|
||||
observer?.disconnect();
|
||||
};
|
||||
}, [autoFitPreview, compactEditor, fitPreview, mobilePane]);
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
@@ -486,11 +501,11 @@ export default function CvBuilderEditor() {
|
||||
<Button size="small" disabled={page >= pages} onClick={() => goToPage(page + 1)}>{t("cvEditorNext")}</Button>
|
||||
</Stack>
|
||||
<Divider orientation="vertical" flexItem sx={{ mx: 0.5 }} />
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2)))}><ZoomOutIcon fontSize="small" /></IconButton>
|
||||
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => setZoom(v as number)} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2)))}><ZoomInIcon fontSize="small" /></IconButton>
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomOut")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.max(MIN_PREVIEW_ZOOM, +(z - 0.1).toFixed(2))); }}><ZoomOutIcon fontSize="small" /></IconButton>
|
||||
<Slider size="small" value={zoom} min={MIN_PREVIEW_ZOOM} max={1} step={0.02} onChange={(_, v) => { setAutoFitPreview(false); setZoom(v as number); }} sx={{ width: 90 }} aria-label={t("cvEditorZoom")} />
|
||||
<IconButton size="small" aria-label={t("cvEditorZoomIn")} onClick={() => { setAutoFitPreview(false); setZoom((z) => Math.min(1, +(z + 0.1).toFixed(2))); }}><ZoomInIcon fontSize="small" /></IconButton>
|
||||
<Typography variant="caption" sx={{ minWidth: 34, textAlign: "right" }}>{Math.round(zoom * 100)}%</Typography>
|
||||
<Button size="small" onClick={fitPreview}>{t("cvEditorFit")}</Button>
|
||||
<Button size="small" variant={autoFitPreview ? "outlined" : "text"} onClick={() => { setAutoFitPreview(true); fitPreview(); }}>{t("cvEditorFit")}</Button>
|
||||
</Stack>
|
||||
{previewOverflow && <Alert severity="warning" sx={{ mb: 1 }}>{t("cvEditorOverflowWarning")}</Alert>}
|
||||
{pages >= 3 && <Alert severity="info" sx={{ mb: 1 }}>{t("cvEditorLongDocument", { pages })}</Alert>}
|
||||
@@ -1137,16 +1152,16 @@ function CustomizeTab({ mode, settings, update, themes }: {
|
||||
{mode === "template" && <Box>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 800, mb: 1 }}>{t("cvEditorTheme")}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mb: 1.25 }}>{t("cvEditorThemeHelp")}</Typography>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1 }}>
|
||||
<Box sx={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 1.25 }}>
|
||||
{themes.map((theme) => {
|
||||
const active = theme.id === settings.themeId;
|
||||
const locked = theme.available === false;
|
||||
return (
|
||||
<Paper key={theme.id} variant="outlined" role="button" tabIndex={locked ? -1 : 0} aria-disabled={locked}
|
||||
onClick={() => { if (!locked) update({ themeId: theme.id }); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update({ themeId: theme.id }); } }}
|
||||
onClick={() => { if (!locked) update(applyCvTheme(settings, theme.id)); }}
|
||||
onKeyDown={(e) => { if (!locked && (e.key === "Enter" || e.key === " ")) { e.preventDefault(); update(applyCvTheme(settings, theme.id)); } }}
|
||||
sx={{ p: 1, cursor: locked ? "not-allowed" : "pointer", opacity: locked ? 0.6 : 1, outline: "none", borderColor: active ? "primary.main" : undefined, borderWidth: active ? 2 : 1, "&:focus-visible": { boxShadow: 3 } }}>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={110} /></Box>
|
||||
<Box sx={{ mb: 1 }}><CvTemplateThumbnail theme={theme} height={170} /></Box>
|
||||
<Typography variant="body2" sx={{ fontWeight: 700 }}>{theme.name}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block" }}>{theme.category}</Typography>
|
||||
<Typography variant="caption" color="text.secondary" sx={{ display: "block", mt: 0.25, lineHeight: 1.3 }}>{theme.description}</Typography>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { FormEvent, useEffect, useMemo, useState } from "react";
|
||||
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, MenuItem, Stack, TextField, Typography } from "@mui/material";
|
||||
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, FormControlLabel, MenuItem, Stack, Switch, TextField, Typography } from "@mui/material";
|
||||
import SearchIcon from "@mui/icons-material/Search";
|
||||
import AddIcon from "@mui/icons-material/Add";
|
||||
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
|
||||
@@ -26,7 +26,7 @@ type DiscoveredJob = {
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
type SavedSearch = { id: number; name: string; query: string; location: string; lastRunAtUtc?: string; resultCount: number; dismissedCount: number };
|
||||
type SavedSearch = { id: number; name: string; query: string; location: string; isActive: boolean; lastRunAtUtc?: string; resultCount: number; dismissedCount: number };
|
||||
type SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> };
|
||||
|
||||
type SortOrder = "updated" | "deadline" | "title";
|
||||
@@ -47,6 +47,7 @@ export default function JobDiscoveryPage() {
|
||||
const [savedName, setSavedName] = useState("");
|
||||
const [savingSearch, setSavingSearch] = useState(false);
|
||||
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
||||
const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
@@ -102,6 +103,15 @@ export default function JobDiscoveryPage() {
|
||||
} catch { setError(t("jobDiscoveryDeleteSavedFailed")); }
|
||||
};
|
||||
|
||||
const toggleSavedSearchAlerts = async (saved: SavedSearch) => {
|
||||
setUpdatingSavedSearchId(saved.id); setError("");
|
||||
try {
|
||||
const response = await api.patch<SavedSearch>(`/job-discovery/saved-searches/${saved.id}`, { isActive: !saved.isActive });
|
||||
setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data : item));
|
||||
} catch { setError(t("jobDiscoveryUpdateAlertsFailed")); }
|
||||
finally { setUpdatingSavedSearchId(null); }
|
||||
};
|
||||
|
||||
const dismissJob = async (jobId: string) => {
|
||||
if (activeSavedSearchId === null) return;
|
||||
try {
|
||||
@@ -148,6 +158,11 @@ export default function JobDiscoveryPage() {
|
||||
<Typography variant="caption" color="text.secondary">{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography>
|
||||
</Box>
|
||||
<Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} />
|
||||
<FormControlLabel
|
||||
sx={{ m: 0 }}
|
||||
control={<Switch size="small" checked={saved.isActive} disabled={updatingSavedSearchId === saved.id} onChange={() => void toggleSavedSearchAlerts(saved)} />}
|
||||
label={<Typography variant="body2">{t("jobDiscoveryAutomaticAlerts")}</Typography>}
|
||||
/>
|
||||
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={loading}>{t("jobDiscoveryRunSaved")}</Button>
|
||||
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
|
||||
</Box>
|
||||
|
||||
@@ -738,6 +738,17 @@ def test_parser_timeout_terminates_descendants(monkeypatch, tmp_path):
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
else:
|
||||
# Minimal CI containers may not run an init process that promptly
|
||||
# reaps orphaned children. A zombie has already been terminated and
|
||||
# cannot execute or retain parser resources, even though kill(pid, 0)
|
||||
# continues to see its process-table entry.
|
||||
stat_path = Path(f"/proc/{descendant_pid}/stat")
|
||||
try:
|
||||
state = stat_path.read_text(encoding="ascii").split()[2]
|
||||
except (FileNotFoundError, IndexError, OSError):
|
||||
state = None
|
||||
if state == "Z":
|
||||
return
|
||||
os.kill(descendant_pid, 9)
|
||||
raise AssertionError("Parser descendant survived the parent deadline")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user