147 lines
7.3 KiB
C#
147 lines
7.3 KiB
C#
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 readonly IJobDiscoverySearchService _discovery;
|
|
private readonly SavedJobSearchRunner? _savedSearchRunner;
|
|
private readonly JobTrackerContext? _db;
|
|
private readonly ICurrentUserService? _currentUser;
|
|
|
|
public JobDiscoveryController(
|
|
IHttpClientFactory clients,
|
|
IConfiguration configuration,
|
|
IMemoryCache cache,
|
|
JobTrackerContext? db = null,
|
|
ICurrentUserService? currentUser = null,
|
|
IJobDiscoverySearchService? discovery = null,
|
|
SavedJobSearchRunner? savedSearchRunner = null)
|
|
{
|
|
_discovery = discovery ?? new NavJobDiscoverySearchService(clients, configuration, cache, TimeProvider.System);
|
|
_savedSearchRunner = savedSearchRunner;
|
|
_db = db;
|
|
_currentUser = currentUser;
|
|
}
|
|
|
|
[HttpGet("search")]
|
|
public async Task<ActionResult<IReadOnlyList<DiscoveredJob>>> Search([FromQuery] string? q, [FromQuery] string? location, CancellationToken cancellationToken)
|
|
{
|
|
try
|
|
{
|
|
return Ok(await _discovery.SearchAsync(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<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();
|
|
}
|
|
|
|
[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")]
|
|
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 runner = _savedSearchRunner ?? new SavedJobSearchRunner(db, _discovery, TimeProvider.System);
|
|
var run = await runner.RunAsync(search, cancellationToken);
|
|
await db.SaveChangesAsync(cancellationToken);
|
|
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)
|
|
{
|
|
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;
|
|
}
|
|
|
|
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 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));
|
|
}
|