Compare commits

...

5 Commits

Author SHA1 Message Date
cesnimda a56e377c98 Merge branch 'main' into codex/scheduled-search-alerts 2026-08-31 22:37:58 +02:00
cesnimda f8fc8d6976 test(capture): validate extension in CI 2026-08-31 22:29:45 +02:00
cesnimda 1152e05687 feat(calendar): add application date integrations 2026-08-31 22:24:38 +02:00
cesnimda caf486dceb feat(capture): add minimal browser extension 2026-08-31 22:23:34 +02:00
cesnimda b7029faaec feat(discovery): schedule saved-search alerts 2026-08-31 22:23:34 +02:00
31 changed files with 1068 additions and 115 deletions
+2
View File
@@ -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
+3
View File
@@ -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
@@ -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);
}
}
@@ -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);
+15
View File
@@ -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);
+3
View File
@@ -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>();
@@ -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)
+44 -1
View File
@@ -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);
}
}
+4
View File
@@ -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",
+20
View File
@@ -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.
+23
View File
@@ -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);
});
+15
View File
@@ -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;
}
+14
View File
@@ -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"
);
});
+2
View File
@@ -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}
+2 -1
View File
@@ -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 |
+29
View File
@@ -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.
@@ -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>
);
}
+34 -2
View File
@@ -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",
+14
View File
@@ -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 }} /> },
+17 -2
View File
@@ -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>