From 405e6d833c2207cbba35022f26ab85a48b975ee8 Mon Sep 17 00:00:00 2001 From: cesnimda Date: Thu, 30 Jul 2026 22:57:17 +0200 Subject: [PATCH] feat: add NAV job discovery --- .../JobDiscoveryControllerTests.cs | 39 +++++++ .../Controllers/JobDiscoveryController.cs | 110 ++++++++++++++++++ docs/implementation-roadmap.md | 6 +- job-tracker-ui/src/App.tsx | 6 + job-tracker-ui/src/job-discovery.test.tsx | 21 ++++ job-tracker-ui/src/views/JobDiscoveryPage.tsx | 54 +++++++++ 6 files changed, 233 insertions(+), 3 deletions(-) create mode 100644 JobTrackerApi.Tests/JobDiscoveryControllerTests.cs create mode 100644 JobTrackerApi/Controllers/JobDiscoveryController.cs create mode 100644 job-tracker-ui/src/job-discovery.test.tsx create mode 100644 job-tracker-ui/src/views/JobDiscoveryPage.tsx diff --git a/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs b/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs new file mode 100644 index 0000000..e4fdd32 --- /dev/null +++ b/JobTrackerApi.Tests/JobDiscoveryControllerTests.cs @@ -0,0 +1,39 @@ +using Xunit; +using System.Net; +using System.Text; +using JobTrackerApi.Controllers; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; + +namespace JobTrackerApi.Tests; + +public sealed class JobDiscoveryControllerTests +{ + [Fact] + public async Task Search_filters_recent_active_nav_jobs() + { + const string token = "eyJ.test.token"; + var feed = """{"next_url":"","items":[{"date_modified":"2026-07-30T10:00:00Z","_feed_entry":{"uuid":"1","status":"ACTIVE","title":"Backend Developer","businessName":"Acme","municipal":"OSLO"}},{"date_modified":"2026-07-30T11:00:00Z","_feed_entry":{"uuid":"2","status":"ACTIVE","title":"Nurse","businessName":"Hospital","municipal":"BERGEN"}}]}"""; + var client = new HttpClient(new Handler(request => request.RequestUri!.AbsolutePath.EndsWith("publicToken") ? token : feed)); + var controller = new JobDiscoveryController(new ClientFactory(client), new ConfigurationBuilder().Build(), new MemoryCache(new MemoryCacheOptions())); + + var result = await controller.Search("backend", "oslo", CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var job = Assert.Single(Assert.IsAssignableFrom>(ok.Value)); + Assert.Equal("Backend Developer", job.Title); + Assert.Equal("NO", job.CountryCode); + } + + private sealed class ClientFactory(HttpClient client) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => client; + } + + private sealed class Handler(Func response) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response(request), Encoding.UTF8, "application/json") }); + } +} diff --git a/JobTrackerApi/Controllers/JobDiscoveryController.cs b/JobTrackerApi/Controllers/JobDiscoveryController.cs new file mode 100644 index 0000000..1416eb7 --- /dev/null +++ b/JobTrackerApi/Controllers/JobDiscoveryController.cs @@ -0,0 +1,110 @@ +using System.Net.Http.Headers; +using System.Text.Json; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Memory; + +namespace JobTrackerApi.Controllers; + +[ApiController] +[Route("api/job-discovery")] +[Authorize(AuthenticationSchemes = "local")] +public sealed class JobDiscoveryController : ControllerBase +{ + private const string BaseUrl = "https://pam-stilling-feed.nav.no"; + private readonly IHttpClientFactory _clients; + private readonly IConfiguration _configuration; + private readonly IMemoryCache _cache; + + public JobDiscoveryController(IHttpClientFactory clients, IConfiguration configuration, IMemoryCache cache) + { + _clients = clients; + _configuration = configuration; + _cache = cache; + } + + [HttpGet("search")] + public async Task>> Search([FromQuery] string? q, [FromQuery] string? location, CancellationToken cancellationToken) + { + try + { + var token = await GetTokenAsync(cancellationToken); + var client = _clients.CreateClient(); + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + var next = "/api/v1/feed"; + + // ponytail: scan the recent event window on demand; add a persisted feed cursor only when + // usage makes the bounded request noticeably slow or NAV private-token terms require it. + 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 id = feed.GetProperty("uuid").GetString(); + if (string.IsNullOrWhiteSpace(id)) continue; + var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null; + if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase)) + { + entries.Remove(id); + continue; + } + + entries[id] = new DiscoveredJob( + id, + 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, + $"https://arbeidsplassen.nav.no/stillinger/stilling/{id}", + "nav", + "NO"); + } + } + + var query = (q ?? "").Trim(); + var place = (location ?? "").Trim(); + return Ok(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()); + } + catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException) + { + return Problem("NAV job discovery is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway); + } + } + + private async Task 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, string Url, string Source, string CountryCode); +} diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index e5bf9a7..583c481 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -170,9 +170,9 @@ Goal: help users find opportunities. **URL preview and bookmarklet/PWA capture a |---|---|---|---|---|---| | 6.1 | **DONE (2026-07-30)** — manual URL import previews into the existing reviewed add-job flow, which persists through the normal create endpoint and warns on duplicate URLs. No second import-history store was added. | **P2** | **M** | Phase 0 | The existing flow delivers the discovery win without another persistence model. | | 6.2 | **DONE (2026-07-30)** — bookmarklet/PWA share capture reuses `jobimport/preview` and opens the reviewed add-job flow in `Saved`. A store extension remains deliberately unnecessary. | **P2** | **L** | 6.1, 1.1 | Capture is available without Chrome-store maintenance. | -| 6.3 | **(c) Official job-board APIs where available** | **P3** | **L** | 6.2, 6.6 | Step (c). Legal and low-maintenance where an API exists. | -| 6.4 | **Search UI + filters** (title, location, remote, industry) | **P3** | **M** | 6.3 | Only worth it once (c) supplies real data. | -| 6.5 | **One-click import into tracker** | **P3** | **S** | 6.3 | Lands in `Saved` — which exists as of Phase 0. | +| 6.3 | **DONE (2026-07-30)** — NAV Job Vacancy Feed integration uses the official authenticated API and rotating public experiment token; `NavJobs:Token` supports a stable private token later. FINN requires a business agreement, while Indeed and LinkedIn do not expose open discovery feeds. | **P3** | **L** | 6.2, 6.6 | Legal official Norway-first discovery without scraping. | +| 6.4 | **DONE (2026-07-30)** — discovery UI searches recent active NAV events by title/company and municipality. | **P3** | **M** | 6.3 | Provides a usable official-feed search surface. | +| 6.5 | **DONE (2026-07-30)** — “Save to tracker” routes discoveries through the existing reviewed URL-import wizard and lands them in `Saved`. | **P3** | **S** | 6.3 | Reuses the established import and duplicate-warning flow. | | 6.6 | **PARTIAL** — `Job.CountryCode` and `Job.Source` exist, but the live `JobApplication` create flow does not yet dual-write `Job`, and import results do not carry market metadata. **Decided: Norway first, but no hardcoding Norway.** | **P2** | **M** | none | Complete this with the first official API so its provider market contract drives the write path. | | 6.7 | **Explicitly out of scope: scraping.** | — | — | — | Recorded so it is not re-proposed. Also out: auto-apply bots (ToS/ethics/quality; contradicts "apply to more *suitable* jobs"). | diff --git a/job-tracker-ui/src/App.tsx b/job-tracker-ui/src/App.tsx index ab5044a..b8c1623 100644 --- a/job-tracker-ui/src/App.tsx +++ b/job-tracker-ui/src/App.tsx @@ -43,6 +43,7 @@ import { getThemeModePref, setThemeModePref, ThemeModePref } from "./themePrefs" const AddJobModal = lazy(() => import("./components/AddJobModal")); const KanbanBoard = lazy(() => import("./components/KanbanBoard")); const DashboardView = lazy(() => import("./components/DashboardView")); +const JobDiscoveryPage = lazy(() => import("./views/JobDiscoveryPage")); const CompaniesTable = lazy(() => import("./components/CompaniesTable")); const SettingsView = lazy(() => import("./components/SettingsView")); const RemindersView = lazy(() => import("./components/RemindersView")); @@ -76,6 +77,7 @@ type MeResponse = { function breadcrumbsFor(path: string, t: (k: any) => string): string[] { if (path.startsWith("/dashboard")) return [t("home"), t("analytics"), t("overview")]; + if (path.startsWith("/discover")) return [t("home"), "Discover jobs"]; if (path.startsWith("/jobs")) return [t("home"), t("jobApplications")]; if (path.startsWith("/reminders")) return [t("home"), t("reminders")]; if (path.startsWith("/kanban")) return [t("home"), t("kanbanBoard")]; @@ -97,6 +99,7 @@ function breadcrumbsFor(path: string, t: (k: any) => string): string[] { function titleFor(path: string, t: (k: any) => string): string { if (path === "/dashboard") return t("dashboard"); if (path.startsWith("/reminders")) return t("reminders"); + if (path.startsWith("/discover")) return "Discover jobs"; if (path.startsWith("/jobs")) return t("jobApplications"); if (path.startsWith("/kanban")) return t("kanbanBoard"); if (path.startsWith("/companies")) return t("companies"); @@ -116,6 +119,7 @@ function titleFor(path: string, t: (k: any) => string): string { function subtitleFor(path: string, t: (k: any) => string): string | undefined { if (path === "/dashboard") return t("dashboardPageSubtitle"); + if (path.startsWith("/discover")) return "Search official job-board feeds and save opportunities to your tracker."; if (path.startsWith("/jobs")) return t("jobsPageSubtitle"); if (path.startsWith("/kanban")) return t("kanbanPageSubtitle"); if (path.startsWith("/reminders")) return t("remindersPageSubtitle"); @@ -242,6 +246,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo const nav: NavItem[] = [ { to: "/dashboard", label: t("dashboard"), icon: , section: t("manage") }, { to: "/jobs", label: t("jobApplications"), icon: , section: t("manage") }, + { to: "/discover", label: "Discover jobs", icon: , section: t("manage") }, { to: "/reminders", label: t("reminders"), icon: , badgeCount: notifCount, section: t("manage") }, { to: "/kanban", label: t("kanbanBoard"), icon: , section: t("manage") }, { to: "/companies", label: t("companies"), icon: , section: t("manage") }, @@ -322,6 +327,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo } /> } /> + } /> } /> } /> } /> diff --git a/job-tracker-ui/src/job-discovery.test.tsx b/job-tracker-ui/src/job-discovery.test.tsx new file mode 100644 index 0000000..817d77a --- /dev/null +++ b/job-tracker-ui/src/job-discovery.test.tsx @@ -0,0 +1,21 @@ +import React from "react"; +import "@testing-library/jest-dom"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { api } from "./api"; +import JobDiscoveryPage from "./views/JobDiscoveryPage"; + +jest.mock("./api", () => ({ api: { get: jest.fn() } })); +const mockedApi = api as jest.Mocked; + +test("searches NAV and offers the existing reviewed save flow", async () => { + mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", countryCode: "NO" }] } as any); + render(); + + fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: "backend" } }); + fireEvent.click(screen.getByRole("button", { name: "Search NAV" })); + + expect(await screen.findByText("Backend Developer")).toBeInTheDocument(); + await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/job-discovery/search", { params: { q: "backend", location: undefined } })); + expect(screen.getByRole("button", { name: "Save to tracker" })).toBeInTheDocument(); +}); diff --git a/job-tracker-ui/src/views/JobDiscoveryPage.tsx b/job-tracker-ui/src/views/JobDiscoveryPage.tsx new file mode 100644 index 0000000..9071660 --- /dev/null +++ b/job-tracker-ui/src/views/JobDiscoveryPage.tsx @@ -0,0 +1,54 @@ +import { FormEvent, useState } from "react"; +import { Alert, Box, Button, Card, CardActions, CardContent, CircularProgress, Stack, TextField, Typography } from "@mui/material"; +import SearchIcon from "@mui/icons-material/Search"; +import AddIcon from "@mui/icons-material/Add"; +import { useNavigate } from "react-router-dom"; +import { api } from "../api"; + +type DiscoveredJob = { id: string; title: string; company?: string; location?: string; modifiedAt?: string; url: string; source: string; countryCode: string; }; + +export default function JobDiscoveryPage() { + const navigate = useNavigate(); + const [query, setQuery] = useState(""); + const [location, setLocation] = useState(""); + const [jobs, setJobs] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + + const search = async (event: FormEvent) => { + event.preventDefault(); setLoading(true); setError(""); + try { + const response = await api.get("/job-discovery/search", { params: { q: query || undefined, location: location || undefined } }); + setJobs(response.data ?? []); + } catch { setError("NAV job discovery is temporarily unavailable."); } + finally { setLoading(false); } + }; + + return ( + + + setQuery(event.target.value)} /> + setLocation(event.target.value)} /> + + + Official Norwegian vacancies from NAV. FINN, Indeed and LinkedIn jobs can still be captured by URL. + {error ? {error} : null} + {!loading && jobs.length === 0 ? Search recent active vacancies by role, company, or municipality. : null} + + {jobs.map((job) => ( + + + {job.title} + {[job.company, job.location].filter(Boolean).join(" · ")} + NAV · Norway{job.modifiedAt ? " · Updated " + new Date(job.modifiedAt).toLocaleDateString() : ""} + + + + + + + ))} + + + ); +}