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));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user