feat(discovery): add saved job searches

This commit is contained in:
cesnimda
2026-08-31 17:17:48 +02:00
parent 78ad0c66f9
commit c2181afaea
13 changed files with 461 additions and 80 deletions
@@ -2,9 +2,13 @@ using Xunit;
using System.Net;
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
using Moq;
namespace JobTrackerApi.Tests;
@@ -47,6 +51,28 @@ public sealed class JobDiscoveryControllerTests
Assert.Equal("Current title", job.Title);
}
[Fact]
public async Task Saved_search_tracks_new_seen_and_dismissed_results_per_owner()
{
var feed = """{"next_url":"","items":[{"date_modified":"2026-08-30T10:00:00Z","_feed_entry":{"uuid":"job-1","status":"ACTIVE","title":"Backend Developer","businessName":"Acme","municipal":"OSLO"}}]}""";
var client = new HttpClient(new Handler(_ => feed));
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["NavJobs:Token"] = "configured-test-token" }).Build();
var current = new Mock<ICurrentUserService>();
current.SetupGet(x => x.UserId).Returns("owner-1");
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>().UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object);
var controller = new JobDiscoveryController(new ClientFactory(client), configuration, new MemoryCache(new MemoryCacheOptions()), db, current.Object);
var created = Assert.IsType<JobDiscoveryController.SavedSearchDto>(Assert.IsType<CreatedAtActionResult>((await controller.CreateSavedSearch(new("Backend", "backend", "oslo"), default)).Result).Value);
var first = Assert.IsType<JobDiscoveryController.SavedSearchRunDto>(Assert.IsType<OkObjectResult>((await controller.RunSavedSearch(created.Id, default)).Result).Value);
Assert.True(Assert.Single(first.Jobs).IsNew);
var second = Assert.IsType<JobDiscoveryController.SavedSearchRunDto>(Assert.IsType<OkObjectResult>((await controller.RunSavedSearch(created.Id, default)).Result).Value);
Assert.False(Assert.Single(second.Jobs).IsNew);
Assert.IsType<NoContentResult>(await controller.DismissSavedSearchResult(created.Id, "job-1", new(true), default));
var listed = Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.SavedSearchDto>>(Assert.IsType<OkObjectResult>((await controller.ListSavedSearches(default)).Result).Value);
Assert.Equal(1, Assert.Single(listed).DismissedCount);
}
private sealed class ClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
@@ -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));
}
+12
View File
@@ -52,6 +52,8 @@ namespace JobTrackerApi.Data
public DbSet<CareerCertification> CareerCertifications => Set<CareerCertification>();
public DbSet<CareerLanguage> CareerLanguages => Set<CareerLanguage>();
public DbSet<CareerEvidence> CareerEvidence => Set<CareerEvidence>();
public DbSet<SavedJobSearch> SavedJobSearches => Set<SavedJobSearch>();
public DbSet<SavedJobSearchResult> SavedJobSearchResults => Set<SavedJobSearchResult>();
public DbSet<InterviewPrepNote> InterviewPrepNotes => Set<InterviewPrepNote>();
public DbSet<AiWorkspaceNote> AiWorkspaceNotes => Set<AiWorkspaceNote>();
public DbSet<CvVariant> CvVariants => Set<CvVariant>();
@@ -426,6 +428,16 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<CareerEvidence>().Property(x => x.SourceType).HasMaxLength(32);
modelBuilder.Entity<CareerEvidence>().Property(x => x.SourceReference).HasMaxLength(500);
modelBuilder.Entity<CareerEvidence>().HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
modelBuilder.Entity<SavedJobSearch>().HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<SavedJobSearch>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<SavedJobSearch>().Property(x => x.Name).HasMaxLength(255);
modelBuilder.Entity<SavedJobSearch>().Property(x => x.Query).HasMaxLength(500);
modelBuilder.Entity<SavedJobSearch>().Property(x => x.Location).HasMaxLength(255);
modelBuilder.Entity<SavedJobSearch>().HasIndex(x => new { x.OwnerUserId, x.UpdatedAtUtc });
modelBuilder.Entity<SavedJobSearchResult>().HasQueryFilter(x => CurrentUserId != null && x.SavedJobSearch != null && x.SavedJobSearch.OwnerUserId == CurrentUserId);
modelBuilder.Entity<SavedJobSearchResult>().Property(x => x.ExternalJobId).HasMaxLength(128);
modelBuilder.Entity<SavedJobSearchResult>().HasIndex(x => new { x.SavedJobSearchId, x.ExternalJobId }).IsUnique();
modelBuilder.Entity<SavedJobSearchResult>().HasOne(x => x.SavedJobSearch).WithMany(x => x.Results).HasForeignKey(x => x.SavedJobSearchId).OnDelete(DeleteBehavior.Cascade);
// Interview prep persistence (career-workspace-implementation-roadmap.md Phase F5).
modelBuilder.Entity<InterviewPrepNote>()
@@ -0,0 +1,24 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations;
[DbContext(typeof(JobTrackerContext))]
[Migration("20260830137000_AdoptSavedJobSearchSchema")]
public sealed class AdoptSavedJobSearchSchema : Migration
{
protected override void Up(MigrationBuilder b) => b.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? MySql : Sqlite);
protected override void Down(MigrationBuilder b) => b.Sql(ActiveProvider.Contains("MySql", StringComparison.OrdinalIgnoreCase) ? "DROP TABLE IF EXISTS `SavedJobSearchResults`; DROP TABLE IF EXISTS `SavedJobSearches`;" : "DROP TABLE IF EXISTS \"SavedJobSearchResults\"; DROP TABLE IF EXISTS \"SavedJobSearches\";");
private const string Sqlite = """
CREATE TABLE IF NOT EXISTS "SavedJobSearches" ("Id" INTEGER NOT NULL CONSTRAINT "PK_SavedJobSearches" PRIMARY KEY AUTOINCREMENT, "OwnerUserId" TEXT NOT NULL, "Name" TEXT NOT NULL, "Query" TEXT NOT NULL, "Location" TEXT NOT NULL, "IsActive" INTEGER NOT NULL, "LastRunAtUtc" TEXT NULL, "CreatedAtUtc" TEXT NOT NULL, "UpdatedAtUtc" TEXT NOT NULL);
CREATE INDEX IF NOT EXISTS "IX_SavedJobSearches_Owner_Updated" ON "SavedJobSearches" ("OwnerUserId", "UpdatedAtUtc");
CREATE TABLE IF NOT EXISTS "SavedJobSearchResults" ("Id" INTEGER NOT NULL CONSTRAINT "PK_SavedJobSearchResults" PRIMARY KEY AUTOINCREMENT, "SavedJobSearchId" INTEGER NOT NULL, "ExternalJobId" TEXT NOT NULL, "FirstSeenAtUtc" TEXT NOT NULL, "LastSeenAtUtc" TEXT NOT NULL, "IsDismissed" INTEGER NOT NULL, CONSTRAINT "FK_SavedJobSearchResults_Search" FOREIGN KEY ("SavedJobSearchId") REFERENCES "SavedJobSearches" ("Id") ON DELETE CASCADE);
CREATE UNIQUE INDEX IF NOT EXISTS "IX_SavedJobSearchResults_Search_External" ON "SavedJobSearchResults" ("SavedJobSearchId", "ExternalJobId");
""";
private const string MySql = """
CREATE TABLE IF NOT EXISTS `SavedJobSearches` (`Id` int NOT NULL AUTO_INCREMENT, `OwnerUserId` varchar(255) NOT NULL, `Name` varchar(255) NOT NULL, `Query` varchar(500) NOT NULL, `Location` varchar(255) NOT NULL, `IsActive` tinyint(1) NOT NULL, `LastRunAtUtc` datetime(6) NULL, `CreatedAtUtc` datetime(6) NOT NULL, `UpdatedAtUtc` datetime(6) NOT NULL, PRIMARY KEY (`Id`)) CHARACTER SET=utf8mb4;
CREATE INDEX IF NOT EXISTS `IX_SavedJobSearches_Owner_Updated` ON `SavedJobSearches` (`OwnerUserId`, `UpdatedAtUtc`);
CREATE TABLE IF NOT EXISTS `SavedJobSearchResults` (`Id` int NOT NULL AUTO_INCREMENT, `SavedJobSearchId` int NOT NULL, `ExternalJobId` varchar(128) NOT NULL, `FirstSeenAtUtc` datetime(6) NOT NULL, `LastSeenAtUtc` datetime(6) NOT NULL, `IsDismissed` tinyint(1) NOT NULL, PRIMARY KEY (`Id`), CONSTRAINT `FK_SavedJobSearchResults_Search` FOREIGN KEY (`SavedJobSearchId`) REFERENCES `SavedJobSearches` (`Id`) ON DELETE CASCADE) CHARACTER SET=utf8mb4;
CREATE UNIQUE INDEX IF NOT EXISTS `IX_SavedJobSearchResults_Search_External` ON `SavedJobSearchResults` (`SavedJobSearchId`, `ExternalJobId`);
""";
}
@@ -1005,6 +1005,35 @@ namespace JobTrackerApi.Migrations
b.ToTable("CareerEvidence");
});
modelBuilder.Entity("JobTrackerApi.Models.SavedJobSearch", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<DateTimeOffset>("CreatedAtUtc").HasColumnType("TEXT");
b.Property<bool>("IsActive").HasColumnType("INTEGER");
b.Property<DateTimeOffset?>("LastRunAtUtc").HasColumnType("TEXT");
b.Property<string>("Location").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("Name").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<string>("Query").IsRequired().HasMaxLength(500).HasColumnType("TEXT");
b.Property<DateTimeOffset>("UpdatedAtUtc").HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OwnerUserId", "UpdatedAtUtc");
b.ToTable("SavedJobSearches");
});
modelBuilder.Entity("JobTrackerApi.Models.SavedJobSearchResult", b =>
{
b.Property<int>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<string>("ExternalJobId").IsRequired().HasMaxLength(128).HasColumnType("TEXT");
b.Property<DateTimeOffset>("FirstSeenAtUtc").HasColumnType("TEXT");
b.Property<bool>("IsDismissed").HasColumnType("INTEGER");
b.Property<DateTimeOffset>("LastSeenAtUtc").HasColumnType("TEXT");
b.Property<int>("SavedJobSearchId").HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("SavedJobSearchId", "ExternalJobId").IsUnique();
b.ToTable("SavedJobSearchResults");
});
modelBuilder.Entity("JobTrackerApi.Models.Correspondence", b =>
{
b.Property<int>("Id")
@@ -2721,6 +2750,17 @@ namespace JobTrackerApi.Migrations
b.Navigation("JobApplication");
});
modelBuilder.Entity("JobTrackerApi.Models.SavedJobSearchResult", b =>
{
b.HasOne("JobTrackerApi.Models.SavedJobSearch", "SavedJobSearch")
.WithMany("Results")
.HasForeignKey("SavedJobSearchId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("SavedJobSearch");
});
modelBuilder.Entity("JobTrackerApi.Models.SubmittedApplicationPackage", b =>
{
b.HasOne("JobTrackerApi.Models.JobApplication", "JobApplication")
@@ -2976,6 +3016,11 @@ namespace JobTrackerApi.Migrations
{
b.Navigation("Attachments");
});
modelBuilder.Entity("JobTrackerApi.Models.SavedJobSearch", b =>
{
b.Navigation("Results");
});
#pragma warning restore 612, 618
}
}
+26
View File
@@ -0,0 +1,26 @@
namespace JobTrackerApi.Models;
public sealed class SavedJobSearch
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public string Query { get; set; } = string.Empty;
public string Location { get; set; } = string.Empty;
public bool IsActive { get; set; } = true;
public DateTimeOffset? LastRunAtUtc { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
public List<SavedJobSearchResult> Results { get; set; } = new();
}
public sealed class SavedJobSearchResult
{
public int Id { get; set; }
public int SavedJobSearchId { get; set; }
public SavedJobSearch? SavedJobSearch { get; set; }
public string ExternalJobId { get; set; } = string.Empty;
public DateTimeOffset FirstSeenAtUtc { get; set; }
public DateTimeOffset LastSeenAtUtc { get; set; }
public bool IsDismissed { get; set; }
}
@@ -152,6 +152,13 @@ public sealed class AccountDataExportService(
Evidence = careerEvidence,
}, (careerProfileId is null ? 0 : 1) + careerVersions.Count + careerExperiences.Count + careerEducation.Count + careerSkills.Count + careerProjects.Count + careerCertifications.Count + careerLanguages.Count + careerEvidence.Count);
var savedSearches = await db.SavedJobSearches.IgnoreQueryFilters().AsNoTracking()
.Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var savedSearchIds = savedSearches.Select(item => item.Id).ToList();
var savedSearchResults = await db.SavedJobSearchResults.IgnoreQueryFilters().AsNoTracking()
.Where(item => savedSearchIds.Contains(item.SavedJobSearchId)).OrderBy(item => item.Id).ToListAsync(cancellationToken);
await AddJsonAsync("data/job-discovery.json", new { Searches = savedSearches, Results = savedSearchResults }, savedSearches.Count + savedSearchResults.Count);
var variants = await db.CvVariants.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var variantVersions = await db.CvVariantVersions.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.Id).ToListAsync(cancellationToken);
@@ -249,6 +249,9 @@ public sealed class AccountDeletionService(
deleted += await db.CareerCertifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerLanguages.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerEvidence.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
var savedSearchIds = await db.SavedJobSearches.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).Select(item => item.Id).ToListAsync(cancellationToken);
deleted += await db.SavedJobSearchResults.IgnoreQueryFilters().Where(item => savedSearchIds.Contains(item.SavedJobSearchId)).ExecuteDeleteAsync(cancellationToken);
deleted += await db.SavedJobSearches.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProfileVersions.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CareerProfiles.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.CvExtractionRuns.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
@@ -51,6 +51,8 @@ internal static class StartupSchemaOwnership
"Jobs",
"MicrosoftGraphConnections",
"RuleSettings",
"SavedJobSearches",
"SavedJobSearchResults",
"SubmittedApplicationPackages",
"SubmittedPackageAttachments",
"SystemEmailSettings",
+1
View File
@@ -109,6 +109,7 @@ Updated: 2026-08-31
- Submitted-package focused verification: storage/snapshot plus provider migration tests passed 6/6; the complete migration chain passed 18/18; application-assets UI passed 16/16; ESLint and the optimized Next build/TypeScript passed.
- Submitted-package complete regression: backend 740/740 and frontend 60 suites with 261/261 tests passed. The full run also exposed and fixed a date-sensitive reminder fixture that mixed a fixed worker clock with `DateTime.Now` seed data.
- Career-evidence focused verification: backend/controller/schema gates passed 21/21, the evidence UI regression passed 1/1, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed. Complete regression then passed backend 741/741 and frontend 61 suites with 262/262 tests.
- Saved job-search implementation: tenant-owned NAV search definitions now retain per-vacancy first-seen, last-seen and dismissal state; repeat runs identify only genuinely new results, while one-off discovery remains unchanged. Account export/deletion and provider-safe schema ownership include the new records. Focused backend tests passed 3/3, focused frontend tests passed 6/6, schema/migration gates passed 20/20, ESLint passed with zero warnings, and the optimized Next build/TypeScript passed.
- Focused frontend: 2 suites, 6 tests passed.
- Full frontend: 64 suites, 272 tests passed.
- Next production build and TypeScript: passed.
+34
View File
@@ -274,6 +274,23 @@ export const translations = {
jobDiscoveryWorkArrangementMissing: "Work arrangement not provided by this source",
jobDiscoveryViewListing: "View listing",
jobDiscoverySaveTracker: "Save to tracker",
jobDiscoverySavedSearches: "Saved searches",
jobDiscoverySavedSearchesHelp: "Run a saved NAV search to see which vacancies are genuinely new since your last check.",
jobDiscoverySavedName: "Search name",
jobDiscoverySavedDefaultName: "Recent vacancies",
jobDiscoverySaveSearch: "Save search",
jobDiscoveryNoSavedSearches: "No saved searches yet.",
jobDiscoverySaveSearchFailed: "The search could not be saved.",
jobDiscoveryDeleteSavedTitle: "Delete saved search?",
jobDiscoveryDeleteSavedConfirm: "Delete “{name}” and its seen/dismissed history?",
jobDiscoveryDeleteSavedFailed: "The saved search could not be deleted.",
jobDiscoveryDismissFailed: "The vacancy could not be dismissed.",
jobDiscoveryAllRecent: "All recent vacancies",
jobDiscoveryLastRun: "Last checked {date}",
jobDiscoveryTrackedCount: "{count} tracked",
jobDiscoveryRunSaved: "Check for new jobs",
jobDiscoveryNewResult: "New",
jobDiscoveryDismiss: "Dismiss",
jobDetails: "Job details",
jobDetailsWorkspaceSubtitle: "Manage this application, its documents, timeline, and correspondence.",
operations: "Operations",
@@ -2632,6 +2649,23 @@ export const translations = {
jobDiscoveryWorkArrangementMissing: "Arbeidsform er ikke oppgitt av kilden",
jobDiscoveryViewListing: "Vis stilling",
jobDiscoverySaveTracker: "Lagre i oversikten",
jobDiscoverySavedSearches: "Lagrede søk",
jobDiscoverySavedSearchesHelp: "Kjør et lagret NAV-søk for å se hvilke stillinger som faktisk er nye siden sist.",
jobDiscoverySavedName: "Navn på søket",
jobDiscoverySavedDefaultName: "Nylige stillinger",
jobDiscoverySaveSearch: "Lagre søk",
jobDiscoveryNoSavedSearches: "Ingen lagrede søk ennå.",
jobDiscoverySaveSearchFailed: "Søket kunne ikke lagres.",
jobDiscoveryDeleteSavedTitle: "Slette lagret søk?",
jobDiscoveryDeleteSavedConfirm: "Slette «{name}» og historikken over viste og avviste stillinger?",
jobDiscoveryDeleteSavedFailed: "Det lagrede søket kunne ikke slettes.",
jobDiscoveryDismissFailed: "Stillingen kunne ikke avvises.",
jobDiscoveryAllRecent: "Alle nylige stillinger",
jobDiscoveryLastRun: "Sist sjekket {date}",
jobDiscoveryTrackedCount: "{count} fulgt",
jobDiscoveryRunSaved: "Se etter nye stillinger",
jobDiscoveryNewResult: "Ny",
jobDiscoveryDismiss: "Avvis",
jobDetails: "Jobbdetaljer",
jobDetailsWorkspaceSubtitle: "Administrer søknaden, dokumentene, tidslinjen og korrespondansen.",
operations: "Operasjoner",
+34 -21
View File
@@ -3,26 +3,30 @@ 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 { ConfirmProvider } from "./confirm";
import { PromptProvider } from "./prompt";
import { I18nProvider } from "./i18n/I18nProvider";
import JobDiscoveryPage from "./views/JobDiscoveryPage";
jest.mock("./api", () => ({ api: { get: jest.fn() } }));
jest.mock("./api", () => ({ api: { get: jest.fn(), post: jest.fn(), patch: jest.fn(), delete: jest.fn() } }));
const mockedApi = api as jest.Mocked<typeof api>;
function renderPage() {
return render(<I18nProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></I18nProvider>);
return render(<I18nProvider><ConfirmProvider><PromptProvider><MemoryRouter><JobDiscoveryPage /></MemoryRouter></PromptProvider></ConfirmProvider></I18nProvider>);
}
beforeEach(() => window.localStorage.clear());
function searchResponse(data: any[]) {
mockedApi.get.mockImplementation((url) => Promise.resolve({ data: String(url).endsWith("saved-searches") ? [] : data } as any));
}
beforeEach(() => { window.localStorage.clear(); searchResponse([]); });
afterEach(() => jest.clearAllMocks());
test("shows honest source metadata and offers the existing reviewed save flow", async () => {
mockedApi.get.mockResolvedValue({ data: [{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }] } as any);
searchResponse([{ id: "abc", title: "Backend Developer", company: "Acme", location: "OSLO", modifiedAt: "2026-08-01T10:00:00Z", deadline: "2026-08-15T23:59:59Z", url: "https://arbeidsplassen.nav.no/stillinger/stilling/abc", source: "nav", sourceName: "NAV Arbeidsplassen", acquisitionType: "searched", retrievedAt: "2026-08-10T10:00:00Z", countryCode: "NO" }]);
renderPage();
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();
expect(screen.getByText("NAV Arbeidsplassen")).toBeInTheDocument();
expect(screen.getByText(/Searched listing · Retrieved/)).toBeInTheDocument();
@@ -32,51 +36,60 @@ test("shows honest source metadata and offers the existing reviewed save flow",
});
test("distinguishes the initial guidance from a completed empty search", async () => {
mockedApi.get.mockResolvedValue({ data: [] } as any);
renderPage();
expect(screen.getByText("Search recent active vacancies by role, company, or municipality.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
expect(await screen.findByText(/No active vacancies matched this search/)).toBeInTheDocument();
expect(screen.queryByText("Search recent active vacancies by role, company, or municipality.")).not.toBeInTheDocument();
});
test("retries the last submitted search after an error", async () => {
mockedApi.get.mockRejectedValueOnce(new Error("offline")).mockResolvedValueOnce({ data: [] } as any);
let searchAttempts = 0;
mockedApi.get.mockImplementation((url) => {
if (String(url).endsWith("saved-searches")) return Promise.resolve({ data: [] } as any);
searchAttempts += 1;
return searchAttempts === 1 ? Promise.reject(new Error("offline")) : Promise.resolve({ data: [] } as any);
});
renderPage();
fireEvent.change(screen.getByLabelText("Role or company"), { target: { value: " utvikler " } });
fireEvent.change(screen.getByLabelText("Municipality"), { target: { value: " Oslo " } });
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
fireEvent.click(await screen.findByRole("button", { name: "Retry" }));
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledTimes(2));
await waitFor(() => expect(searchAttempts).toBe(2));
expect(mockedApi.get).toHaveBeenLastCalledWith("/job-discovery/search", { params: { q: "utvikler", location: "Oslo" } });
});
test("sorts returned vacancies by title", async () => {
mockedApi.get.mockResolvedValue({ data: [
searchResponse([
{ id: "2", title: "Zoologist", url: "https://arbeidsplassen.nav.no/2", source: "nav", countryCode: "NO" },
{ id: "1", title: "Analyst", url: "https://arbeidsplassen.nav.no/1", source: "nav", countryCode: "NO" },
] } as any);
]);
renderPage();
fireEvent.click(screen.getByRole("button", { name: "Search NAV" }));
await screen.findByText("2 vacancies found");
fireEvent.mouseDown(screen.getByRole("combobox", { name: "Sort results" }));
fireEvent.click(await screen.findByRole("option", { name: "Title AZ" }));
const titles = screen.getAllByRole("heading", { level: 6 }).map((element) => element.textContent);
expect(titles).toEqual(["Analyst", "Zoologist"]);
expect(screen.getAllByRole("heading", { level: 6 }).map((element) => element.textContent)).toEqual(["Analyst", "Zoologist"]);
});
test("renders the discovery workflow in Norwegian Bokmål", async () => {
window.localStorage.setItem("uiLanguage", "nb");
mockedApi.get.mockResolvedValue({ data: [] } as any);
renderPage();
expect(screen.getByLabelText("Rolle eller bedrift")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Søk i NAV" }));
expect(await screen.findByText(/Ingen aktive stillinger samsvarte/)).toBeInTheDocument();
});
test("runs a saved search, marks unseen vacancies, and dismisses them", async () => {
const saved = { id: 7, name: "Backend Oslo", query: "backend", location: "Oslo", resultCount: 0, dismissedCount: 0 };
mockedApi.get.mockResolvedValue({ data: [saved] } as any);
mockedApi.post.mockResolvedValue({ data: { search: { ...saved, resultCount: 1 }, jobs: [{ job: { id: "new-1", title: "Platform Engineer", url: "https://example.test/new-1", source: "nav", countryCode: "NO" }, isNew: true, isDismissed: false }] } } as any);
mockedApi.patch.mockResolvedValue({ data: null } as any);
renderPage();
fireEvent.click(await screen.findByRole("button", { name: "Check for new jobs" }));
expect(await screen.findByText("Platform Engineer")).toBeInTheDocument();
expect(screen.getByText("New")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
await waitFor(() => expect(screen.queryByText("Platform Engineer")).not.toBeInTheDocument());
expect(mockedApi.patch).toHaveBeenCalledWith("/job-discovery/saved-searches/7/results/new-1/dismiss", { isDismissed: true });
});
+95 -2
View File
@@ -1,10 +1,14 @@
import { FormEvent, useMemo, useState } from "react";
import { FormEvent, useEffect, useMemo, useState } from "react";
import { Alert, Box, Button, Card, CardActions, CardContent, Chip, CircularProgress, MenuItem, Stack, TextField, Typography } from "@mui/material";
import SearchIcon from "@mui/icons-material/Search";
import AddIcon from "@mui/icons-material/Add";
import BookmarkAddOutlinedIcon from "@mui/icons-material/BookmarkAddOutlined";
import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline";
import RefreshIcon from "@mui/icons-material/Refresh";
import { useNavigate } from "react-router-dom";
import { api } from "../api";
import { useI18n } from "../i18n/I18nProvider";
import { useDialogActions } from "../dialogs";
type DiscoveredJob = {
id: string;
@@ -19,13 +23,18 @@ type DiscoveredJob = {
acquisitionType?: string;
retrievedAt?: string;
countryCode: string;
isNew?: boolean;
};
type SavedSearch = { id: number; name: string; query: string; location: string; lastRunAtUtc?: string; resultCount: number; dismissedCount: number };
type SavedSearchRun = { search: SavedSearch; jobs: Array<{ job: DiscoveredJob; isNew: boolean; isDismissed: boolean }> };
type SortOrder = "updated" | "deadline" | "title";
export default function JobDiscoveryPage() {
const navigate = useNavigate();
const { language, t } = useI18n();
const { confirmAction } = useDialogActions();
const [query, setQuery] = useState("");
const [location, setLocation] = useState("");
const [jobs, setJobs] = useState<DiscoveredJob[]>([]);
@@ -34,6 +43,18 @@ export default function JobDiscoveryPage() {
const [hasSearched, setHasSearched] = useState(false);
const [lastSearch, setLastSearch] = useState({ query: "", location: "" });
const [sortOrder, setSortOrder] = useState<SortOrder>("updated");
const [savedSearches, setSavedSearches] = useState<SavedSearch[]>([]);
const [savedName, setSavedName] = useState("");
const [savingSearch, setSavingSearch] = useState(false);
const [activeSavedSearchId, setActiveSavedSearchId] = useState<number | null>(null);
useEffect(() => {
let active = true;
api.get<SavedSearch[]>("/job-discovery/saved-searches")
.then((response) => { if (active) setSavedSearches(Array.isArray(response.data) ? response.data : []); })
.catch(() => { /* One-off discovery remains usable if saved-search storage is unavailable. */ });
return () => { active = false; };
}, []);
const runSearch = async (nextQuery: string, nextLocation: string) => {
setLoading(true); setError(""); setJobs([]); setHasSearched(true); setLastSearch({ query: nextQuery, location: nextLocation });
@@ -49,6 +70,47 @@ export default function JobDiscoveryPage() {
void runSearch(query.trim(), location.trim());
};
const saveSearch = async () => {
const name = savedName.trim() || query.trim() || location.trim() || t("jobDiscoverySavedDefaultName");
setSavingSearch(true); setError("");
try {
const response = await api.post<SavedSearch>("/job-discovery/saved-searches", { name, query: query.trim(), location: location.trim() });
setSavedSearches((current) => [response.data, ...current]);
setSavedName("");
} catch { setError(t("jobDiscoverySaveSearchFailed")); }
finally { setSavingSearch(false); }
};
const runSavedSearch = async (saved: SavedSearch) => {
setLoading(true); setError(""); setHasSearched(true); setActiveSavedSearchId(saved.id);
setQuery(saved.query); setLocation(saved.location); setLastSearch({ query: saved.query, location: saved.location });
try {
const response = await api.post<SavedSearchRun>(`/job-discovery/saved-searches/${saved.id}/run`);
const visible = response.data.jobs.filter((item) => !item.isDismissed).map((item) => ({ ...item.job, isNew: item.isNew }));
setJobs(visible);
setSavedSearches((current) => current.map((item) => item.id === saved.id ? response.data.search : item));
} catch { setError(t("jobDiscoveryUnavailable")); }
finally { setLoading(false); }
};
const deleteSavedSearch = async (saved: SavedSearch) => {
if (!(await confirmAction(t("jobDiscoveryDeleteSavedConfirm", { name: saved.name }), { title: t("jobDiscoveryDeleteSavedTitle"), confirmLabel: t("adminUsersDelete"), destructive: true }))) return;
try {
await api.delete(`/job-discovery/saved-searches/${saved.id}`);
setSavedSearches((current) => current.filter((item) => item.id !== saved.id));
if (activeSavedSearchId === saved.id) setActiveSavedSearchId(null);
} catch { setError(t("jobDiscoveryDeleteSavedFailed")); }
};
const dismissJob = async (jobId: string) => {
if (activeSavedSearchId === null) return;
try {
await api.patch(`/job-discovery/saved-searches/${activeSavedSearchId}/results/${encodeURIComponent(jobId)}/dismiss`, { isDismissed: true });
setJobs((current) => current.filter((item) => item.id !== jobId));
setSavedSearches((current) => current.map((item) => item.id === activeSavedSearchId ? { ...item, dismissedCount: item.dismissedCount + 1 } : item));
} catch { setError(t("jobDiscoveryDismissFailed")); }
};
const sortedJobs = useMemo(() => [...jobs].sort((left, right) => {
if (sortOrder === "title") return left.title.localeCompare(right.title);
if (sortOrder === "deadline") {
@@ -67,6 +129,33 @@ export default function JobDiscoveryPage() {
<TextField label={t("jobDiscoveryMunicipality")} value={location} onChange={(event) => setLocation(event.target.value)} />
<Button type="submit" variant="contained" startIcon={loading ? <CircularProgress size={18} color="inherit" /> : <SearchIcon />} disabled={loading}>{t("jobDiscoverySearchNav")}</Button>
</Box>
<Card variant="outlined">
<CardContent sx={{ display: "grid", gap: 1.5 }}>
<Box>
<Typography component="h2" variant="h6" sx={{ fontWeight: 800 }}>{t("jobDiscoverySavedSearches")}</Typography>
<Typography variant="body2" color="text.secondary">{t("jobDiscoverySavedSearchesHelp")}</Typography>
</Box>
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", sm: "minmax(0, 1fr) auto" }, gap: 1 }}>
<TextField size="small" label={t("jobDiscoverySavedName")} value={savedName} onChange={(event) => setSavedName(event.target.value)} />
<Button variant="outlined" startIcon={<BookmarkAddOutlinedIcon />} onClick={() => void saveSearch()} disabled={savingSearch}>{t("jobDiscoverySaveSearch")}</Button>
</Box>
{savedSearches.length === 0 ? <Typography variant="body2" color="text.secondary">{t("jobDiscoveryNoSavedSearches")}</Typography> : (
<Stack spacing={1}>
{savedSearches.map((saved) => (
<Box key={saved.id} sx={{ display: "flex", flexWrap: "wrap", alignItems: "center", gap: 1, p: 1.25, border: 1, borderColor: activeSavedSearchId === saved.id ? "primary.main" : "divider", borderRadius: 2 }}>
<Box sx={{ flex: "1 1 220px", minWidth: 0 }}>
<Typography sx={{ fontWeight: 700 }}>{saved.name}</Typography>
<Typography variant="caption" color="text.secondary">{[saved.query, saved.location].filter(Boolean).join(" · ") || t("jobDiscoveryAllRecent")}{saved.lastRunAtUtc ? ` · ${t("jobDiscoveryLastRun", { date: formatDate(saved.lastRunAtUtc) ?? "" })}` : ""}</Typography>
</Box>
<Chip size="small" label={t("jobDiscoveryTrackedCount", { count: saved.resultCount })} />
<Button size="small" startIcon={<RefreshIcon />} onClick={() => void runSavedSearch(saved)} disabled={loading}>{t("jobDiscoveryRunSaved")}</Button>
<Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => void deleteSavedSearch(saved)}>{t("adminUsersDelete")}</Button>
</Box>
))}
</Stack>
)}
</CardContent>
</Card>
<Alert severity="info" variant="outlined" sx={{ color: "text.primary", "& .MuiAlert-icon": { color: "info.main" } }}>{t("jobDiscoverySourceHelp")}</Alert>
{error ? <Alert severity="error" variant="outlined" sx={{ color: "text.primary" }} action={<Button color="inherit" onClick={() => void runSearch(lastSearch.query, lastSearch.location)} disabled={loading}>{t("retry")}</Button>}>{error}</Alert> : null}
{!loading && !hasSearched ? <Typography color="text.secondary">{t("jobDiscoveryGuidance")}</Typography> : null}
@@ -86,7 +175,10 @@ export default function JobDiscoveryPage() {
{sortedJobs.map((job) => (
<Card key={job.id} variant="outlined" sx={{ display: "flex", flexDirection: "column" }}>
<CardContent>
<Chip label={job.sourceName || job.source.toUpperCase()} size="small" color="primary" variant="outlined" sx={{ mb: 1 }} />
<Stack direction="row" spacing={1} sx={{ mb: 1 }}>
<Chip label={job.sourceName || job.source.toUpperCase()} size="small" color="primary" variant="outlined" />
{job.isNew ? <Chip label={t("jobDiscoveryNewResult")} size="small" color="success" /> : null}
</Stack>
<Typography variant="h6" sx={{ fontWeight: 800 }}>{job.title}</Typography>
<Typography color="text.secondary">{[job.company, job.location || t("jobDiscoveryLocationMissing")].filter(Boolean).join(" · ")}</Typography>
<Stack spacing={0.25} sx={{ mt: 1 }}>
@@ -102,6 +194,7 @@ export default function JobDiscoveryPage() {
<CardActions sx={{ mt: "auto" }}>
<Button href={job.url} target="_blank" rel="noreferrer">{t("jobDiscoveryViewListing")}</Button>
<Button startIcon={<AddIcon />} onClick={() => navigate("/jobs?add=" + encodeURIComponent(job.url))}>{t("jobDiscoverySaveTracker")}</Button>
{activeSavedSearchId !== null ? <Button color="inherit" onClick={() => void dismissJob(job.id)}>{t("jobDiscoveryDismiss")}</Button> : null}
</CardActions>
</Card>
))}