using System.Net.Http.Headers; using System.Text.Json; using JobTrackerApi.Data; using JobTrackerApi.Models; using JobTrackerApi.Services; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; 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; private readonly JobTrackerContext? _db; private readonly ICurrentUserService? _currentUser; public JobDiscoveryController( IHttpClientFactory clients, IConfiguration configuration, IMemoryCache cache, JobTrackerContext? db = null, ICurrentUserService? currentUser = null) { _clients = clients; _configuration = configuration; _cache = cache; _db = db; _currentUser = currentUser; } [HttpGet("search")] public async Task>> Search([FromQuery] string? q, [FromQuery] string? location, CancellationToken cancellationToken) { try { return Ok(await SearchCoreAsync(q, location, cancellationToken)); } catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException) { return Problem("NAV job discovery is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway); } } [HttpGet("saved-searches")] public async Task>> ListSavedSearches(CancellationToken cancellationToken) { if (!TryGetPersistence(out var db, out var owner)) return Unauthorized(); var searches = await db.SavedJobSearches.AsNoTracking().Include(x => x.Results) .Where(x => x.OwnerUserId == owner).OrderByDescending(x => x.UpdatedAtUtc).ToListAsync(cancellationToken); return Ok(searches.Select(ToDto).ToList()); } [HttpPost("saved-searches")] public async Task> CreateSavedSearch([FromBody] SavedSearchRequest request, CancellationToken cancellationToken) { if (!TryGetPersistence(out var db, out var owner)) return Unauthorized(); var name = request.Name?.Trim() ?? ""; var query = request.Query?.Trim() ?? ""; var location = request.Location?.Trim() ?? ""; if (name.Length is < 1 or > 255 || query.Length > 500 || location.Length > 255) return ValidationProblem("Provide a name up to 255 characters and valid search filters."); var now = DateTimeOffset.UtcNow; var search = new SavedJobSearch { OwnerUserId = owner, Name = name, Query = query, Location = location, CreatedAtUtc = now, UpdatedAtUtc = now }; db.SavedJobSearches.Add(search); await db.SaveChangesAsync(cancellationToken); return CreatedAtAction(nameof(ListSavedSearches), ToDto(search)); } [HttpDelete("saved-searches/{id:int}")] public async Task DeleteSavedSearch(int id, CancellationToken cancellationToken) { if (!TryGetPersistence(out var db, out var owner)) return Unauthorized(); var search = await db.SavedJobSearches.FirstOrDefaultAsync(x => x.Id == id && x.OwnerUserId == owner, cancellationToken); if (search is null) return NotFound(); db.SavedJobSearches.Remove(search); await db.SaveChangesAsync(cancellationToken); return NoContent(); } [HttpPost("saved-searches/{id:int}/run")] public async Task> RunSavedSearch(int id, 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(); try { var jobs = await SearchCoreAsync(search.Query, search.Location, cancellationToken); var now = DateTimeOffset.UtcNow; var existing = search.Results.ToDictionary(x => x.ExternalJobId, StringComparer.OrdinalIgnoreCase); var results = new List(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); return Ok(new SavedSearchRunDto(ToDto(search), results)); } catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException) { return Problem("NAV job discovery is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway); } } [HttpPatch("saved-searches/{id:int}/results/{externalJobId}/dismiss")] public async Task DismissSavedSearchResult(int id, string externalJobId, [FromBody] DismissRequest request, CancellationToken cancellationToken) { if (!TryGetPersistence(out var db, out var owner)) return Unauthorized(); var result = await db.SavedJobSearchResults .FirstOrDefaultAsync(x => x.SavedJobSearchId == id && x.ExternalJobId == externalJobId && x.SavedJobSearch!.OwnerUserId == owner, cancellationToken); if (result is null) return NotFound(); result.IsDismissed = request.IsDismissed; await db.SaveChangesAsync(cancellationToken); return NoContent(); } private bool TryGetPersistence(out JobTrackerContext db, out string owner) { db = _db!; owner = _currentUser?.UserId ?? ""; return db is not null && owner.Length > 0; } private async Task> SearchCoreAsync(string? q, string? location, CancellationToken cancellationToken) { var retrievedAt = DateTimeOffset.UtcNow; var token = await GetTokenAsync(cancellationToken); var client = _clients.CreateClient(); var entries = new Dictionary(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 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 DismissRequest(bool IsDismissed); public sealed record SavedSearchDto(int Id, string Name, string Query, string Location, bool IsActive, DateTimeOffset? LastRunAtUtc, int ResultCount, int DismissedCount); public sealed record SavedSearchJobDto(DiscoveredJob Job, bool IsNew, bool IsDismissed); public sealed record SavedSearchRunDto(SavedSearchDto Search, IReadOnlyList Jobs); private static SavedSearchDto ToDto(SavedJobSearch search) => new(search.Id, search.Name, search.Query, search.Location, search.IsActive, search.LastRunAtUtc, search.Results.Count, search.Results.Count(x => x.IsDismissed)); }