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); }