Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a56e377c98 | |||
| f8fc8d6976 | |||
| 1152e05687 | |||
| caf486dceb | |||
| b7029faaec |
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 |
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }} /> },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user