feat(discovery): add saved job searches
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
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;
|
||||
@@ -15,12 +19,21 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
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)
|
||||
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")]
|
||||
@@ -28,62 +41,7 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
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";
|
||||
|
||||
// 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,
|
||||
feed.TryGetProperty("applicationDue", out var applicationDue) && applicationDue.TryGetDateTimeOffset(out var deadline) ? deadline : null,
|
||||
$"https://arbeidsplassen.nav.no/stillinger/stilling/{id}",
|
||||
"nav",
|
||||
"NAV Arbeidsplassen",
|
||||
"searched",
|
||||
retrievedAt,
|
||||
"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());
|
||||
return Ok(await SearchCoreAsync(q, location, cancellationToken));
|
||||
}
|
||||
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
|
||||
{
|
||||
@@ -91,6 +49,137 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("saved-searches")]
|
||||
public async Task<ActionResult<IReadOnlyList<SavedSearchDto>>> 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<ActionResult<SavedSearchDto>> 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<IActionResult> 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<ActionResult<SavedSearchRunDto>> 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<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);
|
||||
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<IActionResult> 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<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();
|
||||
@@ -124,4 +213,10 @@ public sealed class JobDiscoveryController : ControllerBase
|
||||
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<SavedSearchJobDto> 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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user