Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a56e377c98 | |||
| f8fc8d6976 | |||
| 1152e05687 | |||
| caf486dceb | |||
| b7029faaec |
@@ -104,6 +104,8 @@ EMAIL_FOLLOWUPREMINDERS_ENABLED=false
|
|||||||
EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2
|
EMAIL_FOLLOWUPREMINDERS_UPCOMINGDAYS=2
|
||||||
WORKER_RULES_ENABLED=false
|
WORKER_RULES_ENABLED=false
|
||||||
WORKER_FOLLOWUP_REMINDERS_ENABLED=false
|
WORKER_FOLLOWUP_REMINDERS_ENABLED=false
|
||||||
|
WORKER_SAVED_SEARCH_ALERTS_ENABLED=true
|
||||||
|
SAVED_SEARCH_INTERVAL_HOURS=6
|
||||||
WORKER_DAILY_EXPORT_ENABLED=false
|
WORKER_DAILY_EXPORT_ENABLED=false
|
||||||
WORKER_JOB_ENRICHMENT_ENABLED=false
|
WORKER_JOB_ENRICHMENT_ENABLED=false
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,9 @@ jobs:
|
|||||||
python3 scripts/test-ollama-evaluation.py
|
python3 scripts/test-ollama-evaluation.py
|
||||||
python3 scripts/test-supply-chain.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
|
- name: Scan tracked files and generate dependency SBOM
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/supply-chain.py scan-secrets
|
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 System.Text;
|
||||||
using JobTrackerApi.Controllers;
|
using JobTrackerApi.Controllers;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
using JobTrackerApi.Services;
|
using JobTrackerApi.Services;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -25,7 +26,7 @@ public sealed class JobDiscoveryControllerTests
|
|||||||
var result = await controller.Search("backend", "oslo", CancellationToken.None);
|
var result = await controller.Search("backend", "oslo", CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
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("Backend Developer", job.Title);
|
||||||
Assert.Equal("nav", job.Source);
|
Assert.Equal("nav", job.Source);
|
||||||
Assert.Equal("NAV Arbeidsplassen", job.SourceName);
|
Assert.Equal("NAV Arbeidsplassen", job.SourceName);
|
||||||
@@ -46,7 +47,7 @@ public sealed class JobDiscoveryControllerTests
|
|||||||
var result = await controller.Search(null, null, CancellationToken.None);
|
var result = await controller.Search(null, null, CancellationToken.None);
|
||||||
|
|
||||||
var ok = Assert.IsType<OkObjectResult>(result.Result);
|
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("1", job.Id);
|
||||||
Assert.Equal("Current title", job.Title);
|
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));
|
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);
|
var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
|
||||||
Assert.Equal(1, Assert.Single(listed).DismissedCount);
|
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
|
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 System.Text.Json;
|
||||||
using JobTrackerApi.Data;
|
using JobTrackerApi.Data;
|
||||||
using JobTrackerApi.Models;
|
using JobTrackerApi.Models;
|
||||||
@@ -15,10 +14,8 @@ namespace JobTrackerApi.Controllers;
|
|||||||
[Authorize(AuthenticationSchemes = "local")]
|
[Authorize(AuthenticationSchemes = "local")]
|
||||||
public sealed class JobDiscoveryController : ControllerBase
|
public sealed class JobDiscoveryController : ControllerBase
|
||||||
{
|
{
|
||||||
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
|
private readonly IJobDiscoverySearchService _discovery;
|
||||||
private readonly IHttpClientFactory _clients;
|
private readonly SavedJobSearchRunner? _savedSearchRunner;
|
||||||
private readonly IConfiguration _configuration;
|
|
||||||
private readonly IMemoryCache _cache;
|
|
||||||
private readonly JobTrackerContext? _db;
|
private readonly JobTrackerContext? _db;
|
||||||
private readonly ICurrentUserService? _currentUser;
|
private readonly ICurrentUserService? _currentUser;
|
||||||
|
|
||||||
@@ -27,11 +24,12 @@ public sealed class JobDiscoveryController : ControllerBase
|
|||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
IMemoryCache cache,
|
IMemoryCache cache,
|
||||||
JobTrackerContext? db = null,
|
JobTrackerContext? db = null,
|
||||||
ICurrentUserService? currentUser = null)
|
ICurrentUserService? currentUser = null,
|
||||||
|
IJobDiscoverySearchService? discovery = null,
|
||||||
|
SavedJobSearchRunner? savedSearchRunner = null)
|
||||||
{
|
{
|
||||||
_clients = clients;
|
_discovery = discovery ?? new NavJobDiscoverySearchService(clients, configuration, cache, TimeProvider.System);
|
||||||
_configuration = configuration;
|
_savedSearchRunner = savedSearchRunner;
|
||||||
_cache = cache;
|
|
||||||
_db = db;
|
_db = db;
|
||||||
_currentUser = currentUser;
|
_currentUser = currentUser;
|
||||||
}
|
}
|
||||||
@@ -41,7 +39,7 @@ public sealed class JobDiscoveryController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
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)
|
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
|
||||||
{
|
{
|
||||||
@@ -86,6 +84,19 @@ public sealed class JobDiscoveryController : ControllerBase
|
|||||||
return NoContent();
|
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")]
|
[HttpPost("saved-searches/{id:int}/run")]
|
||||||
public async Task<ActionResult<SavedSearchRunDto>> RunSavedSearch(int id, CancellationToken cancellationToken)
|
public async Task<ActionResult<SavedSearchRunDto>> RunSavedSearch(int id, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -95,26 +106,10 @@ public sealed class JobDiscoveryController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jobs = await SearchCoreAsync(search.Query, search.Location, cancellationToken);
|
var runner = _savedSearchRunner ?? new SavedJobSearchRunner(db, _discovery, TimeProvider.System);
|
||||||
var now = DateTimeOffset.UtcNow;
|
var run = await runner.RunAsync(search, cancellationToken);
|
||||||
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;
|
|
||||||
await db.SaveChangesAsync(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)
|
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;
|
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 SavedSearchRequest(string? Name, string? Query, string? Location);
|
||||||
|
public sealed record UpdateSavedSearchRequest(bool IsActive);
|
||||||
public sealed record DismissRequest(bool IsDismissed);
|
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 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);
|
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<IAiOperationHandler, CvProcessingOperationHandler>();
|
||||||
builder.Services.AddSingleton<AiOperationWorker>();
|
builder.Services.AddSingleton<AiOperationWorker>();
|
||||||
builder.Services.AddScoped<UserNotificationStore>();
|
builder.Services.AddScoped<UserNotificationStore>();
|
||||||
|
builder.Services.AddSingleton<IJobDiscoverySearchService, NavJobDiscoverySearchService>();
|
||||||
|
builder.Services.AddScoped<SavedJobSearchRunner>();
|
||||||
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
builder.Services.AddScoped<IEmailSettingsResolver, EmailSettingsResolver>();
|
||||||
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
builder.Services.AddScoped<IAppEmailSender, SmtpEmailSender>();
|
||||||
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
|
builder.Services.AddScoped<ICvProcessingQueue, CvProcessingQueue>();
|
||||||
@@ -174,6 +176,7 @@ builder.Services.AddSingleton<IDatabaseBackupRunner, SqliteDatabaseBackupRunner>
|
|||||||
builder.Services.AddHostedService<DatabaseBackupHostedService>();
|
builder.Services.AddHostedService<DatabaseBackupHostedService>();
|
||||||
builder.Services.AddHostedService<RulesHostedService>();
|
builder.Services.AddHostedService<RulesHostedService>();
|
||||||
builder.Services.AddHostedService<FollowUpReminderHostedService>();
|
builder.Services.AddHostedService<FollowUpReminderHostedService>();
|
||||||
|
builder.Services.AddHostedService<SavedJobSearchAlertHostedService>();
|
||||||
builder.Services.AddHostedService<DailyExportHostedService>();
|
builder.Services.AddHostedService<DailyExportHostedService>();
|
||||||
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
builder.Services.AddHostedService<JobEnrichmentHostedService>();
|
||||||
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
builder.Services.AddHostedService<SummarizerProbeHostedService>();
|
||||||
|
|||||||
@@ -16,13 +16,28 @@ public sealed class BackgroundTenantRunner(
|
|||||||
string worker,
|
string worker,
|
||||||
Func<IServiceProvider, CancellationToken, Task> work,
|
Func<IServiceProvider, CancellationToken, Task> work,
|
||||||
CancellationToken cancellationToken)
|
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();
|
await using var enumerationScope = scopes.CreateAsyncScope();
|
||||||
var ownerIds = await enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>()
|
var ownerIds = await findOwners(enumerationScope.ServiceProvider.GetRequiredService<JobTrackerContext>());
|
||||||
.JobApplications.IgnoreQueryFilters().AsNoTracking()
|
|
||||||
.Where(job => job.OwnerUserId != null)
|
|
||||||
.Select(job => job.OwnerUserId!)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner))
|
var owners = ownerIds.Where(owner => !string.IsNullOrWhiteSpace(owner))
|
||||||
.Distinct(StringComparer.Ordinal)
|
.Distinct(StringComparer.Ordinal)
|
||||||
.Order(StringComparer.Ordinal)
|
.Order(StringComparer.Ordinal)
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public interface IGmailOAuthService
|
|||||||
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
Task<IReadOnlyList<GmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken);
|
||||||
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||||
Task<GmailSendResult> SendAsync(string ownerUserId, GmailSendRequest request, 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);
|
public sealed record GmailOAuthExchangeResult(string GmailAddress);
|
||||||
@@ -47,7 +48,8 @@ internal sealed class GmailTokenResponse
|
|||||||
public sealed class GmailOAuthService : IGmailOAuthService
|
public sealed class GmailOAuthService : IGmailOAuthService
|
||||||
{
|
{
|
||||||
public const string SendScope = "https://www.googleapis.com/auth/gmail.send";
|
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 IConfiguration _cfg;
|
||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly IDataProtector _protector;
|
private readonly IDataProtector _protector;
|
||||||
@@ -419,6 +421,47 @@ public sealed class GmailOAuthService : IGmailOAuthService
|
|||||||
public static bool HasSendScope(string? scope) =>
|
public static bool HasSendScope(string? scope) =>
|
||||||
!string.IsNullOrWhiteSpace(scope) && scope.Split(' ', StringSplitOptions.RemoveEmptyEntries).Contains(SendScope, StringComparer.OrdinalIgnoreCase);
|
!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)
|
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
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<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
|
||||||
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
|
||||||
Task<MicrosoftGraphSendResult> SendAsync(string ownerUserId, MicrosoftGraphSendRequest request, 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);
|
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
|
||||||
@@ -47,7 +48,8 @@ internal sealed class MicrosoftGraphTokenResponse
|
|||||||
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
||||||
{
|
{
|
||||||
public const string SendScope = "https://graph.microsoft.com/Mail.Send";
|
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 IConfiguration _cfg;
|
||||||
private readonly JobTrackerContext _db;
|
private readonly JobTrackerContext _db;
|
||||||
private readonly IDataProtector _protector;
|
private readonly IDataProtector _protector;
|
||||||
@@ -340,6 +342,47 @@ public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
|
|||||||
string.Equals(value, "Mail.Send", StringComparison.OrdinalIgnoreCase));
|
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)
|
private static void ValidateSendRequest(string to, string subject, string bodyText)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(to)) throw new ArgumentException("Recipient is required.", nameof(to));
|
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": {
|
"Workers": {
|
||||||
"RulesEnabled": false,
|
"RulesEnabled": false,
|
||||||
"FollowUpRemindersEnabled": false,
|
"FollowUpRemindersEnabled": false,
|
||||||
|
"SavedJobSearchAlertsEnabled": true,
|
||||||
"DailyExportEnabled": false,
|
"DailyExportEnabled": false,
|
||||||
"JobEnrichmentEnabled": false,
|
"JobEnrichmentEnabled": false,
|
||||||
"AiOperationsEnabled": false
|
"AiOperationsEnabled": false
|
||||||
},
|
},
|
||||||
|
"JobDiscovery": {
|
||||||
|
"SavedSearchIntervalHours": 6
|
||||||
|
},
|
||||||
"Ai": {
|
"Ai": {
|
||||||
"ExternalProcessingEnabled": false,
|
"ExternalProcessingEnabled": false,
|
||||||
"ExternalProvider": "ollama",
|
"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.
|
# notification/privacy/entitlement prerequisites have been explicitly rolled out.
|
||||||
- Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false}
|
- Workers__RulesEnabled=${WORKER_RULES_ENABLED:-false}
|
||||||
- Workers__FollowUpRemindersEnabled=${WORKER_FOLLOWUP_REMINDERS_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__DailyExportEnabled=${WORKER_DAILY_EXPORT_ENABLED:-false}
|
||||||
- Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
|
- Workers__JobEnrichmentEnabled=${WORKER_JOB_ENRICHMENT_ENABLED:-false}
|
||||||
- Workers__AiOperationsEnabled=${WORKER_AI_OPERATIONS_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.
|
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.
|
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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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",
|
jobDiscoveryViewListing: "View listing",
|
||||||
jobDiscoverySaveTracker: "Save to tracker",
|
jobDiscoverySaveTracker: "Save to tracker",
|
||||||
jobDiscoverySavedSearches: "Saved searches",
|
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",
|
jobDiscoverySavedName: "Search name",
|
||||||
jobDiscoverySavedDefaultName: "Recent vacancies",
|
jobDiscoverySavedDefaultName: "Recent vacancies",
|
||||||
jobDiscoverySaveSearch: "Save search",
|
jobDiscoverySaveSearch: "Save search",
|
||||||
@@ -2675,7 +2691,23 @@ export const translations = {
|
|||||||
jobDiscoveryViewListing: "Vis stilling",
|
jobDiscoveryViewListing: "Vis stilling",
|
||||||
jobDiscoverySaveTracker: "Lagre i oversikten",
|
jobDiscoverySaveTracker: "Lagre i oversikten",
|
||||||
jobDiscoverySavedSearches: "Lagrede søk",
|
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",
|
jobDiscoverySavedName: "Navn på søket",
|
||||||
jobDiscoverySavedDefaultName: "Nylige stillinger",
|
jobDiscoverySavedDefaultName: "Nylige stillinger",
|
||||||
jobDiscoverySaveSearch: "Lagre søk",
|
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());
|
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
|
||||||
expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
|
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 Attachments from "../components/Attachments";
|
||||||
import Correspondence from "../components/Correspondence";
|
import Correspondence from "../components/Correspondence";
|
||||||
import ApplicationChecklist from "../components/ApplicationChecklist";
|
import ApplicationChecklist from "../components/ApplicationChecklist";
|
||||||
|
import ApplicationCalendarActions from "../components/ApplicationCalendarActions";
|
||||||
import {
|
import {
|
||||||
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
ApplicationAnalysis, ApplicationMatch, ApplicationTimeline,
|
||||||
} from "../components/ApplicationIntelligence";
|
} from "../components/ApplicationIntelligence";
|
||||||
@@ -387,6 +388,7 @@ function OverviewDetails({ jobId, overview, onReload, onEdit }: { jobId: number;
|
|||||||
const panels = [
|
const panels = [
|
||||||
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
{ id: "details", title: t("workspaceJobDetails"), content: <JobDetailsSection overview={overview} onEdit={onEdit} /> },
|
||||||
{ id: "tasks", title: t("workspaceChecklist"), content: <ApplicationChecklist jobId={jobId} onChanged={onReload} /> },
|
{ 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: "timeline", title: t("workspaceActivityHistory"), content: <ApplicationTimeline jobId={jobId} /> },
|
||||||
{ id: "documents", title: t("workspaceDocuments"), content: <Attachments 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 }} /> },
|
{ 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 { 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 SearchIcon from "@mui/icons-material/Search";
|
||||||
import AddIcon from "@mui/icons-material/Add";
|
import AddIcon from "@mui/icons-material/Add";
|
||||||
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
|
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
|
||||||
@@ -26,7 +26,7 @@ type DiscoveredJob = {
|
|||||||
isNew?: boolean;
|
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 SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> };
|
||||||
|
|
||||||
type SortOrder = "updated" | "deadline" | "title";
|
type SortOrder = "updated" | "deadline" | "title";
|
||||||
@@ -47,6 +47,7 @@ export default function JobDiscoveryPage() {
|
|||||||
const [savedName, setSavedName] = useState("");
|
const [savedName, setSavedName] = useState("");
|
||||||
const [savingSearch, setSavingSearch] = useState(false);
|
const [savingSearch, setSavingSearch] = useState(false);
|
||||||
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
|
||||||
|
const [updatingSavedSearchId, setUpdatingSavedSearchId] = useState<number | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -102,6 +103,15 @@ export default function JobDiscoveryPage() {
|
|||||||
} catch { setError(t("jobDiscoveryDeleteSavedFailed")); }
|
} 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) => {
|
const dismissJob = async (jobId: string) => {
|
||||||
if (activeSavedSearchId === null) return;
|
if (activeSavedSearchId === null) return;
|
||||||
try {
|
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>
|
<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>
|
</Box>
|
||||||
<Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} />
|
<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" 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>
|
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
Reference in New Issue
Block a user