Compare commits

..

2 Commits

Author SHA1 Message Date
cesnimda 0ec9f98e6e docs(global-jobs): record blocked planning publication 2026-10-28 23:51:45 +01:00
cesnimda 2c38d40c92 docs(global-jobs): complete GJ-00 research and architecture plan 2026-10-28 23:51:15 +01:00
165 changed files with 1476 additions and 8103 deletions
@@ -4,7 +4,6 @@ using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
@@ -15,42 +14,6 @@ namespace JobTrackerApi.MariaDb.Tests;
public sealed partial class CoverLetterWriterTests
{
[Fact]
public async Task Premium_letter_entitlement_uses_actual_roles_and_preserves_downgraded_saved_design()
{
await using var connection = await MariaDbDatabase.CreateAsync();
await using var db = Context(connection);
db.Users.Add(new() { Id = Owner, UserName = "synthetic-letter-owner" });
var job = await Seed(db, "Original letter");
var design = new CoverLetterDocumentSettings { DesignVersion = 2, ThemeId = "technical" };
await Assert.ThrowsAsync<CoverLetterThemeAccessException>(() => Assets(db).SaveCoverLetterAsync(
Owner, job.Id, "Rejected", CoverLetterSources.Manual, null, 1, default, design));
Assert.Empty(await db.CoverLetterVersions.ToListAsync());
Assert.Equal("Original letter", (await db.JobApplications.AsNoTracking().SingleAsync()).CoverLetterText);
var role = new IdentityRole("Premium") { NormalizedName = "PREMIUM" };
db.Roles.Add(role);
db.UserRoles.Add(new IdentityUserRole<string> { UserId = Owner, RoleId = role.Id });
await db.SaveChangesAsync();
var saved = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, "Approved premium letter", CoverLetterSources.Manual, null, 1, default, design);
Assert.Equal(2, saved!.Revision);
Assert.Equal("technical", saved.DocumentSettings!.ThemeId);
await db.UserRoles.Where(row => row.UserId == Owner).ExecuteDeleteAsync();
var retained = await Assets(db).SaveCoverLetterAsync(Owner, job.Id, "Approved after downgrade", CoverLetterSources.Manual, null, saved.Revision, default, design);
Assert.Equal(3, retained!.Revision);
var historyCount = await db.CoverLetterVersions.CountAsync();
await Assert.ThrowsAsync<CoverLetterThemeAccessException>(() => Assets(db).SaveCoverLetterAsync(Owner, job.Id,
"Rejected switch", CoverLetterSources.Manual, null, retained.Revision, default, new() { DesignVersion = 2, ThemeId = "executive" }));
await using var verify = Context(connection);
var actual = await verify.JobApplications.AsNoTracking().SingleAsync();
Assert.Equal("Approved after downgrade", actual.CoverLetterText);
Assert.Equal(3, actual.Version);
Assert.Equal("technical", CoverLetterDocumentSettingsJson.Deserialize(actual.CoverLetterDocumentSettingsJson).ThemeId);
Assert.Equal(historyCount, await verify.CoverLetterVersions.CountAsync());
Assert.Contains("Approved after downgrade", (await Assets(verify).RenderSavedCoverLetterAsync(Owner, job.Id, 3, default))!.Html);
}
[Fact]
public async Task Document_schema_upgrade_preserves_legacy_letter_history_and_frozen_rows_on_mariadb()
{
@@ -1,146 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class CvBlankPersistenceTests
{
private const string Owner = "rd21-owner";
private static CvVariantService Service(JobTrackerContext db) => new(db, new CareerProfileService(db), new ThemedCvRenderer());
private static CvVariantSettings Blank() => new() { SectionsExplicit = true, IncludeProfileContact = false };
private static async Task SeedProfile(JobTrackerContext db)
{
db.Users.Add(new() { Id = Owner, UserName = "synthetic-cv-blank" });
await db.SaveChangesAsync();
await new CareerProfileService(db).SaveVersionAsync(Owner, new()
{
Contact = new() { FullName = "Canonical Person", Headline = "Canonical headline", Email = "synthetic@example.test" },
Summary = { "Canonical summary" },
Jobs = { new() { Id = "canonical-role", Title = "Canonical role", Company = "Synthetic company", Bullets = { "Canonical evidence" } } },
Skills = { "Canonical skill" },
}, "manual", 0, default);
}
[Fact]
public async Task Blank_json_readback_duplicate_design_and_restore_preserve_canonical_profile_on_mariadb()
{
await using var database = await MariaDbDatabase.CreateAsync();
int id;
string initialJson;
string canonicalJson;
string? identityJson;
await using (var seed = database.Context())
{
await SeedProfile(seed);
canonicalJson = (await seed.CareerProfiles.SingleAsync()).ProfileJson;
identityJson = (await seed.Users.SingleAsync()).ProfileCvStructureJson;
var created = await Service(seed).CreateAsync(Owner, "Blank", null, Blank(), default);
id = created.Id;
initialJson = created.SettingsJson;
}
int copyId;
await using (var edit = database.Context())
{
var service = Service(edit);
var read = (await service.GetAsync(Owner, id, default))!;
Assert.Equal(initialJson, read.SettingsJson);
var settings = CvVariantSettingsJson.Deserialize(read.SettingsJson);
Assert.True(settings.SectionsExplicit);
Assert.False(settings.IncludeProfileContact);
Assert.Empty(settings.Sections);
var rendered = await service.RenderAsync(Owner, id, new("Fallback identity", "data:image/png;base64,AA=="), default, 1);
Assert.False(rendered!.HasContent);
Assert.DoesNotContain("Canonical", rendered.Html);
Assert.DoesNotContain("Fallback identity", rendered.Html);
var copy = (await service.DuplicateAsync(Owner, id, "Blank copy", default))!;
copyId = copy.Id;
Assert.Equal(initialJson, copy.SettingsJson);
settings.ThemeId = "minimal";
settings.AccentColor = "#123456";
await service.SaveAsync(Owner, id, null, settings, "manual", 1, default);
}
await using (var reopen = database.Context())
{
var service = Service(reopen);
var settings = CvVariantSettingsJson.Deserialize((await service.GetAsync(Owner, id, default))!.SettingsJson);
Assert.Equal("minimal", settings.ThemeId);
Assert.True(settings.SectionsExplicit);
Assert.False(settings.IncludeProfileContact);
Assert.False((await service.RenderAsync(Owner, id, new("Fallback", null), default, 2))!.HasContent);
settings.Headline = "Authored CV-only headline";
await service.SaveAsync(Owner, id, null, settings, "manual", 2, default);
var restored = (await service.RestoreVersionAsync(Owner, id, 1, 3, default))!;
Assert.Equal(4, restored.Version);
Assert.Equal(initialJson, restored.SettingsJson);
}
await using var verify = database.Context();
Assert.Equal(initialJson, (await verify.CvVariants.SingleAsync(cv => cv.Id == id)).SettingsJson);
Assert.Equal(initialJson, (await verify.CvVariants.SingleAsync(cv => cv.Id == copyId)).SettingsJson);
Assert.Equal(new[] { 1, 2, 3, 4 }, await verify.CvVariantVersions.Where(row => row.CvVariantId == id).OrderBy(row => row.Version).Select(row => row.Version).ToArrayAsync());
Assert.Single(await verify.CvVariantVersions.Where(row => row.CvVariantId == copyId).ToListAsync());
Assert.Equal(canonicalJson, (await verify.CareerProfiles.SingleAsync()).ProfileJson);
Assert.Equal(identityJson, (await verify.Users.SingleAsync()).ProfileCvStructureJson);
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
Assert.Equal("Canonical role", (await verify.CareerExperiences.SingleAsync()).Title);
var profile = await new CareerProfileService(verify).LoadStructuredAsync(Owner, default);
Assert.Equal("Canonical summary", Assert.Single(profile.Summary));
Assert.Equal("Canonical skill", Assert.Single(profile.Skills));
Assert.Equal("Canonical evidence", Assert.Single(Assert.Single(profile.Jobs).Bullets));
}
[Fact]
public async Task Competing_blank_edits_and_foreign_access_cannot_overwrite_winner_or_append_history_on_mariadb()
{
await using var database = await MariaDbDatabase.CreateAsync();
int id;
await using (var seed = database.Context())
{
await SeedProfile(seed);
id = (await Service(seed).CreateAsync(Owner, "Blank", null, Blank(), default)).Id;
}
await using var winner = database.Context();
await using var staleSave = database.Context();
await using var staleRestore = database.Context();
Assert.Equal(1, (await Service(staleSave).GetAsync(Owner, id, default))!.Version);
Assert.Equal(1, (await Service(staleRestore).GetAsync(Owner, id, default))!.Version);
var settings = Blank();
settings.ThemeId = "minimal";
settings.Headline = "Acknowledged CV-only winner";
await Service(winner).SaveAsync(Owner, id, "Winner", settings, "manual", 1, default);
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Service(staleSave).SaveAsync(Owner, id, "Stale name", Blank(), "autosave", 1, default));
await Assert.ThrowsAsync<DbUpdateConcurrencyException>(() => Service(staleRestore).RestoreVersionAsync(Owner, id, 1, 1, default));
await using (var foreign = database.Context("foreign"))
{
var service = Service(foreign);
Assert.Null(await service.GetAsync("foreign", id, default));
Assert.Null(await service.SaveAsync("foreign", id, "Foreign", Blank(), "manual", 2, default));
Assert.Null(await service.DuplicateAsync("foreign", id, null, default));
Assert.Null(await service.RestoreVersionAsync("foreign", id, 1, 2, default));
Assert.Null(await service.RenderAsync("foreign", id, new("Foreign", null), default, 2));
Assert.Empty(await service.ListVersionsAsync("foreign", id, default));
}
await using var verify = database.Context();
var saved = await verify.CvVariants.SingleAsync();
Assert.Equal(2, saved.Version);
Assert.Equal("Winner", saved.Name);
Assert.Equal(CvVariantSettingsJson.Serialize(settings), saved.SettingsJson);
var history = await verify.CvVariantVersions.OrderBy(row => row.Version).ToListAsync();
Assert.Equal(new[] { 1, 2 }, history.Select(row => row.Version));
Assert.Equal(CvVariantSettingsJson.Serialize(Blank()), history[0].SettingsJson);
Assert.Equal(saved.SettingsJson, history[1].SettingsJson);
Assert.Equal(1, (await verify.CareerProfiles.SingleAsync()).Version);
Assert.Single(await verify.CareerProfileVersions.ToListAsync());
Assert.Equal("Canonical role", (await verify.CareerExperiences.SingleAsync()).Title);
}
}
@@ -20,63 +20,6 @@ public class CvImportLifecycleConcurrencyTests
private const string Owner = "rd21-owner";
private static readonly ProfileCvController.ApplyReviewedRequest Review = new(0, new[] { new CvImportDecision("skills.0", "use") });
[Fact]
public async Task Document_only_claim_is_owner_bound_and_translates_on_MariaDb_without_restricting_general_claims()
{
await using var database = await MariaDbDatabase.CreateAsync();
Guid documentId, legacyId, strategyId, mismatchedId, wrongTriggerId;
await using (var seed = database.Context())
{
seed.Users.AddRange(new ApplicationUser { Id = Owner, UserName = "claim-owner" },
new ApplicationUser { Id = "other-owner", UserName = "claim-other" });
var document = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "document_import", Status = "queued" };
var foreign = new CvExtractionRun { OwnerUserId = "other-owner", ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "document_import", Status = "queued" };
var legacy = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = "legacy-text", Trigger = "document_import", Status = "queued" };
var wrongTrigger = new CvExtractionRun { OwnerUserId = Owner, ParserVersion = ProfileCvController.DocumentParserVersion, Trigger = "text_extraction", Status = "queued" };
seed.CvExtractionRuns.AddRange(document, foreign, legacy, wrongTrigger);
await seed.SaveChangesAsync();
UserOperation Queued(CvExtractionRun run, int priority)
{
var operation = Operation(run.Id, OperationStatuses.Queued);
operation.IdempotencyKey = operation.Id.ToString("N");
operation.LeaseToken = null;
operation.Priority = priority;
return operation;
}
var eligible = Queued(document, 100);
var legacyOperation = Queued(legacy, 1000);
var mismatched = Queued(foreign, 900);
var wrongTriggerOperation = Queued(wrongTrigger, 800);
var strategy = Queued(document, 1100);
strategy.TaskType = StrategySnapshotService.TaskType;
strategy.SubjectType = "job_application";
seed.UserOperations.AddRange(eligible, legacyOperation, mismatched, wrongTriggerOperation, strategy);
await seed.SaveChangesAsync();
(documentId, legacyId, strategyId, mismatchedId, wrongTriggerId) =
(eligible.Id, legacyOperation.Id, strategy.Id, mismatched.Id, wrongTriggerOperation.Id);
}
await using var neutral = database.Context(null!);
var store = new UserOperationStore(neutral, TimeProvider.System);
var claimed = Assert.IsType<UserOperationLease>(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, documentImportsOnly: true));
Assert.Equal(documentId, claimed.OperationId);
Assert.Equal(Owner, claimed.OwnerUserId);
Assert.Null(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, documentImportsOnly: true));
var generalCv = Assert.IsType<UserOperationLease>(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, new[] { CvProcessingQueue.TaskType }));
Assert.Equal(legacyId, generalCv.OperationId);
var generalOther = Assert.IsType<UserOperationLease>(await store.ClaimNextAsync(TimeSpan.FromMinutes(1), default, new[] { StrategySnapshotService.TaskType }));
Assert.Equal(strategyId, generalOther.OperationId);
var excluded = await neutral.UserOperations.IgnoreQueryFilters().AsNoTracking()
.Where(operation => operation.Id == mismatchedId || operation.Id == wrongTriggerId).ToListAsync();
Assert.Equal(2, excluded.Count);
Assert.All(excluded, operation =>
{
Assert.Equal(OperationStatuses.Queued, operation.Status);
Assert.Equal(0, operation.AttemptCount);
Assert.Null(operation.LeaseToken);
});
}
[Fact]
public async Task Acknowledged_discard_prevents_an_apply_that_already_read_pending()
{
@@ -1,65 +0,0 @@
using System.Security.Claims;
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class DashboardGeometryPersistenceTests
{
private const string Geometry = "{\"version\":2,\"revision\":0,\"widgets\":[{\"id\":\"attention\",\"visible\":false,\"size\":\"standard\",\"x\":4,\"y\":17,\"w\":8,\"h\":10}]}";
private static AuthController Controller(JobTrackerContext db, string body, string owner = "rd21-owner")
{
var http = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, owner) }, "test")) };
http.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes(body));
return new(new ConfigurationBuilder().Build(), null!, null!, null!, null!, null!, null!, null!, db)
{ ControllerContext = new() { HttpContext = http } };
}
[Fact]
public async Task Geometry_is_durable_owner_scoped_and_stale_or_legacy_writes_preserve_other_preferences()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using (var seed = database.Context())
{
seed.Users.AddRange(new ApplicationUser { Id = "rd21-owner", UserName = "geometry", UiLanguage = "nb-NO" }, new ApplicationUser { Id = "other", UserName = "untouched" });
await seed.SaveChangesAsync();
}
await using (var write = database.Context())
Assert.IsType<OkObjectResult>(await Controller(write, Geometry).UpdateDashboardPreference(default));
await using var verify = database.Context();
var owner = await verify.Users.AsNoTracking().SingleAsync(u => u.Id == "rd21-owner");
var saved = DashboardLayout.Read(owner.DashboardLayoutJson, owner.DashboardLayoutRevision);
Assert.Equal(1, saved.Revision);
Assert.Equal(new DashboardWidget("attention", false, "standard", 4, 17, 8, 10), Assert.Single(saved.Widgets));
Assert.Equal("nb-NO", owner.UiLanguage);
Assert.Null((await verify.Users.AsNoTracking().SingleAsync(u => u.Id == "other")).DashboardLayoutJson);
Assert.IsType<ConflictObjectResult>(await Controller(verify, Geometry).UpdateDashboardPreference(default));
Assert.IsType<ConflictObjectResult>(await Controller(verify, "{\"version\":1,\"revision\":1,\"widgets\":[]}").UpdateDashboardPreference(default));
Assert.Equal(owner.DashboardLayoutJson, (await verify.Users.AsNoTracking().SingleAsync(u => u.Id == owner.Id)).DashboardLayoutJson);
}
[Fact]
public async Task Concurrent_geometry_saves_have_one_winner_and_one_conflict()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using (var seed = database.Context())
{
seed.Users.Add(new() { Id = "rd21-owner", UserName = "concurrent-geometry" });
await seed.SaveChangesAsync();
}
await using var first = database.Context();
await using var second = database.Context();
var results = await Task.WhenAll(Controller(first, Geometry).UpdateDashboardPreference(default), Controller(second, Geometry.Replace("\"h\":10", "\"h\":12")).UpdateDashboardPreference(default));
Assert.Single(results.OfType<OkObjectResult>());
Assert.Single(results.OfType<ConflictObjectResult>());
await using var readback = database.Context();
Assert.Equal(1, (await readback.Users.SingleAsync()).DashboardLayoutRevision);
}
}
@@ -79,11 +79,10 @@ public sealed class StartupAndLockTests(ITestOutputHelper output)
db.Add(job);
await db.SaveChangesAsync();
var migrations = (await db.Database.GetAppliedMigrationsAsync()).ToArray();
Assert.Equal("20261028090000_AddUserOperationEvents", migrations[^1]);
Assert.Equal("20261026090000_AddCvImportReviewEvidence", migrations[^1]);
// Only this test's newly-created schema is downgraded; no existing database is selected.
const string beforeRd21 = "20261021090000_AddDashboardLayoutPreference";
await db.GetService<IMigrator>().MigrateAsync(beforeRd21);
Assert.Equal(migrations.SkipWhile(migration => migration != beforeRd21).Skip(1), await db.Database.GetPendingMigrationsAsync());
await db.GetService<IMigrator>().MigrateAsync(migrations[^3]);
Assert.Equal(migrations[^2..], await db.Database.GetPendingMigrationsAsync());
db.ChangeTracker.Clear();
await database.App.InitializeJobTrackerAsync();
await using var verify = database.Context();
@@ -1,123 +0,0 @@
using JobTrackerApi.Models;
using JobTrackerApi.Controllers;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Xunit;
namespace JobTrackerApi.MariaDb.Tests;
public sealed class UserOperationHistoryPersistenceTests
{
[Fact]
public async Task Administrator_metadata_grouping_paging_associations_and_string_cursors_use_real_MariaDB_SQL()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
db.Users.Add(new() { Id = "rd21-owner", UserName = "synthetic-history", Email = "history@example.test" });
var artifact = new CvUploadArtifact { OwnerUserId = "rd21-owner", OriginalFileName = "synthetic.pdf", MimeType = "application/pdf", ByteSize = 400 };
db.CvUploadArtifacts.Add(artifact);
await db.SaveChangesAsync();
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", ArtifactId = artifact.Id, Trigger = "document_import", ParserVersion = "docling-v1", DocumentJson = "{\"PageCount\":2,\"Text\":\"SECRET_SOURCE\"}" };
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
var operation = (await new UserOperationStore(db, TimeProvider.System).CreateAsync(Request("admin-query") with { SubjectType = CvProcessingQueue.SubjectType, SubjectId = run.Id.ToString() }, default)).Operation;
var controller = new AdminProcessingController(db, new ConfigurationBuilder().Build(), TimeProvider.System);
var listing = Assert.IsType<AdminProcessingController.PageDto>(Assert.IsType<OkObjectResult>((await controller.List(owner: "rd21-owner", pageSize: 1)).Result).Value);
Assert.Equal(1, listing.Total);
Assert.Equal(1, listing.Counts[OperationStatuses.Queued]);
Assert.Equal("synthetic.pdf", Assert.Single(listing.Items).Document!.Name);
var detail = Assert.IsType<AdminProcessingController.ItemDto>(Assert.IsType<OkObjectResult>((await controller.Detail(operation.Id, default)).Result).Value);
Assert.Equal(2, detail.Document!.Pages);
Assert.DoesNotContain("SECRET", System.Text.Json.JsonSerializer.Serialize(detail));
var events = Assert.IsType<AdminProcessingController.EventPageDto>(Assert.IsType<OkObjectResult>((await controller.Events(operation.Id)).Result).Value);
var item = Assert.Single(events.Items);
Assert.True(long.TryParse(item.Id, out _));
Assert.Equal(DateTimeKind.Utc, item.AtUtc.Kind);
}
[Fact]
public async Task Admin_diagnostic_filters_translate_and_preserve_owner_joins_in_MariaDB()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var db = database.Context();
db.Users.Add(new() { Id = "rd21-owner", UserName = "diagnostic-owner", Email = "diagnostic@example.test" });
db.Users.Add(new() { Id = "foreign", UserName = "diagnostic-foreign", Email = "foreign@example.test" });
var company = new Company { OwnerUserId = "rd21-owner", Name = "Diagnostic company" };
var job = new JobApplication { OwnerUserId = "rd21-owner", Company = company, JobTitle = "Diagnostic engineer", Status = "Applied" };
var artifact = new CvUploadArtifact { OwnerUserId = "rd21-owner", OriginalFileName = "synthetic.pdf", MimeType = "application/pdf", ByteSize = 400 };
db.JobApplications.Add(job); db.CvUploadArtifacts.Add(artifact);
await db.SaveChangesAsync();
var run = new CvExtractionRun { OwnerUserId = "rd21-owner", ArtifactId = artifact.Id, Trigger = "document_import", ParserVersion = ProfileCvController.DocumentParserVersion };
db.CvExtractionRuns.Add(run); await db.SaveChangesAsync();
var store = new UserOperationStore(db, TimeProvider.System);
await store.CreateAsync(Request("diagnostic-document") with { SubjectType = CvProcessingQueue.SubjectType, SubjectId = run.Id.ToString() }, default);
var strategy = (await store.CreateAsync(Request("diagnostic-strategy") with { TaskType = StrategySnapshotService.TaskType, SubjectType = "job_strategy", SubjectId = StrategySnapshotService.EncodeSubject(job.Id, []) }, default)).Operation;
strategy.CreatedAtUtc = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc);
strategy.FailureCategory = "timeout";
var failedAt = DateTime.UtcNow.AddMinutes(-5);
db.UserOperationEvents.Add(new() { OperationId = strategy.Id, OwnerUserId = strategy.OwnerUserId, Kind = "RetryScheduled", Status = OperationStatuses.WaitingForRetry, AtUtc = failedAt, Stage = "cv_extracting", FailureCode = "timeout" });
db.UserOperations.Add(new() { Id = Guid.NewGuid(), OwnerUserId = "foreign", IdempotencyKey = "foreign-diagnostic", TaskType = StrategySnapshotService.TaskType, SubjectType = "job_strategy", SubjectId = StrategySnapshotService.EncodeSubject(job.Id, []), Status = OperationStatuses.Queued, CreatedAtUtc = DateTime.UtcNow });
await db.SaveChangesAsync();
var controller = new AdminProcessingController(db, new ConfigurationBuilder().Build(), TimeProvider.System);
var results = new[]
{
await controller.List(jobId: job.Id), await controller.List(q: "Diagnostic engineer"),
await controller.List(worker: "document"), await controller.List(failureCode: "timeout"),
await controller.List(createdBefore: new DateTimeOffset(2026, 10, 16, 0, 0, 0, TimeSpan.Zero)),
};
foreach (var result in results)
{
var page = Assert.IsType<AdminProcessingController.PageDto>(Assert.IsType<OkObjectResult>(result.Result).Value);
Assert.Equal(1, page.Total);
Assert.Equal("rd21-owner", Assert.Single(page.Items).Owner.Id);
}
var detail = Assert.IsType<AdminProcessingController.ItemDto>(Assert.IsType<OkObjectResult>((await controller.Detail(strategy.Id, default)).Result).Value);
Assert.Equal(failedAt, Assert.IsType<DateTime>(detail.LastFailureAtUtc), TimeSpan.FromMilliseconds(1));
Assert.Equal("cv_extracting", detail.LastFailureStage);
}
private static CreateUserOperation Request(string key) => new("cv.process", key, "authorized", "local_only");
[Fact]
public async Task Concurrent_claim_and_repeated_stage_have_one_durable_event_each()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using (var owner = database.Context())
await new UserOperationStore(owner, TimeProvider.System).CreateAsync(Request("history-concurrency"), default);
await using var first = database.Context(null!);
await using var second = database.Context(null!);
var claims = await Task.WhenAll(new UserOperationStore(first, TimeProvider.System).ClaimNextAsync(TimeSpan.FromSeconds(30), default),
new UserOperationStore(second, TimeProvider.System).ClaimNextAsync(TimeSpan.FromSeconds(30), default));
var lease = Assert.Single(claims.OfType<UserOperationLease>());
await using var ownerOne = database.Context();
await using var ownerTwo = database.Context();
var stages = await Task.WhenAll(new UserOperationStore(ownerOne, TimeProvider.System).ReportStageAsync(lease.OperationId, lease.LeaseToken, "cv_extracting", default),
new UserOperationStore(ownerTwo, TimeProvider.System).ReportStageAsync(lease.OperationId, lease.LeaseToken, "cv_extracting", default));
Assert.All(stages, count => Assert.Equal(1, count));
await using var readback = database.Context();
var events = await readback.UserOperationEvents.OrderBy(item => item.Id).ToListAsync();
Assert.Equal(new[] { "Created", "Claimed", "StageChanged" }, events.Select(item => item.Kind));
Assert.All(events, item => Assert.Equal("rd21-owner", item.OwnerUserId));
await using var other = database.Context("other");
Assert.Empty(await other.UserOperationEvents.ToListAsync());
}
[Fact]
public async Task Event_insert_failure_rolls_back_completion_and_notifications_in_MariaDB()
{
await using var database = await MariaDbDatabase.CreateAsync();
await using var owner = database.Context();
var store = new UserOperationStore(owner, TimeProvider.System);
var id = (await store.CreateAsync(Request("history-rollback"), default)).Operation.Id;
await using var worker = database.Context(null!);
var lease = Assert.IsType<UserOperationLease>(await new UserOperationStore(worker, TimeProvider.System).ClaimNextAsync(TimeSpan.FromSeconds(30), default));
await owner.Database.ExecuteSqlRawAsync("CREATE TRIGGER fail_history BEFORE INSERT ON UserOperationEvents FOR EACH ROW SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'synthetic event insert failure';");
await Assert.ThrowsAnyAsync<Exception>(() => store.CompleteAsync(id, lease.LeaseToken, null, default));
await using var readback = database.Context();
Assert.Equal(OperationStatuses.Running, (await readback.UserOperations.SingleAsync()).Status);
Assert.Equal(2, await readback.UserOperationEvents.CountAsync());
Assert.Empty(await readback.UserNotifications.ToListAsync());
}
}
@@ -392,30 +392,6 @@
"System.Drawing.Common": "6.0.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "4.153.1",
"SkiaSharp.NativeAssets.macOS": "4.153.1"
}
},
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
@@ -569,8 +545,6 @@
"Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )",
"QRCoder": "[1.8.0, )",
"SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )",
"SkiaSharp": "[4.153.1, )",
"SkiaSharp.NativeAssets.Linux.NoDependencies": "[4.153.1, )",
"Stripe.net": "[52.2.0, )",
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )",
"System.Security.Cryptography.Pkcs": "[8.0.2, )"
@@ -1,267 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Security.Claims;
using System.Text.Encodings.Web;
using System.Text.Json;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Options;
using System.Threading.RateLimiting;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AdminProcessingControllerTests
{
[Theory]
[InlineData("jobId=9999")]
[InlineData("createdFrom=2099-01-01T00:00:00Z")]
[InlineData("createdBefore=2000-01-01T00:00:00Z")]
[InlineData("worker=untracked")]
[InlineData("q=SECRET_CV_CONTENT")]
public async Task Diagnostic_filters_are_applied_before_pagination(string query)
{
await using var host = await Host.Start();
using var client = host.Client("admin");
var result = await client.GetFromJsonAsync<JsonElement>("/api/admin/processing?" + query);
Assert.Equal(0, result.GetProperty("total").GetInt32());
Assert.Empty(result.GetProperty("items").EnumerateArray());
}
[Theory]
[InlineData("jobId=-1")]
[InlineData("worker=SECRET")]
[InlineData("createdFrom=0001-01-01T00:00:00Z")]
[InlineData("failureCode=SECRET")]
[InlineData("createdFrom=2026-11-01T00:00:00Z&createdBefore=2026-10-01T00:00:00Z")]
public async Task Invalid_diagnostic_filters_fail_closed(string query)
{
await using var host = await Host.Start();
using var client = host.Client("admin");
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync("/api/admin/processing?" + query)).StatusCode);
}
[Theory]
[InlineData("jobId=2", 1)]
[InlineData("q=Owned%20job%20title", 1)]
[InlineData("q=owner%40example.test", 2)]
[InlineData("worker=document", 1)]
[InlineData("worker=ai", 2)]
[InlineData("failureCode=timeout", 1)]
[InlineData("failureCode=processing_failed", 1)]
[InlineData("createdBefore=2026-10-16T00:00:00Z", 1)]
public async Task Diagnostic_filters_preserve_actual_owner_relationships(string query, int expected)
{
await using var host = await Host.Start();
using var client = host.Client("admin");
var result = await client.GetFromJsonAsync<JsonElement>("/api/admin/processing?" + query + "&pageSize=1");
Assert.Equal(expected, result.GetProperty("total").GetInt32());
Assert.Single(result.GetProperty("items").EnumerateArray());
Assert.DoesNotContain("SECRET", result.GetRawText());
}
[Fact]
public async Task Detail_exposes_import_identity_and_links_only_the_administrators_own_application()
{
await using var host = await Host.Start();
using var admin = host.Client("admin");
var document = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
Assert.Equal(1, document.GetProperty("document").GetProperty("importId").GetInt32());
var job = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.JobOperationId}");
Assert.False(job.GetProperty("job").GetProperty("canOpen").GetBoolean());
using var ownerAdmin = host.Client("owner-admin");
job = await ownerAdmin.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.JobOperationId}");
Assert.True(job.GetProperty("job").GetProperty("canOpen").GetBoolean());
var search = await admin.GetFromJsonAsync<JsonElement>($"/api/admin/processing?q={host.OperationId}");
Assert.Equal(1, search.GetProperty("total").GetInt32());
}
[Fact]
public async Task Processing_reads_share_a_bounded_account_rate_limit()
{
await using var host = await Host.Start(2);
using var admin = host.Client("admin");
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync("/api/admin/processing")).StatusCode);
Assert.Equal(HttpStatusCode.OK, (await admin.GetAsync($"/api/admin/processing/{host.OperationId}")).StatusCode);
Assert.Equal(HttpStatusCode.TooManyRequests, (await admin.GetAsync($"/api/admin/processing/{host.OperationId}/events")).StatusCode);
using var other = host.Client("owner-admin");
Assert.Equal(HttpStatusCode.OK, (await other.GetAsync("/api/admin/processing")).StatusCode);
}
[Theory]
[InlineData("succeeded")]
[InlineData("waiting_for_retry")]
public async Task Failure_metadata_uses_the_recorded_failure_instead_of_later_success(string status)
{
await using var host = await Host.Start();
var failedAt = DateTime.UtcNow.AddMinutes(-2);
await using (var scope = host.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == host.OperationId);
operation.Status = status; operation.FailureCategory = "timeout";
operation.CompletedAtUtc = status == "succeeded" ? failedAt.AddMinutes(1) : null;
operation.ProgressStage = "cv_review_ready";
db.UserOperationEvents.Add(new() { Id = 9007199254740995, OperationId = operation.Id, OwnerUserId = "owner", Kind = "RetryScheduled", Status = "waiting_for_retry", AtUtc = failedAt, Stage = "cv_extracting", FailureCode = "timeout" });
await db.SaveChangesAsync();
}
using var client = host.Client("admin");
var result = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
Assert.Equal(status, result.GetProperty("status").GetString());
Assert.Equal(failedAt, result.GetProperty("lastFailureAtUtc").GetDateTime());
Assert.Equal("cv_extracting", result.GetProperty("lastFailureStage").GetString());
}
[Fact]
public async Task Missing_failure_history_does_not_turn_success_time_into_failure_evidence()
{
await using var host = await Host.Start();
await using (var scope = host.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var operation = await db.UserOperations.IgnoreQueryFilters().SingleAsync(item => item.Id == host.OperationId);
operation.Status = "succeeded"; operation.FailureCategory = "timeout"; operation.CompletedAtUtc = DateTime.UtcNow;
await db.SaveChangesAsync();
}
using var client = host.Client("admin");
var result = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
Assert.Equal(JsonValueKind.Null, result.GetProperty("lastFailureAtUtc").ValueKind);
Assert.Equal(JsonValueKind.Null, result.GetProperty("lastFailureStage").ValueKind);
}
[Fact]
public async Task User_authored_identity_labels_are_bounded_before_admin_projection()
{
await using var host = await Host.Start();
using var client = host.Client("admin");
var detail = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
var name = detail.GetProperty("owner").GetProperty("name").GetString()!;
Assert.True(name.Length <= 161);
Assert.EndsWith("…", name);
}
[Theory]
[InlineData(null, HttpStatusCode.Unauthorized)]
[InlineData("ordinary", HttpStatusCode.Forbidden)]
[InlineData("admin", HttpStatusCode.OK)]
public async Task Processing_routes_require_the_server_administrator_role(string? identity, HttpStatusCode expected)
{
await using var host = await Host.Start();
using var client = host.Client(identity);
foreach (var route in new[] { "/api/admin/processing", $"/api/admin/processing/{host.OperationId}", $"/api/admin/processing/{host.OperationId}/events" })
Assert.Equal(expected, (await client.GetAsync(route)).StatusCode);
}
[Fact]
public async Task Admin_projection_filters_owners_and_never_returns_raw_processing_contents_or_lease_data()
{
await using var host = await Host.Start();
using var client = host.Client("admin");
var listing = await client.GetFromJsonAsync<JsonElement>("/api/admin/processing?owner=owner&status=running&pageSize=1");
Assert.Equal(1, listing.GetProperty("total").GetInt32());
Assert.Single(listing.GetProperty("items").EnumerateArray());
var detail = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}");
Assert.Contains("owner@example.test", detail.GetRawText());
Assert.Contains("CV.pdf", detail.GetRawText());
Assert.Equal(2, detail.GetProperty("document").GetProperty("pages").GetInt32());
Assert.DoesNotContain("folder", detail.GetProperty("document").GetProperty("name").GetString());
Assert.DoesNotContain("SECRET", detail.GetRawText());
Assert.DoesNotContain("SECRET", listing.GetRawText());
var foreign = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.ForeignSubjectOperationId}");
Assert.Equal(JsonValueKind.Null, foreign.GetProperty("job").ValueKind);
Assert.DoesNotContain("Owned job title", foreign.GetRawText());
}
[Fact]
public async Task Event_cursor_is_exact_ordered_bounded_retained_and_redacted()
{
await using var host = await Host.Start();
using var client = host.Client("admin");
var first = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}/events?take=1");
Assert.True(first.GetProperty("hasMore").GetBoolean());
var item = Assert.Single(first.GetProperty("items").EnumerateArray());
Assert.Equal("9007199254740994", item.GetProperty("id").GetString());
Assert.DoesNotContain("SECRET", first.GetRawText());
var after = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}/events?after=9007199254740993");
Assert.Single(after.GetProperty("items").EnumerateArray());
var before = await client.GetFromJsonAsync<JsonElement>($"/api/admin/processing/{host.OperationId}/events?before=9007199254740994");
Assert.Equal("9007199254740993", Assert.Single(before.GetProperty("items").EnumerateArray()).GetProperty("id").GetString());
foreach (var query in new[] { "take=101", "after=-1", "after=1&before=2" })
Assert.Equal(HttpStatusCode.BadRequest, (await client.GetAsync($"/api/admin/processing/{host.OperationId}/events?{query}")).StatusCode);
}
private sealed class Host(WebApplication app, string directory, Guid operationId, Guid foreignSubjectId) : IAsyncDisposable
{
public IServiceProvider Services => app.Services;
public Guid OperationId => operationId;
public Guid ForeignSubjectOperationId => foreignSubjectId;
public Guid JobOperationId => Guid.Parse("22222222-2222-4222-8222-222222222222");
public HttpClient Client(string? identity)
{
var client = new HttpClient { BaseAddress = new Uri(app.Services.GetRequiredService<IServer>().Features.Get<IServerAddressesFeature>()!.Addresses.Single()) };
if (identity is not null) client.DefaultRequestHeaders.Add("X-Test-Identity", identity);
return client;
}
public static async Task<Host> Start(int permits = 120)
{
var directory = Path.Combine(Path.GetTempPath(), "admin-processing-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(directory);
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders(); builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddControllers().AddApplicationPart(typeof(AuthController).Assembly);
builder.Services.AddAuthentication("test").AddScheme<AuthenticationSchemeOptions, TestAuthentication>("test", _ => { });
builder.Services.AddAuthorization(); builder.Services.AddHttpContextAccessor();
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy(AdminProcessingController.RateLimitPolicy, context => RateLimitPartition.GetFixedWindowLimiter(
RateLimitPartitionKeys.Account(context, AdminProcessingController.RateLimitPolicy),
_ => new FixedWindowRateLimiterOptions { PermitLimit = permits, Window = TimeSpan.FromMinutes(1), QueueLimit = 0 }));
});
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddDbContext<JobTrackerContext>(options => options.UseSqlite($"Data Source={Path.Combine(directory, "processing.db")};Pooling=False"));
var app = builder.Build();
app.UseAuthentication(); app.UseAuthorization(); app.UseRateLimiter(); app.MapControllers();
var operationId = Guid.NewGuid(); var foreign = Guid.NewGuid();
await using (var scope = app.Services.CreateAsyncScope())
{
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
await db.Database.EnsureCreatedAsync();
db.Users.AddRange(new ApplicationUser { Id = "owner", Email = "owner@example.test", DisplayName = "Synthetic owner " + new string('n', 2000) }, new ApplicationUser { Id = "other", Email = "other@example.test" });
var company = new Company { Id = 1, OwnerUserId = "owner", Name = "Synthetic company" };
db.Companies.Add(company);
db.JobApplications.Add(new() { Id = 2, OwnerUserId = "owner", CompanyId = 1, JobTitle = "Owned job title", Status = "Applied" });
db.CvUploadArtifacts.Add(new() { Id = 1, OwnerUserId = "owner", OriginalFileName = "../folder/CV.pdf", MimeType = "application/pdf", ByteSize = 500, StoragePath = "SECRET_PATH" });
db.CvExtractionRuns.Add(new() { Id = 1, ArtifactId = 1, OwnerUserId = "owner", Trigger = "document_import", ParserVersion = "docling-v1", DocumentJson = "{\"PageCount\":2,\"Text\":\"SECRET_CV_CONTENT\"}" });
db.UserOperations.AddRange(new UserOperation { Id = operationId, OwnerUserId = "owner", IdempotencyKey = "one", TaskType = "cv.process", SubjectType = "cv_extraction_run", SubjectId = "1", Status = "running", CreatedAtUtc = DateTime.UtcNow, LeaseToken = "SECRET_TOKEN", FailureMessage = "SECRET_EXCEPTION", FailureCategory = "SECRET_CATEGORY", ResultReference = "SECRET_RESULT", ProgressStage = "SECRET_STAGE" },
new UserOperation { Id = foreign, OwnerUserId = "other", IdempotencyKey = "two", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "queued", CreatedAtUtc = DateTime.UtcNow },
new UserOperation { Id = Guid.Parse("22222222-2222-4222-8222-222222222222"), OwnerUserId = "owner", IdempotencyKey = "three", TaskType = "strategy.snapshot", SubjectType = "job_strategy", SubjectId = "2|", Status = "failed", FailureCategory = "timeout", CreatedAtUtc = new DateTime(2026, 10, 15, 12, 0, 0, DateTimeKind.Utc) });
db.UserOperationEvents.AddRange(
new UserOperationEvent { Id = 100, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow.AddDays(-31), Kind = "Created", Status = "queued" },
new UserOperationEvent { Id = 9007199254740993, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "Created", Status = "running" },
new UserOperationEvent { Id = 9007199254740994, OperationId = operationId, OwnerUserId = "owner", AtUtc = DateTime.UtcNow, Kind = "SECRET_EVENT", Status = "SECRET_STATUS", Stage = "SECRET_STAGE", FailureCode = "SECRET_FAILURE" });
await db.SaveChangesAsync();
}
await app.StartAsync(); return new(app, directory, operationId, foreign);
}
public async ValueTask DisposeAsync() { await app.DisposeAsync(); Directory.Delete(directory, true); }
}
private sealed class TestAuthentication(IOptionsMonitor<AuthenticationSchemeOptions> options, ILoggerFactory logger, UrlEncoder encoder) : AuthenticationHandler<AuthenticationSchemeOptions>(options, logger, encoder)
{
protected override Task<AuthenticateResult> HandleAuthenticateAsync()
{
if (!Request.Headers.TryGetValue("X-Test-Identity", out var identity)) return Task.FromResult(AuthenticateResult.NoResult());
var claims = new List<Claim> { new(ClaimTypes.NameIdentifier, identity == "owner-admin" ? "owner" : identity.ToString()) };
if (identity == "admin" || identity == "owner-admin") claims.Add(new(ClaimTypes.Role, "Admin"));
return Task.FromResult(AuthenticateResult.Success(new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity(claims, "test")), "test")));
}
}
}
@@ -106,37 +106,13 @@ public sealed class AiPrivacyPolicyTests
Assert.Equal("strategy.snapshot", capture.TaskType);
}
[Fact]
public async Task Correlation_comes_only_from_the_actual_operation_scope_for_all_sidecar_paths()
{
await using var fixture = await Fixture.CreateAsync(adminEnabled: false);
var execution = new AiOperationExecutionScope();
var capture = new CaptureHandler();
using var client = new HttpClient(new AiPrivacyHeaderHandler(new HttpContextAccessor(), fixture.Policy, execution) { InnerHandler = capture });
var id = Guid.NewGuid();
var lease = new UserOperationLease(id, "user-1", "lease", "cv.process", "local_only", "cv_extraction_run", "1", 1, null);
using (execution.Use(new(lease, "local_only")))
{
using var request = new HttpRequestMessage(HttpMethod.Post, "http://ai-service/cv-import/extract");
request.Headers.Add("X-Operation-Id", "SECRET_untrusted_header");
await client.SendAsync(request);
Assert.Equal(id.ToString("D"), capture.Correlation);
}
using var ordinary = new HttpRequestMessage(HttpMethod.Get, "http://ai-service/health");
ordinary.Headers.Add("X-Operation-Id", "SECRET_untrusted_header");
await client.SendAsync(ordinary);
Assert.Null(capture.Correlation);
}
private sealed class CaptureHandler : HttpMessageHandler
{
public string? ExternalAllowed { get; private set; }
public string? TaskType { get; private set; }
public string? Correlation { get; private set; }
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
Correlation = request.Headers.TryGetValues("X-Operation-Id", out var correlation) ? correlation.Single() : null;
ExternalAllowed = request.Headers.TryGetValues(AiPrivacyPolicy.ExternalAllowedHeader, out var values)
? values.Single()
: null;
@@ -106,7 +106,7 @@ public sealed class ApiAuthorizationPostureTests
[Fact]
public void Admin_controllers_require_the_admin_role_not_merely_a_signed_in_user()
{
foreach (var name in new[] { "AdminAuditController", "AdminSystemController", "AdminProcessingController", "UsersController" })
foreach (var name in new[] { "AdminAuditController", "AdminSystemController", "UsersController" })
{
var type = Controllers().Single(t => t.Name == name);
var attribute = type.GetCustomAttribute<AuthorizeAttribute>(inherit: true);
@@ -364,20 +364,6 @@ public sealed class ApplicationIntelligenceTests
Assert.Empty(match.RelevantExperience);
}
[Fact]
public async Task Match_does_not_inherit_experience_from_an_explicit_blank_cv()
{
var (db, intelligence, _) = New("user-1");
await using var dispose = db;
var job = await SeedJobAsync(db, "user-1");
await SeedProfileAsync(db, "user-1");
await AttachCvAsync(db, "user-1", job.Id, CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}"));
var match = await intelligence.MatchAsync("user-1", job.Id, default);
Assert.True(match!.HasSelectedCv);
Assert.Empty(match.RelevantExperience);
Assert.Empty(match.RelevantProjects);
}
[Fact]
public async Task Match_is_not_readable_for_another_users_application()
{
@@ -1,93 +0,0 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AvatarRetentionTests
{
[Fact]
public async Task Hourly_retention_removes_old_orphans_but_preserves_current_recent_and_unrelated_files()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-retention-" + Guid.NewGuid().ToString("N"));
try
{
var paths = Paths(root);
await using var db = TestHostFactory.CreateInMemoryDb();
var current = await AvatarStorage.StageAsync(root, "owner", new byte[] { 1 }, default);
var orphan = await AvatarStorage.StageAsync(root, "owner", new byte[] { 2 }, default);
var recent = await AvatarStorage.StageAsync(root, "owner", new byte[] { 3 }, default);
var missingOwner = await AvatarStorage.StageAsync(root, "deleted-owner", new byte[] { 4 }, default);
var legacyOrphan = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 5 }, "image/webp", default);
var legacyCurrent = await AvatarStorage.StoreAsync(root, "legacy-owner", new byte[] { 6 }, "image/jpeg", default);
var unrelated = Path.Combine(Path.GetDirectoryName(orphan[5..])!, "unrelated.png");
await File.WriteAllTextAsync(unrelated, "keep");
var now = DateTimeOffset.UtcNow;
foreach (var file in new[] { current, orphan, missingOwner, legacyOrphan, legacyCurrent }) File.SetLastWriteTimeUtc(file[5..], now.AddDays(-2).UtcDateTime);
db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", AvatarImageDataUrl = current });
db.Users.Add(new ApplicationUser { Id = "legacy-owner", UserName = "legacy-owner", AvatarImageDataUrl = legacyCurrent });
await db.SaveChangesAsync();
await CvImportRetentionHostedService.SweepAsync(db, paths, now, default);
Assert.True(File.Exists(current[5..]));
Assert.True(File.Exists(recent[5..]));
Assert.True(File.Exists(unrelated));
Assert.False(File.Exists(orphan[5..]));
Assert.False(File.Exists(missingOwner[5..]));
Assert.False(File.Exists(legacyOrphan[5..]));
Assert.True(File.Exists(legacyCurrent[5..]));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Account_inventory_includes_only_its_own_staged_portraits_even_without_a_current_avatar()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-inventory-" + Guid.NewGuid().ToString("N"));
try
{
var paths = Paths(root);
await using var db = TestHostFactory.CreateInMemoryDb();
var own = await AvatarStorage.StageAsync(root, "owner", new byte[] { 1 }, default);
var other = await AvatarStorage.StageAsync(root, "other", new byte[] { 2 }, default);
db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner" });
await db.SaveChangesAsync();
var inventory = await new AccountOwnedFileInventory(db, paths, Mock.Of<IAttachmentStorage>()).BuildAsync("owner", default);
var avatar = Assert.Single(inventory.Files, file => file.Category == "avatar");
Assert.Equal(own[5..], avatar.SourcePath);
Assert.DoesNotContain(inventory.Files, file => file.SourcePath == other[5..]);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
private static AppPaths Paths(string root) => new(
new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build(),
Mock.Of<IHostEnvironment>(environment => environment.ContentRootPath == root));
[Theory]
[InlineData("image/png")]
[InlineData("image/jpeg")]
[InlineData("image/webp")]
public async Task Account_inventory_covers_exact_legacy_leftovers_after_portrait_replacement(string contentType)
{
var root = Path.Combine(Path.GetTempPath(), "avatar-legacy-inventory-" + Guid.NewGuid().ToString("N"));
try
{
var paths = Paths(root);
await using var db = TestHostFactory.CreateInMemoryDb();
var legacy = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1 }, contentType, default);
var current = await AvatarStorage.StageAsync(root, "owner", new byte[] { 2 }, default);
db.Users.Add(new ApplicationUser { Id = "owner", UserName = "owner", AvatarImageDataUrl = current });
await db.SaveChangesAsync();
var inventory = await new AccountOwnedFileInventory(db, paths, Mock.Of<IAttachmentStorage>()).BuildAsync("owner", default);
Assert.Contains(inventory.Files, file => file.SourcePath == legacy[5..]);
Assert.Single(inventory.Files, file => file.SourcePath == current[5..]);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
}
-270
View File
@@ -1,270 +0,0 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using Moq;
using SkiaSharp;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AvatarUploadTests
{
[Theory]
[InlineData(SKEncodedImageFormat.Png)]
[InlineData(SKEncodedImageFormat.Jpeg)]
[InlineData(SKEncodedImageFormat.Webp)]
public void Actual_supported_images_are_decoded_and_normalized_to_metadata_free_png(SKEncodedImageFormat format)
{
var source = Image(format, 12, 8).Concat(System.Text.Encoding.UTF8.GetBytes("private-camera-metadata")).ToArray();
var normalized = AvatarImageProcessor.Normalize(source);
Assert.NotNull(normalized);
Assert.True(normalized.Length <= 1_000_000);
Assert.DoesNotContain("private-camera-metadata", System.Text.Encoding.UTF8.GetString(normalized));
using var codec = SKCodec.Create(new MemoryStream(normalized));
Assert.Equal(SKEncodedImageFormat.Png, codec.EncodedFormat);
Assert.Equal(12, codec.Info.Width);
Assert.Equal(8, codec.Info.Height);
using var decoded = SKBitmap.Decode(normalized);
Assert.NotNull(decoded);
}
[Theory]
[InlineData(2049, 1)]
[InlineData(1, 2049)]
[InlineData(2001, 2000)]
public void Oversized_dimensions_are_rejected(int width, int height) =>
Assert.Null(AvatarImageProcessor.Normalize(Image(SKEncodedImageFormat.Png, width, height)));
[Fact]
public void Truncated_pixels_and_unsupported_formats_are_rejected()
{
var png = Image(SKEncodedImageFormat.Png, 12, 8);
Assert.Null(AvatarImageProcessor.Normalize(png[..40]));
Assert.Null(AvatarImageProcessor.Normalize(Convert.FromBase64String("R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7")));
}
[Fact]
public void Animated_webp_is_rejected_instead_of_silently_selecting_a_frame()
{
using var first = new SKBitmap(12, 8);
using var second = new SKBitmap(12, 8);
first.Erase(SKColors.Red);
second.Erase(SKColors.Blue);
using var animated = SKWebpEncoder.EncodeAnimated(new SKWebpEncoderFrame[]
{
new(first, TimeSpan.FromMilliseconds(100)), new(second, TimeSpan.FromMilliseconds(100)),
}, SKWebpEncoderOptions.Default);
Assert.NotNull(animated);
Assert.Null(AvatarImageProcessor.Normalize(animated.ToArray()));
}
[Theory]
[InlineData(1)] [InlineData(2)] [InlineData(3)] [InlineData(4)]
[InlineData(5)] [InlineData(6)] [InlineData(7)] [InlineData(8)]
public void Exif_orientation_is_applied_before_metadata_is_removed(int orientation)
{
var jpeg = Image(SKEncodedImageFormat.Jpeg, 12, 8);
using var original = SKBitmap.Decode(jpeg);
var normalized = AvatarImageProcessor.Normalize(WithOrientation(jpeg, orientation));
Assert.NotNull(normalized);
using var result = SKBitmap.Decode(normalized);
Assert.Equal(orientation >= 5 ? original.Height : original.Width, result.Width);
Assert.Equal(orientation >= 5 ? original.Width : original.Height, result.Height);
for (var y = 0; y < result.Height; y++)
for (var x = 0; x < result.Width; x++)
{
var source = orientation switch
{
2 => (original.Width - 1 - x, y),
3 => (original.Width - 1 - x, original.Height - 1 - y),
4 => (x, original.Height - 1 - y),
5 => (y, x),
6 => (y, original.Height - 1 - x),
7 => (original.Width - 1 - y, original.Height - 1 - x),
8 => (original.Width - 1 - y, x),
_ => (x, y),
};
Assert.Equal(original.GetPixel(source.Item1, source.Item2), result.GetPixel(x, y));
}
using var codec = SKCodec.Create(new MemoryStream(normalized));
Assert.Equal(SKEncodedOrigin.TopLeft, codec.EncodedOrigin);
}
[Fact]
public async Task Identity_failure_preserves_previous_file_and_removes_staged_image()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-identity-" + Guid.NewGuid().ToString("N"));
try
{
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Failed(new IdentityError { Description = "private failure" }));
await using var db = TestHostFactory.CreateInMemoryDb();
var controller = Controller(root, user, users.Object, db);
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
var result = Assert.IsType<BadRequestObjectResult>(await controller.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")));
Assert.DoesNotContain("private failure", result.Value?.ToString() ?? string.Empty);
Assert.Equal(stored, user.AvatarImageDataUrl);
Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored));
Assert.Single(Directory.GetFiles(root, "*", SearchOption.AllDirectories));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Actual_stream_bytes_are_bounded_even_when_declared_length_is_small()
{
var user = new ApplicationUser { Id = "owner" };
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var file = new Mock<IFormFile>();
file.SetupGet(value => value.Length).Returns(8);
file.SetupGet(value => value.FileName).Returns("portrait.png");
file.Setup(value => value.OpenReadStream()).Returns(new MemoryStream(new byte[1_000_001]));
await using var db = TestHostFactory.CreateInMemoryDb();
var result = Assert.IsType<BadRequestObjectResult>(await Controller(Path.GetTempPath(), user, users.Object, db).UploadAvatar(file.Object));
Assert.Equal("Avatar image is too large.", result.Value);
users.Verify(manager => manager.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
[Fact]
public async Task Successful_identity_ack_is_not_reported_as_failed_when_old_file_cleanup_is_blocked()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-cleanup-" + Guid.NewGuid().ToString("N"));
try
{
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
await using var db = TestHostFactory.CreateInMemoryDb();
await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None);
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
var response = await Controller(root, user, users.Object, db).UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png"));
Assert.IsType<OkObjectResult>(response);
Assert.NotEqual(stored, user.AvatarImageDataUrl);
Assert.NotNull(AvatarStorage.Resolve(user.AvatarImageDataUrl));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Ambiguous_identity_exception_retains_staged_target_for_reference_aware_reconciliation()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-ambiguous-" + Guid.NewGuid().ToString("N"));
try
{
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
string? committedTarget = null;
users.Setup(manager => manager.UpdateAsync(user)).Callback(() => committedTarget = user.AvatarImageDataUrl)
.ThrowsAsync(new IOException("Synthetic acknowledgement loss after commit"));
await using var db = TestHostFactory.CreateInMemoryDb();
await using var stream = new MemoryStream(Image(SKEncodedImageFormat.Png, 12, 8));
await Assert.ThrowsAsync<IOException>(() => Controller(root, user, users.Object, db)
.UploadAvatar(new FormFile(stream, 0, stream.Length, "file", "portrait.png")));
Assert.NotNull(committedTarget);
Assert.NotEqual(stored, committedTarget);
Assert.NotNull(AvatarStorage.Resolve(committedTarget));
Assert.Equal(stored, user.AvatarImageDataUrl);
Assert.Equal("data:image/jpeg;base64,AQID", AvatarStorage.Resolve(stored));
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
[Fact]
public async Task Removal_is_acknowledged_when_identity_succeeded_but_file_cleanup_is_blocked()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-remove-" + Guid.NewGuid().ToString("N"));
try
{
var stored = await AvatarStorage.StoreAsync(root, "owner", new byte[] { 1, 2, 3 }, "image/jpeg", default);
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
await using var db = TestHostFactory.CreateInMemoryDb();
await using var locked = new FileStream(stored[5..], FileMode.Open, FileAccess.Read, FileShare.None);
Assert.IsType<NoContentResult>(await Controller(root, user, users.Object, db).DeleteAvatar());
Assert.Null(user.AvatarImageDataUrl);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
private static byte[] Image(SKEncodedImageFormat format, int width, int height)
{
using var bitmap = new SKBitmap(width, height);
using (var canvas = new SKCanvas(bitmap)) canvas.Clear(SKColors.Navy);
bitmap.SetPixel(0, 0, SKColors.Red);
bitmap.SetPixel(width - 1, 0, SKColors.Green);
bitmap.SetPixel(0, height - 1, SKColors.Yellow);
using var image = SKImage.FromBitmap(bitmap);
using var data = image.Encode(format, 100);
return data.ToArray();
}
private static byte[] WithOrientation(byte[] jpeg, int orientation)
{
using var payload = new MemoryStream();
using (var writer = new BinaryWriter(payload, System.Text.Encoding.UTF8, true))
{
writer.Write(new byte[] { 69, 120, 105, 102, 0, 0, 73, 73 });
writer.Write((ushort)42); writer.Write(8); writer.Write((ushort)1);
writer.Write((ushort)0x112); writer.Write((ushort)3); writer.Write(1);
writer.Write((ushort)orientation); writer.Write((ushort)0); writer.Write(0);
}
var bytes = payload.ToArray();
return jpeg[..2].Concat(new byte[] { 0xff, 0xe1, (byte)((bytes.Length + 2) >> 8), (byte)(bytes.Length + 2) })
.Concat(bytes).Concat(jpeg[2..]).ToArray();
}
[Fact]
public async Task Signature_only_png_is_rejected_without_replacing_the_previous_portrait()
{
var root = Path.Combine(Path.GetTempPath(), "avatar-validation-" + Guid.NewGuid().ToString("N"));
try
{
var originalBytes = new byte[] { 1, 2, 3 };
var stored = await AvatarStorage.StoreAsync(root, "owner", originalBytes, "image/png", default);
var user = new ApplicationUser { Id = "owner", AvatarImageDataUrl = stored };
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
users.Setup(manager => manager.UpdateAsync(user)).ReturnsAsync(IdentityResult.Success);
await using var db = TestHostFactory.CreateInMemoryDb();
var controller = Controller(root, user, users.Object, db);
await using var stream = new MemoryStream(new byte[] { 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a });
var file = new FormFile(stream, 0, stream.Length, "file", "portrait.png");
var response = await controller.UploadAvatar(file);
Assert.IsType<BadRequestObjectResult>(response);
Assert.Equal(stored, user.AvatarImageDataUrl);
Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored));
users.Verify(manager => manager.UpdateAsync(It.IsAny<ApplicationUser>()), Times.Never);
}
finally { if (Directory.Exists(root)) Directory.Delete(root, true); }
}
private static AuthController Controller(string root, ApplicationUser user, UserManager<ApplicationUser> users, JobTrackerApi.Data.JobTrackerContext db) =>
new(new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build(),
users, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(),
Mock.Of<IMicrosoftTokenValidator>(), Mock.Of<ILogger<AuthController>>(), Mock.Of<ITwoFactorPendingTokenService>(), db)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext { User = new ClaimsPrincipal(new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, user.Id) }, "local")) },
},
};
}
@@ -7,25 +7,6 @@ namespace JobTrackerApi.Tests;
public sealed class CoverLetterDocumentContractTests
{
[Fact]
public void Matching_letter_themes_use_the_CV_design_without_rewriting_legacy_output_or_body()
{
var renderer = new CoverLetterDocumentRenderer();
foreach (var theme in CvThemeCatalog.Themes)
{
var legacy = new CoverLetterDocumentSettings { ThemeId = theme.Id, SenderName = "Reviewed sender" };
var original = renderer.Render("Reviewed body", legacy).Html;
var revised = CoverLetterDocumentSettingsJson.Deserialize("{\"themeId\":\"" + theme.Id + "\",\"designVersion\":2,\"senderName\":\"Reviewed sender\"}");
var html = renderer.Render("Reviewed body", revised).Html;
Assert.Contains("letter-theme-" + theme.Id, html);
Assert.Contains("Reviewed sender", html);
Assert.Contains("Reviewed body", html);
Assert.Contains("\"designVersion\":2", CoverLetterDocumentSettingsJson.Serialize(revised));
Assert.Contains(theme.Accent, html);
Assert.Equal(original, renderer.Render("Reviewed body", CoverLetterDocumentSettingsJson.Deserialize(CoverLetterDocumentSettingsJson.Serialize(legacy))).Html);
Assert.DoesNotContain("designVersion", CoverLetterDocumentSettingsJson.Serialize(legacy));
}
}
[Theory]
[InlineData("en", "Cover letter", "Cover letter text")]
[InlineData("nb-NO", "Søknadsbrev", "Brevtekst")]
@@ -1,127 +0,0 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CoverLetterThemeEntitlementTests
{
[Theory]
[InlineData("save")]
[InlineData("preview")]
[InlineData("restore")]
public async Task Free_user_cannot_activate_a_premium_version_two_letter_through_any_design_writer(string action)
{
var (db, job, controller, _) = await Fixture();
await using var dispose = db;
var premium = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 };
db.CoverLetterVersions.Add(new CoverLetterVersion { OwnerUserId = "owner", JobApplicationId = job.Id, Version = 1,
Text = "Historical premium", Source = CoverLetterSources.Manual, DocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(premium) });
await db.SaveChangesAsync();
var response = action switch
{
"preview" => (await controller.PreviewCoverLetter(job.Id, new("Reviewed text", premium), default)).Result,
"restore" => (await controller.RestoreCoverLetter(job.Id, 1, new(job.Version), default)).Result,
_ => (await controller.SaveCoverLetter(job.Id, new("Reviewed text", "manual", null, job.Version, premium), default)).Result,
};
Assert.Equal(403, Assert.IsAssignableFrom<ObjectResult>(response).StatusCode);
Assert.Null(job.CoverLetterDocumentSettingsJson);
Assert.Null(job.CoverLetterText);
}
[Fact]
public async Task Shared_service_rejects_premium_design_before_changing_letter_or_history()
{
var (db, job, _, service) = await Fixture();
await using var dispose = db;
await Assert.ThrowsAsync<CoverLetterThemeAccessException>(() => service.SaveCoverLetterAsync("owner", job.Id, "Unsaved", "manual", null,
job.Version, default, new() { DesignVersion = 2, ThemeId = "technical" }));
Assert.Null(job.CoverLetterText);
Assert.Null(job.CoverLetterDocumentSettingsJson);
Assert.Empty(db.CoverLetterVersions);
}
[Theory]
[InlineData(null)]
[InlineData(1)]
public async Task Legacy_letter_metadata_remains_editable_and_previewable_for_free_users(int? designVersion)
{
var (db, job, controller, _) = await Fixture();
await using var dispose = db;
var legacy = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = designVersion };
Assert.IsType<OkObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Legacy text", legacy), default)).Result);
Assert.IsType<OkObjectResult>((await controller.SaveCoverLetter(job.Id, new("Legacy text", "manual", null, job.Version, legacy), default)).Result);
}
[Fact]
public async Task Premium_account_can_save_and_preview_a_new_premium_letter_design()
{
var (db, job, controller, service) = await Fixture(premium: true);
await using var dispose = db;
var design = new CoverLetterDocumentSettings { ThemeId = "technical", DesignVersion = 2 };
Assert.IsType<OkObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Reviewed text", design), default)).Result);
Assert.IsType<OkObjectResult>((await controller.SaveCoverLetter(job.Id, new("Reviewed text", "manual", null, job.Version, design), default)).Result);
Assert.NotNull(await service.RenderSavedCoverLetterAsync("owner", job.Id, job.Version, default));
}
[Fact]
public async Task Downgraded_account_keeps_current_premium_design_but_cannot_switch_to_another_premium_design()
{
var (db, job, controller, service) = await Fixture();
await using var dispose = db;
var stored = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 };
job.CoverLetterDocumentSettingsJson = CoverLetterDocumentSettingsJson.Serialize(stored);
job.CoverLetterText = "Stored premium text";
await db.SaveChangesAsync();
Assert.NotNull(await service.GetCoverLetterAsync("owner", job.Id, default));
Assert.NotNull(await service.RenderSavedCoverLetterAsync("owner", job.Id, job.Version, default));
Assert.IsType<OkObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Text edit", stored), default)).Result);
Assert.IsType<OkObjectResult>((await controller.SaveCoverLetter(job.Id, new("Text edit", "manual", null, job.Version, stored), default)).Result);
var different = new CoverLetterDocumentSettings { ThemeId = "technical", DesignVersion = 2 };
Assert.Equal(403, Assert.IsAssignableFrom<ObjectResult>((await controller.PreviewCoverLetter(job.Id, new("Text edit", different), default)).Result).StatusCode);
Assert.Equal(403, Assert.IsAssignableFrom<ObjectResult>((await controller.SaveCoverLetter(job.Id, new("Text edit", "manual", null, job.Version, different), default)).Result).StatusCode);
Assert.Equal("executive", CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson).ThemeId);
}
[Fact]
public async Task Foreign_application_remains_not_found_before_entitlement_checks()
{
var (db, job, controller, _) = await Fixture();
await using var dispose = db;
job.OwnerUserId = "other";
await db.SaveChangesAsync();
var design = new CoverLetterDocumentSettings { ThemeId = "executive", DesignVersion = 2 };
Assert.IsType<NotFoundResult>((await controller.PreviewCoverLetter(job.Id, new("Foreign", design), default)).Result);
Assert.IsType<NotFoundResult>((await controller.SaveCoverLetter(job.Id, new("Foreign", "manual", null, job.Version, design), default)).Result);
}
private static async Task<(JobTrackerContext db, JobApplication job, ApplicationAssetsController controller, ApplicationAssetsService service)> Fixture(bool premium = false)
{
var db = TestHostFactory.CreateInMemoryDb("owner");
var user = new ApplicationUser { Id = "owner", UserName = "owner" };
db.Users.Add(user);
if (premium)
{
db.Roles.Add(new IdentityRole("Premium") { Id = "premium-role" });
db.UserRoles.Add(new IdentityUserRole<string> { UserId = user.Id, RoleId = "premium-role" });
}
var company = new Company { OwnerUserId = user.Id, Name = "Synthetic" };
var job = new JobApplication { OwnerUserId = user.Id, Company = company, JobTitle = "Synthetic role" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var service = new ApplicationAssetsService(db, Mock.Of<ICvVariantService>(), Mock.Of<IApplicationIntelligenceService>());
var users = TestHostFactory.CreateUserManager();
users.Setup(manager => manager.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var controller = new ApplicationAssetsController(users.Object, service, Mock.Of<ISubmittedApplicationPackageService>())
{ ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() } };
return (db, job, controller, service);
}
}
-14
View File
@@ -19,20 +19,6 @@ public sealed class CvAiReadinessTests
{
private const string Source = "Built and maintained internal web applications.";
private const string Paraphrase = "Developed and maintained internal web applications.";
[Fact]
public async Task Explicit_blank_document_AI_does_not_send_unselected_profile_facts()
{
await using var fixture = new Fixture();
await fixture.Open();
await fixture.Variants.SaveAsync("owner", fixture.Cv.Id, null,
CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}"), "manual", 1, default);
var error = await Assert.ThrowsAsync<CvAiSuggestionException>(() => fixture.Suggestions.GenerateAsync(
"owner", fixture.Cv.Id, 2, null, "document-improve", null, null, "Rewrite", default));
Assert.Equal("cv_ai_source_empty", error.Code);
fixture.Ai.VerifyNoOtherCalls();
}
private sealed class Fixture : IAsyncDisposable
{
public readonly SqliteConnection Connection = new("Data Source=:memory:");
-154
View File
@@ -28,160 +28,6 @@ public sealed class CvBuilderTests
// ---- Resolver (the lens over the master profile) ----
[Theory]
[InlineData("modern")]
[InlineData("ats-classic")]
[InlineData("code")]
public void Revised_design_has_space_below_the_main_title_and_header(string themeId)
{
var settings = new CvVariantSettings { ThemeId = themeId, DesignVersion = 2 };
var html = new ThemedCvRenderer().Render(CvVariantResolver.Build(Rich(), settings, "", null), CvThemeCatalog.Resolve(themeId), settings).Html;
Assert.Contains(".header .name{margin-bottom:3mm;}", html);
Assert.Contains(".header + .main{padding-top:5mm;}", html);
}
[Theory]
[InlineData("technical", "left")]
[InlineData("creative", "left")]
[InlineData("nordic", "right")]
public void Revised_sidebar_background_fills_each_physical_page_including_its_margins(string themeId, string edge)
{
var settings = new CvVariantSettings { ThemeId = themeId, DesignVersion = 2 };
var html = new ThemedCvRenderer().Render(CvVariantResolver.Build(Rich(), settings, "", null), CvThemeCatalog.Resolve(themeId), settings).Html;
Assert.Contains("@top-" + edge + "{content:", html);
Assert.Contains("@bottom-" + edge + "{content:", html);
Assert.Contains("body::before{content:", html);
}
[Fact]
public void Design_revision_missing_and_explicit_one_preserve_identical_legacy_html_for_every_theme()
{
foreach (var theme in CvThemeCatalog.Themes)
{
var legacy = new CvVariantSettings { ThemeId = theme.Id };
var explicitOne = CvVariantSettingsJson.Deserialize("{\"designVersion\":1,\"themeId\":\"" + theme.Id + "\"}");
var renderer = new ThemedCvRenderer();
var model = CvVariantResolver.Build(Rich(), legacy, "Fallback", null);
Assert.Equal(renderer.Render(model, theme, legacy).Html, renderer.Render(model, theme, explicitOne).Html);
Assert.DoesNotContain("designVersion", CvVariantSettingsJson.Serialize(legacy));
}
}
[Fact]
public void Design_revision_two_is_persisted_and_changes_presentation_without_changing_content()
{
var settings = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"modern\"}");
Assert.Contains("\"designVersion\":2", CvVariantSettingsJson.Serialize(settings));
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
var theme = CvThemeCatalog.Resolve("modern");
var renderer = new ThemedCvRenderer();
var revised = renderer.Render(model, theme, settings).Html;
var legacy = renderer.Render(model, theme, new() { ThemeId = "modern" }).Html;
Assert.NotEqual(legacy, revised);
Assert.Contains("Ada Lovelace", revised);
Assert.Contains("Builder of engines.", revised);
Assert.Contains("Built X", revised);
Assert.Contains("AZ-204", revised);
}
[Theory]
[InlineData("technical", "#0f4c5c", "#ffffff")]
[InlineData("nordic", "#eef3f8", "#1f2937")]
[InlineData("creative", "#be185d", "#ffffff")]
public void Design_revision_two_sidebar_uses_its_own_palette_and_room_for_identity(string themeId, string background, string ink)
{
var settings = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"" + themeId + "\"}");
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
var html = new ThemedCvRenderer().Render(model, CvThemeCatalog.Resolve(themeId), settings).Html;
Assert.Contains("--cv-sidebar-bg:" + background, html);
Assert.Contains("--cv-sidebar-ink:" + ink, html);
Assert.Contains("background:var(--cv-sidebar-bg)", html);
Assert.Contains("color:var(--cv-sidebar-ink)", html);
Assert.Contains("padding:8mm", html);
Assert.Contains("68mm", html);
}
[Fact]
public async Task Design_revision_duplicate_and_restore_keep_their_stored_opt_in()
{
var (db, service) = NewService("user-1");
await using var dispose = db;
var created = await service.CreateAsync("user-1", "Legacy design", null, new(), default);
var revisionTwo = CvVariantSettingsJson.Deserialize("{\"designVersion\":2,\"themeId\":\"technical\"}");
await service.SaveAsync("user-1", created.Id, null, revisionTwo, "manual", 1, default);
var copy = (await service.DuplicateAsync("user-1", created.Id, null, default))!;
Assert.Contains("\"designVersion\":2", copy.SettingsJson);
var restored = (await service.RestoreVersionAsync("user-1", created.Id, 1, 2, default))!;
Assert.DoesNotContain("designVersion", restored.SettingsJson);
Assert.Contains("\"designVersion\":2", (await db.CvVariantVersions.SingleAsync(row => row.CvVariantId == created.Id && row.Version == 2)).SettingsJson);
}
[Fact]
public void Explicit_blank_roundtrips_without_inheriting_profile_content_or_header()
{
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[],\"showPhoto\":true}");
var json = CvVariantSettingsJson.Serialize(settings);
Assert.Contains("\"sectionsExplicit\":true", json);
Assert.Contains("\"includeProfileContact\":false", json);
var model = CvVariantResolver.Build(Rich(), CvVariantSettingsJson.Deserialize(json), "Fallback", "data:image/png;base64,AA==");
Assert.Empty(model.Sections);
Assert.Empty(model.FullName);
Assert.Null(model.Headline);
Assert.Empty(model.Contact);
Assert.Null(model.PhotoDataUrl);
foreach (var theme in CvThemeCatalog.Themes)
{
var html = new ThemedCvRenderer().Render(model, theme, settings).Html;
Assert.DoesNotContain("<header", html);
Assert.DoesNotContain("Ada Lovelace", html);
Assert.DoesNotContain("Fallback", html);
}
}
[Fact]
public void Explicit_sections_include_only_selected_content_and_allow_authored_headline()
{
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"headline\":\"Authored headline\",\"sections\":[{\"key\":\"skills\"}]}");
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
Assert.Equal("skills", Assert.Single(model.Sections).Key);
Assert.Equal("Authored headline", model.Headline);
Assert.Empty(model.FullName);
Assert.Empty(model.Contact);
}
[Fact]
public void Legacy_inclusion_flags_remain_absent_and_preserve_profile_inheritance()
{
var settings = CvVariantSettingsJson.Deserialize("{\"sections\":[]}");
var json = CvVariantSettingsJson.Serialize(settings);
Assert.DoesNotContain("sectionsExplicit", json);
Assert.DoesNotContain("includeProfileContact", json);
var model = CvVariantResolver.Build(Rich(), settings, "Fallback", null);
Assert.Equal("Ada Lovelace", model.FullName);
Assert.NotEmpty(model.Sections);
Assert.NotEmpty(model.Contact);
}
[Fact]
public async Task Explicit_blank_survives_save_duplicate_restore_and_design_change()
{
var (db, service) = NewService("user-1");
await using var dispose = db;
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}");
var created = await service.CreateAsync("user-1", "Blank", null, settings, default);
settings.ThemeId = "clean";
await service.SaveAsync("user-1", created.Id, null, settings, "manual", 1, default);
var copy = await service.DuplicateAsync("user-1", created.Id, null, default);
var restored = await service.RestoreVersionAsync("user-1", created.Id, 1, 2, default);
foreach (var document in new[] { copy!, restored! })
{
var stored = CvVariantSettingsJson.Deserialize(document.SettingsJson);
Assert.Contains("\"sectionsExplicit\":true", document.SettingsJson);
Assert.Contains("\"includeProfileContact\":false", document.SettingsJson);
Assert.Empty(CvVariantResolver.Build(Rich(), stored, "Fallback", null).Sections);
}
}
[Fact]
public void Resolver_defaults_include_every_populated_section()
{
@@ -16,27 +16,6 @@ namespace JobTrackerApi.Tests;
public sealed class CvExportRevisionTests
{
[Fact]
public async Task Explicit_blank_export_is_rejected_before_pdf_allocation()
{
var current = new Mock<ICurrentUserService>();
current.SetupGet(x => x.UserId).Returns("owner");
await using var db = new JobTrackerContext(new DbContextOptionsBuilder<JobTrackerContext>()
.UseInMemoryDatabase(Guid.NewGuid().ToString()).Options, current.Object);
var variants = new CvVariantService(db, new CareerProfileService(db), new ThemedCvRenderer());
var settings = CvVariantSettingsJson.Deserialize("{\"sectionsExplicit\":true,\"includeProfileContact\":false,\"sections\":[]}");
var cv = await variants.CreateAsync("owner", "Blank", null, settings, default);
var user = new ApplicationUser { Id = "owner", FirstName = "Inherited name" };
var users = TestHostFactory.CreateUserManager(user);
users.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var pdf = new Mock<ICvPdfExporter>(MockBehavior.Strict);
var controller = new CvVariantController(users.Object, variants, pdf.Object, Mock.Of<ISummarizerService>())
{ ControllerContext = new() { HttpContext = new DefaultHttpContext() } };
var result = Assert.IsType<BadRequestObjectResult>(await controller.ExportPdf(cv.Id, default, 1));
Assert.Equal("cv_empty_content", JsonSerializer.SerializeToElement(result.Value).GetProperty("code").GetString());
pdf.VerifyNoOtherCalls();
}
[Theory]
[InlineData(1, "owner", 409)]
[InlineData(0, "owner", 409)]
@@ -19,60 +19,6 @@ namespace JobTrackerApi.Tests;
public sealed class CvProcessingOperationTests
{
[Fact]
public async Task Document_worker_processes_previously_queued_imports_while_generative_jobs_remain_disabled()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.SeedUserAsync();
await using (var scope = fixture.Provider.CreateAsyncScope())
{
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Upload(File()));
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic document transport");
Assert.IsType<AcceptedResult>(await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" }));
}
var configuration = fixture.Provider.GetRequiredService<IConfiguration>();
configuration["Workers:AiOperationsEnabled"] = "false";
configuration["Workers:CvDocumentImportsEnabled"] = "true";
using var hosted = new AiOperationHostedService(fixture.Provider.GetRequiredService<AiOperationWorker>(), configuration);
await hosted.StartAsync(default);
try
{
var ready = false;
for (var attempt = 0; attempt < 50 && !ready; attempt++)
{
await Task.Delay(100);
await using var scope = fixture.Provider.CreateAsyncScope();
var processingDb = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
ready = await processingDb.CvExtractionRuns.IgnoreQueryFilters()
.AnyAsync(run => run.Trigger == "document_import" && run.Status == "pending_review") &&
await processingDb.UserOperations.IgnoreQueryFilters().AnyAsync(operation => operation.Status == OperationStatuses.Succeeded);
}
Assert.True(ready, "Document-only processing must run even while generative AI workers are disabled.");
}
finally { await hosted.StopAsync(default); }
await using var verify = fixture.Provider.CreateAsyncScope();
var db = verify.ServiceProvider.GetRequiredService<JobTrackerContext>();
Assert.Equal("queued", (await db.CvExtractionRuns.IgnoreQueryFilters().SingleAsync(run => run.Trigger != "document_import")).Status);
Assert.Equal(1, await db.UserOperations.IgnoreQueryFilters().CountAsync(operation => operation.Status == OperationStatuses.Succeeded));
Assert.Equal(1, await db.UserOperations.IgnoreQueryFilters().CountAsync(operation => operation.Status == OperationStatuses.Queued));
}
[Fact]
public async Task Disabled_document_and_AI_workers_reject_import_with_an_actionable_service_error()
{
await using var fixture = await Fixture.CreateAsync();
await fixture.SeedUserAsync();
fixture.Provider.GetRequiredService<IConfiguration>()["Workers:AiOperationsEnabled"] = "false";
await using var scope = fixture.Provider.CreateAsyncScope();
using var owner = scope.ServiceProvider.GetRequiredService<CurrentUserService>().UseBackgroundUser("user-1");
var bytes = Encoding.UTF8.GetBytes("%PDF-1.4\nSynthetic document transport");
var response = await CreateController(scope.ServiceProvider).Import(new FormFile(new MemoryStream(bytes), 0, bytes.Length, "file", "synthetic.pdf") { Headers = new HeaderDictionary(), ContentType = "application/pdf" });
Assert.Equal(503, Assert.IsType<ObjectResult>(response).StatusCode);
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
Assert.Empty(await db.UserOperations.ToListAsync());
Assert.Equal("failed", (await db.CvExtractionRuns.SingleAsync()).Status);
}
[Fact]
public async Task Worker_that_lost_its_lease_cannot_overwrite_a_recovered_successful_review()
{
@@ -362,23 +308,15 @@ public sealed class CvProcessingOperationTests
public static async Task<Fixture> CreateAsync()
{
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-operation-{Guid.NewGuid():N}");
Directory.CreateDirectory(tempRoot);
var connection = new SqliteConnection($"Data Source={Path.Combine(tempRoot, "operations.db")};Pooling=False;Default Timeout=5");
var connection = new SqliteConnection("Data Source=:memory:");
await connection.OpenAsync();
await using (var command = connection.CreateCommand())
{
command.CommandText = "PRAGMA journal_mode=WAL;";
await command.ExecuteNonQueryAsync();
}
var tempRoot = Path.Combine(Path.GetTempPath(), $"jobtracker-cv-operation-{Guid.NewGuid():N}");
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Data:Root"] = tempRoot,
["Data:CvArtifactsRoot"] = Path.Combine(tempRoot, "CvArtifacts"),
["AiQueue:HeartbeatSeconds"] = "5",
["Ai:ExternalProcessingEnabled"] = "false",
["Workers:AiOperationsEnabled"] = "true",
["Workers:CvDocumentImportsEnabled"] = "false",
}).Build();
var environment = new Mock<IHostEnvironment>();
environment.SetupGet(item => item.ContentRootPath).Returns(tempRoot);
@@ -413,8 +351,7 @@ public sealed class CvProcessingOperationTests
services.AddHttpContextAccessor();
services.AddScoped<CurrentUserService>();
services.AddScoped<ICurrentUserService>(provider => provider.GetRequiredService<CurrentUserService>());
// Separate connections observe committed state, unlike a shared in-memory connection.
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection.ConnectionString));
services.AddDbContext<JobTrackerContext>((_, options) => options.UseSqlite(connection));
services.AddIdentityCore<ApplicationUser>().AddRoles<IdentityRole>().AddEntityFrameworkStores<JobTrackerContext>();
services.AddSingleton(TimeProvider.System);
services.AddSingleton(new AppPaths(configuration, environment.Object));
@@ -28,61 +28,6 @@ public sealed class DashboardPreferencesTests
private const string Route = "/api/auth/preferences/dashboard";
private const string Layout = "{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"cards\",\"visible\":true,\"size\":\"full\"}]}";
[Fact]
public async Task Geometry_roundtrip_preserves_hidden_positions_and_blocks_legacy_downgrade()
{
await using var host = await Host.Start();
using var owner = host.Client("owner");
using var other = host.Client("other");
const string geometry = "{\"version\":2,\"revision\":0,\"widgets\":[{\"id\":\"attention\",\"visible\":false,\"size\":\"standard\",\"x\":4,\"y\":17,\"w\":8,\"h\":10}]}";
Assert.Equal(HttpStatusCode.OK, (await owner.PutAsync(Route, Json(geometry))).StatusCode);
var actual = await owner.GetFromJsonAsync<JsonElement>(Route);
Assert.Equal(2, actual.GetProperty("version").GetInt32());
Assert.Equal(1, actual.GetProperty("revision").GetInt64());
var widget = actual.GetProperty("widgets")[0];
Assert.False(widget.GetProperty("visible").GetBoolean());
Assert.Equal(4, widget.GetProperty("x").GetInt32());
Assert.Equal(17, widget.GetProperty("y").GetInt32());
Assert.Equal(8, widget.GetProperty("w").GetInt32());
Assert.Equal(10, widget.GetProperty("h").GetInt32());
Assert.Equal(HttpStatusCode.Conflict, (await owner.PutAsync(Route, Json(Layout.Replace("\"revision\":0", "\"revision\":1")))).StatusCode);
Assert.Equal(actual.GetRawText(), (await owner.GetFromJsonAsync<JsonElement>(Route)).GetRawText());
Assert.Equal(0, (await other.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
}
[Theory]
[InlineData(true, 4, HttpStatusCode.BadRequest)]
[InlineData(true, 6, HttpStatusCode.OK)]
[InlineData(false, 4, HttpStatusCode.OK)]
public async Task Visible_geometry_cannot_overlap_but_hidden_positions_and_adjacent_edges_are_supported(bool visible, int x, HttpStatusCode expected)
{
await using var host = await Host.Start();
using var owner = host.Client("owner");
var request = new { version = 2, revision = 0, widgets = new[] {
new { id = "attention", visible = true, size = "standard", x = 0, y = 0, w = 6, h = 8 },
new { id = "funnel", visible, size = "standard", x, y = 0, w = 6, h = 8 },
} };
Assert.Equal(expected, (await owner.PutAsJsonAsync(Route, request)).StatusCode);
Assert.Equal(expected == HttpStatusCode.OK ? 1 : 0, (await owner.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
}
[Theory]
[InlineData(-1, 0, 6, 8)]
[InlineData(8, 0, 6, 8)]
[InlineData(0, 250, 6, 8)]
[InlineData(0, 0, 3, 8)]
[InlineData(0, 0, 6, 3)]
[InlineData(0, 0, 6, 25)]
[InlineData(0.5, 0, 6, 8)]
public async Task Unbounded_or_fractional_geometry_is_rejected_before_persistence(double x, double y, double w, double h)
{
await using var host = await Host.Start();
using var owner = host.Client("owner");
var request = new { version = 2, revision = 0, widgets = new[] { new { id = "attention", visible = true, size = "standard", x, y, w, h } } };
Assert.Equal(HttpStatusCode.BadRequest, (await owner.PutAsJsonAsync(Route, request)).StatusCode);
Assert.Equal(0, (await owner.GetFromJsonAsync<JsonElement>(Route)).GetProperty("revision").GetInt64());
}
[Fact]
public async Task Http_roundtrip_is_owner_scoped_and_preserves_other_preferences()
{
@@ -186,10 +131,10 @@ public sealed class DashboardPreferencesTests
await using var host = await Host.Start();
using var client = host.Client("owner");
await using var db = host.Db();
const string future = "{\"version\":3,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=4 WHERE Id='owner'");
var response = await client.GetFromJsonAsync<JsonElement>(Route);
Assert.Equal(3, response.GetProperty("version").GetInt32());
Assert.Equal(2, response.GetProperty("version").GetInt32());
Assert.Equal(4, response.GetProperty("revision").GetInt64());
Assert.Empty(response.GetProperty("widgets").EnumerateArray());
Assert.Equal(HttpStatusCode.Conflict, (await client.PutAsync(Route, Json("{\"version\":1,\"revision\":4,\"widgets\":[]}"))).StatusCode);
@@ -212,7 +157,7 @@ public sealed class DashboardPreferencesTests
{
await preRead.Task.WaitAsync(TimeSpan.FromSeconds(10));
await using var db = host.Db();
const string future = "{\"version\":3,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
const string future = "{\"version\":2,\"widgets\":[{\"id\":\"future-widget\",\"custom\":\"preserve\"}]}";
await db.Database.ExecuteSqlInterpolatedAsync($"UPDATE AspNetUsers SET DashboardLayoutJson={future}, DashboardLayoutRevision=1 WHERE Id='owner'");
releaseBody.TrySetResult();
Assert.Equal(HttpStatusCode.Conflict, (await pending).StatusCode);
@@ -245,7 +190,7 @@ public sealed class DashboardPreferencesTests
[InlineData("{\"version\":1,\"revision\":\"0\",\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":9223372036854775807,\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":9007199254740991,\"widgets\":[]}", 400)]
[InlineData("{\"version\":3,\"revision\":0,\"widgets\":[]}", 400)]
[InlineData("{\"version\":2,\"revision\":0,\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":-1,\"widgets\":[]}", 400)]
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":null}", 400)]
[InlineData("{\"version\":1,\"revision\":0,\"widgets\":[{\"id\":\"unknown\",\"visible\":true,\"size\":\"full\"}]}", 400)]
@@ -9,171 +9,6 @@ namespace JobTrackerApi.Tests;
public sealed class UserOperationStoreTests
{
[Fact]
public async Task Retry_uses_current_durable_state_and_emits_one_event_without_a_tracked_stale_operation()
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
var store = fixture.Store(owner);
var id = (await store.CreateAsync(Request("retry-current"), default)).Operation.Id;
await using var worker = fixture.Context(null);
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
Assert.True(await store.FailAsync(id, lease.LeaseToken, false, "timeout", "Synthetic timeout", TimeSpan.Zero, default));
Assert.True(await store.RetryAsync(id, default));
Assert.False(await store.RetryAsync(id, default));
await using var readback = fixture.Context("user-1");
Assert.Equal(OperationStatuses.Queued, (await readback.UserOperations.SingleAsync()).Status);
Assert.Single(await readback.UserOperationEvents.Where(item => item.Kind == "Retried").ToListAsync());
Assert.Empty(await readback.UserNotifications.ToListAsync());
}
[Fact]
public async Task Stage_history_is_deduplicated_owner_scoped_and_redacts_unknown_values()
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
var store = fixture.Store(owner);
var id = (await store.CreateAsync(Request("stage-history"), default)).Operation.Id;
await using var worker = fixture.Context(null);
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
Assert.Equal(0, await store.ReportStageAsync(id, "wrong", "cv_extracting", default));
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "SECRET document and token", default));
Assert.True(await store.FailAsync(id, lease.LeaseToken, false, "SECRET error", "SECRET document content", TimeSpan.Zero, default));
var events = await owner.UserOperationEvents.OrderBy(item => item.Id).ToListAsync();
Assert.Equal(new[] { "Created", "Claimed", "StageChanged", "StageChanged", "Failed" }, events.Select(item => item.Kind));
Assert.Equal("cv_extracting", events[2].Stage);
Assert.Null(events[3].Stage);
Assert.Equal("processing_failed", events[4].FailureCode);
Assert.DoesNotContain("SECRET", System.Text.Json.JsonSerializer.Serialize(events));
Assert.Empty(await worker.UserOperationEvents.ToListAsync());
await using var other = fixture.Context("user-2");
Assert.Empty(await other.UserOperationEvents.ToListAsync());
}
[Theory]
[InlineData("Claimed")]
[InlineData("StageChanged")]
[InlineData("Completed")]
[InlineData("Retried")]
public async Task Failed_event_insert_rolls_back_claim_stage_or_completion(string kind)
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
var store = fixture.Store(owner);
var id = (await store.CreateAsync(Request("rollback-history"), default)).Operation.Id;
await using var worker = fixture.Context(null);
UserOperationLease? lease = null;
if (kind != "Claimed") lease = await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default);
if (kind == "Retried") Assert.True(await store.FailAsync(id, lease!.LeaseToken, false, "timeout", "Synthetic timeout", TimeSpan.Zero, default));
await owner.Database.ExecuteSqlRawAsync("CREATE TRIGGER fail_history BEFORE INSERT ON UserOperationEvents BEGIN SELECT RAISE(ABORT, 'synthetic event failure'); END;");
await Assert.ThrowsAnyAsync<Exception>(async () =>
{
if (kind == "Claimed") await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default);
else if (kind == "StageChanged") await store.ReportStageAsync(id, lease!.LeaseToken, "cv_extracting", default);
else if (kind == "Retried") await store.RetryAsync(id, default);
else await store.CompleteAsync(id, lease!.LeaseToken, null, default);
});
await using var readback = fixture.Context("user-1");
var operation = await readback.UserOperations.SingleAsync();
Assert.Equal(kind == "Claimed" ? OperationStatuses.Queued : kind == "Retried" ? OperationStatuses.Failed : OperationStatuses.Running, operation.Status);
Assert.Equal(kind == "Claimed" ? 0 : 1, operation.AttemptCount);
Assert.Null(operation.ProgressStage);
Assert.Equal(kind == "Retried" ? 1 : 0, await readback.UserNotifications.CountAsync());
Assert.Equal(kind == "Claimed" ? 1 : kind == "Retried" ? 3 : 2, await readback.UserOperationEvents.CountAsync());
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Expired_or_cancel_requested_lease_cannot_publish_a_stage(bool cancel)
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
var store = fixture.Store(owner);
var id = (await store.CreateAsync(Request("inactive-stage"), default)).Operation.Id;
await using var worker = fixture.Context(null);
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
if (cancel) Assert.True(await store.RequestCancellationAsync(id, default));
else fixture.Time.Advance(TimeSpan.FromSeconds(21));
Assert.Equal(0, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
Assert.Empty(await owner.UserOperationEvents.Where(item => item.Kind == "StageChanged").ToListAsync());
}
[Fact]
public async Task Worker_recovery_history_matches_actual_reclaims_and_final_expiry()
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
await fixture.Store(owner).CreateAsync(Request("recover-history", maxAttempts: 2), default);
await using var worker = fixture.Context(null);
var store = fixture.Store(worker);
Assert.NotNull(await store.ClaimNextAsync(TimeSpan.FromSeconds(5), default));
fixture.Time.Advance(TimeSpan.FromSeconds(6));
Assert.NotNull(await store.ClaimNextAsync(TimeSpan.FromSeconds(5), default));
fixture.Time.Advance(TimeSpan.FromSeconds(6));
Assert.Null(await store.ClaimNextAsync(TimeSpan.FromSeconds(5), default));
var events = await owner.UserOperationEvents.OrderBy(item => item.Id).ToListAsync();
Assert.Equal(new[] { "Created", "Claimed", "LeaseRecovered", "Claimed", "Expired" }, events.Select(item => item.Kind));
Assert.Equal(new[] { 0, 1, 1, 2, 2 }, events.Select(item => item.Attempt));
Assert.Equal(OperationStatuses.Failed, events[^1].Status);
Assert.Equal("lease_expired", events[^1].FailureCode);
}
[Fact]
public async Task Stage_event_does_not_flush_unrelated_tracked_user_changes()
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
owner.Users.Add(new() { Id = "user-1", UserName = "synthetic", DisplayName = "Original" });
var store = fixture.Store(owner);
var id = (await store.CreateAsync(Request("no-flush"), default)).Operation.Id;
await using var worker = fixture.Context(null);
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
(await owner.Users.SingleAsync()).DisplayName = "Uncommitted";
Assert.Equal(1, await store.ReportStageAsync(id, lease.LeaseToken, "cv_extracting", default));
await using var readback = fixture.Context("user-1");
Assert.Equal("Original", (await readback.Users.SingleAsync()).DisplayName);
Assert.Equal(3, await readback.UserOperationEvents.CountAsync());
}
[Fact]
public async Task History_retention_and_operation_deletion_preserve_other_owners()
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
await fixture.Store(owner).CreateAsync(Request("old-history"), default);
fixture.Time.Advance(TimeSpan.FromDays(31));
await using var other = fixture.Context("user-2");
await fixture.Store(other).CreateAsync(Request("fresh-history"), default);
await using var neutral = fixture.Context(null);
Assert.Equal(1, await OperationEventLog.PruneAsync(neutral, fixture.Time.GetUtcNow().UtcDateTime, default));
Assert.Empty(await owner.UserOperationEvents.ToListAsync());
Assert.Single(await other.UserOperationEvents.ToListAsync());
Assert.Single(await owner.UserOperations.AsNoTracking().ToListAsync());
await other.UserOperations.ExecuteDeleteAsync();
Assert.Empty(await neutral.UserOperationEvents.IgnoreQueryFilters().ToListAsync());
Assert.Single(await owner.UserOperations.AsNoTracking().ToListAsync());
}
[Fact]
public async Task Durable_history_records_only_successful_transitions_not_duplicate_creation_or_heartbeat()
{
await using var fixture = await Fixture.CreateAsync();
await using var owner = fixture.Context("user-1");
var store = fixture.Store(owner);
var created = await store.CreateAsync(Request("history"), default);
await store.CreateAsync(Request("history"), default);
Assert.Equal(1, await owner.Database.SqlQueryRaw<int>("SELECT COUNT(*) AS Value FROM UserOperationEvents").SingleAsync());
await using var worker = fixture.Context(null);
var lease = Assert.IsType<UserOperationLease>(await fixture.Store(worker).ClaimNextAsync(TimeSpan.FromSeconds(20), default));
Assert.Equal(1, await store.HeartbeatAsync(created.Operation.Id, lease.LeaseToken, TimeSpan.FromSeconds(20), null, null, default));
Assert.Equal(1, await store.CompleteAsync(created.Operation.Id, lease.LeaseToken, null, default));
Assert.Equal(0, await store.CompleteAsync(created.Operation.Id, lease.LeaseToken, null, default));
Assert.Equal(3, await owner.Database.SqlQueryRaw<int>("SELECT COUNT(*) AS Value FROM UserOperationEvents").SingleAsync());
}
[Fact]
public async Task Create_is_idempotent_within_owner_and_isolated_between_owners()
{
-26
View File
@@ -418,30 +418,6 @@
"System.Drawing.Common": "6.0.0"
}
},
"SkiaSharp": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "4.153.1",
"SkiaSharp.NativeAssets.macOS": "4.153.1"
}
},
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
@@ -590,8 +566,6 @@
"Pomelo.EntityFrameworkCore.MySql": "[9.0.0, )",
"QRCoder": "[1.8.0, )",
"SQLitePCLRaw.lib.e_sqlite3": "[2.1.12, )",
"SkiaSharp": "[4.153.1, )",
"SkiaSharp.NativeAssets.Linux.NoDependencies": "[4.153.1, )",
"Stripe.net": "[52.2.0, )",
"System.IdentityModel.Tokens.Jwt": "[8.14.0, )",
"System.Security.Cryptography.Pkcs": "[8.0.2, )"
@@ -1,223 +0,0 @@
using System.Globalization;
using System.Security.Claims;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/admin/processing")]
[Authorize(Roles = "Admin")]
[EnableRateLimiting(RateLimitPolicy)]
public sealed class AdminProcessingController(JobTrackerContext db, IConfiguration configuration, TimeProvider clock) : ControllerBase
{
public const string RateLimitPolicy = "admin-processing";
private static readonly HashSet<string> EventKinds = Enum.GetNames<OperationEventKind>().ToHashSet(StringComparer.Ordinal);
public sealed record IdentityDto(string Id, string? Name, string? Email);
public sealed record JobDto(int Id, string Title, string? Company, bool CanOpen);
public sealed record DocumentDto(int ImportId, string Name, string? Type, long Bytes, int? Pages, string? State);
public sealed record ItemDto(Guid Id, IdentityDto Owner, string TaskType, string Status, string? Stage, int AttemptCount,
int MaxAttempts, DateTime CreatedAtUtc, DateTime? StartedAtUtc, DateTime? CompletedAtUtc, DateTime? LastHeartbeatAtUtc,
double DurationSeconds, string Worker, bool WorkerEnabled, string? FailureCode, JobDto? Job, DocumentDto? Document,
DateTime? LastFailureAtUtc, string? LastFailureStage);
public sealed record WorkerDto(string Name, bool FlagEnabled, bool IndividualOperationsRecorded);
public sealed record PageDto(IReadOnlyList<ItemDto> Items, int Total, int Page, int PageSize,
IReadOnlyDictionary<string, int> Counts, IReadOnlyList<WorkerDto> Workers, string CvInterpretation);
public sealed record EventDto(string Id, DateTime AtUtc, string Kind, string Status, string? Stage, int Attempt, string? FailureCode);
public sealed record EventPageDto(IReadOnlyList<EventDto> Items, bool HasMore, int RetentionDays);
[HttpGet]
public async Task<ActionResult<PageDto>> List([FromQuery] string? status = null, [FromQuery] string? taskType = null,
[FromQuery] string? owner = null, [FromQuery] int page = 1, [FromQuery] int pageSize = 25,
[FromQuery] int? jobId = null, [FromQuery] string? worker = null, [FromQuery] string? failureCode = null,
[FromQuery] string? q = null, [FromQuery] DateTimeOffset? createdFrom = null, [FromQuery] DateTimeOffset? createdBefore = null,
CancellationToken cancellationToken = default)
{
if (page is < 1 or > 1000 || pageSize is < 1 or > 100 || owner?.Length > 255
|| (status is not null && OperationEventLog.SafeStatus(status) == "unknown")
|| (taskType is not null && taskType is not (CvProcessingQueue.TaskType or StrategySnapshotService.TaskType or "other"))
|| jobId <= 0 || q?.Length > 200 || (worker is not null && worker is not ("document" or "ai" or "untracked"))
|| (failureCode is not null && failureCode != "processing_failed" && OperationEventLog.SafeFailure(failureCode) != failureCode)
|| createdFrom?.UtcDateTime.Year < 1000 || createdBefore?.UtcDateTime.Year < 1000
|| (createdFrom is not null && createdBefore is not null && createdFrom >= createdBefore)) return BadRequest();
var all = ReadOperations();
var query = all;
// Historical tables use different MariaDB collations; owner identifiers must compare exactly.
var identifiers = db.Database.IsMySql() ? "utf8mb4_bin" : "BINARY";
if (status is not null) query = query.Where(item => item.Status == status);
if (!string.IsNullOrWhiteSpace(owner)) query = query.Where(item => item.OwnerUserId == owner);
if (taskType == "other") query = query.Where(item => item.TaskType != CvProcessingQueue.TaskType && item.TaskType != StrategySnapshotService.TaskType);
else if (taskType is not null) query = query.Where(item => item.TaskType == taskType);
if (createdFrom is not null) { var from = createdFrom.Value.UtcDateTime; query = query.Where(item => item.CreatedAtUtc >= from); }
if (createdBefore is not null) { var before = createdBefore.Value.UtcDateTime; query = query.Where(item => item.CreatedAtUtc < before); }
if (jobId is not null)
{
var prefix = jobId.Value.ToString(CultureInfo.InvariantCulture) + "|";
query = query.Where(item => item.TaskType == StrategySnapshotService.TaskType && item.SubjectType == "job_strategy"
&& item.SubjectId != null && item.SubjectId.StartsWith(prefix)
&& db.JobApplications.IgnoreQueryFilters().Any(job => job.Id == jobId && EF.Functions.Collate(job.OwnerUserId, identifiers) == item.OwnerUserId));
}
if (worker is not null)
{
var documents = db.CvExtractionRuns.IgnoreQueryFilters().Where(run => run.ParserVersion == ProfileCvController.DocumentParserVersion && run.Trigger == "document_import");
if (worker == "untracked") query = query.Where(item => item.TaskType != CvProcessingQueue.TaskType && item.TaskType != StrategySnapshotService.TaskType);
else if (worker == "document") query = query.Where(item => item.TaskType == CvProcessingQueue.TaskType && item.SubjectType == CvProcessingQueue.SubjectType
&& documents.Any(run => EF.Functions.Collate(run.OwnerUserId, identifiers) == item.OwnerUserId && run.Id.ToString() == item.SubjectId));
else query = query.Where(item => (item.TaskType == CvProcessingQueue.TaskType || item.TaskType == StrategySnapshotService.TaskType)
&& !(item.TaskType == CvProcessingQueue.TaskType && item.SubjectType == CvProcessingQueue.SubjectType
&& documents.Any(run => EF.Functions.Collate(run.OwnerUserId, identifiers) == item.OwnerUserId && run.Id.ToString() == item.SubjectId)));
}
if (failureCode is not null)
{
var known = OperationEventLog.FailureCodes.ToArray();
query = failureCode == "processing_failed"
? query.Where(item => item.FailureCategory != null && !known.Contains(item.FailureCategory))
: query.Where(item => item.FailureCategory == failureCode);
}
if (!string.IsNullOrWhiteSpace(q))
{
q = q.Trim();
var users = db.Users.Where(user => user.Id.Contains(q) || (user.DisplayName != null && user.DisplayName.Contains(q))
|| (user.Email != null && user.Email.Contains(q))).Select(user => user.Id);
var jobs = db.JobApplications.IgnoreQueryFilters().Where(job => job.JobTitle.Contains(q)
|| (EF.Functions.Collate(job.Company.OwnerUserId, identifiers) == job.OwnerUserId && job.Company.Name.Contains(q)));
var isId = Guid.TryParse(q, out var operationId);
query = query.Where(item => users.Any(id => EF.Functions.Collate(id, identifiers) == item.OwnerUserId) || (isId && item.Id == operationId)
|| (item.TaskType == StrategySnapshotService.TaskType && item.SubjectType == "job_strategy" && item.SubjectId != null
&& jobs.Any(job => EF.Functions.Collate(job.OwnerUserId, identifiers) == item.OwnerUserId && item.SubjectId.StartsWith(job.Id.ToString() + "|"))));
}
var total = await query.CountAsync(cancellationToken);
var operations = await query.OrderByDescending(item => item.CreatedAtUtc).ThenByDescending(item => item.Id)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(cancellationToken);
var grouped = await all.GroupBy(item => item.Status).Select(group => new { Status = group.Key, Count = group.Count() }).ToListAsync(cancellationToken);
var counts = grouped.GroupBy(item => OperationEventLog.SafeStatus(item.Status)).ToDictionary(group => group.Key, group => group.Sum(item => item.Count));
var workers = new[]
{
new WorkerDto("AiOperations", Enabled("AiOperations"), true),
new WorkerDto("CvDocumentImports", Enabled("CvDocumentImports"), true),
new WorkerDto("JobEnrichment", Enabled("JobEnrichment"), false),
new WorkerDto("Rules", Enabled("Rules"), false),
new WorkerDto("FollowUpReminders", Enabled("FollowUpReminders"), false),
new WorkerDto("DailyExport", Enabled("DailyExport"), false),
new WorkerDto("SavedJobSearchAlerts", configuration.GetValue("Workers:SavedJobSearchAlertsEnabled", true), false),
};
return Ok(new PageDto(await Project(operations, false, cancellationToken), total, page, pageSize, counts, workers, "deterministic"));
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<ItemDto>> Detail(Guid id, CancellationToken cancellationToken)
{
var operation = await ReadOperations().SingleOrDefaultAsync(item => item.Id == id, cancellationToken);
if (operation is null) return NotFound();
return Ok((await Project(new[] { operation }, true, cancellationToken)).Single());
}
[HttpGet("{id:guid}/events")]
public async Task<ActionResult<EventPageDto>> Events(Guid id, [FromQuery] long? after = null, [FromQuery] long? before = null,
[FromQuery] int take = 100, CancellationToken cancellationToken = default)
{
if (take is < 1 or > 100 || after < 0 || before < 0 || (after is not null && before is not null)) return BadRequest();
var owner = await db.UserOperations.IgnoreQueryFilters().Where(item => item.Id == id).Select(item => item.OwnerUserId).SingleOrDefaultAsync(cancellationToken);
if (owner is null) return NotFound();
var cutoff = clock.GetUtcNow().UtcDateTime.AddDays(-OperationEventLog.RetentionDays);
var query = db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking().Where(item => item.OperationId == id && item.OwnerUserId == owner && item.AtUtc >= cutoff);
if (after is not null) query = query.Where(item => item.Id > after);
if (before is not null) query = query.Where(item => item.Id < before);
var rows = await (after is not null ? query.OrderBy(item => item.Id) : query.OrderByDescending(item => item.Id)).Take(take + 1).ToListAsync(cancellationToken);
var hasMore = rows.Count > take;
rows = rows.Take(take).ToList();
if (after is null) rows.Reverse();
var items = rows.Select(item => new EventDto(item.Id.ToString(CultureInfo.InvariantCulture), Utc(item.AtUtc),
EventKinds.Contains(item.Kind) ? item.Kind : "Unknown",
OperationEventLog.SafeStatus(item.Status), OperationEventLog.SafeStage(item.Stage), Math.Clamp(item.Attempt, 0, 10), OperationEventLog.SafeFailure(item.FailureCode))).ToArray();
return Ok(new EventPageDto(items, hasMore, OperationEventLog.RetentionDays));
}
private bool Enabled(string worker) => configuration.GetValue($"Workers:{worker}Enabled", false);
private IQueryable<UserOperation> ReadOperations() => db.UserOperations.IgnoreQueryFilters().AsNoTracking().Select(item => new UserOperation
{
Id = item.Id, OwnerUserId = item.OwnerUserId, TaskType = item.TaskType, SubjectType = item.SubjectType, SubjectId = item.SubjectId,
Status = item.Status, ProgressStage = item.ProgressStage, AttemptCount = item.AttemptCount, MaxAttempts = item.MaxAttempts,
CreatedAtUtc = item.CreatedAtUtc, StartedAtUtc = item.StartedAtUtc, CompletedAtUtc = item.CompletedAtUtc,
LastHeartbeatAtUtc = item.LastHeartbeatAtUtc, FailureCategory = item.FailureCategory,
});
private static DateTime Utc(DateTime value) => DateTime.SpecifyKind(value, DateTimeKind.Utc);
private static DateTime? Utc(DateTime? value) => value is null ? null : Utc(value.Value);
private static string SafeTask(string value) => value is CvProcessingQueue.TaskType or StrategySnapshotService.TaskType ? value : "other";
private static int? Subject(UserOperation operation)
{
if (operation.SubjectId?.Length > 128) return null;
if (operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectType == CvProcessingQueue.SubjectType)
return int.TryParse(operation.SubjectId, NumberStyles.None, CultureInfo.InvariantCulture, out var id) && id > 0 ? id : null;
if (operation.TaskType == StrategySnapshotService.TaskType && operation.SubjectType == "job_strategy")
{
try { return StrategySnapshotService.DecodeSubject(operation.SubjectId).JobId; }
catch (AiOperationFailure) { return null; }
}
return null;
}
private static string FileName(string value) => new string(value.Split('/', '\\').Last().Where(character => !char.IsControl(character)).Take(120).ToArray());
private static int? Pages(string? json)
{
if (json is null || json.Length > 2 * 1024 * 1024) return null;
try
{
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Object &&
(document.RootElement.TryGetProperty("PageCount", out var field) || document.RootElement.TryGetProperty("pageCount", out field)) &&
field.ValueKind == JsonValueKind.Number && field.TryGetInt32(out var count) && count is > 0 and <= 5000) return count;
}
catch (JsonException) { }
return null;
}
private async Task<IReadOnlyList<ItemDto>> Project(IReadOnlyList<UserOperation> operations, bool includePages, CancellationToken token)
{
var selected = includePages && operations.Count == 1 ? operations[0] : null;
var code = OperationEventLog.SafeFailure(selected?.FailureCategory);
var cutoff = clock.GetUtcNow().UtcDateTime.AddDays(-OperationEventLog.RetentionDays);
var lastFailure = selected is null || code is null ? null : await db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking()
.Where(item => item.OperationId == selected.Id && item.OwnerUserId == selected.OwnerUserId && item.AtUtc >= cutoff && item.FailureCode == code
&& (item.Kind == "Failed" || item.Kind == "RetryScheduled" || item.Kind == "LeaseRecovered" || item.Kind == "Expired"))
.OrderByDescending(item => item.Id).Select(item => new { item.AtUtc, item.Stage }).FirstOrDefaultAsync(token);
var ownerIds = operations.Select(item => item.OwnerUserId).Distinct().ToArray();
var users = await db.Users.AsNoTracking().Where(item => ownerIds.Contains(item.Id))
.Select(item => new { item.Id, Name = item.DisplayName ?? item.UserName, item.Email })
.Select(item => new IdentityDto(item.Id, item.Name != null && item.Name.Length > 160 ? item.Name.Substring(0, 160) + "…" : item.Name,
item.Email != null && item.Email.Length > 254 ? item.Email.Substring(0, 254) + "…" : item.Email)).ToDictionaryAsync(item => item.Id, token);
var cvIds = operations.Where(item => item.TaskType == CvProcessingQueue.TaskType).Select(Subject).OfType<int>().ToArray();
var jobIds = operations.Where(item => item.TaskType == StrategySnapshotService.TaskType).Select(Subject).OfType<int>().ToArray();
var runs = await db.CvExtractionRuns.IgnoreQueryFilters().AsNoTracking().Where(item => cvIds.Contains(item.Id))
.Select(item => new { item.Id, item.OwnerUserId, item.ArtifactId, item.ParserVersion, item.Trigger, item.Status, DocumentJson = includePages ? item.DocumentJson : null }).ToDictionaryAsync(item => item.Id, token);
var artifactIds = runs.Values.Select(item => item.ArtifactId).OfType<int>().ToArray();
var artifacts = await db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().Where(item => artifactIds.Contains(item.Id))
.Select(item => new { item.Id, item.OwnerUserId, item.OriginalFileName, item.MimeType, item.ByteSize }).ToDictionaryAsync(item => item.Id, token);
var jobs = await db.JobApplications.IgnoreQueryFilters().AsNoTracking().Where(item => jobIds.Contains(item.Id))
.Select(item => new { item.Id, item.OwnerUserId, JobTitle = item.JobTitle.Length > 200 ? item.JobTitle.Substring(0, 200) + "…" : item.JobTitle,
Company = item.Company.OwnerUserId == item.OwnerUserId ? (item.Company.Name.Length > 160 ? item.Company.Name.Substring(0, 160) + "…" : item.Company.Name) : null }).ToDictionaryAsync(item => item.Id, token);
return operations.Select(operation =>
{
var subject = Subject(operation);
var run = operation.TaskType == CvProcessingQueue.TaskType && subject is int cvId && runs.TryGetValue(cvId, out var foundRun) && foundRun.OwnerUserId == operation.OwnerUserId ? foundRun : null;
var job = operation.TaskType == StrategySnapshotService.TaskType && subject is int jobId && jobs.TryGetValue(jobId, out var foundJob) && foundJob.OwnerUserId == operation.OwnerUserId ? new JobDto(foundJob.Id, foundJob.JobTitle, foundJob.Company, foundJob.OwnerUserId == HttpContext?.User.FindFirstValue(ClaimTypes.NameIdentifier)) : null;
var artifact = run?.ArtifactId is int artifactId && artifacts.TryGetValue(artifactId, out var foundArtifact) && foundArtifact.OwnerUserId == operation.OwnerUserId ? foundArtifact : null;
var document = artifact is null ? null : new DocumentDto(run!.Id, FileName(artifact.OriginalFileName),
artifact.MimeType is "application/pdf" or "application/vnd.openxmlformats-officedocument.wordprocessingml.document" ? artifact.MimeType : null,
Math.Max(0, artifact.ByteSize), Pages(run?.DocumentJson), run?.Status is "pending_review" or "applied" or "expired" or "failed" or "cancelled" or "running" or "queued" ? run.Status : null);
var worker = SafeTask(operation.TaskType) == "other" ? "untracked" : run is { ParserVersion: ProfileCvController.DocumentParserVersion, Trigger: "document_import" } ? "document" : "ai";
var duration = operation.StartedAtUtc is DateTime started ? Math.Max(0, ((operation.CompletedAtUtc ?? clock.GetUtcNow().UtcDateTime) - started).TotalSeconds) : 0;
return new ItemDto(operation.Id, users.GetValueOrDefault(operation.OwnerUserId) ?? new(operation.OwnerUserId, null, null), SafeTask(operation.TaskType),
OperationEventLog.SafeStatus(operation.Status), OperationEventLog.SafeStage(operation.ProgressStage), Math.Clamp(operation.AttemptCount, 0, 10),
Math.Clamp(operation.MaxAttempts, 1, 10), Utc(operation.CreatedAtUtc), Utc(operation.StartedAtUtc), Utc(operation.CompletedAtUtc), Utc(operation.LastHeartbeatAtUtc),
duration, worker, worker != "untracked" && (Enabled("AiOperations") || (worker == "document" && Enabled("CvDocumentImports"))), OperationEventLog.SafeFailure(operation.FailureCategory), job, document,
Utc(lastFailure?.AtUtc ?? (operation.Status == OperationStatuses.Failed ? operation.CompletedAtUtc : null)),
OperationEventLog.SafeStage(lastFailure?.Stage ?? (operation.Status == OperationStatuses.Failed ? operation.ProgressStage : null)));
}).ToArray();
}
}
@@ -79,13 +79,9 @@ public sealed class ApplicationAssetsController : ControllerBase
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema.");
try
{
var result = await _assets.SaveCoverLetterAsync(
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings);
return result is null ? NotFound() : Ok(result);
}
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
var result = await _assets.SaveCoverLetterAsync(
userId, jobId, request?.Text, request?.Source ?? CoverLetterSources.Manual, request?.AiAction, request?.ExpectedVersion, ct, request?.DocumentSettings);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("cover-letter/preview")]
@@ -94,12 +90,8 @@ public sealed class ApplicationAssetsController : ControllerBase
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
if (request.DocumentSettings is { SchemaVersion: not 1 }) return BadRequest("Unsupported letter settings schema.");
try
{
var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct);
return result is null ? NotFound() : Ok(result);
}
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
var result = await _assets.PreviewCoverLetterAsync(userId, jobId, request.Text, request.DocumentSettings, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpPost("cover-letter/export-pdf")]
@@ -126,12 +118,8 @@ public sealed class ApplicationAssetsController : ControllerBase
{
var userId = await CurrentUserIdAsync();
if (userId is null) return Unauthorized();
try
{
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
return result is null ? NotFound() : Ok(result);
}
catch (CoverLetterThemeAccessException) { return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Pro."); }
var result = await _assets.RestoreCoverLetterAsync(userId, jobId, version, request?.ExpectedVersion, ct);
return result is null ? NotFound() : Ok(result);
}
[HttpGet("submitted-packages")]
+12 -48
View File
@@ -16,7 +16,6 @@ namespace JobTrackerApi.Controllers;
[Route("api/auth")]
public sealed class AuthController : ControllerBase
{
private static readonly SemaphoreSlim AvatarProcessingSlots = new(2, 2);
private readonly IConfiguration _cfg;
private readonly UserManager<ApplicationUser> _users;
private readonly ITokenService _tokens;
@@ -565,8 +564,7 @@ public sealed class AuthController : ControllerBase
var existing = await _db.Users.AsNoTracking().Where(x => x.Id == userId)
.Select(x => new { x.DashboardLayoutJson, x.DashboardLayoutRevision }).SingleOrDefaultAsync(cancellationToken);
if (existing is null) return Unauthorized();
var storedVersion = DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version;
if (storedVersion > 2)
if (DashboardLayout.Read(existing.DashboardLayoutJson, 0).Version > 1)
return Conflict(new { error = "dashboard_version_unsupported" });
if (Request.ContentLength > DashboardLayout.MaxBytes) return StatusCode(StatusCodes.Status413PayloadTooLarge);
// Bound actual bytes as well as Content-Length, including chunked bodies.
@@ -587,8 +585,6 @@ public sealed class AuthController : ControllerBase
}
catch (JsonException) { return BadRequest("Invalid dashboard layout JSON."); }
if (layout is null) return BadRequest("Invalid dashboard layout version, revision or widgets.");
if (layout.Version < storedVersion)
return Conflict(new { error = "dashboard_version_unsupported" });
if (layout.Revision != existing.DashboardLayoutRevision)
return Conflict(new { error = "dashboard_revision_conflict" });
var json = JsonSerializer.Serialize(layout, new JsonSerializerOptions(JsonSerializerDefaults.Web));
@@ -937,16 +933,9 @@ public sealed class AuthController : ControllerBase
}
await using var stream = file.OpenReadStream();
var bytes = new byte[MaxAvatarBytes + 1];
var length = 0;
while (length < bytes.Length)
{
var read = await stream.ReadAsync(bytes.AsMemory(length), HttpContext.RequestAborted);
if (read == 0) break;
length += read;
}
if (length > MaxAvatarBytes) return BadRequest("Avatar image is too large.");
Array.Resize(ref bytes, length);
using var memory = new MemoryStream();
await stream.CopyToAsync(memory);
var bytes = memory.ToArray();
var detectedContentType = DetectAvatarContentType(bytes);
if (detectedContentType is null)
@@ -954,40 +943,15 @@ public sealed class AuthController : ControllerBase
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
if (!await AvatarProcessingSlots.WaitAsync(0, HttpContext.RequestAborted))
return StatusCode(503, new { code = "avatar_busy", message = "Image processing is busy. Try again shortly." });
try
user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
byte[]? normalized;
try { normalized = AvatarImageProcessor.Normalize(bytes); }
catch (Exception exception) when (exception is DllNotFoundException or TypeInitializationException or EntryPointNotFoundException or BadImageFormatException)
{
return StatusCode(503, new { code = "avatar_decoder_unavailable", message = "Image processing is unavailable. Your previous photo has been preserved." });
}
if (normalized is null) return BadRequest("Select a valid, still PNG, JPEG or WebP image up to 2048 pixels per side and 4 megapixels. Crop or resize larger images.");
var previous = user.AvatarImageDataUrl;
var staged = await AvatarStorage.StageAsync(_avatarDataRoot, user.Id, normalized, HttpContext.RequestAborted);
try
{
user.AvatarImageDataUrl = staged;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
{
user.AvatarImageDataUrl = previous;
AvatarStorage.TryDelete(staged);
return BadRequest("The photo could not be saved. Your previous photo has been preserved.");
}
}
catch
{
user.AvatarImageDataUrl = previous;
// An acknowledgement failure may follow a committed identity write; retention checks references.
throw;
}
AvatarStorage.TryDelete(previous);
return Ok(new { avatarImageDataUrl = $"data:image/png;base64,{Convert.ToBase64String(normalized)}" });
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
finally { AvatarProcessingSlots.Release(); }
return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) });
}
[HttpDelete("avatar")]
@@ -1008,7 +972,7 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
AvatarStorage.TryDelete(storedAvatar);
AvatarStorage.Delete(storedAvatar);
return NoContent();
}
@@ -50,7 +50,7 @@ public sealed class CvVariantController : ControllerBase
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var proThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).ProThemes;
var themes = CvThemeCatalog.RevisedThemes.Select(t => new
var themes = CvThemeCatalog.Themes.Select(t => new
{
id = t.Id,
name = t.Name,
@@ -67,7 +67,6 @@ public sealed class CvVariantController : ControllerBase
supportedSettings = t.SupportedSettings,
pageMarginMm = t.PageMarginMm,
bodyFont = t.BodyFont,
headingFont = t.HeadingFont,
baseFontSizePt = t.BodySizePt,
lineHeight = t.LineHeight,
});
@@ -83,7 +82,7 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
if (!CvThemeCatalog.Exists(themeId)) return NotFound();
var theme = CvThemeCatalog.Resolve(themeId);
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, DesignVersion = 2, ShowIcons = true });
var settings = CvVariantSettingsJson.Normalize(new CvVariantSettings { ThemeId = theme.Id, ShowIcons = true });
var render = _renderer.Render(TemplatePreviewModel(), theme, settings);
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
@@ -241,7 +240,6 @@ public sealed class CvVariantController : ControllerBase
{
var render = await _variants.RenderAsync(user.Id, id, Person(user), ct, expectedVersion);
if (render is null) return NotFound();
if (!render.HasContent) return BadRequest(new { code = "cv_empty_content" });
var artifact = await _pdf.ExportAsync(user.Id, new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
@@ -23,8 +23,8 @@ public sealed partial class ProfileCvController
cancellationToken.ThrowIfCancellationRequested();
if (context is null) return;
var lease = context.Lease;
var updated = await new UserOperationStore(_db, TimeProvider.System)
.ReportStageAsync(lease.OperationId, lease.LeaseToken, stage, cancellationToken);
var updated = await _db.UserOperations.Where(o => o.Id == lease.OperationId && o.LeaseToken == lease.LeaseToken && o.Status == OperationStatuses.Running && o.CancellationRequestedAtUtc == null && o.LeaseExpiresAtUtc > DateTime.UtcNow)
.ExecuteUpdateAsync(s => s.SetProperty(o => o.ProgressStage, stage), cancellationToken);
if (updated != 1) throw new OperationCanceledException("The document processing lease is no longer active.", cancellationToken);
}
await Stage("cv_validating_file");
-11
View File
@@ -66,7 +66,6 @@ namespace JobTrackerApi.Data
public DbSet<SubmittedPackageAttachment> SubmittedPackageAttachments => Set<SubmittedPackageAttachment>();
public DbSet<InterviewPrepItem> InterviewPrepItems => Set<InterviewPrepItem>();
public DbSet<UserOperation> UserOperations => Set<UserOperation>();
public DbSet<UserOperationEvent> UserOperationEvents => Set<UserOperationEvent>();
public DbSet<UserNotification> UserNotifications => Set<UserNotification>();
public DbSet<EmailSendAttempt> EmailSendAttempts => Set<EmailSendAttempt>();
public DbSet<EmailDraft> EmailDrafts => Set<EmailDraft>();
@@ -322,16 +321,6 @@ namespace JobTrackerApi.Data
modelBuilder.Entity<UserOperation>()
.HasIndex(x => new { x.Status, x.AvailableAtUtc, x.Priority });
modelBuilder.Entity<UserOperationEvent>().HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<UserOperationEvent>().Property(x => x.OwnerUserId).HasMaxLength(255);
modelBuilder.Entity<UserOperationEvent>().Property(x => x.Kind).HasMaxLength(40);
modelBuilder.Entity<UserOperationEvent>().Property(x => x.Status).HasMaxLength(32);
modelBuilder.Entity<UserOperationEvent>().Property(x => x.Stage).HasMaxLength(64);
modelBuilder.Entity<UserOperationEvent>().Property(x => x.FailureCode).HasMaxLength(64);
modelBuilder.Entity<UserOperationEvent>().HasIndex(x => new { x.OperationId, x.Id });
modelBuilder.Entity<UserOperationEvent>().HasIndex(x => x.AtUtc);
modelBuilder.Entity<UserOperationEvent>().HasOne<UserOperation>().WithMany().HasForeignKey(x => x.OperationId).OnDelete(DeleteBehavior.Cascade);
modelBuilder.Entity<UserNotification>()
.HasQueryFilter(x => CurrentUserId != null && x.OwnerUserId == CurrentUserId);
modelBuilder.Entity<UserNotification>().Property(x => x.OwnerUserId).HasMaxLength(255);
-2
View File
@@ -28,8 +28,6 @@
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="SkiaSharp" Version="4.153.1" />
<PackageReference Include="SkiaSharp.NativeAssets.Linux.NoDependencies" Version="4.153.1" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageReference Include="Stripe.net" Version="52.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
@@ -1,50 +0,0 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace JobTrackerApi.Migrations;
[DbContext(typeof(JobTrackerContext))]
[Migration("20261028090000_AddUserOperationEvents")]
public sealed class AddUserOperationEvents : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
if (ActiveProvider?.Contains("MySql", StringComparison.OrdinalIgnoreCase) == true)
migrationBuilder.Sql("""
CREATE TABLE `UserOperationEvents` (
`Id` bigint NOT NULL AUTO_INCREMENT,
`OperationId` char(36) CHARACTER SET ascii COLLATE ascii_general_ci NOT NULL,
`OwnerUserId` varchar(255) NOT NULL,
`AtUtc` datetime(6) NOT NULL,
`Kind` varchar(40) NOT NULL,
`Status` varchar(32) NOT NULL,
`Stage` varchar(64) NULL,
`Attempt` int NOT NULL,
`FailureCode` varchar(64) NULL,
CONSTRAINT `PK_UserOperationEvents` PRIMARY KEY (`Id`),
CONSTRAINT `FK_UserOperationEvents_UserOperations_OperationId` FOREIGN KEY (`OperationId`) REFERENCES `UserOperations` (`Id`) ON DELETE CASCADE
) CHARACTER SET=utf8mb4;
""");
else
migrationBuilder.CreateTable("UserOperationEvents", table => new
{
Id = table.Column<long>(type: "INTEGER", nullable: false).Annotation("Sqlite:Autoincrement", true),
OperationId = table.Column<Guid>(type: "TEXT", nullable: false),
OwnerUserId = table.Column<string>(type: "TEXT", maxLength: 255, nullable: false),
AtUtc = table.Column<DateTime>(type: "TEXT", nullable: false),
Kind = table.Column<string>(type: "TEXT", maxLength: 40, nullable: false),
Status = table.Column<string>(type: "TEXT", maxLength: 32, nullable: false),
Stage = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
Attempt = table.Column<int>(type: "INTEGER", nullable: false),
FailureCode = table.Column<string>(type: "TEXT", maxLength: 64, nullable: true),
}, constraints: table =>
{
table.PrimaryKey("PK_UserOperationEvents", x => x.Id);
table.ForeignKey("FK_UserOperationEvents_UserOperations_OperationId", x => x.OperationId, "UserOperations", "Id", onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex("IX_UserOperationEvents_OperationId_Id", "UserOperationEvents", new[] { "OperationId", "Id" });
migrationBuilder.CreateIndex("IX_UserOperationEvents_AtUtc", "UserOperationEvents", "AtUtc");
}
protected override void Down(MigrationBuilder migrationBuilder) => migrationBuilder.DropTable("UserOperationEvents");
}
@@ -2265,24 +2265,6 @@ namespace JobTrackerApi.Migrations
b.ToTable("TwoFactorRecoveryCodes");
});
modelBuilder.Entity("JobTrackerApi.Models.UserOperationEvent", b =>
{
b.Property<long>("Id").ValueGeneratedOnAdd().HasColumnType("INTEGER");
b.Property<Guid>("OperationId").HasColumnType("TEXT");
b.Property<string>("OwnerUserId").IsRequired().HasMaxLength(255).HasColumnType("TEXT");
b.Property<DateTime>("AtUtc").HasColumnType("TEXT");
b.Property<string>("Kind").IsRequired().HasMaxLength(40).HasColumnType("TEXT");
b.Property<string>("Status").IsRequired().HasMaxLength(32).HasColumnType("TEXT");
b.Property<string>("Stage").HasMaxLength(64).HasColumnType("TEXT");
b.Property<int>("Attempt").HasColumnType("INTEGER");
b.Property<string>("FailureCode").HasMaxLength(64).HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("OperationId", "Id");
b.HasIndex("AtUtc");
b.ToTable("UserOperationEvents");
b.HasOne("JobTrackerApi.Models.UserOperation", null).WithMany().HasForeignKey("OperationId").OnDelete(DeleteBehavior.Cascade).IsRequired();
});
modelBuilder.Entity("JobTrackerApi.Models.UserNotification", b =>
{
b.Property<Guid>("Id")
@@ -1,5 +1,4 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models;
@@ -7,10 +6,6 @@ namespace JobTrackerApi.Models;
public sealed class CoverLetterDocumentSettings
{
public int SchemaVersion { get; set; } = 1;
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? DesignVersion { get; set; }
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public string? HeadingFont { get; set; }
public string Language { get; set; } = "en";
public string SenderName { get; set; } = "";
public string SenderTitle { get; set; } = "";
@@ -43,18 +38,16 @@ public static class CoverLetterDocumentSettingsJson
public static CoverLetterDocumentSettings Normalize(CoverLetterDocumentSettings? value)
{
value ??= new();
var theme = CvThemeCatalog.Resolve(value.ThemeId, value.DesignVersion);
var theme = CvThemeCatalog.Resolve(value.ThemeId);
var style = CvVariantSettingsJson.Normalize(new()
{
AccentColor = value.AccentColor, BodyFont = value.BodyFont, HeadingFont = value.HeadingFont,
AccentColor = value.AccentColor, BodyFont = value.BodyFont,
BaseFontSizePt = value.BaseFontSizePt, LineHeight = value.LineHeight, PageMarginMm = value.PageMarginMm,
});
static string Text(string? text, int limit) => (text ?? "")[..Math.Min(text?.Length ?? 0, limit)];
return new()
{
SchemaVersion = 1, ThemeId = theme.Id,
DesignVersion = value.DesignVersion is 1 or 2 ? value.DesignVersion : null,
HeadingFont = value.DesignVersion == 2 ? style.HeadingFont ?? theme.HeadingFont : null,
Language = value.Language?.Trim().ToLowerInvariant() is "nb" or "nb-no" ? "nb-NO" : "en",
SenderName = Text(value.SenderName, 300), SenderTitle = Text(value.SenderTitle, 300),
SenderEmail = Text(value.SenderEmail, 300), SenderPhone = Text(value.SenderPhone, 100), SenderLocation = Text(value.SenderLocation, 300),
-41
View File
@@ -38,8 +38,6 @@ public sealed class CvTheme
public double PageMarginMm { get; init; } = 16;
public double SectionGapMm { get; init; } = 6;
public double EntryGapMm { get; init; } = 4.5;
public double NameGapMm { get; init; }
public double HeaderContentGapMm { get; init; }
// plain | band | centered | kicker
public string HeaderStyle { get; init; } = "plain";
@@ -161,45 +159,6 @@ public static class CvThemeCatalog
},
};
// Versioned data keeps existing saved lenses and frozen output on their original design.
public static readonly IReadOnlyList<CvTheme> RevisedThemes = Themes.Select(Revised).ToArray();
private static CvTheme Revised(CvTheme source)
{
var (nameSize, sectionGap, entryGap) = source.Id switch
{
"modern" => (25d, 5.8, 4.0),
"minimal" => (25d, 7.5, 4.8),
"executive" => (28d, 6.5, 4.6),
"technical" => (24d, 4.8, 3.2),
"ats-classic" => (23d, 5.0, 3.4),
"nordic" => (23d, 6.0, 4.2),
"elegant" => (27d, 7.5, 5.0),
"creative" => (20d, 5.5, 3.8),
_ => (25.5, 5.2, 3.6),
};
return new CvTheme
{
Id = source.Id, Name = source.Name, Category = source.Category, Description = source.Description,
Layout = source.Layout, SidebarWidthMm = 68,
Accent = source.Accent, Ink = source.Ink, Muted = source.Muted, Line = source.Line,
Paper = source.Paper, SidebarBg = source.SidebarBg, SidebarInk = source.SidebarInk, HeadingColor = source.HeadingColor,
HeadingFont = source.HeadingFont, BodyFont = source.BodyFont, UtilityFont = source.UtilityFont,
NameSizePt = nameSize, HeadingSizePt = source.HeadingSizePt, BodySizePt = source.BodySizePt, LineHeight = source.LineHeight,
PageMarginMm = source.PageMarginMm, SectionGapMm = sectionGap, EntryGapMm = entryGap,
NameGapMm = source.Id is "modern" or "ats-classic" or "code" ? 3 : 0,
HeaderContentGapMm = source.Id is "modern" or "ats-classic" or "code" ? 5 : 0,
HeaderStyle = source.HeaderStyle, HeadingStyle = source.HeadingStyle, PhotoShape = source.PhotoShape,
DefaultIcons = source.DefaultIcons, AtsFriendly = source.AtsFriendly, Premium = source.Premium,
NumberSections = source.NumberSections, DefaultSkillsStyle = source.DefaultSkillsStyle,
SupportedSettings = new(source.SupportedSettings), SidebarSections = new(source.SidebarSections),
};
}
public static CvTheme Resolve(string? id, int? designVersion) => designVersion == 2
? RevisedThemes.FirstOrDefault(theme => theme.Id == (id ?? string.Empty).Trim().ToLowerInvariant()) ?? RevisedThemes[0]
: Resolve(id);
public static CvTheme Resolve(string? id)
{
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
@@ -9,8 +9,6 @@ namespace JobTrackerApi.Models;
public sealed class CvVariantSettings
{
public string ThemeId { get; set; } = "modern";
// Missing or version 1 retains the original template output.
public int? DesignVersion { get; set; }
// Theme overrides (null => use the theme's own value).
public string? AccentColor { get; set; }
@@ -46,10 +44,6 @@ public sealed class CvVariantSettings
public bool ShowPageNumbers { get; set; }
public bool ShowIcons { get; set; } = true;
// Missing flags preserve legacy profile inheritance, including serialized history snapshots.
public bool? SectionsExplicit { get; set; }
public bool? IncludeProfileContact { get; set; }
// Section order + visibility + optional renamed heading. Empty => default order, all visible.
public List<CvSectionSetting> Sections { get; set; } = new();
@@ -134,7 +128,6 @@ public static class CvVariantSettingsJson
{
s ??= new CvVariantSettings();
s.ThemeId = string.IsNullOrWhiteSpace(s.ThemeId) ? "modern" : s.ThemeId.Trim().ToLowerInvariant();
s.DesignVersion = s.DesignVersion is 1 or 2 ? s.DesignVersion : null;
s.AccentColor = NormalizeColor(s.AccentColor);
s.TextColor = NormalizeColor(s.TextColor);
s.MutedColor = NormalizeColor(s.MutedColor);
+5 -36
View File
@@ -1,14 +1,9 @@
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace JobTrackerApi.Models;
public sealed record DashboardWidget(string Id, bool Visible, string Size,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? X = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? Y = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? W = null,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] int? H = null);
public sealed record DashboardWidget(string Id, bool Visible, string Size);
public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<DashboardWidget> Widgets)
{
public const int MaxBytes = 16 * 1024;
@@ -19,7 +14,7 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<D
public static DashboardLayout? Parse(JsonElement root, bool reading = false, long revision = 0)
{
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v is not (1 or 2)
if (root.ValueKind != JsonValueKind.Object || !root.TryGetProperty("version", out var version) || version.ValueKind != JsonValueKind.Number || !version.TryGetInt32(out var v) || v != 1
|| !root.TryGetProperty("widgets", out var widgets) || widgets.ValueKind != JsonValueKind.Array || widgets.GetArrayLength() > 32) return null;
if (!reading && (!root.TryGetProperty("revision", out var rev) || rev.ValueKind != JsonValueKind.Number || !rev.TryGetInt64(out revision) || revision < 0 || revision >= MaxRevision)) return null;
var result = new List<DashboardWidget>();
@@ -40,35 +35,9 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<D
if (!reading) return null;
size = id == "cards" ? "full" : "standard";
}
if (v == 1) result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
else
{
static bool Integer(JsonElement item, string key, out int number)
{
number = 0;
return item.TryGetProperty(key, out var field) && field.ValueKind == JsonValueKind.Number && field.TryGetInt32(out number);
}
if (!Integer(widget, "x", out var x) || !Integer(widget, "y", out var y) || !Integer(widget, "w", out var w) || !Integer(widget, "h", out var h)
|| x is < 0 or > 12 || y is < 0 or > 256 || w is < 4 or > 12 || h is < 4 or > 24 || x + w > 12 || y + h > 256
|| (id == "cards" && w != 12) || (!reading && size != (w == 12 ? "full" : "standard")))
{
if (reading) continue;
return null;
}
result.Add(new DashboardWidget(id, visible.GetBoolean(), w == 12 ? "full" : "standard", x, y, w, h));
}
result.Add(new DashboardWidget(id, visible.GetBoolean(), size));
}
if (!reading && v == 2)
{
var visibleWidgets = result.Where(widget => widget.Visible).ToArray();
for (var first = 0; first < visibleWidgets.Length; first++)
for (var second = first + 1; second < visibleWidgets.Length; second++)
{
var a = visibleWidgets[first]; var b = visibleWidgets[second];
if (a.X < b.X + b.W && a.X + a.W > b.X && a.Y < b.Y + b.H && a.Y + a.H > b.Y) return null;
}
}
return new DashboardLayout(v, revision, result);
return new DashboardLayout(1, revision, result);
}
public static DashboardLayout Read(string? json, long revision)
@@ -80,7 +49,7 @@ public sealed record DashboardLayout(int Version, long Revision, IReadOnlyList<D
using var document = JsonDocument.Parse(json);
if (document.RootElement.ValueKind == JsonValueKind.Object
&& document.RootElement.TryGetProperty("version", out var version) && version.ValueKind == JsonValueKind.Number
&& version.TryGetInt32(out var futureVersion) && futureVersion > 2)
&& version.TryGetInt32(out var futureVersion) && futureVersion > 1)
return new(futureVersion, revision, []);
return Parse(document.RootElement, reading: true, revision) ?? new(1, revision, []);
}
@@ -1,20 +0,0 @@
namespace JobTrackerApi.Models;
public enum OperationEventKind
{
Created, Claimed, StageChanged, Completed, RetryScheduled, Failed,
CancellationRequested, Cancelled, Retried, LeaseRecovered, Expired
}
public sealed class UserOperationEvent
{
public long Id { get; set; }
public Guid OperationId { get; set; }
public string OwnerUserId { get; set; } = string.Empty;
public DateTime AtUtc { get; set; }
public string Kind { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty;
public string? Stage { get; set; }
public int Attempt { get; set; }
public string? FailureCode { get; set; }
}
+1 -11
View File
@@ -187,7 +187,7 @@ builder.Services.AddHttpClient("ai-service", client =>
?? builder.Configuration["Summarizer:BaseUrl"]
?? "http://127.0.0.1:8001";
client.BaseAddress = new Uri(baseUrl);
client.Timeout = TimeSpan.FromSeconds(Math.Clamp(builder.Configuration.GetValue("Ai:RequestTimeoutSeconds", 30), 5, 300));
client.Timeout = TimeSpan.FromSeconds(30);
var serviceToken = builder.Configuration["Ai:ServiceToken"];
if (!string.IsNullOrWhiteSpace(serviceToken))
@@ -460,16 +460,6 @@ builder.Services.AddRateLimiter(options =>
QueueLimit = 0,
}));
options.AddPolicy(AdminProcessingController.RateLimitPolicy, context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: RateLimitPartitionKeys.Account(context, AdminProcessingController.RateLimitPolicy),
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 120,
Window = TimeSpan.FromMinutes(1),
QueueLimit = 0,
}));
options.AddPolicy("public-pdf", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: RateLimitPartitionKeys.PublicPdf(context),
@@ -248,9 +248,6 @@ public sealed class AccountDataExportService(
}).ToListAsync(cancellationToken);
var notifications = await db.UserNotifications.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId).OrderBy(item => item.CreatedAtUtc).ToListAsync(cancellationToken);
await AddJsonAsync("data/operations.json", new { Operations = operations, Notifications = notifications }, operations.Count + notifications.Count);
var operationEvents = await db.UserOperationEvents.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
.OrderBy(item => item.Id).ToListAsync(cancellationToken);
await AddJsonAsync("data/operation-events.json", operationEvents, operationEvents.Count);
var gmail = await db.GmailConnections.IgnoreQueryFilters().AsNoTracking().Where(item => item.OwnerUserId == ownerUserId)
.Select(item => new { item.GmailAddress, item.Scope, item.ConnectedAt, item.LastSyncedAt, item.LastSyncAttemptedAt, item.LastSyncSucceededAt, item.LastSyncMode, item.LastSyncSource, item.LastSyncStatus, item.LastSyncError }).ToListAsync(cancellationToken);
@@ -264,7 +264,6 @@ public sealed class AccountDeletionService(
deleted += await db.UserRuleSettings.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserNotifications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserOperations.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.UserOperationEvents.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.JobApplications.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Jobs.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
deleted += await db.Companies.IgnoreQueryFilters().Where(item => item.OwnerUserId == owner).ExecuteDeleteAsync(cancellationToken);
@@ -48,11 +48,6 @@ public sealed class AccountOwnedFileInventory(JobTrackerContext db, AppPaths pat
var avatar = await db.Users.AsNoTracking().Where(item => item.Id == ownerUserId).Select(item => item.AvatarImageDataUrl).FirstOrDefaultAsync(cancellationToken);
AddAvatar(files, warnings, ownerUserId, avatar);
foreach (var portrait in AvatarStorage.PortraitFiles(paths.DataRoot, AppPaths.GetOwnerStorageKey(ownerUserId)))
{
if (files.Any(file => string.Equals(file.SourcePath, Path.GetFullPath(portrait), OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) continue;
AddPath(files, warnings, portrait, $"files/avatar/{SafeSegment(Path.GetFileName(portrait))}", "avatar", candidate => IsManagedPath(paths.DataRoot, candidate));
}
AddDirectory(files, warnings, paths.GetOwnerCvExportsRoot(ownerUserId), "files/generated-cv", "generated-cv");
AddDirectory(files, warnings, paths.GetOwnerDailyExportsRoot(null, ownerUserId), "files/daily-exports", "daily-export");
if (includeAccountExports)
+6 -7
View File
@@ -155,13 +155,13 @@ public sealed class AiOperationWorker(
private readonly IReadOnlyDictionary<string, IAiOperationHandler> _handlers = registeredHandlers
.ToDictionary(handler => handler.TaskType, StringComparer.Ordinal);
public async Task<bool> RunOnceAsync(CancellationToken stoppingToken, bool documentImportsOnly = false)
public async Task<bool> RunOnceAsync(CancellationToken stoppingToken)
{
if (_handlers.Count == 0) return false;
var leaseSeconds = Math.Clamp(configuration.GetValue("AiQueue:LeaseSeconds", 120), 30, 1800);
await using var claimScope = scopes.CreateAsyncScope();
var lease = await claimScope.ServiceProvider.GetRequiredService<UserOperationStore>()
.ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray(), documentImportsOnly);
.ClaimNextAsync(TimeSpan.FromSeconds(leaseSeconds), stoppingToken, _handlers.Keys.ToArray());
if (lease is null) return false;
await using var ownerScope = scopes.CreateAsyncScope();
@@ -255,18 +255,17 @@ public sealed class AiOperationHostedService(AiOperationWorker worker, IConfigur
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
var aiEnabled = configuration.GetValue("Workers:AiOperationsEnabled", false);
if (!aiEnabled && !configuration.GetValue("Workers:CvDocumentImportsEnabled", false)) return;
if (!configuration.GetValue("Workers:AiOperationsEnabled", false)) return;
var concurrency = Math.Clamp(configuration.GetValue("AiQueue:WorkerConcurrency", 1), 1, 4);
await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken, !aiEnabled)));
await Task.WhenAll(Enumerable.Range(0, concurrency).Select(_ => RunWorkerAsync(stoppingToken)));
}
private async Task RunWorkerAsync(CancellationToken stoppingToken, bool documentImportsOnly)
private async Task RunWorkerAsync(CancellationToken stoppingToken)
{
var idleDelayMs = Math.Clamp(configuration.GetValue("AiQueue:IdleDelayMs", 1000), 100, 10_000);
while (!stoppingToken.IsCancellationRequested)
{
if (!await worker.RunOnceAsync(stoppingToken, documentImportsOnly))
if (!await worker.RunOnceAsync(stoppingToken))
await Task.Delay(idleDelayMs, stoppingToken);
}
}
@@ -62,9 +62,6 @@ public sealed class AiPrivacyHeaderHandler(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
request.Headers.Remove("X-Operation-Id");
if (executionScope.Current?.Lease.OperationId is Guid id && id != Guid.Empty)
request.Headers.Add("X-Operation-Id", id.ToString("D"));
if (request.RequestUri?.AbsolutePath.StartsWith("/cv/", StringComparison.OrdinalIgnoreCase) == true)
{
var operationContext = executionScope.Current;
@@ -45,8 +45,6 @@ public sealed record CoverLetterDto(
int Revision = 1,
CoverLetterDocumentSettings? DocumentSettings = null);
public sealed class CoverLetterThemeAccessException() : Exception("This theme requires Pro.");
public interface IApplicationAssetsService
{
Task<ApplicationCvDto?> GetCvAsync(string ownerUserId, int jobApplicationId, CancellationToken ct);
@@ -295,9 +293,7 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
public async Task<CoverLetterRenderDto?> PreviewCoverLetterAsync(string ownerUserId, int jobApplicationId, string? text, CoverLetterDocumentSettings? settings, CancellationToken ct)
{
var job = await LoadJobAsync(ownerUserId, jobApplicationId, ct);
if (job is null) return null;
await RequireLetterThemeAsync(_db, job, settings, ct);
return _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
return job is null ? null : _letters.Render(text, settings ?? CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
}
public async Task<CoverLetterRenderDto?> RenderSavedCoverLetterAsync(string ownerUserId, int jobApplicationId, int? expectedVersion, CancellationToken ct)
@@ -335,7 +331,6 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
internal static async Task StageCoverLetterAsync(JobTrackerContext db, JobApplication job, string? text,
string source, string? aiAction, CancellationToken ct, CoverLetterDocumentSettings? settings = null)
{
await RequireLetterThemeAsync(db, job, settings, ct);
var currentSettings = CoverLetterDocumentSettingsJson.Serialize(CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson));
var nextSettings = settings is null ? currentSettings : CoverLetterDocumentSettingsJson.Serialize(settings);
if (string.Equals(job.CoverLetterText, text, StringComparison.Ordinal) && nextSettings == currentSettings) return;
@@ -367,21 +362,6 @@ public sealed class ApplicationAssetsService : IApplicationAssetsService
if (settings is not null) job.CoverLetterDocumentSettingsJson = nextSettings;
}
private static async Task RequireLetterThemeAsync(JobTrackerContext db, JobApplication job, CoverLetterDocumentSettings? settings, CancellationToken ct)
{
if (settings is null) return;
var next = CoverLetterDocumentSettingsJson.Normalize(settings);
if (next.DesignVersion != 2 || CvThemeCatalog.CanUse(next.ThemeId, false)) return;
var current = CoverLetterDocumentSettingsJson.Deserialize(job.CoverLetterDocumentSettingsJson);
if (current.DesignVersion == 2 && current.ThemeId == next.ThemeId) return;
var roles = await (from userRole in db.UserRoles.AsNoTracking()
join role in db.Roles.AsNoTracking() on userRole.RoleId equals role.Id
where userRole.UserId == job.OwnerUserId && role.Name != null
select role.Name!).ToListAsync(ct);
if (!CvThemeCatalog.CanUse(next.ThemeId, AccountPlans.ForRoles(roles).ProThemes))
throw new CoverLetterThemeAccessException();
}
private Task<JobApplication?> LoadJobAsync(string ownerUserId, int jobApplicationId, CancellationToken ct) =>
_db.JobApplications.AsNoTracking()
.FirstOrDefaultAsync(j => j.Id == jobApplicationId && j.OwnerUserId == ownerUserId, ct);
@@ -377,8 +377,7 @@ public sealed class ApplicationIntelligenceService : IApplicationIntelligenceSer
settings.Overrides.TryGetValue(itemKey, out var itemOverride) ? itemOverride : new CvItemOverride();
private static bool SectionVisible(CvVariantSettings settings, string key) =>
settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase)) is { } section
? !section.Hidden : settings.SectionsExplicit != true;
settings.Sections.FirstOrDefault(section => string.Equals(section.Key, key, StringComparison.OrdinalIgnoreCase))?.Hidden != true;
private static string ReadJsonArray(string? json)
{
@@ -1,38 +0,0 @@
using SkiaSharp;
namespace JobTrackerApi.Services;
public static class AvatarImageProcessor
{
public static byte[]? Normalize(byte[] source)
{
if (source.Length is 0 or > 1_000_000) return null;
using var data = SKData.CreateCopy(source);
using var codec = SKCodec.Create(data);
if (codec is null || codec.EncodedFormat is not (SKEncodedImageFormat.Png or SKEncodedImageFormat.Jpeg or SKEncodedImageFormat.Webp)) return null;
var info = codec.Info;
if (info.Width is < 1 or > 2048 || info.Height is < 1 or > 2048 || (long)info.Width * info.Height > 4_000_000 || codec.FrameCount > 1) return null;
using var pixels = new SKBitmap(info.Width, info.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
if (codec.GetPixels(pixels.Info, pixels.GetPixels()) != SKCodecResult.Success) return null;
var rotated = codec.EncodedOrigin is SKEncodedOrigin.LeftTop or SKEncodedOrigin.RightTop or SKEncodedOrigin.RightBottom or SKEncodedOrigin.LeftBottom;
using var oriented = new SKBitmap(rotated ? info.Height : info.Width, rotated ? info.Width : info.Height, SKColorType.Rgba8888, SKAlphaType.Premul);
using (var canvas = new SKCanvas(oriented))
{
switch (codec.EncodedOrigin)
{
case SKEncodedOrigin.TopRight: canvas.Translate(info.Width, 0); canvas.Scale(-1, 1); break;
case SKEncodedOrigin.BottomRight: canvas.Translate(info.Width, info.Height); canvas.RotateDegrees(180); break;
case SKEncodedOrigin.BottomLeft: canvas.Translate(0, info.Height); canvas.Scale(1, -1); break;
case SKEncodedOrigin.LeftTop: canvas.RotateDegrees(90); canvas.Scale(1, -1); break;
case SKEncodedOrigin.RightTop: canvas.Translate(info.Height, 0); canvas.RotateDegrees(90); break;
case SKEncodedOrigin.RightBottom: canvas.Translate(info.Height, info.Width); canvas.RotateDegrees(90); canvas.Scale(-1, 1); break;
case SKEncodedOrigin.LeftBottom: canvas.Translate(0, info.Width); canvas.RotateDegrees(270); break;
}
canvas.DrawBitmap(pixels, 0, 0, new SKSamplingOptions(SKFilterMode.Nearest), null);
}
// Re-encoding decoded pixels drops source metadata and trailing payloads.
using var image = SKImage.FromBitmap(oriented);
using var encoded = image.Encode(SKEncodedImageFormat.Png, 100);
return encoded is null || encoded.Size > 1_000_000 ? null : encoded.ToArray();
}
}
-60
View File
@@ -1,7 +1,5 @@
using System.Security.Cryptography;
using System.Text;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
@@ -9,64 +7,6 @@ public static class AvatarStorage
{
private const string FilePrefix = "file:";
public static async Task<string> StageAsync(string dataRoot, string userId, byte[] bytes, CancellationToken cancellationToken)
{
var key = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
var folder = Path.Combine(dataRoot, "Avatars", key);
Directory.CreateDirectory(folder);
var path = Path.Combine(folder, $"avatar-{Guid.NewGuid():N}.png");
try { await File.WriteAllBytesAsync(path, bytes, cancellationToken); }
catch
{
TryDelete(FilePrefix + path);
throw;
}
return FilePrefix + path;
}
public static IEnumerable<string> PortraitFiles(string dataRoot, string ownerKey)
{
var root = Path.Combine(dataRoot, "Avatars");
var folder = Path.Combine(root, ownerKey);
if (ownerKey.Length != 64 || !ownerKey.All(Uri.IsHexDigit)
|| !Directory.Exists(folder) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0
|| (File.GetAttributes(folder) & FileAttributes.ReparsePoint) != 0) yield break;
foreach (var path in Directory.EnumerateFiles(folder, "avatar*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true }))
{
var name = Path.GetFileName(path);
if (name is "avatar.png" or "avatar.jpg" or "avatar.webp"
|| (name.StartsWith("avatar-", StringComparison.Ordinal) && name.EndsWith(".png", StringComparison.Ordinal)
&& name.Length == 43 && Guid.TryParseExact(name[7..^4], "N", out _))) yield return path;
}
}
public static async Task<int> SweepOrphansAsync(JobTrackerContext db, string dataRoot, DateTimeOffset now, CancellationToken token)
{
var root = Path.Combine(dataRoot, "Avatars");
if (!Directory.Exists(root) || (File.GetAttributes(root) & FileAttributes.ReparsePoint) != 0) return 0;
var candidates = Directory.EnumerateDirectories(root, "*", new EnumerationOptions { AttributesToSkip = FileAttributes.ReparsePoint, IgnoreInaccessible = true })
.Take(1000).SelectMany(folder => PortraitFiles(dataRoot, Path.GetFileName(folder)).Take(1000))
.Where(path => File.GetLastWriteTimeUtc(path) <= now.AddDays(-1).UtcDateTime)
.Take(1000).Select(path => FilePrefix + Path.GetFullPath(path)).ToList();
var referenced = (await db.Users.AsNoTracking().Where(user => candidates.Contains(user.AvatarImageDataUrl!))
.Select(user => user.AvatarImageDataUrl!).ToListAsync(token))
.ToHashSet(OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal);
var removed = 0;
foreach (var candidate in candidates)
{
token.ThrowIfCancellationRequested();
if (!referenced.Contains(candidate) && TryDelete(candidate)) removed++;
}
return removed;
}
public static bool TryDelete(string? value)
{
try { Delete(value); return true; }
catch (IOException) { return false; }
catch (UnauthorizedAccessException) { return false; }
}
public static async Task<string> StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken)
{
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
@@ -15,8 +15,6 @@ public sealed class CoverLetterDocumentRenderer
static string E(string? value) => WebUtility.HtmlEncode(value ?? "");
static string F(double? value) => value!.Value.ToString("0.##", CultureInfo.InvariantCulture);
var font = s.BodyFont!;
var theme = CvThemeCatalog.Resolve(s.ThemeId, s.DesignVersion);
var fonts = s.DesignVersion == 2 ? CvDocumentFonts.Embed(font, s.HeadingFont!, theme.UtilityFont) : CvDocumentFonts.Embed(font);
var size = s.PageSize == "letter" ? "215.9mm 279.4mm" : "210mm 297mm";
var width = s.PageSize == "letter" ? "215.9mm" : "210mm";
var height = s.PageSize == "letter" ? "279.4mm" : "297mm";
@@ -42,7 +40,7 @@ public sealed class CoverLetterDocumentRenderer
<!DOCTYPE html><html lang="{{E(s.Language)}}"><head><meta charset="utf-8">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; font-src data:; base-uri 'none'">
<title>{{E(string.IsNullOrEmpty(title) ? letterLabel : title)}}</title><style>
{{fonts}}
{{CvDocumentFonts.Embed(font)}}
:root{--cv-page-margin:{{F(s.PageMarginMm)}}mm}
*{box-sizing:border-box}html,body{margin:0;padding:0;color:#111827;background:#fff}
body{font-family:{{CvDocumentFonts.Stack(font)}};font-size:{{F(s.BaseFontSizePt)}}pt;line-height:{{F(s.LineHeight)}}}
@@ -58,35 +56,6 @@ public sealed class CoverLetterDocumentRenderer
<div class="date">{{E(s.Date)}}</div><div class="recipient">{{E(s.Recipient)}}</div>{{subjectHeading}}
<main aria-label="{{E(bodyLabel)}}">{{body}}</main></article></body></html>
""";
if (s.DesignVersion == 2)
{
var css = $"h1,h2{{font-family:{CvDocumentFonts.Stack(s.HeadingFont!)};}}h1{{font-size:{F(theme.NameSizePt - 2)}pt;}}header{{border-bottom-width:.4mm;}}";
css += theme.HeaderStyle switch
{
"band" when theme.Layout == "header-band" => $"header{{background:{s.AccentColor};color:{ThemedCvRenderer.ContrastInk(s.AccentColor!)};padding:6mm;margin-bottom:8mm;border:0;}}header h1{{color:inherit;}}",
"centered" => "header{text-align:center;padding-bottom:7mm;margin-bottom:9mm;}h1{font-weight:600;}",
"kicker" => $"header::before{{content:'{letterLabel}';display:block;letter-spacing:.2em;text-transform:uppercase;color:{s.AccentColor};font-size:8pt;margin-bottom:3mm;}}",
_ => "header{padding-bottom:6mm;margin-bottom:8mm;}",
};
if (theme.HeadingStyle == "plain" && theme.HeaderStyle == "plain") css += "header{border:0;}";
if (theme.NumberSections) css += $".contact,.date{{font-family:{CvDocumentFonts.Stack(theme.UtilityFont)};font-size:9pt;}}";
if (theme.Layout is "sidebar-left" or "sidebar-right")
{
var right = theme.Layout == "sidebar-right";
var edge = right ? "right" : "left";
var headerColumn = right ? 2 : 1;
var contentColumn = right ? 1 : 2;
var columns = right ? $"minmax(0,1fr) {F(theme.SidebarWidthMm)}mm" : $"{F(theme.SidebarWidthMm)}mm minmax(0,1fr)";
css += $".letter-paper{{display:grid;grid-template-columns:{columns};grid-template-rows:auto auto auto auto;align-content:start;padding:0;background:transparent;position:relative;z-index:1;}}" +
$".letter-paper>header{{grid-column:{headerColumn};grid-row:1 / span 4;padding:8mm;color:{theme.SidebarInk};border:0;margin:0;}}header h1{{color:inherit;}}" +
$".letter-paper>.date,.letter-paper>.recipient,.letter-paper>h2,.letter-paper>main{{grid-column:{contentColumn};padding-left:{F(s.PageMarginMm)}mm;padding-right:{F(s.PageMarginMm)}mm;background:white;min-width:0;}}" +
$"body::before{{content:'';position:fixed;{edge}:0;top:0;bottom:0;width:{F(theme.SidebarWidthMm)}mm;background:{theme.SidebarBg};}}" +
$"@page{{@top-{edge}{{content:'';background:{theme.SidebarBg};width:{F(theme.SidebarWidthMm)}mm;}}@bottom-{edge}{{content:'';background:{theme.SidebarBg};width:{F(theme.SidebarWidthMm)}mm;}}}}" +
"@media print{.letter-paper{padding:0;}}";
}
html = html.Replace("</style>", css + "</style>", StringComparison.Ordinal)
.Replace("class=\"page letter-paper\"", $"class=\"page letter-paper letter-theme-{theme.Id}\"", StringComparison.Ordinal);
}
return new(html, s.ThemeId, "cover-letter.pdf");
}
}
@@ -46,7 +46,6 @@ public sealed class CvAiSuggestionService(
if (document != (targetId is null)) throw Error(400, "cv_ai_scope_invalid");
var snapshot = await SnapshotAsync(owner, id, ct);
RequireCurrent(snapshot, version, null);
if (document && snapshot.Blocks.Count == 0) throw Error(400, "cv_ai_source_empty");
var source = Source(targetId is null ? snapshot.Blocks : snapshot.EditableBlocks, targetId);
if (inputText is not null && !string.Equals(ContentLines(inputText), ContentLines(source.Text), StringComparison.Ordinal))
throw Error(409, "cv_ai_source_changed");
@@ -22,7 +22,6 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
public static async Task<int> SweepAsync(JobTrackerContext db, AppPaths paths, DateTimeOffset now, CancellationToken token)
{
await OperationEventLog.PruneAsync(db, now.UtcDateTime, token);
var runs = await db.CvExtractionRuns.IgnoreQueryFilters().Include(r => r.Artifact)
.Where(r => r.ParserVersion == "docling-v1" && r.Artifact != null && r.Artifact.StoragePath != "")
.OrderBy(r => r.Id).Take(1000).ToListAsync(token);
@@ -44,7 +43,6 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
operation.Status = Models.OperationStatuses.Cancelled;
operation.CancellationRequestedAtUtc = now.UtcDateTime;
operation.CompletedAtUtc = now.UtcDateTime;
db.UserOperationEvents.Add(OperationEventLog.Create(operation, Models.OperationEventKind.Cancelled, now.UtcDateTime));
}
await db.SaveChangesAsync(token);
try
@@ -92,6 +90,6 @@ public sealed class CvImportRetentionHostedService(IServiceScopeFactory scopes,
catch (UnauthorizedAccessException) { }
}
}
return removed + await AvatarStorage.SweepOrphansAsync(db, paths.DataRoot, now, token);
return removed;
}
}
+2 -9
View File
@@ -23,26 +23,19 @@ public interface ICvProcessingQueue
/// Compatibility name for the CV producer bridge. Durable scheduling and execution are owned by
/// the shared AI operation queue; this type does not keep an in-memory CV queue.
/// </summary>
public sealed class CvProcessingQueue(AiOperationAdmission admission, JobTrackerContext db, IConfiguration configuration) : ICvProcessingQueue
public sealed class CvProcessingQueue(AiOperationAdmission admission) : ICvProcessingQueue
{
public const string TaskType = "cv.process";
public const string SubjectType = "cv_extraction_run";
public async Task<AiOperationAdmissionResult?> EnqueueAsync(int runId, CancellationToken cancellationToken = default)
{
var document = await db.CvExtractionRuns.AnyAsync(run => run.Id == runId &&
run.ParserVersion == ProfileCvController.DocumentParserVersion && run.Trigger == "document_import", cancellationToken);
if (document && !configuration.GetValue("Workers:AiOperationsEnabled", false) &&
!configuration.GetValue("Workers:CvDocumentImportsEnabled", false))
throw new AiOperationAdmissionException("cv_worker_disabled", "CV processing is disabled on this server. Ask the administrator to enable document processing, then retry the upload.", StatusCodes.Status503ServiceUnavailable);
return await admission.EnqueueAsync(
=> await admission.EnqueueAsync(
TaskType,
$"run:{runId}",
SubjectType,
runId.ToString(System.Globalization.CultureInfo.InvariantCulture),
AiOperationPriorities.UserVisible,
cancellationToken);
}
}
public sealed class NoOpCvProcessingQueue : ICvProcessingQueue
+16 -22
View File
@@ -68,25 +68,22 @@ public static class CvVariantResolver
settings = CvVariantSettingsJson.Normalize(settings);
var model = new CvRenderModel
{
FullName = settings.IncludeProfileContact == false ? string.Empty : Trim(profile.Contact.FullName) ?? fallbackName,
Headline = Trim(settings.Headline) ?? (settings.IncludeProfileContact == false ? null : Trim(profile.Contact.Headline)),
PhotoDataUrl = settings.ShowPhoto && settings.IncludeProfileContact != false ? Trim(photoDataUrl) : null,
FullName = Trim(profile.Contact.FullName) ?? fallbackName,
Headline = Trim(settings.Headline) ?? Trim(profile.Contact.Headline),
PhotoDataUrl = settings.ShowPhoto ? Trim(photoDataUrl) : null,
};
if (settings.IncludeProfileContact != false)
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
AddContact(model.Contact, "location", profile.Contact.Location, null);
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
foreach (var link in profile.Contact.Links)
{
AddContact(model.Contact, "email", profile.Contact.Email, v => $"mailto:{v}");
AddContact(model.Contact, "phone", profile.Contact.Phone, v => $"tel:{new string(v.Where(character => char.IsDigit(character) || character == '+').ToArray())}");
AddContact(model.Contact, "location", profile.Contact.Location, null);
AddContact(model.Contact, "web", profile.Contact.Website, AsUrl);
AddContact(model.Contact, "linkedin", profile.Contact.LinkedIn, AsUrl);
AddContact(model.Contact, "web", profile.Contact.GitHub, AsUrl);
foreach (var link in profile.Contact.Links)
{
var url = Trim(link.Url);
if (url is null) continue;
model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
}
var url = Trim(link.Url);
if (url is null) continue;
model.Contact.Add(new CvContactItem { Icon = "web", Value = Trim(link.Label) ?? url, Href = AsUrl(url) });
}
var built = new Dictionary<string, CvRenderSection>(StringComparer.OrdinalIgnoreCase)
@@ -141,13 +138,10 @@ public static class CvVariantResolver
settingByKey[section.Key] = section;
if (!ordered.Contains(section.Key, StringComparer.OrdinalIgnoreCase)) ordered.Add(section.Key);
}
if (settings.SectionsExplicit != true)
if (ordered.Count == 0) ordered.AddRange(DefaultOrder);
foreach (var key in built.Keys)
{
if (ordered.Count == 0) ordered.AddRange(DefaultOrder);
foreach (var key in built.Keys)
{
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
}
if (!ordered.Contains(key, StringComparer.OrdinalIgnoreCase)) ordered.Add(key);
}
foreach (var key in ordered)
@@ -1,61 +0,0 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
public static class OperationEventLog
{
public const int RetentionDays = 30;
public static string SafeStatus(string? value) => value is
OperationStatuses.Queued or OperationStatuses.Running or OperationStatuses.WaitingForRetry or OperationStatuses.WaitingForExternalFallback
or OperationStatuses.Succeeded or OperationStatuses.Failed or OperationStatuses.Cancelled ? value : "unknown";
public static string? SafeStage(string? value) => value is "cv_validating_file" or "cv_extracting" or "cv_identifying_sections"
or "cv_analysing" or "cv_validating_details" or "cv_preparing_review" or "cv_review_ready" ? value : null;
public static IReadOnlyList<string> FailureCodes { get; } = Array.AsReadOnly(new[]
{
"timeout", "lease_expired", "deadline_exceeded", "entitlement_changed", "document_unavailable",
"sidecar_unavailable", "upstream_unavailable", "invalid_response", "circuit_open", "rate_limited",
"permission_denied", "cancelled", "upload_rejected", "parser_failed", "malformed_output", "provider_unavailable",
});
public static string? SafeFailure(string? value) => value is null ? null : FailureCodes.Contains(value, StringComparer.Ordinal) ? value : "processing_failed";
public static UserOperationEvent Create(UserOperation operation, OperationEventKind kind, DateTime now,
string? status = null, string? stage = null, string? failure = null, int? attempt = null) => new()
{
OperationId = operation.Id, OwnerUserId = operation.OwnerUserId, AtUtc = now, Kind = kind.ToString(),
Status = SafeStatus(status ?? operation.Status), Stage = SafeStage(stage ?? operation.ProgressStage),
Attempt = Math.Clamp(attempt ?? operation.AttemptCount, 0, 10), FailureCode = SafeFailure(failure),
};
public static async Task AppendAsync(JobTrackerContext db, UserOperation operation, OperationEventKind kind, DateTime now,
CancellationToken token, string? status = null, string? stage = null, string? failure = null, int? attempt = null)
{
var item = Create(operation, kind, now, status, stage, failure, attempt);
if (!db.Database.IsRelational())
{
db.UserOperationEvents.Add(item);
await db.SaveChangesAsync(token);
return;
}
if (db.Database.CurrentTransaction is null) throw new InvalidOperationException("Operation history requires the state transition transaction.");
// Parameterized insert avoids flushing unrelated entities tracked by a caller.
await db.Database.ExecuteSqlInterpolatedAsync($"INSERT INTO `UserOperationEvents` (`OperationId`,`OwnerUserId`,`AtUtc`,`Kind`,`Status`,`Stage`,`Attempt`,`FailureCode`) VALUES ({item.OperationId},{item.OwnerUserId},{item.AtUtc},{item.Kind},{item.Status},{item.Stage},{item.Attempt},{item.FailureCode})", token);
}
public static async Task<int> PruneAsync(JobTrackerContext db, DateTime now, CancellationToken token)
{
var cutoff = now.AddDays(-RetentionDays);
var ids = await db.UserOperationEvents.IgnoreQueryFilters().Where(item => item.AtUtc < cutoff)
.OrderBy(item => item.Id).Select(item => item.Id).Take(5000).ToListAsync(token);
if (!db.Database.IsRelational())
{
var items = await db.UserOperationEvents.IgnoreQueryFilters().Where(item => ids.Contains(item.Id)).ToListAsync(token);
db.UserOperationEvents.RemoveRange(items);
return await db.SaveChangesAsync(token);
}
return await db.UserOperationEvents.IgnoreQueryFilters().Where(item => ids.Contains(item.Id)).ExecuteDeleteAsync(token);
}
}
@@ -61,7 +61,6 @@ internal static class StartupSchemaOwnership
"TwoFactorRecoveryCodes",
"UserNotifications",
"UserOperations",
"UserOperationEvents",
"UserRuleSettings",
"UserSessions",
};
+18 -46
View File
@@ -6,10 +6,7 @@ using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html)
{
public bool HasContent { get; init; } = true;
}
public sealed record ThemedCvRenderResult(string ThemeId, string SuggestedFileName, string Html);
public interface IThemedCvRenderer
{
@@ -24,7 +21,6 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
public ThemedCvRenderResult Render(CvRenderModel model, CvTheme theme, CvVariantSettings settings)
{
settings = CvVariantSettingsJson.Normalize(settings);
if (settings.DesignVersion == 2) theme = CvThemeCatalog.Resolve(theme.Id, settings.DesignVersion);
var accent = Override(settings.AccentColor, theme.Accent);
var headerInk = ContrastInk(accent);
var ink = Override(settings.TextColor, theme.Ink);
@@ -49,11 +45,11 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var css = CvDocumentFonts.Embed(headingFont, bodyFont, theme.UtilityFont)
+ BuildCss(theme, settings, accent, headerInk, ink, muted, paper, headingColor, CvDocumentFonts.Stack(headingFont), CvDocumentFonts.Stack(bodyFont), headingStyle, density, pageDims, layout, twoColumn);
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle, settings.IncludeProfileContact == false);
var header = RenderHeader(model, theme, showIcons, twoColumn, headerStyle);
var body = layout switch
{
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle, settings.IncludeProfileContact == false)}</div>",
"sidebar-left" => $@"<div class=""cols"">{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}<section class=""main"">{mainHtml}</section></div>",
"sidebar-right" => $@"<div class=""cols""><section class=""main"">{mainHtml}</section>{Sidebar(model, sidebarHtml, theme, showIcons, sidebarSections, headerStyle)}</div>",
_ => $@"{header}<section class=""main"">{mainHtml}</section>",
};
var html = $@"<!DOCTYPE html>
@@ -69,12 +65,7 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
</html>";
var fileName = Slug($"{model.FullName}-{theme.Id}") + ".pdf";
return new ThemedCvRenderResult(theme.Id, fileName, html)
{
HasContent = model.Sections.Any(section => !section.IsEmpty)
|| !string.IsNullOrWhiteSpace(model.FullName) || !string.IsNullOrWhiteSpace(model.Headline)
|| model.Contact.Count > 0,
};
return new ThemedCvRenderResult(theme.Id, fileName, html);
}
private static (string sidebar, string main) SplitColumns(CvRenderModel model, CvTheme theme, IReadOnlyCollection<string> sidebarKeys, CvVariantSettings settings)
@@ -91,22 +82,20 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
return (sidebar.ToString(), RenderSections(mainSections, theme, settings));
}
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle, bool hideEmptyHeader)
private static string Sidebar(CvRenderModel model, string sectionsHtml, CvTheme theme, bool showIcons, IReadOnlyCollection<string> sidebarSections, string headerStyle)
{
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle, hideEmptyHeader);
var header = RenderHeader(model, theme, showIcons, twoColumn: true, headerStyle);
var contact = sidebarSections.Contains("contact", StringComparer.OrdinalIgnoreCase)
? RenderContactBlock(model.Contact, showIcons, sidebar: true)
: string.Empty;
return $@"<aside class=""sidebar"">{header}{contact}{sectionsHtml}</aside>";
}
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle, bool hideEmptyHeader)
private static string RenderHeader(CvRenderModel model, CvTheme theme, bool showIcons, bool twoColumn, string headerStyle)
{
if (hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) && string.IsNullOrWhiteSpace(model.Headline)
&& model.Contact.Count == 0 && string.IsNullOrWhiteSpace(model.PhotoDataUrl)) return string.Empty;
var photo = RenderPhoto(model.PhotoDataUrl, theme.PhotoShape);
var kicker = headerStyle == "kicker" ? @"<div class=""kicker"">Curriculum Vitae</div>" : string.Empty;
var name = hideEmptyHeader && string.IsNullOrWhiteSpace(model.FullName) ? string.Empty : $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
var name = $@"<h1 class=""name"">{Enc(model.FullName)}</h1>";
var headline = string.IsNullOrWhiteSpace(model.Headline) ? string.Empty : $@"<div class=""headline"">{Enc(model.Headline)}</div>";
var contact = twoColumn ? string.Empty : RenderContactBlock(model.Contact, showIcons, sidebar: false);
var headerClass = twoColumn ? "hero" : $"header header-{headerStyle}";
@@ -239,12 +228,6 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
var headingSize = settings.HeadingSizePt ?? t.HeadingSizePt;
var lineHeight = settings.LineHeight ?? t.LineHeight;
var sidebarWidth = settings.SidebarWidthMm ?? t.SidebarWidthMm;
var sidebarPadding = settings.DesignVersion == 2 ? "8" : margin;
var sidebarBackground = settings.DesignVersion == 2 ? "var(--cv-sidebar-bg)" : "var(--cv-accent-color)";
var sidebarInk = settings.DesignVersion == 2 ? "var(--cv-sidebar-ink)" : "var(--cv-accent-ink)";
var sidebarVariables = settings.DesignVersion == 2 ? $"--cv-sidebar-bg:{t.SidebarBg};--cv-sidebar-ink:{t.SidebarInk};" : string.Empty;
var sidebarDetailCss = settings.DesignVersion == 2
? ".sidebar .bullets li::marker,.sidebar .skill-group-name{color:var(--cv-sidebar-ink);}" : string.Empty;
var headingCss = headingStyle switch
{
"underline" => ".section-title{border-bottom:1.5px solid var(--cv-accent-soft);padding-bottom:1.5mm;}",
@@ -257,13 +240,13 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
: $"{F(sidebarWidth)}mm minmax(0,1fr)";
var layoutCss = twoColumn
? $@".cols{{display:grid;grid-template-columns:{columnTemplate};min-height:{page.h};}}
.sidebar{{background:{sidebarBackground};color:{sidebarInk};padding:{sidebarPadding}mm;}}
.sidebar .section-title{{color:{sidebarInk};border-color:color-mix(in srgb,{sidebarInk} 38%,transparent);}}
.sidebar .tag{{border-color:color-mix(in srgb,{sidebarInk} 45%,transparent);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:{sidebarInk};}}
.sidebar{{background:var(--cv-accent-color);color:var(--cv-accent-ink);padding:{margin}mm;}}
.sidebar .section-title{{color:var(--cv-accent-ink);border-color:color-mix(in srgb,var(--cv-accent-ink) 38%,transparent);}}
.sidebar .tag{{border-color:color-mix(in srgb,var(--cv-accent-ink) 45%,transparent);}}
.sidebar .contact,.sidebar .headline,.sidebar .entry-meta,.sidebar .entry-subtitle{{color:var(--cv-accent-ink);}}
.sidebar a{{color:inherit;}}
.main{{padding:{margin}mm;}}
.hero .name{{color:{sidebarInk};}}"
.hero .name{{color:var(--cv-accent-ink);}}"
: $@".main{{padding:0 {margin}mm {margin}mm {margin}mm;}}
.header{{padding:{margin}mm {margin}mm {F(t.SectionGapMm * density)}mm {margin}mm;display:flex;gap:6mm;align-items:center;}}
.header-band{{background:{accent};color:{headerInk};}}
@@ -276,20 +259,9 @@ public sealed class ThemedCvRenderer : IThemedCvRenderer
? $@".main{{counter-reset:cv-section;}} .main>.section>.section-title{{counter-increment:cv-section;font-family:{CvDocumentFonts.Stack(t.UtilityFont)};font-weight:600;}} .main>.section>.section-title::before{{content:counter(cv-section,decimal-leading-zero) ' — ';color:var(--cv-accent-color);}}"
: string.Empty;
var revisedCss = settings.DesignVersion == 2
? $".header .name{{margin-bottom:{F(t.NameGapMm * density)}mm;}}.header + .main{{padding-top:{F(t.HeaderContentGapMm * density)}mm;}}" : string.Empty;
if (settings.DesignVersion == 2 && twoColumn)
{
var edge = layout == "sidebar-right" ? "right" : "left";
// Margin boxes paint the sidebar bleed without changing the repeated text safe area.
revisedCss += $"@page{{@top-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}@bottom-{edge}{{content:\"\";background:{t.SidebarBg};width:{F(sidebarWidth)}mm;}}}}" +
$"body::before{{content:\"\";position:fixed;{edge}:0;top:0;bottom:0;width:{F(sidebarWidth)}mm;background:{t.SidebarBg};z-index:0;}}" +
".page{position:relative;background:transparent;}.cols{position:relative;z-index:1;}.main{background:var(--cv-paper);}.sidebar{background:transparent;}";
}
return $@"
*{{box-sizing:border-box;}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;{sidebarVariables}}}
:root{{--cv-accent-color:{accent};--cv-accent-ink:{headerInk};--cv-accent-soft:color-mix(in srgb,{accent} 28%,transparent);--cv-ink:{ink};--cv-muted:{muted};--cv-paper:{paper};--cv-page-margin:{margin}mm;}}
html,body{{min-width:0;}}
body{{margin:0;background:#e9edf2;color:var(--cv-ink);font-family:{bodyFont};font-size:{F(bodySize)}pt;line-height:{F(lineHeight)};-webkit-print-color-adjust:exact;print-color-adjust:exact;}}
.page{{width:{page.w};min-height:{page.h};margin:0 auto;background:var(--cv-paper);overflow:visible;overflow-wrap:anywhere;word-break:normal;}}
@@ -343,7 +315,7 @@ h1,h2{{font-family:{headingFont};}}
.entry-meta{{color:{muted};font-size:{F(bodySize - 0.5)}pt;white-space:normal;text-align:right;max-width:100%;overflow-wrap:anywhere;}}
.entry-subtitle{{color:{muted};font-size:{F(bodySize)}pt;margin:.4mm 0 1.2mm 0;}}
.entry-tags{{margin-top:1.4mm;}}
{layoutCss}{sidebarDetailCss}
{layoutCss}
{numberedSections}
/* Print quality: keep normal entries whole, but allow intentionally classified long entries and
long list items to flow. An unsplittable block taller than a page is otherwise clipped. */
@@ -367,7 +339,7 @@ body{{background:transparent;}}
.main,.sidebar{{padding-top:0;padding-bottom:0;}}
}}
@page{{size:{page.w} {page.h};margin:{margin}mm 0;}}
" + revisedCss;
";
}
private static double DensityScale(string? density) => (density ?? "balanced").Trim().ToLowerInvariant() switch
@@ -392,7 +364,7 @@ body{{background:transparent;}}
private static string Override(string? value, string fallback) => string.IsNullOrWhiteSpace(value) ? fallback : value.Trim();
internal static string ContrastInk(string background)
private static string ContrastInk(string background)
{
if (background.Length != 7 || background[0] != '#' || !background.Skip(1).All(Uri.IsHexDigit)) return "#000";
+24 -91
View File
@@ -75,8 +75,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
DeadlineAtUtc = request.DeadlineAtUtc,
};
db.UserOperations.Add(operation);
var createdEvent = OperationEventLog.Create(operation, OperationEventKind.Created, now);
db.UserOperationEvents.Add(createdEvent);
AiUsageRecord? usage = null;
if (request.UsageReservedTokens > 0)
{
@@ -97,7 +95,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
catch (DbUpdateException)
{
db.Entry(operation).State = EntityState.Detached;
db.Entry(createdEvent).State = EntityState.Detached;
if (usage is not null) db.Entry(usage).State = EntityState.Detached;
existing = await db.UserOperations.FirstOrDefaultAsync(
item => item.TaskType == request.TaskType && item.IdempotencyKey == request.IdempotencyKey,
@@ -110,8 +107,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
public async Task<UserOperationLease?> ClaimNextAsync(
TimeSpan leaseDuration,
CancellationToken cancellationToken,
IReadOnlyCollection<string>? allowedTaskTypes = null,
bool documentImportsOnly = false)
IReadOnlyCollection<string>? allowedTaskTypes = null)
{
if (db.CurrentUserId is not null) throw new InvalidOperationException("Worker claims require a neutral background scope.");
ValidateLeaseDuration(leaseDuration);
@@ -127,12 +123,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
(operation.DeadlineAtUtc == null || operation.DeadlineAtUtc > now));
if (allowedTaskTypes is { Count: > 0 })
candidates = candidates.Where(operation => allowedTaskTypes.Contains(operation.TaskType));
if (documentImportsOnly)
candidates = candidates.Where(operation => operation.TaskType == CvProcessingQueue.TaskType &&
operation.SubjectType == CvProcessingQueue.SubjectType &&
db.CvExtractionRuns.IgnoreQueryFilters().Any(run => run.OwnerUserId == operation.OwnerUserId &&
run.Id.ToString() == operation.SubjectId && run.ParserVersion == Controllers.ProfileCvController.DocumentParserVersion &&
run.Trigger == "document_import"));
var candidateIds = await candidates
.OrderByDescending(operation => operation.Priority)
.ThenBy(operation => operation.CreatedAtUtc)
@@ -143,7 +133,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
foreach (var candidateId in candidateIds)
{
var leaseToken = Guid.NewGuid().ToString("N");
await using var transaction = await BeginTransactionAsync(cancellationToken);
var affected = await db.UserOperations.IgnoreQueryFilters()
.Where(operation => operation.Id == candidateId &&
(operation.Status == OperationStatuses.Queued || operation.Status == OperationStatuses.WaitingForRetry) &&
@@ -162,23 +151,20 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
var claimed = await db.UserOperations.IgnoreQueryFilters().AsNoTracking()
.SingleAsync(operation => operation.Id == candidateId && operation.LeaseToken == leaseToken, cancellationToken);
await OperationEventLog.AppendAsync(db, claimed, OperationEventKind.Claimed, now, cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return new UserOperationLease(claimed.Id, claimed.OwnerUserId, leaseToken, claimed.TaskType, claimed.PrivacyPolicy, claimed.SubjectType, claimed.SubjectId, claimed.AttemptCount, claimed.DeadlineAtUtc);
}
return null;
}
public async Task<int> HeartbeatAsync(Guid operationId, string leaseToken, TimeSpan leaseDuration, string? progressStage, int? progressPercent, CancellationToken cancellationToken)
public Task<int> HeartbeatAsync(Guid operationId, string leaseToken, TimeSpan leaseDuration, string? progressStage, int? progressPercent, CancellationToken cancellationToken)
{
EnsureOwnerScope();
ValidateLeaseDuration(leaseDuration);
if (progressPercent is < 0 or > 100) throw new ArgumentOutOfRangeException(nameof(progressPercent));
ValidateOptional(progressStage, 64, nameof(progressStage));
if (progressStage is not null && await ReportStageAsync(operationId, leaseToken, progressStage, cancellationToken) != 1) return 0;
var now = UtcNow;
return await db.UserOperations
return db.UserOperations
.Where(operation => operation.Id == operationId && operation.Status == OperationStatuses.Running && operation.LeaseToken == leaseToken)
.ExecuteUpdateAsync(setters => setters
.SetProperty(operation => operation.LeaseExpiresAtUtc, now.Add(leaseDuration))
@@ -188,26 +174,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
cancellationToken);
}
public async Task<int> ReportStageAsync(Guid operationId, string leaseToken, string stage, CancellationToken cancellationToken)
{
EnsureOwnerScope();
ValidateRequired(stage, 64, nameof(stage));
var now = UtcNow;
await using var transaction = await BeginTransactionAsync(cancellationToken);
var active = db.UserOperations.Where(item => item.Id == operationId && item.LeaseToken == leaseToken &&
item.Status == OperationStatuses.Running && item.CancellationRequestedAtUtc == null && item.LeaseExpiresAtUtc > now);
var updated = await active.Where(item => item.ProgressStage != stage)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.ProgressStage, stage), cancellationToken);
if (updated == 1)
{
var operation = await active.AsNoTracking().SingleAsync(cancellationToken);
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.StageChanged, now, cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return 1;
}
return await active.AnyAsync(cancellationToken) ? 1 : 0;
}
public Task<int> CompleteAsync(Guid operationId, string leaseToken, string? resultReference, CancellationToken cancellationToken)
=> CompleteAsync(operationId, leaseToken, resultReference, null, null, null, cancellationToken);
@@ -267,8 +233,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
.SetProperty(item => item.EstimatedTokenCount, estimatedTokens), cancellationToken);
}
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Succeeded, now));
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Completed, now, cancellationToken,
status: OperationStatuses.Succeeded, stage: completionStage ?? "");
await db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
@@ -325,12 +289,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Failed, now));
await db.SaveChangesAsync(cancellationToken);
}
if (affected == 1)
{
await OperationEventLog.AppendAsync(db, operation, canRetry ? OperationEventKind.RetryScheduled : OperationEventKind.Failed, now, cancellationToken,
status: canRetry ? OperationStatuses.WaitingForRetry : OperationStatuses.Failed, stage: progressStage ?? "", failure: category);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
if (affected == 1 && transaction is not null) await transaction.CommitAsync(cancellationToken);
return affected == 1;
}
@@ -352,7 +311,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
{
await SynchronizeCvRunAsync(operation, OperationStatuses.Cancelled, "CV processing was cancelled.", now, cancellationToken);
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Cancelled, now));
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Cancelled, now, cancellationToken, status: OperationStatuses.Cancelled);
await db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return true;
@@ -361,11 +319,7 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
var requested = await db.UserOperations
.Where(item => item.Id == operationId && item.Status == OperationStatuses.Running && item.CancellationRequestedAtUtc == null)
.ExecuteUpdateAsync(setters => setters.SetProperty(item => item.CancellationRequestedAtUtc, now), cancellationToken);
if (requested == 1)
{
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.CancellationRequested, now, cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
if (requested == 1 && transaction is not null) await transaction.CommitAsync(cancellationToken);
return requested == 1;
}
@@ -389,7 +343,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
{
await SynchronizeCvRunAsync(operation, OperationStatuses.Cancelled, "CV processing was cancelled.", now, cancellationToken);
db.UserNotifications.Add(CreateTerminalNotification(operation, OperationStatuses.Cancelled, now));
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Cancelled, now, cancellationToken, status: OperationStatuses.Cancelled);
await db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
@@ -400,30 +353,26 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
{
EnsureOwnerScope();
await using var transaction = await BeginTransactionAsync(cancellationToken);
var operation = await db.UserOperations.AsNoTracking().FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken);
var operation = await db.UserOperations.FirstOrDefaultAsync(item => item.Id == operationId, cancellationToken);
if (operation is null || operation.Status is not (OperationStatuses.Failed or OperationStatuses.Cancelled)) return false;
if (operation.TaskType == CvProcessingQueue.TaskType && operation.SubjectType == CvProcessingQueue.SubjectType
&& await SynchronizeCvRunAsync(operation, OperationStatuses.Queued, null, UtcNow, cancellationToken) != 1) return false;
var now = UtcNow;
var affected = await db.UserOperations.Where(item => item.Id == operationId && item.Status == operation.Status && item.CompletedAtUtc == operation.CompletedAtUtc)
.ExecuteUpdateAsync(setters => setters
.SetProperty(item => item.Status, OperationStatuses.Queued)
.SetProperty(item => item.AttemptCount, 0)
.SetProperty(item => item.AvailableAtUtc, now)
.SetProperty(item => item.StartedAtUtc, (DateTime?)null)
.SetProperty(item => item.CompletedAtUtc, (DateTime?)null)
.SetProperty(item => item.CancellationRequestedAtUtc, (DateTime?)null)
.SetProperty(item => item.FailureCategory, (string?)null)
.SetProperty(item => item.FailureMessage, (string?)null)
.SetProperty(item => item.ResultReference, (string?)null)
.SetProperty(item => item.Provider, (string?)null)
.SetProperty(item => item.Model, (string?)null)
.SetProperty(item => item.ProgressStage, (string?)null)
.SetProperty(item => item.ProgressPercent, (int?)null), cancellationToken);
if (affected != 1) return false;
await db.UserNotifications.Where(item => item.OperationId == operationId).ExecuteDeleteAsync(cancellationToken);
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.Retried, now, cancellationToken,
status: OperationStatuses.Queued, stage: "", attempt: 0);
operation.Status = OperationStatuses.Queued;
operation.AttemptCount = 0;
operation.AvailableAtUtc = UtcNow;
operation.StartedAtUtc = null;
operation.CompletedAtUtc = null;
operation.CancellationRequestedAtUtc = null;
operation.FailureCategory = null;
operation.FailureMessage = null;
operation.ResultReference = null;
operation.Provider = null;
operation.Model = null;
operation.ProgressStage = null;
operation.ProgressPercent = null;
var existingNotification = await db.UserNotifications.FirstOrDefaultAsync(item => item.OperationId == operationId, cancellationToken);
if (existingNotification is not null) db.UserNotifications.Remove(existingNotification);
await db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
return true;
}
@@ -452,18 +401,11 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
foreach (var operation in deadlineExpired)
await FinalizeRecoveredAsync(operation, RecoveryTerminal.QueuedDeadline, OperationStatuses.Failed, "deadline_exceeded", "The operation deadline elapsed before work could complete.", now, cancellationToken);
var recoverable = await db.UserOperations.IgnoreQueryFilters().AsNoTracking()
await db.UserOperations.IgnoreQueryFilters()
.Where(operation => operation.Status == OperationStatuses.Running && operation.LeaseExpiresAtUtc <= now &&
operation.CancellationRequestedAtUtc == null && operation.AttemptCount < operation.MaxAttempts &&
(operation.DeadlineAtUtc == null || operation.DeadlineAtUtc > now))
.ToListAsync(cancellationToken);
foreach (var operation in recoverable)
{
await using var transaction = await BeginTransactionAsync(cancellationToken);
var affected = await db.UserOperations.IgnoreQueryFilters().Where(item => item.Id == operation.Id &&
item.Status == OperationStatuses.Running && item.LeaseExpiresAtUtc <= now && item.CancellationRequestedAtUtc == null &&
item.AttemptCount < item.MaxAttempts && (item.DeadlineAtUtc == null || item.DeadlineAtUtc > now))
.ExecuteUpdateAsync(setters => setters
.ExecuteUpdateAsync(setters => setters
.SetProperty(operation => operation.Status, OperationStatuses.WaitingForRetry)
.SetProperty(operation => operation.AvailableAtUtc, now)
.SetProperty(operation => operation.FailureCategory, "lease_expired")
@@ -471,13 +413,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
.SetProperty(operation => operation.LeaseToken, (string?)null)
.SetProperty(operation => operation.LeaseExpiresAtUtc, (DateTime?)null),
cancellationToken);
if (affected == 1)
{
await OperationEventLog.AppendAsync(db, operation, OperationEventKind.LeaseRecovered, now, cancellationToken,
status: OperationStatuses.WaitingForRetry, failure: "lease_expired");
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
}
}
private async Task FinalizeRecoveredAsync(UserOperation operation, RecoveryTerminal reason, string status, string category, string message, DateTime now, CancellationToken cancellationToken)
@@ -501,8 +436,6 @@ public sealed class UserOperationStore(JobTrackerContext db, TimeProvider timePr
{
await SynchronizeCvRunAsync(operation, status, message, now, cancellationToken);
db.UserNotifications.Add(CreateTerminalNotification(operation, status, now));
await OperationEventLog.AppendAsync(db, operation, status == OperationStatuses.Cancelled ? OperationEventKind.Cancelled : OperationEventKind.Expired,
now, cancellationToken, status: status, failure: category);
await db.SaveChangesAsync(cancellationToken);
if (transaction is not null) await transaction.CommitAsync(cancellationToken);
}
@@ -1,7 +1,4 @@
{
"Workers": {
"CvDocumentImportsEnabled": true
},
"Database": {
"Provider": "mariadb",
"ServerVersion": "12.2.2"
+1 -2
View File
@@ -20,8 +20,7 @@
"SavedJobSearchAlertsEnabled": true,
"DailyExportEnabled": false,
"JobEnrichmentEnabled": false,
"AiOperationsEnabled": false,
"CvDocumentImportsEnabled": false
"AiOperationsEnabled": false
},
"JobDiscovery": {
"SavedSearchIntervalHours": 6
-26
View File
@@ -112,22 +112,6 @@
"System.Drawing.Common": "6.0.0"
}
},
"SkiaSharp": {
"type": "Direct",
"requested": "[4.153.1, )",
"resolved": "4.153.1",
"contentHash": "TghYLOUaP3q6rTjQw7U9uQaaXh8lEs9rfA1U7/VtmyAptVDtj/SmjtcWzFWmcC/VMWxe3QgbV9uyvqDL7ySFtQ==",
"dependencies": {
"SkiaSharp.NativeAssets.Win32": "4.153.1",
"SkiaSharp.NativeAssets.macOS": "4.153.1"
}
},
"SkiaSharp.NativeAssets.Linux.NoDependencies": {
"type": "Direct",
"requested": "[4.153.1, )",
"resolved": "4.153.1",
"contentHash": "NrnPjcxjcRau4GMOY0BTpUdMey+MnmLU3jC5AuExEFHPS2NkX1tdkXW0n/c5nQMMHBTAMpHnt7qEOpXs2FNfIQ=="
},
"SQLitePCLRaw.lib.e_sqlite3": {
"type": "Direct",
"requested": "[2.1.12, )",
@@ -489,16 +473,6 @@
"resolved": "13.0.3",
"contentHash": "HrC5BXdl00IP9zeV+0Z848QWPAoCr9P3bDEZguI+gkLcBKAOxix/tLEAAHC+UvDNPv4a2d18lOReHMOagPa+zQ=="
},
"SkiaSharp.NativeAssets.macOS": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "YTojWeWIhnpF7uC8vpMxB5wRONKkDB2t3r/nNPA3DL2pgj9fqMXRevA/rIanYazWnco4jo1ufgp3HkXuh1Tz8A=="
},
"SkiaSharp.NativeAssets.Win32": {
"type": "Transitive",
"resolved": "4.153.1",
"contentHash": "Yqg6rChTVh0BUE8QQxtOA3uUBnihfgID4yex/AvDa0+os2bVcBjmI3Qj9tnOB+R1hd/Jry1Gwl8qw5JI9HZG8Q=="
},
"SQLitePCLRaw.bundle_e_sqlite3": {
"type": "Transitive",
"resolved": "2.1.10",
-57
View File
@@ -1,57 +0,0 @@
# Local workers and models
This overlay targets the separately configured `jobjakt-dev` stack in the primary checkout. It does not replace that checkout's standalone compose file. Production services, databases and model caches are excluded.
The local runtime uses Ollama 0.40.11 and `qwen2.5:7b` (Q4_K_M, Apache-2.0), matching the model name in the repository's production configuration. Exact deployed digest parity has not been established. The frozen official registry manifest is in `model-manifests/qwen2.5-7b.json`; its SHA256 is `845dbda0ea48ed749caafd9e6037047aa19acfcfd82e704d7ca97d631a0b697e`. The image digest and resource limits are pinned in `ollama.compose.yml`. CPU inference is limited to four CPUs and 8GB; cloud processing is disabled. Model files use a dedicated development volume; the API binds only `127.0.0.1:11434`.
Initial local setup, explicitly authorised by the owner on 27 October 2026:
```powershell
rtk proxy docker compose -f dev/ollama.compose.yml up -d --wait
rtk proxy docker compose -f dev/ollama.compose.yml exec ollama ollama pull qwen2.5:7b
rtk proxy python dev/verify_ollama.py --manifest dev/model-manifests/qwen2.5-7b.json --output .artifacts/pre-phase10-ux/local-ai/verification.json
```
Verification checks the actual image, dedicated cache and every model/config/template/license blob against the frozen manifest before inference. A future changed upstream tag must fail verification; review the artifact before updating the pin. The download network remains available for explicit model maintenance; no cloud provider is enabled.
The existing approved DistilBART CPU checkpoint is reused through a read-only mount after all files are checked against `docs/redesign/phase-9/p9-sec-02/full-model/ARTIFACTS.json`. Transformers is offline, so this operation cannot fetch replacement weights. The parser-capable sidecar is also checked against its previously qualified local image ID.
Preflight and apply (run from this worktree; replace paths only with the verified development checkout and approved artifact):
```powershell
rtk proxy python dev/enable_local_workers.py --checkout "E:/Storage/Projects/Active/Job tracker" --bart-artifact "E:/Storage/Projects/Active/Job tracker/.artifacts/p9-sec-02/full-model/artifact" --output .artifacts/pre-phase10-ux/local-ai/worker-preflight.json
rtk proxy python dev/enable_local_workers.py --checkout "E:/Storage/Projects/Active/Job tracker" --bart-artifact "E:/Storage/Projects/Active/Job tracker/.artifacts/p9-sec-02/full-model/artifact" --output .artifacts/pre-phase10-ux/local-ai/workers-applied.json --apply
rtk proxy python dev/check_local_ai.py --output .artifacts/pre-phase10-ux/local-ai/sidecar-smoke.json
```
The apply helper validates project, Development environment, loopback backend ports, development database, current backend image, offline model mount and Mailpit settings. It recreates only the development backend/sidecar without a build or pull, preserving the database and volumes. It enables rules, reminders, saved-search alerts, daily exports, job enrichment and queued AI processing. These features still respect account entitlements, AI consent, configured schedules and available input. Email goes only to Mailpit at `http://localhost:8026`; it is not delivered externally.
This configuration helper does not build or publish source changes. Its initial backend image used a30-second limit, which could interrupt CPU generation. The separately qualified source activation below supplies the new240-second development timeout.
OAuth/mail synchronisation, billing and external data providers need their own approved credentials and configuration. Enabling workers cannot provide them. CV document import uses deterministic Docling; generative CV-import assistance stays disabled pending its separate source-grounding/accuracy qualification.
## Evidence, 27 October 2026
The local setup and worker apply passed. Independent model-blob verification passed. A real constrained JSON CPU response passed in 20.546 seconds including model loading. Authenticated sidecar checks passed: trained local job summarisation in 2.518 seconds and an Ollama-backed CV rewrite in 40.997 seconds. These checks assert actual nonempty content, not semantic correctness. No production digest parity or CV extraction accuracy is claimed.
Official sources: [Ollama model](https://ollama.com/library/qwen2.5:7b), [frozen registry source](https://registry.ollama.ai/v2/library/qwen2.5/manifests/7b), [Ollama Docker guidance](https://docs.ollama.com/docker), [local/cloud configuration](https://docs.ollama.com/faq).
## Local engine recovery, 28 October 2026
The resumed session found Docker Desktop4.86.0 stopped. Startup failed with Windows error1920 on orphaned AF_UNIX entries in `Docker/run/dockerInference` and `docker-secrets-engine/engine.sock`. Exact socket rename/delete attempts failed. Directory inspection confirmed only zero-byte reparse-point runtime entries; the Docker WSL distribution was stopped.
After stopping only the failed local Desktop/backend processes, the runtime directories were moved aside and recreated. Backups remain in `C:/Users/Connor/AppData/Local/Docker/run.pre-p10-stale-20261028`, `run.pre-p10-stale-20261028-02` and `C:/Users/Connor/AppData/Local/docker-secrets-engine.pre-p10-stale-20261028`. They contain only the inspected stale IPC entries. The Linux engine then responded successfully as `docker-desktop`. Model files, database volumes and Docker settings were not reset. This was local development recovery; production was not accessed.
The same failure pattern is reported in [Docker's Windows issue tracker](https://github.com/docker/for-win/issues/15064). The recovery above is supported by current local inspection and a successful engine response; it is not a general instruction to move Docker data directories or reset an installation.
## Qualified local source activation, 28 October 2026
The existing development stack now runs source checkpoint `3f4db8ca8183704945915ae4d2053e641bafa5b2`. Local URL: **http://jobbjakt.local**. The frontend deliberately rejects an unmatched `localhost` Host header; the canonical host was preserved. Mailpit remains at `http://localhost:8026`.
Backend image: `sha256:462b1d14fe871d64b16a2db28fda30a433657dd4d8ed5f61587a6b80685fb0cf`. Frontend image: `sha256:a59b8313233a20f6a2f0a8cc23785d224b58678e5758c3985fa55f238402886f`. All qualified source-file hashes were rechecked before activation. The backend is the same immutable image used by the passing Linux qualification. The frontend was rebuilt from that source with `/api` routing and the existing local nginx configuration. The qualified static test export was archived separately before this build.
Raw activation/rollback identities, source-image compose override, build logs and actual HTTP checks are in `.artifacts/pre-phase10-ux/local-ai/source-3f4db8c/`. The one-off activation script is retained alongside that directory as execution evidence. Backend/frontend were recreated with no dependency recreation, builds, pulls or volume removal during the compose apply. Previous image IDs remain recorded. Existing database, sidecar, model and email services were retained.
Confirmed: both images healthy; served index matches the built bytes; backend health200; anonymous proxied account request401; actual backend environment has all seven authorised workers enabled and the240-second AI timeout. Ollama blobs were reverified after engine recovery. These checks establish the running source/configuration; model-quality and broader feature qualifications remain separately stated in B_ACCEPTANCE.md.
For subsequent compose operations, retain the generated `source-images.override.json` **after** `dev/local-workers.override.yml`, the primary standalone development compose file and its `.env.dev`. Supply the same read-only `JOBTRACKER_DEV_BART_ARTIFACT` path. Running only the primary compose file can revert these local image/configuration selections. Do not substitute production configuration.
-55
View File
@@ -1,55 +0,0 @@
"""Exercise the isolated development sidecar with fictional data only."""
import argparse
import json
from pathlib import Path
import subprocess
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
# Authentication stays inside the container; neither secrets nor model text are logged.
program = r'''
import json
import os
import time
import urllib.request
records = []
def request(path, payload, field):
started = time.monotonic()
req = urllib.request.Request("http://127.0.0.1:8001" + path,
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json", "X-AI-Service-Token": os.environ["AI_SERVICE_TOKEN"]})
with urllib.request.urlopen(req, timeout=240) as response:
result = json.load(response)
value = result.get(field)
assert isinstance(value, str) and value.strip(), "Missing generated content: " + field
records.append({"path": path, "status": "PASS", "seconds": round(time.monotonic() - started, 3), "characters": len(value)})
assert os.environ["OLLAMA_BASE_URL"] == "http://jobjakt-dev-ollama:11434"
assert os.environ["AI_ROUTING_MODE"] == "local_only"
assert os.environ["EXTERNAL_AI_ENABLED"].lower() == "false"
request("/summarize", {"text": "Fictional Systems seeks a software engineer. Responsibilities include developing C# APIs, writing SQL queries and maintaining Docker services. Candidates should communicate clearly and work with a small team. This fictional advertisement is solely a development check.", "max_length": 60, "min_length": 10}, "summary")
request("/cv/rewrite", {"instruction": "Correct grammar only. Return exactly one sentence. Preserve the facts and add no headings.", "text": "A fictional engineer build reliable software.", "max_length": 48, "min_length": 8}, "rewritten_text")
print(json.dumps({"status": "PASS", "model": os.environ["OLLAMA_MODEL"], "routing": "local_only", "checks": records,
"limits": "Synthetic sidecar connectivity and nonempty response only; no factual extraction accuracy or production parity claim."}))
'''
identity = json.loads(subprocess.check_output(["rtk", "proxy", "docker", "inspect", "jobjakt-dev-ai-service-1"], text=True))[0]
assert identity["Config"]["Labels"]["com.docker.compose.project"] == "jobjakt-dev"
assert identity["Image"] == "sha256:8a29cb90fcc2817b0d784826d55a172c943f5ad7e1b6105e690a2d79ee15fe87"
result = subprocess.run(["rtk", "proxy", "docker", "exec", "-i", "jobjakt-dev-ai-service-1", "python", "-"],
input=program, text=True, capture_output=True, timeout=500)
if result.returncode:
# Avoid forwarding exception bodies which might contain request data.
raise SystemExit("Local synthetic sidecar check failed; exit " + str(result.returncode))
record = json.loads(result.stdout)
assert record["status"] == "PASS" and len(record["checks"]) == 2
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
print(json.dumps(record))
if __name__ == "__main__":
main()
-74
View File
@@ -1,74 +0,0 @@
"""Guard and apply worker settings to the existing isolated local development stack."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import subprocess
ROOT = Path(__file__).resolve().parent.parent
SIDECAR = "sha256:8a29cb90fcc2817b0d784826d55a172c943f5ad7e1b6105e690a2d79ee15fe87"
def capture(arguments, environment):
return subprocess.check_output(["rtk", "proxy", *arguments], env=environment, text=True)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--checkout", type=Path, required=True)
parser.add_argument("--bart-artifact", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--apply", action="store_true")
args = parser.parse_args()
checkout = args.checkout.resolve()
artifact = args.bart_artifact.resolve()
environment = {**os.environ, "JOBTRACKER_DEV_BART_ARTIFACT": str(artifact)}
manifest = json.loads((ROOT / "docs/redesign/phase-9/p9-sec-02/full-model/ARTIFACTS.json").read_text())["weights-only"]
for row in manifest["files"]:
path = artifact / row["file"]
assert path.is_file() and path.stat().st_size == row["bytes"], "Local model file missing or wrong size"
with path.open("rb") as stream:
assert hashlib.file_digest(stream, "sha256").hexdigest() == row["sha256"], "Local model integrity mismatch"
assert json.loads((ROOT / ".artifacts/pre-phase10-ux/local-ai/verification.json").read_text())["status"] == "PASS"
compose = ["docker", "compose", "--project-directory", str(checkout), "--env-file", str(checkout / ".env.dev"),
"-f", str(checkout / "docker-compose.dev.yml"), "-f", str(ROOT / "dev/local-workers.override.yml")]
config = json.loads(capture([*compose, "config", "--format", "json"], environment))
assert config["name"] == "jobjakt-dev", "Only the local development project is permitted"
backend = config["services"]["backend"]
sidecar = config["services"]["ai-service"]
settings = backend["environment"]
assert settings["ASPNETCORE_ENVIRONMENT"] == "Development"
connection = settings["ConnectionStrings__JobTracker"].lower()
assert "server=mariadb;" in connection and "database=jobtracker_dev;" in connection
assert settings["Ai__ExternalProcessingEnabled"].lower() == "false"
assert settings["Email__SmtpHost"] == "mailpit" and settings["Email__SmtpPort"] == "1025"
assert not settings["Email__SmtpUser"] and not settings["Email__SmtpPassword"]
assert all(port.get("host_ip") == "127.0.0.1" for port in backend.get("ports", []))
assert sidecar["environment"]["HF_HUB_OFFLINE"] == "1"
assert sidecar["environment"]["TRANSFORMERS_OFFLINE"] == "1"
assert sidecar["environment"]["EXTERNAL_AI_ENABLED"].lower() == "false"
assert sidecar["environment"]["OLLAMA_BASE_URL"] == "http://jobjakt-dev-ollama:11434"
assert any(volume.get("source") == str(artifact) and volume.get("read_only") for volume in sidecar["volumes"])
assert json.loads(capture(["docker", "image", "inspect", sidecar["image"]], environment))[0]["Id"] == SIDECAR
current = json.loads(capture(["docker", "inspect", "jobjakt-dev-backend-1"], environment))[0]
assert current["Config"]["Labels"]["com.docker.compose.project"] == "jobjakt-dev"
assert json.loads(capture(["docker", "image", "inspect", backend["image"]], environment))[0]["Id"] == current["Image"], "This operation changes worker configuration, not backend code"
record = {"project": config["name"], "verifiedBartRevision": manifest["sourceRevision"], "sidecarImage": SIDECAR,
"enabledWorkers": {key: value for key, value in settings.items() if key.startswith("Workers__")},
"emailSink": "Mailpit", "externalAI": False, "applied": False}
args.output.parent.mkdir(parents=True, exist_ok=True)
if args.apply:
with args.output.with_suffix(".log").open("w", encoding="utf-8") as log:
result = subprocess.run(["rtk", "proxy", *compose, "up", "-d", "--no-deps", "--no-build", "--pull", "never",
"--wait", "--wait-timeout", "120", "backend", "ai-service"], env=environment, stdout=log, stderr=subprocess.STDOUT)
record["exitCode"] = result.returncode
record["applied"] = result.returncode == 0
args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
print(json.dumps(record))
if args.apply:
raise SystemExit(record["exitCode"])
if __name__ == "__main__":
main()
-47
View File
@@ -1,47 +0,0 @@
# Apply only to the separately verified standalone jobjakt-dev stack.
services:
backend:
environment:
Workers__RulesEnabled: "true"
Workers__FollowUpRemindersEnabled: "true"
Workers__SavedJobSearchAlertsEnabled: "true"
Workers__DailyExportEnabled: "true"
Workers__JobEnrichmentEnabled: "true"
Workers__AiOperationsEnabled: "true"
Workers__CvDocumentImportsEnabled: "true"
Exports__DailyEnabled: "true"
Email__FollowUpReminders__Enabled: "true"
Email__Enabled: "true"
Email__SmtpHost: mailpit
Email__SmtpPort: "1025"
Email__SmtpEnableSsl: "false"
Email__SmtpUser: ""
Email__SmtpPassword: ""
Ai__ExternalProcessingEnabled: "false"
Ai__RequestTimeoutSeconds: "240"
ai-service:
# Previously qualified local image contains both the legacy and isolated Docling runtimes.
image: jobtracker-cv-import-sidecar:dev-04
environment:
OLLAMA_BASE_URL: http://jobjakt-dev-ollama:11434
OLLAMA_MODEL: qwen2.5:7b
AI_PROVIDER: ollama
AI_ROUTING_MODE: local_only
EXTERNAL_AI_ENABLED: "false"
CV_IMPORT_AI_ENABLED: "false"
AI_SERVICE_SKIP_MODEL_LOAD: "0"
SKIP_MODEL_LOAD: "0"
HF_HOME: /models
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
HF_HUB_DISABLE_TELEMETRY: "1"
OMP_NUM_THREADS: "2"
MKL_NUM_THREADS: "2"
volumes:
- type: bind
source: ${JOBTRACKER_DEV_BART_ARTIFACT:?Verify the isolated trained checkpoint first}
target: /models/hub/models--sshleifer--distilbart-cnn-12-6/snapshots/a4f8f3ea906ed274767e9906dbaede7531d660ff
read_only: true
mem_limit: 4g
cpus: 2
pids_limit: 256
-1
View File
@@ -1 +0,0 @@
{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","digest":"sha256:2f15b3218f0552c60647ce60ada83632d2c09755b16259b13e3e4458e9ae419d","size":487},"layers":[{"mediaType":"application/vnd.ollama.image.model","digest":"sha256:2bada8a7450677000f678be90653b85d364de7db25eb5ea54136ada5f3933730","size":4683073952},{"mediaType":"application/vnd.ollama.image.system","digest":"sha256:66b9ea09bd5b7099cbb4fc820f31b575c0366fa439b08245566692c6784e281e","size":68},{"mediaType":"application/vnd.ollama.image.template","digest":"sha256:eb4402837c7829a690fa845de4d7f3fd842c2adee476d5341da8a46ea9255175","size":1482},{"mediaType":"application/vnd.ollama.image.license","digest":"sha256:832dd9e00a68dd83b3c3fb9f5588dad7dcf337a0db50f7d9483f310cd292e92e","size":11343}]}
-47
View File
@@ -1,47 +0,0 @@
# Local development only. Uses the existing isolated jobjakt-dev AI network.
name: jobjakt-dev-ollama
services:
ollama:
image: ollama/ollama:0.40.11@sha256:459985308d740b99071719baec2740a60f5b2b2d9b835c171991ae25690203da
environment:
OLLAMA_HOST: 0.0.0.0:11434
OLLAMA_NO_CLOUD: "1"
OLLAMA_NUM_PARALLEL: "1"
OLLAMA_MAX_LOADED_MODELS: "1"
OLLAMA_CONTEXT_LENGTH: "4096"
OLLAMA_KEEP_ALIVE: 5m
ports:
- "127.0.0.1:11434:11434"
volumes:
- models:/root/.ollama
networks:
ai_internal:
aliases: [jobjakt-dev-ollama]
model_download: {}
mem_limit: 8g
cpus: 4
pids_limit: 256
read_only: true
tmpfs:
- /tmp:size=256m,mode=1777
cap_drop: [ALL]
security_opt: [no-new-privileges:true]
restart: unless-stopped
healthcheck:
test: [CMD, ollama, list]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
logging:
options:
max-size: 10m
max-file: "3"
networks:
ai_internal:
external: true
name: jobjakt-dev_ai_internal
model_download: {}
volumes:
models:
name: jobjakt-dev-ollama-models
-56
View File
@@ -1,56 +0,0 @@
"""Verify the dedicated development model cache before local inference."""
import argparse
import hashlib
import json
import re
import subprocess
import urllib.request
from pathlib import Path
MODEL = "qwen2.5:7b"
MANIFEST = "845dbda0ea48ed749caafd9e6037047aa19acfcfd82e704d7ca97d631a0b697e"
IMAGE = "ollama/ollama:0.40.11@sha256:459985308d740b99071719baec2740a60f5b2b2d9b835c171991ae25690203da"
CONTAINER = "jobjakt-dev-ollama-ollama-1"
def run(*arguments):
return subprocess.check_output(["rtk", "proxy", *arguments], text=True).strip()
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--manifest", required=True, type=Path)
parser.add_argument("--output", required=True, type=Path)
args = parser.parse_args()
source = args.manifest.read_bytes()
assert hashlib.sha256(source).hexdigest() == MANIFEST, "Registry manifest changed"
manifest = json.loads(source)
container = json.loads(run("docker", "inspect", CONTAINER))[0]
assert container["Config"]["Labels"]["com.docker.compose.project"] == "jobjakt-dev-ollama"
assert container["Image"] == json.loads(run("docker", "image", "inspect", IMAGE))[0]["Id"]
assert "OLLAMA_NO_CLOUD=1" in container["Config"]["Env"]
assert container["HostConfig"]["ReadonlyRootfs"]
assert container["HostConfig"]["Memory"] == 8 * 1024**3
assert all(mount.get("Name") == "jobjakt-dev-ollama-models" for mount in container["Mounts"] if mount["Type"] == "volume")
descriptors = [manifest["config"], *manifest["layers"], {"digest": "sha256:" + MANIFEST}]
checked = []
for descriptor in descriptors:
digest = descriptor["digest"]
assert re.fullmatch(r"sha256:[a-f0-9]{64}", digest)
path = "/root/.ollama/models/blobs/" + digest.replace(":", "-")
actual = run("docker", "exec", CONTAINER, "sha256sum", path).split()[0]
assert actual == digest[7:], "Downloaded model blob changed"
checked.append(digest)
with urllib.request.urlopen("http://127.0.0.1:11434/api/tags", timeout=5) as response:
models = json.load(response)["models"]
actual = next(model for model in models if model["name"] == MODEL)
assert actual["digest"] == MANIFEST
args.output.parent.mkdir(parents=True, exist_ok=True)
record = {"status": "PASS", "model": MODEL, "manifestSha256": MANIFEST, "checkedBlobs": checked,
"image": IMAGE, "details": actual["details"], "limits": "Local artifact verification only; no inference, production digest parity or extraction accuracy claim."}
args.output.write_text(json.dumps(record, indent=2) + "\n", encoding="utf-8")
print(json.dumps(record))
if __name__ == "__main__":
main()
-2
View File
@@ -5,8 +5,6 @@ services:
- ASPNETCORE_ENVIRONMENT=Development
- App__PublicBaseUrl=http://localhost:3000
- Proxy__TrustForwardedHeaders=false
# Deterministic document extraction only; generative jobs stay disabled.
- Workers__CvDocumentImportsEnabled=true
ports:
- "5202:8080"
+4 -17
View File
@@ -91,7 +91,7 @@ def sources(commands):
'scripts/verify-cv-fidelity.py', 'docs/redesign/phase-9/p9-requal-01/pdf-proof.py'}
selected = [name for name in paths if name.startswith(prefixes) or name in exact
or name.startswith(('Directory.', 'job-tracker-ui/playwright', 'job-tracker-ui/scripts/'))
or name.startswith(('docs/cv-redesign/', 'docs/pre-phase10-ux/')) and name.endswith(('.py', '.ts', '.json'))]
or name.startswith('docs/cv-redesign/') and name.endswith(('.py', '.ts', '.json'))]
return {name: digest((ROOT / name).read_bytes()) for name in sorted(set(selected)) if (ROOT / name).is_file()}
@@ -240,8 +240,6 @@ def run(args):
record['frontend'] = tree(static)
if not record['frontend']:
raise ValueError('Prebuilt frontend output is required.')
if not any(b'http://localhost:5302/api' in file.read_bytes() for file in static.rglob('*.js')):
raise ValueError('Build the static frontend with NEXT_PUBLIC_API_BASE_URL=http://localhost:5302/api; serving-time environment cannot change compiled client routing.')
record.update({'buildRecord': str(Path(args.build_record).resolve()), 'buildRecordSha256': digest(Path(args.build_record).read_bytes()),
'sourceSha256': build_record['sourceSha256'], 'imageId': args.image, 'commit': build_record['commit'],
'schema': schema, 'dbContainer': db, 'dbProject': project, 'apiContainer': api, 'network': network})
@@ -318,18 +316,12 @@ def run(args):
'NEXT_PUBLIC_API_BASE_URL': 'http://localhost:5302/api', 'CV_FIDELITY_OUTPUT': str(out / 'fidelity'),
'CV_SETTINGS_FIDELITY_OUTPUT': str(out / 'settings-fidelity'), 'LETTER_FIDELITY_OUTPUT': str(out / 'letter-fidelity'),
'PLAYWRIGHT_JSON_OUTPUT_NAME': str(out / 'linux-renderer.json')}
config = CONFIG
if args.include_template_library:
config = '../docs/pre-phase10-ux/linux-template.config.ts'
env.update({'PRE_P10_DESIGN_VERSION': '2', 'CV_TEMPLATE_OUTPUT': str(out / 'template-library'),
'LETTER_LIBRARY_OUTPUT': str(out / 'letter-library')})
commands.run(['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=' + config, '--retries=0',
commands.run(['node', 'node_modules/@playwright/test/cli.js', 'test', '--config=' + CONFIG, '--retries=0',
'--reporter=list,json', '--output=' + str(out / 'test-results')], cwd=ROOT / 'job-tracker-ui', env=env, timeout=1800)
native = json.loads((out / 'linux-renderer.json').read_text(encoding='utf-8'))
stats = native.get('stats', {})
expected_cases = 8 if args.include_template_library else 6
if stats.get('expected') != expected_cases or any(stats.get(key) != 0 for key in ('unexpected', 'flaky', 'skipped')) or native.get('errors'):
raise ValueError(f'Expected {expected_cases} passing browser cases with no skips, retries or errors.')
if stats.get('expected') != 6 or any(stats.get(key) != 0 for key in ('unexpected', 'flaky', 'skipped')) or native.get('errors'):
raise ValueError('Expected six passing browser cases with no skips, retries or errors.')
record['browserStats'] = stats
if sql(migration_sql).splitlines() != record['migrations'] or sources(commands) != current_sources or tree(static) != record['frontend']:
raise ValueError('Source/runtime/migration drift during rendering.')
@@ -343,10 +335,6 @@ def run(args):
raise ValueError('The original CV16 + settings8 document membership is incomplete.')
if {fixture.get('fixture') for fixture in letter.get('fixtures', [])} != {'light-en-a4', 'dark-nb-a4', 'light-nb-letter', 'dark-en-letter'}:
raise ValueError('The independent letter4 document membership is incomplete.')
if args.include_template_library:
for folder, count in (('template-library', 36), ('letter-library', 18)):
if len(json.loads((out / folder / 'measurements.json').read_text(encoding='utf-8')).get('fixtures', [])) != count:
raise ValueError('Incomplete revised design corpus: ' + folder)
record['exitCode'] = 0
except Exception as error:
record['error'] = scrub(error)
@@ -385,7 +373,6 @@ def main():
renderer.add_argument('--output', required=True)
renderer.add_argument('--build-record', required=True)
renderer.add_argument('--image', required=True)
renderer.add_argument('--include-template-library', action='store_true', help='Also render the separate PRE-P10 revised CV and matching-letter corpora.')
args = parser.parse_args()
return build(args) if args.mode == 'build' else run(args)
+1 -4
View File
@@ -121,9 +121,6 @@ class LifecycleTests(unittest.TestCase):
out = root / '.artifacts/cv-ux-01/renderer'
saved = root / 'build.json'
saved.write_text(json.dumps(build_record()), encoding='utf-8')
static = root / 'job-tracker-ui/out'
static.mkdir(parents=True)
(static / 'fixture.js').write_text('http://localhost:5302/api')
class Commands:
def __init__(self, output):
@@ -171,7 +168,7 @@ class LifecycleTests(unittest.TestCase):
raise OSError('Synthetic startup failure')
return io.BytesIO(json.dumps({'version': 'cv-ux-linux-' + runner.identity(SOURCE)[:12]} if url.endswith('/health') else {'status': 'ready'}).encode())
options = SimpleNamespace(output=str(out), image=IMAGE, build_record=str(saved), include_template_library=False)
options = SimpleNamespace(output=str(out), image=IMAGE, build_record=str(saved))
with patch.object(runner, 'ROOT', root), patch.object(runner, 'Commands', Commands), \
patch.object(runner, 'sources', return_value=SOURCE), \
patch.object(runner, 'tree', side_effect=lambda p: build_record()['publish'] if p.name == 'publish' else {'index.html': '1' * 64}), \
+45
View File
@@ -0,0 +1,45 @@
# Acceptance and evidence matrix
28 October 2026. **All implementation scenarios below are NOT RUN in GJ-00.** This is the proposed acceptance contract, requiring owner approval for quantitative targets. Documentation checks are separately recorded in [Evidence](EVIDENCE.md).
| ID / stage | Required proof | Failure / boundary |
|---|---|---|
| GJ-PREF /01 | NO residence + SE/GB work countries; 0..N choices; second-device persistence; explicit save/default actions | No nationality requirement or silent permanent override |
| GJ-REV /01,06 | Concurrent preference/search writes; stale response preserves draft, expected revision at DB write; deleted account cannot schedule | No acknowledged edit loss, tenant bleed or stale worker notification |
| GJ-LANG /01,05,08 | English/local prominence; native labels, More Languages, Coming Later; independent advert/UI/document language; missing keys/plurals/errors | No enabled unqualified locale or false Swedish/Nynorsk completion |
| GJ-LOC /02 | Whole Warwickshire → Warwick only → Warwick OR Leamington → whole county after clearing towns; parent remains scope; deselect clears descendants | No parent-wide OR after refinement, no auto-child checkboxes or hidden filters |
| GJ-LOC-DATA /02 | Vestfold/Tønsberg/Sandefjord, Vestland/Bergen, Oslo dual type, Stockholm and UK unitary/overlap data; real versioned IDs | No district used as town, no substring precision or old-ID silent broadening |
| GJ-RADIUS /02 | Known point inside/on/outside 10/25/50km, multiple origins, country constraint, absent/coarse coordinate, remote eligibility | No county-centroid false pass, driving-time or worldwide-remote claim |
| GJ-OCC /03 | Ten sector groups × EN/Bokmål/SV queries; aliases, ambiguity, exact/related/free keyword; cancellation and outage | No IT-only vocabulary, lost unknown term or silent broadening |
| GJ-RIGHTS /04 | Deny unapproved capability in API/details/cache/export/notifications; reviewed manifests/grants and source labels | Public documentation/key/fixture cannot stand in for commercial grant |
| GJ-NAV /04 | Complete bootstrap, durable cursor, replay/out-of-order/update/INACTIVE/masking/expiry and changed geography; crash/429/401 | No stale contact exposure, skipped deletion, fake live search or public-token production mode |
| GJ-SE /04 | JobSearch permitted details; JobAd Links truncated text/original link/IDs/daily freshness/window; 404 | No follow-up third-party scrape or exhaustive result count from bounded window |
| GJ-GB /04 | Commercial Adzuna grant + permitted snippet/redirect; NHS/DWP connector only if independently approved | External/manual fallback shown honestly; no trial-based ongoing production |
| GJ-FINN /04 | New discovery and existing preview/import flows perform zero FINN network calls; manual URL/fields work | Plugin/network parsers cannot bypass source-rights denial |
| GJ-DEDUP /04,06 | Same authoritative ID clusters; similar teacher/restaurant vacancies stay distinct; one source withdraws | No false automatic merge or rights borrowed across duplicates |
| GJ-DETAIL /05 | Desktop 3-pane, tablet, mobile full detail/deep link; salary period/currency/prediction/unknown; original completeness/provenance | Missing fact never invented; restricted content absent from API and DOM |
| GJ-ALERT /06 | Initial baseline, daily/weekly/timezone/DST, cross-search dedupe, pause/unsubscribe, restart/duplicate worker, changed search, withdrawn before send | No spam, stale/expired alert or another user's data; ambiguous email retry reported |
| GJ-MATCH /06 | Explicit Career fact/version + advert requirement + ESCO relation; regulated licence absent/unknown, cleaner/no profile | No inferred fluency/authorisation/hiring probability or AI requirement |
| GJ-TRACK /06 | Save versus Track distinction; reviewed idempotent application association and native CV/letter flow | Preserve Job/Application split, revisions/history/null inheritance/frozen packages |
| GJ-ADMIN /07 | Rights/access/health/lag/backlog/quota/expiry, system sync versus owned import, correlation/structured redaction | Server Admin denial on list/detail/stream/export, no fake owner or raw host logs |
| GJ-SEC /04–08 | SSRF DNS/private IP/redirect/credential leakage, malformed oversized compressed payload, unsafe HTML/links/images, injection, pagination loops, tenant IDs | Zero accepted critical data loss, isolation failure, misleading rights/content or secret exposure |
| GJ-PRIV /06–08 | Preferences/bookmarks/subscriptions export+erase, contact minimization, queued alert cancellation, source revocation and restored-backup suppression | Copied provider content not exempted as “user data”; lawful frozen-history policy separately approved |
| GJ-OPS /07–08 | DB cursor/lease failure, worker crash, provider/index outage, bounded fanout/backoff, additive migration and previous-binary rollback | No loss of user records; rollback cannot republish deleted source data |
| GJ-PRESERVE /08 | All57 functionality groups/33 routes, Career/CV/import/application/RD21/letter/package/PDF regressions at frozen integrated source | No weakening old acceptance or replacing manual/provider evidence with fixtures |
| GJ-A11Y /05,08 | Keyboard/screen-reader semantics, focus recovery, reduced motion, themes, 200% zoom, 320/390/768/1440 CSS widths, actual device/native-language review | Automated axe incomplete results stay incomplete; emulation is not physical device acceptance |
## Proposed quantitative qualification protocol
Approve before feature performance/relevance claims; targets here are **not measured**. Use frozen dataset, hardware/runtime/config, warm/cold distinction, 20 concurrent search clients for 10 minutes, source-cached versus upstream latency reported separately, error counts and DB query plans. Suggested capacity corpus: 250k active source ads, 1m lifecycle events, 10k private saved searches; synthetic records cover all countries/sectors, geography precision and deletion rates.
- Autocomplete: warmed local p95 <=200ms for 10 suggestions, UI debounce measured separately. Relevant suggestion precision@5 >=0.90 / recall@5 >=0.80 for every sector/language, frozen/native-reviewed labels.
- Local indexed search: warmed p95 <=500ms for page50; API aggregate p95 <=2s when upstream meets bounded timeout. Do not conceal slow upstream by returning stale/rights-invalid data. Missing upstream results must be partial, not exact-global counts.
- Feed: proposed healthy NAV steady-state lag p95 <=2min after observation availability; local observed withdrawal suppression and queued-alert cancellation <=60s. Provider terms requiring immediate changes take precedence; validate actual polling/lag with NAV and do not call these target windows a permission to delay removal. Stale/outage policy must fail closed.
- Delivery: zero duplicate in-app notification for identical delivery key under retries/crash; proposed per-owner daily digest consolidation and max1 daily email digest unless explicitly chosen weekly. External mail ambiguity remains observable, not “exactly once”.
- Data/security/rights: zero accepted canonical data loss, unauthorized cross-owner access, unlicensed full-content display, fabricated credentials or secret leakage. These do not depend on statistical sample averages.
## Evidence accounting
Every result records test ID/native command/config, source SHA plus dirty/input hashes, fixture/dataset/licence version, environment, timestamp, native exit, discovered/passed/failed/skipped and reason. Keep RED and corrected runs. Full-suite unique membership is primary; focused reruns are references, not extra passes. Staged passes do not establish integrated acceptance.
Separate lanes: local deterministic fixtures; actual MariaDB; hosted isolated CI; authorised live source contracts; human/native-language/device/AT; live-model semantic qualification; deployed identity; production. NOT RUN, BLOCKED and OPEN are not passes. A source needs legal eligibility AND technical tests before enabled capability GO. Production/main remain withheld regardless of development success.
+85
View File
@@ -0,0 +1,85 @@
# Architecture ADRs — proposed, not implemented
28 October 2026. Design baseline `4a8c907d70cbed235728f7d690b5409e72b93abe`. Authoritative architecture: [current pointer](../../architecture/current.md) → [overview](../../architecture/overview.md), [frontend](../../architecture/frontend.md), [backend](../../architecture/backend.md). The overview was originally verified September 2026 and has October updates; source inspection below resolves discovery-specific reality. Graphify was queried first; its AST scope excludes docs/config/deploy and is navigation evidence only.
## ADR-01 — extend existing owners
| Existing source owner (repository-relative) | Source-backed behavior / planned reuse |
|---|---|
| `job-tracker-ui/src/views/JobDiscoveryPage.tsx` | Current `/discover` query/location, saved searches, result sorting/dismissal. Extend with filters/list/details, not a parallel tracker. |
| `src/i18n/I18nProvider.tsx`, `translations.ts`; `JobTrackerApi/Controllers/AuthController.cs:597` | EN/Bokmål dictionaries, persisted account language, legacy normalization. Extend qualification registry and preferences, retaining API/auth/save-failure behavior. |
| `JobTrackerApi/Models/ApplicationUser.cs`; `Controllers/RulesController.cs` | Account preferences and owner settings patterns. Add discovery preference aggregate; do not reuse reminder settings for unrelated fields. |
| `Controllers/JobDiscoveryController.cs:37`; `Services/JobDiscoverySearchService.cs` | Current q/location interface; NAV request scans <=20 pages from last14days, substring filters, <=100 results, configured/public-token fallback. This is incomplete country inventory, not a reusable compliant persistent index. |
| `Models/SavedJobSearch.cs`; `Services/SavedJobSearchRunner.cs`; `SavedJobSearchAlertHostedService.cs` | Owner-scoped saved query/location, seen/dismissed IDs, initial-run baseline, periodic in-app new-result notifications. Extend rather than duplicate; add revisions/provider identity/schedules/durable delivery. Existing bare external ID cannot distinguish providers. |
| `Services/JobImport/JobImportService.cs:35`, `Plugins/FinnPlugin.cs` | Preview fetches HTML before parsing/plugins; skill tagging/optional translation. GJ04 must enforce source rights **before any fetch**, including legacy route and Add Job callers. SSRF safety is not a licence grant. No fetch executed by this research. |
| `Services/JobImport/SkillTagger.cs` | Mostly technical regex vocabulary. Preserve existing import contract; not the universal taxonomy owner. |
| `Data/JobTrackerContext.cs`; `Services/CurrentUserService.cs`; `BackgroundTenantRunner.cs` | EF context, owner filtering and scoped background processing; fail closed on absent owner. MariaDB standard; explicit SQLite compatibility retained. |
| `Models/CareerProfile.cs`; `Models/JobApplication.cs`; `Services/ApplicationAssetsService.cs` | Canonical Career Profile and separate application version domain. Discovery reads selected facts with consent; tracking and letter changes stay with existing revision/history owners. |
| `src/components/KanbanBoard.tsx`; `src/App.tsx`; `src/views/ApplicationWorkspacePage.tsx` | Preserve pipeline/routes and tracking workflow; no new automatic application state or second router. |
| `Models/UserOperation.cs`; `Services/UserOperationStore.cs`; `Controllers/AdminSystemController.cs:12`; `src/views/AdminSystemPage.tsx` | Owned recoverable operations and admin-only system surface. Integrate monitoring presentation, do not fake a user owner for source-wide ingestion. Existing maintenance dispatcher can invoke production benchmark jobs; do not reuse it as discovery sync. |
Exact proposed files below are new design names, not claims that they already exist. Before implementation re-resolve their callers/DI/current paths through Graphify and source; later accepted stages may move them. Existing theme/MUI/PageLayout/ViewStateNotice/API client/dialog/toast patterns remain owners. No new state library, AI service or UI framework.
## ADR-02 — hybrid source execution, MariaDB first
| Alternative | Benefit | Cost / decision |
|---|---|---|
| Live queries for everything | Least local storage | NAV is a feed; no complete/lifecycle-safe search. Reject. |
| Index every source | Consistent query semantics | Not all storage rights granted; extra ingestion and staleness. Reject blanket index. |
| Hybrid, source-specific | NAV feed index, SE live queries, licensed GB query or external fallback | Chosen proposal. Partial coverage explicit, common query/results contract, no new search infrastructure. |
Use EF/MariaDB indexed predicates for active inventory (country/status/modified/provider/source IDs), versioned location/occupation mapping tables and bounded text lookup. Evaluate native FULLTEXT against frozen multilingual corpus before selecting collation/token rules; fall back to bounded indexed metadata plus explicit keyword coverage, not unbounded `%term%` country scans. SQLite compatibility needs a provider-specific query implementation/test where retained; it cannot prove MariaDB behavior.
No Elasticsearch, queue broker, map SaaS or geocoder purchase. Proposed qualification load: 250,000 active source records plus 1,000,000 lifecycle events; cap ingest batches and retention. Capacity is a test target, not a measured result. If native storage fails agreed p95 gates, bring measured plans/storage cost to a separate ADR before adding infrastructure.
Cost model: database bytes per retained permitted ad × measured active count + mapping/index overhead + lifecycle retention; API calls = shared query misses + bounded detail checks + feed polls, not one full feed traversal per user/search alert. Infrastructure estimate requires measured row/index size and current operator rates. NAV/SE documented API access is free; storage/operations are not. Adzuna price is unknown pending quote. ESCO local data avoids runtime API dependency; exact artefact licence still gates import. No invented monthly total or new paid resource is authorized.
## ADR-03 — canonical contracts and ownership
All contracts are proposed. IDs and schema versions are explicit; absent source fields stay null/unknown. No mandate to create one table per concept.
| Concept | Minimum fields / invariant | Reconciliation |
|---|---|---|
| `SearchPreference` | owner, revision, schemaVersion, residence?, workCountries[], locationScopes[], occupation refs[], explicit skills[], arrangements[], advertLanguageTags[] | New aggregate linked to existing account; `UiLanguage` remains existing account field. |
| `LocationNode` | internal ID, authority/code/type/country/edition, names/aliases, validity, typed edges, geometry/point precision/licence | Public reference catalogue; [geography contract](GEOGRAPHY_DECISIONS.md). |
| `LocationScope` | rootId, refinements[], mode, radiusOrigins[], datasetVersion | Validated query value, not auto-selected descendants. |
| `OccupationConcept` | namespace/URI/version, language labels, typed related/skill edges, licence/validity | Shared public reference data; [taxonomy](CROSS_INDUSTRY_TAXONOMY.md). |
| `ProviderCapability` | rights evidence, modes/filters/content/storage/lifecycle/link/quotas, enabled/status | Registry + reviewed versioned config; secret references only. |
| `SourceAdvertisement` | canonical inventory ID; source records; title/employer; locations/precision; occupations; pay/currency/period/predicted; schedule/contract/language/requirements; description format/completeness; original/apply URLs; posted/updated/retrieved/expiry/status; provenance | External inventory; never a tenant `JobApplication`. |
| `ProviderSourceRecord` | providerId + externalId unique, content/version hash, observed revision/time, rights version, nullable fields, freshness/status | Multiple records may reference one conservative dedupe group; each retains its rights. |
| `SourceLifecycleEvent` | provider/event key, source ID/version, observedAt, operation upsert/withdraw/mask, cursor reference | Idempotent event + atomic cursor; no expired raw contact content retained by default. |
| `SavedJob` | owner + source identity unique, savedAt, own notes, optional Job/Application link, revision | New bookmark relationship; saving does not create an application. Avoid duplicating source descriptions. |
| `SavedSearch` | existing SavedJobSearch ID/owner/name; revision + schemaVersion + typed query + active state | Extend existing entity/DTO; retain legacy q/location migration and dismissals. |
| `SearchAlert` | owner/search/revision, channel/frequency/timezone, schedule, enabled, delivery idempotency/status | Extend existing alert runner + UserNotification, separate durable delivery/outbox state as needed. |
Proposed request `DiscoveryQueryV1`: workCountries, scopes, occupations + mode, keyword, skills, advertLanguages, arrangements, sourceIds, sort, cursor, pageSize. Proposed response `DiscoveryPageV1`: results, nextCursor, per-source availability/freshness/coverage/unsupportedFilters, exactness, queryFingerprint. Do not report a global total from live windows as exhaustive. Detail `AdvertisementDetailV1` returns permitted fields, completeness and provenance; inactive/rights-denied content is not leaked through an alternate endpoint.
Proposed services: `DiscoveryQueryService.SearchAsync(DiscoveryQueryV1, ownerContext, CancellationToken) -> DiscoveryPageV1`; `LocationScopeCompiler.Compile(scopes, datasetVersion) -> LocationQueryPlan`; `OccupationQueryCompiler.Compile(mode, terms, taxonomyVersion) -> OccupationQueryPlan`; `ProviderRightsPolicy.Evaluate(providerId, capability, now) -> Allowed/Blocked(reason)`; capability-specific live-search, detail and change-feed interfaces. Existing `IJobDiscoverySearchService.SearchAsync(q, location, ct)` remains a compatibility adapter until callers/tests are migrated. Proposed v2 route `/api/job-discovery/v2/search`; do not silently change old array response shape.
## ADR-04 — ingestion, dedupe and freshness
Background feed worker uses bounded pages/bytes/time/retries; allowlisted provider URLs, ETag/conditional requests where supported. Fetch outside DB transaction; commit idempotent latest source state, lifecycle event and cursor together. On crash retry same page; never advance cursor ahead of accepted deletion. Reject invalid status/schema/cursor loops, quarantine safe diagnostics and pause source; preserve last committed checkpoint. Out-of-order events cannot resurrect deleted content. Bootstrap completeness and ongoing lag are separate metrics.
Use a distinct system-owned provider cursor/lease record, not `UserOperation.OwnerUserId="system"`. Reuse bounded processing concepts, correlation/status/redaction and admin presentation; user URL-import tasks keep actual owner. Source sync stays single-writer initially under database lease; multiple backend replicas require explicit claim/fencing/expiry tests before enabling. This does not rewrite existing CV worker leasing.
Deduplicate providerId+externalId deterministically first. Cross-provider automatic grouping requires a shared authoritative requisition/canonical source identity and corroborating employer/location/time. Similar title/body at the same school/restaurant is insufficient. Uncertain pairs stay separate or reviewable. Groups retain source records and show which supplies each field; a permissive duplicate cannot expand another record's rights. Withdrawal recomputes visible alternatives and suppresses restricted fragments.
## ADR-05 — preferences, alerts and tracking integrity
Expected revision is checked at database write, including background search runs. Save a query revision/fingerprint; before publishing notifications recheck search still active, same revision, owner not deleting, source active/fresh and current rights. The first run establishes a baseline. Unique delivery key `(owner, searchId, revision, sourceIdentity, eligibleVersion, channel)` plus durable outbox avoids duplicate generation; do not claim exactly-once email delivery after ambiguous network failure. Default daily in-app digest, optional weekly/email opt-in; timezone/DST scheduling, pause/unsubscribe and bounded retries. Proposed cross-search per-owner daily digest consolidation prevents repeated copies of the same vacancy. Quiet hours and channel limits require explicit product approval.
Tracking uses existing opportunity/application creation service and owner uniqueness checks in one transaction/idempotent request. Preserve `JobApplication.Version`, Career revision, history, CV null-as-inherit, old extraction runs, CV/letter settings, frozen submitted HTML/PDF/packages and compatibility dual writes. A source update must never overwrite a user's reviewed application description, personal notes or canonical profile. If copied source content must be purged, retain user facts separately and use approved redaction/retention handling; do not rewrite frozen evidentiary documents without a separately approved lawful policy.
## ADR-06 — privacy and trust boundaries
Default private user data: optional residence, search interests/scopes/skills references, saved vacancies and subscriptions. Minimize exact home addresses: radius uses chosen public place point, not compulsory home GPS. Process matching locally from consented profile fields, not raw CV sharing with job providers. No nationality/sensitive-class relevance weighting, inferred fluency/licence or automatic application.
Maintain CSRF/auth/owner filters, rate limits and role checks server-side. Public provider inventory is separately authorized from user-owned bookmarks/searches/operations. Test cross-owner IDs and absent identities in API/worker/export/deletion. Admin lists/detail/stream/export require Admin and redact secrets/PII/prompts; log structured error code/correlation rather than raw provider payload. Propose 30-day content-free operations diagnostics, pending owner policy; source PII retention is the shortest allowed necessity/contract period, never this generic diagnostic number.
Enforce URL rights before transport, strict scheme/host validation, DNS/IP checks at connection and redirects, bounded decompress/response/pagination, no cookies/token forwarding. Strip scripts/styles/event handlers/iframes/unsafe links and remote tracking images from permitted descriptions; render plain text until safe HTML sanitizer qualification. Malicious ad text must not control tools/prompts. AI remains disconnected from core discovery and gated independently. Account export/delete must include new private state and remove queued alerts, delivery identifiers and derived private matches. Backup restore policy replays deletions/rights revocations before exposure.
## ADR-07 — additive migration and rollback
GJ-01 adds preference schema/revision without deleting columns; empty old accounts remain valid. GJ-02/03 version reference data with staged import/swap, retaining old IDs. GJ-04 creates inventory/source cursor state; GJ-06 extends SavedJobSearch query schema plus bookmarks/delivery state. Backfill old q/location as legacy keyword/explicit unresolved location, not guessed canonical IDs; preserve ID/name/active/dismissed/first-seen history. Old bare NAV IDs migrate with explicit NAV namespace and unique constraints; never silently dedupe across providers.
Test MariaDB forward migration, old-data read, concurrent writes, rollback/restart and restore. Retain SQLite transition compatibility where still supported. Roll back behavior with feature gates and previous binary while additive tables remain; do not issue destructive Down migrations or erase user-authored rows. A rollback of UI cannot roll back provider deletion obligations: lifecycle suppression/policy enforcement must continue or source content stays disabled. No production migration under this plan.
@@ -0,0 +1,48 @@
# Cross-industry taxonomy — proposed
28 October 2026. Implements the design contract of [GJ-03](../../planning/global-jobs-prompts/04_GJ_03_OCCUPATIONS.md); no dataset import or model inference performed.
## Taxonomy choice and evidence
Use versioned ESCO concept URIs/labels/aliases/skills as the shared vocabulary. The official site reports v1.2.1 and downloadable data/API, including English, Swedish and Norwegian `no`; that pack must not be relabelled as separately qualified Bokmål/Nynorsk. Prefer a pinned local data subset over bundling the reference local API software. [Use ESCO](https://esco.ec.europa.eu/en/use-esco), [download](https://esco.ec.europa.eu/en/use-esco/download), [API/software licensing](https://esco.ec.europa.eu/en/use-esco/use-esco-services-api), accessed 2026-10-28.
Data and API software licences are different: API software is EUPL 1.2 with component licences; EU-owned web content defaults to attributed reuse under [Commission notice](https://commission.europa.eu/legal-notice_en). GJ03-A must retain the exact downloaded dataset licence/readme/checksum and attribution, identify exceptions and approve commercial redistribution of that artefact. General web reuse is not proof of every downloadable file's licence. No email/download registration was submitted here.
Swedish JobTech taxonomy is public CC0 and provides SSYK-2012/ISCO-08 relations, occupation aliases and skills. Pin its versioned export, preserving concept IDs distinct from numeric SSYK codes. [Taxonomy service](https://data.arbetsformedlingen.se/dataservice/employer-market-taxonomy/), [versioned catalogue](https://data.arbetsformedlingen.se/taxonomy), accessed 2026-10-28.
UK ONS supplies SOC2020 and extended job-title indexes, with a 27 August 2026 edition visible at research time. Cross-referencing ESCO during classification development is not a validated one-to-one crosswalk. Import an explicitly licensed/pinned title index only after GJ03-A review; leave uncertain SOC↔ISCO↔ESCO mappings unmapped. [ONS extended SOC](https://www.ons.gov.uk/methodology/classificationsandstandards/standardoccupationalclassificationsoc/standardoccupationalclassificationsocextensionproject), accessed 2026-10-28.
## Contract and matching
`OccupationConcept`: namespace + immutable URI/code + taxonomy version, preferred/alternative labels keyed by original language tag, broader/related/skill edges with evidence, validity/deprecation/replacement and source/licence. Mapping edges carry source/target versions, exact/broad/narrow/related relation and reviewed confidence. A many-to-many crosswalk is not equality.
Occupation describes work; employer title remains original advert text; industry describes economic activity; skill is capability; qualification/licence is an explicit requirement/evidence; schedule and experience are separate facets. “Weekend work” is valid free text, not a forced occupation. Unknown terms never disappear when the taxonomy fails.
Query modes: `exact` resolves selected concept/approved aliases without broadening; `related` adds labelled related concepts only with user opt-in; `keyword` preserves unclassified input. Accent/case normalization supports lookup without rewriting displayed titles; ambiguous tokens (e.g. English “chef” versus Swedish “chef” manager) require language context/disambiguation. `cook/kokk/kock` is a proposed reviewed alias example, not an asserted frozen ESCO mapping. Skills only use explicit user choices or consented profile facts; no pooled private CV text.
Debounce/cancel suggestions, ignore stale responses, cap results, use locale-aware ranking and accessible combobox behavior. Order exact/local preferred labels before aliases and explicit related roles. Allow search without selecting a suggestion; taxonomy outage falls back to literal keyword. UI language affects labels, not an implicit advert-language restriction. Unsupported provider occupation/skill filters require auditable query expansion or disclosed exclusion.
Current `JobImport/SkillTagger.cs` is largely technical regex tags, used by `JobImportService.PreviewAsync`. Retain its existing caller contract while adding universal discovery vocabulary; it cannot define all-occupation search or candidate qualification. GJ-03/GJ-06 must trace every caller before changing shared tagging/matching behavior.
## Frozen benchmark to create in GJ03-A
These are proposed human-reviewed query examples; translations are not yet linguistic acceptance or complete taxonomic equivalents.
| Sector | English queries | Bokmål / Swedish candidates | Required distinction |
|---|---|---|---|
| Education | teacher, teaching assistant | lærer, lærerassistent / lärare, lärarassistent | Teacher versus assistant, subject/level |
| Hospitality | chef, cook, kitchen assistant | kokk, kjøkkenassistent / kock, köksbiträde | Manager ambiguity, assistant not chef |
| Health | nurse, care worker | sykepleier, pleiemedarbeider / sjuksköterska, vårdbiträde | Registration not inferred |
| Trades | electrician, plumber, carpenter | elektriker, rørlegger, tømrer / elektriker, rörmokare, snickare | Different trade licences |
| Logistics | driver, warehouse operator | sjåfør, lagermedarbeider / chaufför, lagerarbetare | Licence category, no inferred permission |
| Retail | shop assistant | butikkmedarbeider / butiksbiträde | Part-time/evening availability |
| Cleaning | cleaner | renholder / lokalvårdare | No formal qualification needed to search |
| Office/finance | accountant, administrator | regnskapsfører, administrator / redovisningsekonom, administratör | Credential and role ambiguity |
| Agriculture | farm worker | gårdsarbeider / lantarbetare | Seasonal roles, not farmer-owner only |
| Technology | software developer | programvareutvikler / systemutvecklare | One sector with equal benchmark weight |
Freeze at least these ten sector groups × three query languages, positive and confusing negative terms, unfamiliar/free-text jobs, shifts/apprenticeships/no experience and missing requirements. Record intended relevant concepts and allowed expansions with provenance and native review. Proposed GJ03-E targets: precision@5 >=0.90 and relevant suggestion recall@5 >=0.80 **per sector/language**; report macro averages and failures rather than hiding weak sectors in a tech-heavy total. Targets need owner approval before execution. Zero invented credentials is mandatory regardless of relevance scores.
Match explanation includes explicit profile fact reference/version, advert requirement field/source version, relation used, matched/not supplied/unknown, and no hiring-probability claim. Profile revision change invalidates cached explanation; AI remains optional and gated. Public taxonomy contains no tenant PII; profile-derived ranking remains owner-scoped.
The frozen corpus must also include waiter, mechanic and public administrator explicitly, as required by GJ-03. Test bounded typo tolerance without overriding exact-title mode or allowing broad roles to swamp precise titles.
@@ -0,0 +1,42 @@
# Dependencies, source authority and entry gates
28 October 2026. GJ-00 research is authorized by the current owner instruction; no implementation approval is inferred.
## Verified development baseline
Local HEAD and remote `refs/heads/test/cv-integration-acceptance` both: `4a8c907d70cbed235728f7d690b5409e72b93abe`. Existing development ancestry includes CV-import `ef425c288b38ae0e91dcc216a5c8b6c02c433ab5`, CV-UX `4a0af62` and QA remediation `89974a257e4192b3d93779000a7df6998afc4051`; ancestry is checked in the GJ-00 evidence lane. `main` is not this planning base.
Current authority order: [CV-QA acceptance](../../cv-integration-qa/ACCEPTANCE.md), [Phase 10 handoff](../../cv-integration-qa/PHASE_10_HANDOFF.md), [CV-QA regression record](../../cv-integration-qa/REGRESSION_RESULTS.md), [architecture](../../architecture/overview.md), [Phase 10 requirements](../../redesign/IMPLEMENTATION_PLAN.md#phase-10--cleanup), [roadmap](../../roadmap/README.md), [TODO](../../roadmap/todo.md), [operator blockers](../../../BLOCKERS.md). Older Phase 7/9 decisions and roadmap continuations retain historical scope; they cannot override the newer bounded CV-QA decision.
The 27 October acceptance grants CONDITIONAL GO for implemented model-independent CV import/development review. Phase 10 is **candidate assessment/planning only**, broad cleanup implementation NO-GO; no unused deletion candidate is verified. Preserve 57 functionality groups and 33 routes, older-run review/accept paths and redirects. Human/device/AT/native-language/contrast/print qualification remains open; live CV-model compatibility/accuracy blocked; production NO-GO. Reported prior totals (backend1336, frontend995, ordinary browser364, separate configurations53, real import7, MariaDB80, Python90) are **historical report claims, not rerun by GJ-00**, and overlap rules remain intact.
No completed `PRODUCT-REVIEW-01` decision or initiative entry approval was found in current docs/roadmap and the supplied pack. Absence is a gate, not permission. Request a concrete review artefact with scope, baseline, unresolved debt and authorized next stage.
## Independent decisions
| Gate | Current disposition | Evidence needed / responsible owner |
|---|---|---|
| GJ-00 safe planning/research | GO | Current instruction; documentation-only output |
| GJ-00 design review | CONDITIONAL GO | Proposed architecture complete enough for review; settle listed rights/data/acceptance qualifications |
| Phase 10 entry/completion as applicable | NOT SATISFIED for global implementation | Development owner records candidate-specific scope/preservation acceptance and required handoff; this plan cannot close it |
| PRODUCT-REVIEW-01 | NOT ESTABLISHED | Product owner publishes signed decision and approved global scope/order |
| INT-GLOBAL-JOBS-01 implementation scope | NO-GO | Owner approves implementation and issues GJ-01 instruction after applicable prior gates |
| GJ-01 current execution | NO-GO | All preceding entries plus fresh accepted development source; no automatic continuation |
| Country live providers | Separate, blocked/conditional | Per-source grants, credentials, lifecycle and bounded live tests; see rights matrix |
| UI language support | EN/Bokmål source support only | Coverage checks + native QA; Swedish and other locales require actual implementation/qualification |
| Geography/occupation imports | Not qualified | Exact artefact versions/hashes/licences, mappings and benchmark approval |
| Hosted MariaDB CI | Inherited blocker | Isolated runner qualification; current workflow explicitly fails closed before DB execution |
| Live AI | BLOCKED | Independent artifact/environment/security/semantic qualification and owner authorization |
| Main merge / production | NO-GO / not authorized | Independent release owner action; never implied by stage pass |
## Stage dependency order
GJ-00 → entry approvals → GJ-01 preferences/i18n → GJ-02 geography → GJ-03 occupations → GJ-04 providers → GJ-05 UI → GJ-06 saved searches/alerts/matching → GJ-07 operations/security → GJ-08 integrated acceptance. Stage prompts remain authoritative. Provider approvals may be pursued independently; an unavailable UK/FINN connector does not block truthful manual/external fallback or a qualified NO/SE source, but blocks any complete live UK/full FINN claim.
Each later branch starts from the latest accepted integration-complete predecessor history, not this old baseline or `main`. Recheck Git ancestry, source hashes, acceptance scope and gates at entry. Keep approved missing-provider scope explicit rather than declaring an unconditional three-country live-search GO.
## Workspace and publication boundaries
At entry the checkout had unrelated tracked changes to `.gitignore`, `AGENTS.md`, `docker-compose.dev.yml`, plus untracked tool/security/Graphify files and supplied `docs/planning/`. Preserve them; do not reset/stash/delete or commit them indiscriminately. Created the specifically authorized planning branch after local/remote collision check. Only `docs/global-jobs/00/` and the supplied `docs/planning/global-jobs-prompts/` are eligible for this planning commit. Existing application source is unchanged.
Remote: `origin`, `https://git.cesnimda.uk/cesnimda/jobtrackingapp.git`. Inspected workflows: push deployment targets `main`; planning branch is outside that trigger and MariaDB branch allowlist; maintenance/backup dispatch is not invoked. No force push, PR, merge or deployment. Verify remote branch HEAD after push and retain exact publication identity in the completion report/evidence record.
+31
View File
@@ -0,0 +1,31 @@
# GJ-00 evidence ledger
28 October 2026, Europe/Paris date. Planning/research only. Runtime feature acceptance is not claimed.
## Source and research
- Baseline: `4a8c907d70cbed235728f7d690b5409e72b93abe`, `test/cv-integration-acceptance`; `git ls-remote` confirmed the exact remote SHA. Planning branch did not exist locally/remotely before creation.
- Current architecture pointer/overview/topics, current roadmap/TODO, Phase 10 requirements, CV-QA acceptance/handoff/regression references and BLOCKERS were reviewed. No completed PRODUCT-REVIEW-01 found; Phase 10 implementation approval not established.
- Graphify query located discovery, saved-search, i18n and related owners; scoped graph-node inventory resolved imports/operations/taxonomy seams. Graph contains 10,691 nodes at entry, excludes documentation/config/deployment; not runtime proof. Some broad queries truncated or found no matching node; followed source references/graph labels rather than treating those as exhaustive results.
- Source inspection verified existing per-request NAV feed scan, substring location filtering, public-token fallback, owned saved-search/alert baseline, EN/Bokmål-only UI, HTML URL preview including FINN, technical skill tagger, owner filters/version domains and Admin system checks.
- Official public pages were browsed under the current explicit research authorization. Each substantive source is linked beside its claim in provider/geography/taxonomy/product documents with access date. Swagger UI home pages initially produced empty parsed content; JobAd Links raw OpenAPI 1.4.5 was readable. JobSearch raw Swagger fetch failed through the browser tool; current official service/dataset pages establish public CC0/no-registration status, not tested response contracts. A guessed ESCO licence URL failed; exact dataset licence qualification is left open, not inferred from that failure.
- ONS confirms county/district/parish relationships; precise town settlement polygons/IDs were not acquired. SCB simplified boundaries are explicitly unsuitable for analysis; exact spatial claims remain gated. General Kartverket terms currently state CC BY 4.0, so older NLOD assumptions were not carried forward.
- Some exploratory Windows wildcard paths did not resolve; explicit known paths were used. One nested PowerShell variable expression failed from shell expansion; replaced with a read-only Python graph-label inventory. Neither failure mutated application data or constitutes a feature test failure.
## Validation and publication
Executed documentation checks: 9/9 required outputs present; UTF-8 reads succeeded without replacement characters; 45 local Markdown targets checked, zero broken; ancestry checks for `4a8c907`, `ef425c2`, `4a0af62`, `89974a2` all passed (native exit 0). Source diff lists only the three pre-existing tracked changes; no application changes. Prompt cross-check corrected the GJ-05 branch/checkpoint labels and explicitly retained waiter/mechanic/public-administrator benchmark cases. These are documentation checks, not application test passes. Staged whitespace/scope and remote publication are checked before completion; exact publication SHA is reported to the owner.
Eligible commit paths: `docs/global-jobs/00/`, `docs/planning/global-jobs-prompts/`. Supplied prompt pack retained unchanged. Pre-existing tracked/untracked changes remain outside the index. Check staged diff before commit; do not use `git add .`.
Publish with ordinary commit/push to `planning/int-global-jobs-00`, then `git ls-remote` exact equality. Completion report provides publication SHA; a commit cannot contain its own hash. No PR or main/deploy workflow action is requested. Post-publication evidence may use a second docs-only checkpoint, with both SHAs reported.
## Lanes not executed
## Publication result
Planning content committed locally as `2c38d40c9213aa511684910bce57b691435a9930`. Staged whitespace check passed; an assertion verified exactly 23 documentation files under the two allowed paths. Two ordinary push attempts failed because `git.cesnimda.uk:443` was unreachable; a subsequent remote-head read also failed. **Push BLOCKED; remote publication NOT VERIFIED.** No force push or alternate remote was attempted. Retry `git push -u origin planning/int-global-jobs-00` when connectivity returns, then compare `git rev-parse HEAD` with `git ls-remote --heads origin refs/heads/planning/int-global-jobs-00`. The later documentation-only publication-status commit records this failure; its hash is supplied in the completion report.
## Unexecuted runtime lanes
Backend/frontend/browser/contract/MariaDB/Python suites: **NOT RUN** for this docs-only stage. Prior acceptance counts are referenced historically only. No live advertisement query/detail/feed call, provider credentials, consumer registration, commercial approval, taxonomy/geography data import, benchmark/load test, physical device/AT/native-language check, live-model inference, hosted CI qualification, deployed-identity check or production operation. Researching documentation is not a live provider contract pass. No Graphify rebuild: application code unchanged and docs are excluded from its code-only build scope.
@@ -0,0 +1,53 @@
# Geography decisions — proposed, exact GJ-02 semantics
28 October 2026. The [GJ-02 prompt](../../planning/global-jobs-prompts/03_GJ_02_GEOGRAPHY.md) is authoritative. No geography ingestion, geometry validation or distance test was run in GJ-00.
## Source and identity decisions
| Country | Authoritative candidates / observed facts | Licence/version and unresolved qualification |
|---|---|---|
| NO | SSB county/municipality classifications; Kartverket administrative geometry and SSR official place names. Vestfold `39`, Tønsberg municipality `3905`, Sandefjord `3907`; Vestland `46`, Bergen `4601`; Oslo county `03`, municipality `0301`. | Pin SSB 2026 IDs/validity plus exact Kartverket download/API metadata and geometry edition/hash in GJ02-A. Kartverket free products are CC BY 4.0, credit © Kartverket and link. Verify SSB export licence separately. SSR point/name is not a settlement polygon. |
| SE | SCB län/kommun plus statistical tätort/locality. Stockholm municipality `0180` must remain distinct from Stockholm settlement; settlement boundaries may cross municipality boundaries. | SCB publishes CC0 simplified county/municipality boundaries but explicitly says they are unsuitable for analysis. Choose qualified Lantmäteriet administrative geometry for exact spatial work or disclose lower precision. Pin exact current SCB locality edition at GJ02-A (2023 listed on inspected download page); verify metadata, not “latest” URL. |
| GB | ONS/GSS administrative IDs, lookup relations and settlement geography; country-specific settlement sources for Scotland/NI need qualification. Warwickshire `E10000031` contains Warwick district `E07000222`; district contains both Warwick and Royal Leamington Spa parishes. Neither district nor a combined built-up area is Warwick town alone. | ONS OGL v3 + product-specific OS/other attribution; Northern Ireland postcode data needs a separate commercial licence. Pin individual product epoch/hash. Exact Warwick/Leamington settlement IDs/geometries and Scottish/NI settlement metadata remain GJ02-A blockers to precision claims. No made-up town IDs. |
Official sources accessed 2026-10-28: [SSB county 2026](https://dataportal.ssb.no/classifications/104/versions/3315/correspondences/2808), [SSB municipalities](https://dataportal.ssb.no/classifications/131/correspondences/1358), [Kartverket services](https://www.kartverket.no/en/api-and-data/eiendomsdata/apent-tilgjengelige-data-og-tjenester), [Kartverket terms](https://www.kartverket.no/api-og-data/vilkar-for-bruk), [SSR guide](https://kartverket.no/en/api-and-data/stedsnavndata/brukarrettleiing-stadnamn-api), [SCB boundaries](https://www.scb.se/hitta-statistik/regional-statistik-och-kartor/regionala-indelningar/digitala-granser/), [SCB localities](https://www.scb.se/vara-tjanster/oppna-data/oppna-geodata/statistiska-tatorter/), [Warwickshire](https://www.ons.gov.uk/explore-local-statistics/areas/E10000031-warwickshire), [Warwick district/parishes](https://www.ons.gov.uk/explore-local-statistics/areas/E07000222-warwick), [ONS licences](https://www.ons.gov.uk/methodology/geography/licences/).
## Canonical contract
`LocationNode` has stable internal ID, source namespace/code/type/country, edition, validity, original/localized names and aliases, optional geometry/representative point with CRS/precision/provenance, and deprecation/replacement links. External code identity is versioned; no code is assumed eternal or globally unique. Preserve original provider location and confidence (`exact`, `area-only`, `approximate`, `unresolved`). Postal areas are not administrative ancestry.
Use typed relationship edges (`administrative-parent`, `settlement-intersects`, `alias-of`) rather than one false universal tree. Country → county/län → municipality/district → town is a **navigation projection** where valid; unitary authorities skip absent levels. Settlement overlaps remain many-to-many. Oslo has distinct county/municipality type identities linked to the same administrative footprint; selecting one should not duplicate results. Tønsberg municipality and settlement likewise remain distinct. Stockholm settlement does not become wholly contained in Stockholm municipality by UI nesting.
`LocationScope` serializes branch root + explicit refinements + mode (`whole`/`specific`) + optional radius origins, schema version and taxonomy edition. Store scopes, not thousands of child checkboxes. Browser tree expansion is lazy/paginated and independent of selection; collapsed branches retain filters. Unknown/stale IDs cause visible review, never silently broaden to a parent. Dataset upgrades retain old IDs and versioned crosswalks; ambiguous replacements require confirmation.
## Exact interaction/query contract
For a selected branch b: effective(b) = union of its explicit deepest refinements when any exist; otherwise whole selected root. Recurse the same rule for nested refinements. Combine independent branches as OR; combine location with occupation/language/other dimensions as AND. Explicit selections on one ancestry path refine that path, never add the broad parent back into the OR union. A separately selected whole-country branch must visibly explain that it subsumes narrower branches.
| Action / state | Effective query | Visible state |
|---|---|---|
| Select Warwickshire | Whole county | County selected, children unchecked |
| Expand only | Still whole county | Expansion changes no filters |
| Select Warwick under selected Warwickshire | Warwick **town only** | County retained as scope being refined; `Warwick only` summary |
| Add Royal Leamington Spa | Warwick town OR Royal Leamington Spa town | Both refinements; no other county areas |
| Remove Warwick | Royal Leamington Spa only | Summary updates |
| Remove last town, keep county | Whole Warwickshire | Whole area state restored |
| Add Bergen branch | Previous effective GB scope OR chosen Bergen scope | Country branches both visible |
| Deselect county | Remove that branch and its descendants | Announce removal; no hidden child filters |
| Collapse / reopen / save / reload | Same scopes | Checked scope/refinement distinction preserved |
Use a scope checkbox plus separate Whole area/Specific places controls and effective summary. Do not expose a checked parent as the conventional “all descendants selected” or mixed-state checkbox. A direct town selection through lookup includes ancestry for navigation; deselecting that selected ancestor clears the associated branch visibly. Do not auto-check children when selecting a parent.
Provider query compiler emits supported area IDs and a local exact filter over permitted data. Provider codes need versioned canonical mapping; ambiguous text alone remains unresolved. A county-only posting cannot pass a strict Warwick-town filter. Approximate/unresolved results may appear only in an explicitly labelled optional broad-results section, excluded from exact counts/alerts. No `Contains("Warwick")` geography. Live-query result-window limits prevent exhaustive local filtering claims.
## Optional radius and remote rules
Radius is off by default. Choose town origin(s) explicitly with known point provenance; examples 10/25/50 km. Radius replaces the town polygon test for that origin, so nearby places outside the parent boundary may be included; keep the selected **country** constraint and disclose “within X km of town”, not “town only”. Multiple origins OR together; whole-area branches still OR with radius branches. Different dimensions still AND. Do not apply a county centroid as if it were a town or job coordinate.
Compute geodesic distance on WGS84 coordinates, inclusive boundary with documented numerical tolerance and fixture points just inside/outside. Missing/area-centroid-only job coordinates fail strict radius membership and remain visibly unknown. No driving-time claim; actual commute time requires a separately licensed routing provider, deferred.
Remote filter is explicit: on-site/hybrid use workplace scope/radius; fully remote matches selected work-country eligibility where declared, with geographic/radius exception visibly labelled and opt-in; region-limited remote still requires that declared region. Unknown eligibility does not mean worldwide. Work location constraints cannot be bypassed by a remote tag.
## GJ02 acceptance obligations
Freeze real licensed IDs/geometries for Warwickshire/Warwick/Leamington, Vestfold/Tønsberg/Sandefjord, Vestland/Bergen, Oslo dual identity and Stockholm län/municipality/settlement. Test all transitions above, nested/sibling branches, cross-country OR, unitary/overlapping geography, stale IDs, missing coordinates, radius bounds, provider code mismatch and persistence roundtrips. Measure index/query plan, lazy lookup and accessible mobile/keyboard behavior; never render 1,000 child checkboxes. Until exact settlement data is qualified, town-precision implementation remains blocked rather than using district substitutes.
@@ -0,0 +1,126 @@
# International Job Discovery Implementation Plan
> **For agentic workers:** Use superpowers:executing-plans to implement this plan task-by-task only after explicit stage authorization. Delegation is not required by this plan. Do not start implementation from this document alone.
**Goal:** Deliver cross-industry, multilingual NO/SE/GB discovery while preserving Jobjakt's current data and document owners.
**Architecture:** Extend existing React/MUI discovery, .NET services and MariaDB. Use a source-rights registry, NAV local index, eligible live-query adapters and honest external/manual fallbacks; no generative model dependency.
**Tech stack:** React 19/Next.js static shell, TypeScript, MUI, ASP.NET/EF Core 9, MariaDB, existing background services and test tools.
**Spec:** [Master](../../planning/global-jobs-prompts/00_MASTER_ORCHESTRATOR.md), separate GJ-01–08 prompts, [architecture/contracts](ARCHITECTURE_ADRS.md), [acceptance](ACCEPTANCE_MATRIX.md).
## Global constraints
- Planning only now; Phase 10, PRODUCT-REVIEW-01, initiative approval and next-stage instruction precede implementation.
- No main merge, production operation, destructive data change, restricted scraping, consumer registration or paid-resource purchase.
- Preserve Career Profile, CV Import/Editor, applications, frozen packages, revisions, histories, all 57 groups/33 routes and MariaDB compatibility.
- Never enable live AI without independent qualification; core suggestions/matching are deterministic.
- Each stage stops after acceptance and publication. Later source branch includes accepted predecessors. Stage prompts override this proposed file breakdown if they conflict.
- Run `graphify query` before code exploration, trace every shared caller before changes, and `graphify update .` after code changes (no update needed for this docs-only stage).
## Review focus
1. Legacy FINN URL preview reaches HTML fetch before plugin selection: GJ04-F must deny it before network while keeping reviewed manual entry usable.
2. A withdrawn source moves location or loses fields: GJ04-G must remove it independent of old/new query filters and surviving duplicate sources.
3. Parent remains checked after town refinement: GJ02-C/D must query only refinements and roundtrip exact scope state.
4. Search edited/deleted while worker is sending: GJ06-B must recheck revision/owner/status and suppress obsolete delivery, including restart.
5. Source-content purge and user application evidence differ: GJ06-D/GJ07-C must enforce approved retention without deleting user notes or silently changing frozen history.
## Execution and evidence pattern
For each checkpoint below: record exact source/callers and baseline → add the specified failing contract/scenario test → run and preserve RED → make smallest complete change → run focused checks then required stage regressions → review diff/security/rights → document measured outcomes, commit/push only its authorized branch and verify remote HEAD → stop at stage end. Do not count focused reruns twice. New fixtures are synthetic or lawfully retained/redacted; no private CVs or credentials in evidence.
Representative existing commands (verify local SDK/package scripts/config at entry): `rtk dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter FullyQualifiedName~JobDiscovery`, `rtk npm test -- --runInBand job-discovery.test.tsx` from UI, and configured Playwright discovery specs. These are planned commands, not executed results. MariaDB uses the repository's existing isolated integration lane after its entry check; do not start Docker/DB or tests with unknown live service targets. TypeScript/lint/build and ordinary/separate browser lanes use current package scripts and explicit configs, serialized with PDF/build lanes. Capture native exits and discovery membership.
## GJ-01 — preferences and languages
Branch `feature/gj01-country-language-preferences`; authority [GJ-01](../../planning/global-jobs-prompts/02_GJ_01_PREFERENCES_I18N.md). Entry: all global gates above. Produces `SearchPreference` contract and persisted locale/default semantics from ADR-03.
Files: extend `JobTrackerApi/Models/ApplicationUser.cs`, `Controllers/AuthController.cs`, `Data/JobTrackerContext.cs`; proposed `Models/SearchPreference.cs`, preference controller/service and additive migration. Extend `job-tracker-ui/src/components/SettingsView.tsx`, `views/JobDiscoveryPage.tsx`, `i18n/I18nProvider.tsx`, `i18n/translations.ts`, `api.ts`/types where existing ownership requires. Test proposed `SearchPreferenceTests.cs`, UI preference/locale tests, existing settings e2e and account export/deletion contracts.
- [ ] GJ01-A: freeze DTO/schema validation, expected revision and empty legacy defaults; RED cross-owner/missing-owner/concurrent-write/old-account tests, then additive persistence. No schema deletions.
- [ ] GJ01-B: first-run/Settings defaults and optional residence distinct from work countries; test cross-device recovery and no nationality collection.
- [ ] GJ01-C: locale registry/native labels/prominence/Coming Later/fallback; complete required translations and missing-key checks; native language review stays separate.
- [ ] GJ01-D: initialize draft query from defaults, explicit Update defaults/save named profile boundary; test temporary overrides never persist accidentally. Named rich saved-search lifecycle is completed in GJ-06 using same query contract.
- [ ] GJ01-E: tenant/export/delete/revision/EN-Bokmål regressions and approved shipped-locale proof. Publish `docs/global-jobs/01/` acceptance and stop. Unqualified Swedish UI journey remains blocked, not silently counted passing.
## GJ-02 — geography
Branch `feature/gj02-geographic-search`; [GJ-02](../../planning/global-jobs-prompts/03_GJ_02_GEOGRAPHY.md). Consumes preferences; produces `LocationNode`, `LocationScope`, `LocationQueryPlan` contracts from ADR-03/geography decisions.
Files: proposed `Models/DiscoveryLocation.cs`, `Services/Discovery/LocationScopeCompiler.cs`, versioned reference importer under existing tooling conventions, `Controllers/DiscoveryLocationsController.cs`, EF mapping/migration, `src/components/discovery/LocationScopePicker.tsx` and shared discovery query types. Test `LocationScopeCompilerTests.cs`, MariaDB mapping/index tests, UI scope transitions, browser discovery specs.
- [ ] GJ02-A: obtain exact licensed snapshots/IDs/geometries, especially Warwick **town** versus district and overlapping SE/NO settlements. Block precise town query if no qualified footprint exists.
- [ ] GJ02-B: idempotent versioned import + typed relations/provider mapping + stale-ID review; tests prove no orphan/broadened saved scopes and no partial dataset swap.
- [ ] GJ02-C: implement scope/refinement controls; test every geography decision-table transition, collapse/save/reload and accessible announcements.
- [ ] GJ02-D: compile exact IDs/polygons, radius/multi-origin/remote rules; assert county-only job fails strict town/radius filter and out-of-parent radius result is disclosed.
- [ ] GJ02-E: measured lookup/query/load, keyboard/mobile/zoom tests and edition/licence ledger. Publish `docs/global-jobs/02/` and stop.
## GJ-03 — occupations
Branch `feature/gj03-occupation-search`; [GJ-03](../../planning/global-jobs-prompts/04_GJ_03_OCCUPATIONS.md). Consumes query/scopes; produces `OccupationConcept`/`OccupationQueryPlan`, without AI.
Files: proposed `Models/DiscoveryOccupation.cs`, `Services/Discovery/OccupationQueryCompiler.cs`, versioned dataset importer, `Controllers/DiscoveryOccupationsController.cs`, `src/components/discovery/OccupationAutocomplete.tsx`; integrate `JobDiscoveryPage`, preserve `JobImport/SkillTagger.cs` callers. Test taxonomy/alias/compiler suites and browser combobox fixtures.
- [ ] GJ03-A: licence/checksum/version manifest for ESCO/SSYK/SOC, reviewed aliases/crosswalk relation types and frozen ten-sector/three-language benchmark; approve metrics before measuring.
- [ ] GJ03-B/C: concept lookup and accessible cancellable typeahead; test stale response, same word/different language, deprecated ID and taxonomy outage.
- [ ] GJ03-D: exact/related/keyword modes, optional skill chips and provider mapping; test “weekend work”, free text and no qualification inference.
- [ ] GJ03-E: report per-sector/language precision/recall and p95/index size; publish `docs/global-jobs/03/` and stop.
## GJ-04 — providers and inventory
Branch `feature/gj04-provider-connectors`; [GJ-04](../../planning/global-jobs-prompts/05_GJ_04_PROVIDERS.md). Consumes canonical query/location/occupation plans; produces source-aware `DiscoveryPageV1` and permitted detail/lifecycle data.
Files: existing `Controllers/JobDiscoveryController.cs`, `Services/JobDiscoverySearchService.cs`, `Services/JobImport/JobImportService.cs`, `Plugins/FinnPlugin.cs`; proposed `Services/Discovery/ProviderRightsPolicy.cs`, `DiscoveryQueryService.cs`, `Providers/` capability-specific adapters, inventory/cursor models, EF mappings/migrations and DI registration at current host owner. Tests extend `JobDiscoveryControllerTests`, `JobImportServiceTests`, transport tests and proposed provider/lifecycle/dedupe MariaDB tests.
- [ ] GJ04-A: reverify matrix, exact API schemas/grants, numeric cache/quota/lag policy and credential references; unknown capability denied. Test every denied mode and rights expiry/revocation before adapter work.
- [ ] GJ04-B: inventory/event/cursor transaction, replay/out-of-order/crash/bootstrap/backoff tests; additive migration and search compatibility adapter.
- [ ] GJ04-C: NAV ingestion only with consumer authorization; fixtures can proceed independently, clearly labelled. Test removal/masking independent of area and no public-token production fallback.
- [ ] GJ04-D: SE JobSearch and truncated JobAd Links, exact taxonomy IDs and bounded windows; original-link/completeness/404/429 tests. JobStream only after separately qualified need.
- [ ] GJ04-E: licensed GB adapter or explicit external-link-only state; do not mark a fixture connector live. No full Adzuna body reconstruction.
- [ ] GJ04-F: FINN URL recognition/bookmark/manual workflow with zero network fetch through both new and existing import entry points; test malicious hosts, redirects and approved-source exceptions.
- [ ] GJ04-G: conservative dedupe, source loss and field-rights lineage, SSRF/XSS/rate/rights tests; separately bounded approved live contracts. Publish `docs/global-jobs/04/` with provider-by-provider decisions and stop.
## GJ-05 — unified discovery UI
Branch `feature/gj05-discovery-ui`; [GJ-05](../../planning/global-jobs-prompts/06_GJ_05_SEARCH_UI.md). Consumes page/detail contracts; produces desktop/mobile results/detail and existing workflow actions.
Files: extend `views/JobDiscoveryPage.tsx`, `App.tsx` routes, existing `PageLayout`/theme/API/types; proposed `components/discovery/JobDetailPanel.tsx` and filter/list pieces only when independently needed. Reuse existing job dialog/actions. Extend `src/job-discovery.test.tsx`, `e2e/job-discovery.spec.ts`, `e2e/phase7-discovery.spec.ts`.
- [ ] GJ05-A/B: filter rail/list/detail with independent partial-source/loading/error/empty states; tests preserve draft filters, deep link/history and return focus.
- [ ] GJ05-C: results/query semantics, sorting/pagination, exact-filter coverage and conservative alternate-source grouping; test stale/partial-source results and missing fields.
- [ ] GJ05-D: render full/truncated/link-only details with safe HTML/plain-text fallback, salary period/prediction/unknown fields and provenance. Assert blocked content never appears in DOM/API/export.
- [ ] GJ05-E: reviewed Save/Track/original-apply actions and native CV/letter document links, with AI gated.
- [ ] GJ05-F: phone full-screen details, tablet/desktop, EN/Bokmål and qualified locales, keyboard/zoom/themes, accessibility/fidelity/regression. Publish `docs/global-jobs/05/`, exact visual/axe/manual limits and stop.
## GJ-06 — searches, alerts and matches
Branch `feature/gj06-alerts-matching`; [GJ-06](../../planning/global-jobs-prompts/07_GJ_06_ALERTS_MATCHING.md). Consumes permitted inventory/query and existing profile/application owners; produces revision-safe `SavedSearch`, `SavedJob`, `SearchAlert` behavior.
Files: extend `Models/SavedJobSearch.cs`, `Services/SavedJobSearchRunner.cs`, `SavedJobSearchAlertHostedService.cs`, `Controllers/JobDiscoveryController.cs`, owned export/deletion services; proposed bookmark/subscription/delivery models and deterministic match service; UI search profile/notification/actions; no parallel application/profile writer. Tests `SavedJobSearchAlertTests`, `SavedJobSearchSqliteTests`, account export/deletion, MariaDB races and browser tracking/documents.
- [ ] GJ06-A: versioned full queries, duplicate/edit/delete/suspend/restore and legacy bare-NAV-ID migration; test revision conflict and exact scope roundtrip.
- [ ] GJ06-B: durable delivery/initial baseline/frequency/DST/pause/unsubscribe/deduplication; test duplicate workers, crash, changed/deleted search, withdrawn job before dispatch, source stale, overlapping searches and ambiguous mail send failure.
- [ ] GJ06-C: evidence-backed occupation/skills/qualification explanations; tests include nurse licence absent, cleaner empty profile, language unknown and profile revision change.
- [ ] GJ06-D: Save distinct from Track, idempotent tracked link, CV/letter native workflow; assert no canonical/CV/frozen-package mutation from source update and approved purge policy behavior.
- [ ] GJ06-E: security/fanout/accessibility/retention checks; publish `docs/global-jobs/06/` and stop.
## GJ-07 — admin and resilience
Branch `feature/gj07-discovery-operations`; [GJ-07](../../planning/global-jobs-prompts/08_GJ_07_ADMIN_SECURITY.md). Consumes feed/alert state; produces admin health, redacted correlation diagnostics and incident/rollback runbooks.
Files: extend `Controllers/AdminSystemController.cs`, `src/views/AdminSystemPage.tsx`, existing operation response/presentation contracts where appropriate; proposed system source-sync state/log projection (not synthetic tenant user), rights/retention controls. Tests admin API/UI, provider failure drills, export/delete/restore and resource limits.
- [ ] GJ07-A/B: provider rights/access/health/quota/lag/cursor/backlog/count metrics and paginated correlation events; distinguish actual system sync from real submitting user.
- [ ] GJ07-C: Admin check on every list/detail/stream/export; assert tenant/non-admin denial, redaction, no unsafe HTML/PII/raw Docker logs, retained source purge and account erasure.
- [ ] GJ07-D: provider loss, invalid schema, repeated cursor, credential revocation, index outage, lease expiry, retry storm and bounded load; verify backup/restore suppression and additive rollback. No production maintenance dispatch. Publish `docs/global-jobs/07/` and stop.
## GJ-08 — integrated acceptance
Branch `test/gj08-global-discovery-acceptance`; [GJ-08](../../planning/global-jobs-prompts/09_GJ_08_ACCEPTANCE.md). Entry: actual GJ-01–07 accepted ancestry, rights scope and source freeze, not sum of stage test counts.
- [ ] GJ08-A: freeze integrated source/config/fixture/dataset/licence identity and discover exact full-suite membership; reconcile blocked capabilities.
- [ ] GJ08-B: run all matrix journeys, backend/frontend/browser/contracts/actual MariaDB/model-disabled sidecar where applicable, TypeScript/lint/build and unchanged preservation/PDF oracles. Serialize build/browser/PDF lanes; retain native exits and failures.
- [ ] GJ08-C: authorised provider smoke separately from fixtures; per-country/source/content/language/geographic coverage report. Complete human/device/native-language/AT/print checks only with real evidence, otherwise OPEN.
- [ ] GJ08-D: source lifecycle/security/load/rollback/release gate review; publish `docs/global-jobs/08/`, per-stage/provider development decisions and production NO-GO unless separately authorized and qualified. Commit/push and stop; never deploy or merge main.
+24
View File
@@ -0,0 +1,24 @@
# Owner actions and external approvals
28 October 2026. No registrations, agreements, emails, purchases or production changes were made. These are actionable handoffs, not drafts sent on the owner's behalf.
| Action / owner | Exact requested outcome | Blocks |
|---|---|---|
| Development owner — Phase 10 | Read `docs/cv-integration-qa/PHASE_10_HANDOFF.md`; nominate exact candidates and current callers/compatibility/preservation tests, then record applicable Phase 10 completion/entry decision. Do not treat “legacy” names as unused. | Global implementation entry; no cleanup authorized here |
| Product owner — PRODUCT-REVIEW-01 | Publish baseline-bound review, approved scope/non-goals, outstanding qualification acceptance, country/locale/provider fallback policy and explicit INT-GLOBAL-JOBS-01/GJ-01 authorization. | GJ-01 |
| Owner — design review | Approve hybrid architecture, branch order, hierarchy/radius/remote behavior, language release policy and proposed metrics/retention; identify required changes. Issue next-stage instruction only after entry gates. | Implementation sign-off; GJ-00 itself is delivered for review |
| Business/operator — NAV | Using [official instructions](https://navikt.github.io/pam-stilling-feed/), send terms acceptance, company identifier, contact person/email/phone to the listed NAV team address. Ask for consumer JWT and approved polling, stale-outage/removal/contact handling expectations. Install secret via approved development secret store and record nonsecret grant evidence. Do not use public token as production access. | NAV live feed enablement/smoke |
| Business/legal — FINN | If broad integration is desired, obtain written grant covering third-party commercial search, accessible inventory, caching/full display, attribution, expiry/removal, quotas and price. Advertiser ownership-only access is insufficient for global search. Otherwise accept manual bookmark/record + external link. | FINN API/full content; manual fallback remains possible |
| Business/legal — Adzuna | Request commercial SaaS licence for NO/SE/GB scope as needed (GB first), approved snippet/redirect/branding, saved-search alerts, local storage/TTL, deletions/termination, quota/SLA and quote. Capture signed permitted scope; no automatic trial/account creation. | GB Adzuna live connector; no full descriptions promised |
| Provider owner — Swedish services | Confirm current OpenAPI/schema/terms and suitable request volume with official JobTech contact; qualify JobSearch details/lifecycle and JobAd Links daily refresh/truncation/window. Ask about quotas/cache and update/delete reconciliation where docs do not specify. Preserve CC0 metadata and origin links. | Source operational acceptance; no registration prerequisite inferred |
| Business/legal — NHSBSA | Ask whether third-party commercial multi-employer search/index/display/alerts are allowed, not merely employer self-serve/ATS posting; request exact feed scope, terms, refresh/removal, attribution and limits. | NHS direct connector |
| Provider owner — DWP/other UK boards | Ask for explicit search-consumer agreement and documented retrieval interface if desired. Bulk upload is not search access. Leave Reed/Indeed/other boards external-only until source-specific rights review. | Other UK connectors |
| Data owner — geography | Freeze canonical administrative/settlement artefacts with version, checksum, codes/edges, CRS/geometry precision and licence; resolve Warwick/Leamington town footprints, NO settlement/municipality differences, SE multi-municipality tätort, Scotland/NI data. Obtain commercial NI postcode permission before BT postcode reuse or omit that dataset. | Precise geographic capability; broad admin-only fallback disclosed |
| Taxonomy owner | Acquire ESCO v1.2.1 candidate files through official process, exact file licence/readme/checksum and attribution; pin JobTech/SOC indexes/crosswalks; review EN/Bokmål/SV benchmark, unknown terms and “chef” ambiguity. | GJ03 data import/relevance qualification |
| Localization owner | Qualify Swedish UI if required for initial SE release; native-review all new EN/Bokmål/SV legal/professional language. Confirm Norway/Sámi and GB regional mappings; Nynorsk/Welsh/Gaelic/Scots/minority languages stay Coming Later until complete. | Shipped-locale claims and related journeys |
| Privacy/release owner | Approve lawful retention of source PII, user notes/copied descriptions/frozen evidence, diagnostic metadata, alert frequency/email consent, export/erase, backup expiry and restored-backup suppression. Consider DPIA need against concrete processing. | PII persistence/alerts and release scope |
| Infrastructure owner | Qualify isolated nonproduction MariaDB CI runner and resource/cost envelope; keep production benchmark/maintenance jobs out of discovery tests. | Hosted provider/DB/load evidence, not local planning |
| Human QA owner | Complete actual device/keyboard/AT/native-language/contrast/print qualifications and record identity/date/scope. | Human/release acceptance only |
| AI owner | Independently qualify environment/artifact/security and semantic quality, then explicitly authorize inference if needed. | Optional AI only; deterministic discovery must work without it |
No one approval substitutes for another. If a provider declines rights, leave it disabled/manual/external-only and document coverage; do not seek undocumented endpoints or scrape the source to bypass the decision. Keep secret agreements/credentials outside public docs; store only reviewed scope and evidence references.
@@ -0,0 +1,54 @@
# Product requirements — proposed, GJ-00
28 October 2026. Authority: [master prompt](../../planning/global-jobs-prompts/00_MASTER_ORCHESTRATOR.md). Implementation remains gated by [entry decisions](DEPENDENCIES_AND_GATES.md).
## Product scope and journeys
Discovery serves all occupations in Norway, Sweden and the United Kingdom initially. ISO country identifiers are `NO`, `SE`, `GB`; UI may display “UK”. Country availability and provider availability are separate. Unavailable sources cannot turn a country into a fabricated searchable inventory.
| Journey | Persisted choice and resulting behavior |
|---|---|
| New resident | Optional residence NO; English UI; Norwegian/English adverts; choose NO work scope. Do not request nationality to enable search. |
| Multi-country mover | Lives in NO, searches SE + GB; separate geographic branches OR together. Residence does not restrict results. |
| Returning jobseeker | Recover defaults across devices; load named search, override a query temporarily, explicitly update defaults only on request. |
| No formal occupation | Search free text such as “no experience”, “weekend work”, kitchen assistant or cleaner. No required taxonomy selection, skill inventory or CV upload. |
| Mixed jobs/shift work | Search retail and care roles, part-time/evenings, multiple towns; unknown schedule fields remain unknown. Missing filters are disclosed per provider. |
| Regulated occupation | Nurse/electrician matches show advertised credentials against explicit profile evidence; no claim of professional authorisation or eligibility. |
## Preference boundaries
`SearchPreference` holds optional residence, desired work countries (0..N), favourite location scopes, occupation interests, explicit skills references, work arrangements, advert languages and revision. Residence, nationality, home country and work countries are distinct; do not add compulsory demographic fields. UI locale stays in existing account preferences. CV and cover-letter languages remain document-specific.
Named `SavedSearch` profiles capture a versioned query, rather than a pointer to mutable defaults. Discovery initializes from defaults; filter edits change draft query state only. “Save search”, “Update this search” and “Update defaults” are explicit, different actions with revision checks. Empty preferences show a country-choice invitation and usable keyword search. Changing UI language never changes advert-language filters or document content.
## Language selection and qualification
Current code supports `en` and `nb` UI dictionaries; API accepts `en`, normalizes `nb`/`nb-NO`/`no` to `nb-NO`, and rejects other locales. This is source-verified support, not fresh linguistic acceptance. Proposed locale registry uses BCP 47, native + English labels, coverage/QA status, fallback and regional relevance. Maintain compatibility for existing stored values; do not silently rewrite document languages.
English and applicable local languages appear prominently, combining residence, selected region/work countries and explicit user preference, with stable ordering and no duplicate choices. Other **qualified** UI languages go under More Languages. Unsupported relevant languages appear separately as Coming Later, without an enabled switch. English fallback must be visible if a requested locale is unavailable. Do not infer language from nationality.
| Context | Prominent language candidates | Current qualification / next work |
|---|---|---|
| Norway | English; Norsk bokmål — Norwegian Bokmål; Norsk nynorsk — Norwegian Nynorsk; relevant Sámi language choices by region | EN/Bokmål implemented. Nynorsk and specific Sámi locales require translation and native review; `no` ESCO labels prove neither. |
| Sweden | English; Svenska — Swedish; regionally relevant minority-language choices | Swedish is a required target, currently unimplemented. Finnish, Meänkieli, Sámi, Romani and Yiddish need separate qualification, not generic “Swedish translation”. |
| GB / Wales | English; Cymraeg — Welsh for Wales | Welsh remains Coming Later until complete and reviewed. |
| GB / Scotland | English; Gàidhlig — Scottish Gaelic; Scots | Region-aware candidates, not currently supported switches. |
| GB / Northern Ireland | English; relevant Irish/Ulster Scots candidates | Confirm local language mapping/status and BCP 47 representation at GJ01-C; no inferred blanket UK official-language list. |
Language-policy evidence accessed 2026-10-28: [ISOF](https://www.isof.se/flersprakighet/lar-dig-mer-om-flersprakighet/spraken-i-sverige), [Welsh Government](https://www.gov.wales/planning-and-environment-decisions-wales/welsh-language-standards), [Scottish Government](https://www.gov.scot/news/growing-scotlands-languages/). These inform prominence; they do not certify translations or impose an assumed legal duty on Jobjakt. Norway/Sámi and Northern Ireland detailed regional mappings remain explicit GJ01-C qualification work.
New controls, validation, empty/error/help text, date/number/plural forms, accessible names and alert text require EN/Bokmål coverage. Swedish cannot be advertised as a supported UI until all shipped surfaces meet the approved coverage/native review gate; a “Swedish UI” acceptance journey stays blocked until then.
## Discovery experience
Retain React Router and MUI ownership: desktop filter rail, results list, in-app details; tablet adaptive panes; mobile full-screen detail with return focus/filter state. A stable in-app details URL supports full-page expansion. Preserve country/role/language/location filters on navigation and saved-query recovery.
Details show permitted original description with completeness badge (`full`, `truncated`, `metadata`, `link-only`), original title, employer, location precision, source, posted/updated/retrieved/expiry timestamps and source/apply links. Structured pay retains amount/range/currency/period plus advertised versus predicted status; unknown is not zero. Show requirements, responsibilities, qualifications, shifts/hours, arrangement and contract only when supplied. Translation is separately labelled and never replaces original text or expands licensed content.
Saved vacancy does not mean applied. Track Application enters existing reviewed application workflow; CV/letter links reuse current owners and never trigger AI or submission. Source withdrawal hides restricted content without deleting user-authored notes/status. Source alternatives remain separate and attributed after conservative dedupe.
Loading, empty, partial coverage, unavailable, stale and expired states must differ. Never label external search links as integrated search. Filters unsupported by a provider must either be applied exactly to authorised data or show the coverage exclusion; silently ignoring them is unacceptable.
## Requirement ownership
Preferences/language GJ-01; hierarchy/radius GJ-02; universal terms GJ-03; rights/source lifecycle/dedupe GJ-04; panels/details/actions GJ-05; alerts/skills/tracking GJ-06; admin/privacy/resilience GJ-07; combined proof GJ-08. Acceptance scenarios are in [the matrix](ACCEPTANCE_MATRIX.md); no part of this document authorizes implementation.
@@ -0,0 +1,42 @@
# Provider rights and feasibility — GJ-00
Access date: **2026-10-28**. Official documentation research only; no consumer registration, account creation, credential use, live advertisement search or provider contract test was performed. “Conditional” means suitable for design with stated gates, not enabled or approved for Jobjakt. No provider has live operational acceptance here.
## Feasibility matrix
| Country / immutable proposed provider ID | Documented access / cost / commercial position | Search and in-app content | Lifecycle / attribution / approval | Decision |
|---|---|---|---|---|
| NO / `nav-feed` | Free; JWT. Private token requires consumer registration and written acceptance; rotating public token is experimental. | Change feed, not live keyword search; local index. Detail fetch supplies original description; FINN adverts excluded. | Apply updates/removals immediately; mask stopped content/contact data. Preserve application deep link. [Feed](https://navikt.github.io/pam-stilling-feed/), [terms](https://arbeidsplassen.nav.no/vilkar-api). | CONDITIONAL GO design; consumer token and lifecycle proof required before enablement. |
| NO / `finn-manual` | Advertiser/business relationship, ownership of accessible data and agreement required for API. No general third-party search grant. | URL bookmark/manual fields + external link only. No page fetch, preview extraction, bulk search or full descriptions assumed. | Any broader caching/display/removal/price rights need explicit agreement. [FINN access](https://www.finn.no/api/). | GO manual-only design; NO-GO API/full-content connector without suitable grant. |
| SE / `se-jobsearch` | Public, CC0, no key/registration listed; no paid access prerequisite documented. | Live free-text/structured search and detail endpoint; design for available original descriptions, not guaranteed populated fields. | Recheck active detail before display/alert; reconcile withdrawals. Source labels/links retained. Rate/SLA/cache specifics need current contract qualification. [Service](https://data.arbetsformedlingen.se/dataservice/jobsearch/), [dataset](https://data.arbetsformedlingen.se/dataset/job-ads/). | CONDITIONAL GO; strongest initial live-query candidate, not tested here. |
| SE / `se-jobad-links` | Public, CC0, no key/registration listed. | Search and referenced metadata; descriptions deliberately truncated; original-site link required by service guidance. Never retrieve full third-party page to fill gaps. | Daily source refresh; SSYK classification is provider-generated. [Service](https://data.arbetsformedlingen.se/dataservice/jobad-links/), [OpenAPI 1.4.5](https://links.api.jobtechdev.se/swagger.json). | CONDITIONAL GO preview/link design; no full-content grant. |
| SE / `se-jobstream` | Public, CC0, no registration listed. | Platsbanken feed candidate for later local indexing; not a second inventory to count twice. | Detailed change/delete protocol, snapshots, lag and reconciliation must be qualified in GJ04-A before choosing ingestion. [Service](https://data.arbetsformedlingen.se/dataservice/jobstream/). | CONDITIONAL future adapter; initial SE plan uses JobSearch. |
| GB / `gb-adzuna` | ID/key; free evaluation tier, commercial trial limited to 14 days. Ongoing revenue/lead service needs licence; price/limits negotiated. No trial begun. | Search response contains description snippet, not full advert; retain provider redirect. | Adzuna branding/link rules; termination requires data removal. Contract must settle caching, alert use, expiry, quotas and link handling. [Terms](https://developer.adzuna.com/docs/terms_of_service), [search](https://developer.adzuna.com/docs/search). | NO-GO live commercial connector pending written approval; conditional snippet-only design. |
| GB / `gb-nhs` | NHS documents employer self-serve XML/RSS and ATS integrations; not evidence of unrestricted SaaS aggregation rights. | External NHS search/link fallback; no bulk/full-detail connector approved. | Ask NHSBSA for commercial job-board eligibility, scope, display, caching, quotas and withdrawal rules. [Official integration guidance](https://www.nhsbsa.nhs.uk/about-nhs-jobs/nhs-jobs-integration-and-benefits). | NO-GO direct integration until rights confirmed. |
| GB / `gb-find-a-job` | Documented DWP bulk upload is employer/ATS publishing, not a search-consumer grant. | External search/manual URL only. | Consumer access/display/retention remain unverified. [Official uploader specification](https://static.findajob.dwp.gov.uk/images/find_a_job_bulk_upload_spec_v3.0.pdf). | NO-GO search API; do not repurpose upload credentials. |
Reed, Indeed, Totaljobs, CV-Library, Civil Service and other boards are **unqualified prospects**, with no connector or rights assumed. External links may be offered without server fetching; a new source requires its own reviewed manifest before any integration. Optional DK/DE/FR/NL expansion is deferred, not claimed researched or supported.
## Capability manifest and enforcement proposal
Each manifest records `providerId`, countries, API/document version, terms URL, evidence/reviewer/date, rights validity/review date, commercial eligibility, credential reference/status, allowed modes, filters, geographic precision, content level, stored fields, cache/PII retention limits, deletion mechanism, attribution, original/apply-link policy, quota/concurrency/backoff and enabled state. Numeric rights limits are nullable **unknown**, never unlimited. Unknown rights deny that capability. Quota absence means bounded conservative traffic plus provider confirmation before load, not permission to stress the service.
Effective permission = reviewed grant AND operational enablement AND valid credentials where required AND supported API capability. Store no tokens in the manifest, query strings in logs, or repository. Capability-specific interfaces avoid fake `Search` implementations for feeds/manual sources. Display permission is enforced server-side for search, details, exports, saved-job views and notifications; UI badges alone are insufficient.
Current JobAd Links schema permits `q`, region/municipality/country concept IDs and occupation fields/groups, with limit <=100 and offset <=2000. This is a bounded search window, not exhaustive pagination or exact town/radius support. API metadata was read; responses were not exercised. The old 1,000-request/day wording seen in a 2023 report is not a verified current quota. Contact provider before higher volume.
No full description is promised for snippet sources. For Adzuna, use approved redirect/attribution rather than bypassing to extracted third-party URLs. Verify usable HTTPS destinations during qualification; never forward provider credentials to redirects. Estimated salaries must remain visibly estimated.
## Lifecycle decisions
NAV: normalize ACTIVE/INACTIVE changes into internal `SourceLifecycleEvent`; “tombstone” is Jobjakt's internal deletion marker, not an invented NAV endpoint/status. Ingest removals even if geography/occupation changed or the ad now fails a filter. Apply record/masking/cache invalidation and cursor advancement transactionally. Do not leave a persistent cache backed by request-time partial feed scans. Detail rechecks and source-health fail-closed policy are required when freshness cannot be maintained. No claim of instantaneous detection beyond the provider feed is possible.
For all sources, distinguish official requirement from proposed operational bound. Proposed: active query caches <=5 minutes, no alert if the source is stale, provider-specific health/freshness threshold reviewed in GJ04-A, and immediate local suppression when a withdrawal is observed. These numbers do not substitute for provider terms. For NAV, outage handling should hide searchable cached content once freshness cannot be assured, and disclose availability loss; owner/provider must approve polling and stale thresholds.
SE live query: short rights-permitted cache, current detail/404 handling, expiry checks, and periodic reconciliation of retained saved references. Daily JobAd Links refresh cannot prove minute-level freshness. Withdrawal checks and uncertainty must remain visible. Historical API presence does not make a job active or justify retaining contact PII.
On expiry/withdrawal/revocation: suppress search/detail, cancel queued alerts and clear source-derived restricted text/contacts across cache/index/exports. Keep minimal provider ID/status event without content where lawful. A dedupe cluster may still show independently authorised active sources; never inherit a wider grant from another source. User-authored notes/status remain separately retained under approved account policy; copied source text does not become user-owned merely by saving it. Backup/restore purge and policy replay must prevent republication.
## Required qualification before GJ04 source GO
Review exact terms/version and written agreement where applicable; record price/quota/retention/display/application-link obligations; obtain operator-managed credentials without printing them; freeze permitted redacted fixtures and schema hash; prove update/delete/404/429/auth failure and rights-revocation behavior; run authorised bounded smoke separately. No fixture pass upgrades commercial approval or provider health.
+23
View File
@@ -0,0 +1,23 @@
# INT-GLOBAL-JOBS-01 — GJ-00 planning checkpoint
28 October 2026. **Planning/research only. Design: CONDITIONAL GO for review. GJ-01 implementation: NO-GO. Production: NO-GO.**
This is a proposed architecture, not implemented behavior or owner sign-off. The authoritative [prompt pack](../../planning/global-jobs-prompts/README.md) governs each later stage. Stop after GJ-00; obtain the next stage instruction and entry decisions before implementation.
| Deliverable | Purpose |
|---|---|
| [Product requirements](PRODUCT_REQUIREMENTS.md) | Journeys, preferences, languages and discovery experience |
| [Provider rights matrix](PROVIDER_RIGHTS_MATRIX.md) | Dated official evidence, capabilities, commercial and lifecycle limits |
| [Cross-industry taxonomy](CROSS_INDUSTRY_TAXONOMY.md) | Occupations, aliases, skills, national mappings and benchmark |
| [Geography decisions](GEOGRAPHY_DECISIONS.md) | Typed locations, exact refinement rules, radius and data licences |
| [Architecture ADRs](ARCHITECTURE_ADRS.md) | Existing owners, proposed contracts, processing, integrity and migration |
| [Dependencies and gates](DEPENDENCIES_AND_GATES.md) | Current source authority and independent entry decisions |
| [Implementation checkpoints](IMPLEMENTATION_CHECKPOINTS.md) | Ordered GJ-01–08 execution plan and file/test ownership |
| [Acceptance matrix](ACCEPTANCE_MATRIX.md) | Checkable future acceptance and evidence boundaries |
| [Owner actions](OWNER_ACTIONS.md) | External approvals and exact operator handoffs |
| [Evidence](EVIDENCE.md) | Research/verification ledger and publication procedure |
| [TODO](TODO.md) | Bounded GJ-00 checklist and deferred execution |
Source baseline: `4a8c907d70cbed235728f7d690b5409e72b93abe`, remote-confirmed `test/cv-integration-acceptance`. Planning branch: `planning/int-global-jobs-00`. No application, migration, Docker, AI or production change belongs to this checkpoint.
Recommendation: review this design now; **do not begin GJ-01** until Phase 10, PRODUCT-REVIEW-01 and initiative entry approval are evidenced. NAV consumer registration and UK licensing can proceed as separate owner actions, without blocking unrelated planning.
+16
View File
@@ -0,0 +1,16 @@
# GJ-00 TODO
28 October 2026. Scope: planning/research only.
- [x] Read README, master and GJ-00 first; read all later stage-specific requirements for dependency planning.
- [x] Verify current branch/source, remote baseline, architecture, roadmap, latest acceptance and Phase 10 boundary.
- [x] Query Graphify before source exploration; inspect scoped existing discovery/import/preferences/alerts/admin/data owners.
- [x] Research official provider access/commercial/display/lifecycle rules; distinguish missing rights and untested API behavior.
- [x] Document product journeys, locale qualification, universal taxonomy, exact geographic refinements and radius.
- [x] Produce nine required deliverables, proposed contracts/ADRs, migration/privacy/security boundaries and ordered implementation plan.
- [x] Run documentation completeness/link/scope/ancestry checks and retain actual results.
- [x] Commit only authorized planning documents and supplied prompt pack.
- [ ] Push planning branch and verify remote HEAD — BLOCKED: two connection failures to git.cesnimda.uk:443.
- [x] Stop after GJ-00; no feature code, provider registration, live AI, main merge or production deployment.
Deferred execution is tracked in [Implementation checkpoints](IMPLEMENTATION_CHECKPOINTS.md); external decisions are tracked in [Owner actions](OWNER_ACTIONS.md). Do not tick later stage boxes from planning evidence.
@@ -0,0 +1,61 @@
# Jobjakt — INT-GLOBAL-JOBS-01 — MASTER CODEX / ASTRA PROMPT
## International, multilingual, cross-industry job discovery
### Mission
Build Jobjakt into a trustworthy, professional job-discovery and application-tracking experience for **everyone**: educators, chefs, cleaners, nurses, drivers, retail workers, construction trades, engineers, office staff, IT professionals and every other occupation. Start with Norway, Sweden and the United Kingdom, using an extensible, provider-aware foundation that can add countries without rebuilding search. Make users' preferred countries and languages persistent rather than requiring repeated filtering.
**All agreed product requirements are in scope:**
1. During onboarding/Settings ask country of residence and separately one or more countries to search; never equate nationality, home country, residence and desired work countries. Save preferred regions/cities, occupation interests, skills, work arrangement and ad languages; apply defaults automatically at discovery; allow temporary overrides without modifying the user's profile. Support multiple named saved search profiles.
2. Provide country-aware interface language choices: **English plus the selected country's local official language(s)** prominently, with all other actually-supported UI languages under More Languages. Countries with several official languages and region-specific languages must be handled properly. Store interface locale, ad language filters and each CV/cover-letter language separately; never imply all translations already exist.
3. Universal occupational title autocomplete: multilingual title aliases and related occupations across **all sectors**, not developer-centric heuristics; optional skills-based suggestions; exact-title vs related roles vs open keyword; preserve local terms and original advert text. Use qualified ESCO and appropriate national classifications with explicit versioning and licences; support unknown/free-text occupations.
4. Accurate hierarchical locations: country → appropriate region/county → municipality/district → town/locality. Use provider-specific geographical adapters and authoritative canonical IDs/geometries, not substring matching. For example Warwickshire selected searches whole county; then selecting Warwick refines **that branch** to Warwick only while parent remains visibly selected; selecting Leamington Spa too searches both towns, not the rest of county. Other selected branches combine as OR. No automatic child-checkbox selection. Add optional radius/commute filtering around a location with explicit semantics for multiple selections, remote roles and missing coordinates.
5. Responsive results with persistent filters and three-column desktop design (filter rail, job list, in-app detail panel), and mobile full-screen detail; optionally expand panel to full Job Details page. Show as much **authorised** source information as available: full or truncated original description, employer, location, requirements, responsibilities, qualifications, salary with period/currency and predicted/estimated distinction, shift/hour schedule, working arrangements, contract, deadlines, original source, posted/updated time, and apply link. Sanitise content; show source completeness and provenance. Link original application method directly; do not fabricate anything.
6. Source adapters with capability flags: direct search, event feed + compliant local indexing, authorised details, URL bookmark/manual import, external search link; provider rules for licensing, caching, update, removal, attribution and click-through. Norway: NAV feed and manual FINN URL workflows; Sweden: JobTech sources; UK: investigate official and licensed aggregators such as Adzuna, job boards, public sector sources. No forbidden scraping. Providers may be disabled independently without preventing other countries from working.
7. Cross-provider deduplication (conservative confidence, separate source records, source labels), saved vacancies, application-tracker integration, job-specific CV and cover letter links, job expiry handling, saved searches, new-job alerts, optional commute radius, occupation/skills/qualification matching with reasoned evidence and no invented claims.
8. Admin source health, sync/index queues, API quotas, failures and job import events integrated where practical with Jobjakt's processing monitor; admin-only redacted diagnostics. Keep external advertisements separate from user applications.
9. Commercial product standards: scalable feed processing, accessible design, English and local languages, localisation coverage, strict multi-tenant security, privacy, GDPR-conscious data handling, good empty/disabled/failure UX, comprehensive cross-sector and cross-country test suites, metrics, rollback strategy.
### Architecture and boundaries
Inspect the actual React, .NET, MariaDB and sidecar code and reuse existing auth, settings, dashboard, application tracker, Career Profile, CV Builder, documents, alerts, admin processing, multilingual framework, concurrency and migration patterns. Do not create a second job tracker or duplicate profile master. Build an explicit provider capability/rights manifest and canonical data contracts; separate job-ad inventory from user-owned saved/job-application records. Do not invent an unlimited global index or require AI for basic features.
### Sequence (run one stage at a time)
- **GJ-00 Discovery, access and architecture** → `01_GJ_00_DISCOVERY.md` (planning only)
- **GJ-01 Country, language and search preferences** → `02_GJ_01_PREFERENCES_I18N.md`
- **GJ-02 Geographic hierarchy, selection semantics and radius** → `03_GJ_02_GEOGRAPHY.md`
- **GJ-03 Cross-sector occupation suggestions and queries** → `04_GJ_03_OCCUPATIONS.md`
- **GJ-04 Provider adapters, rights and ingestion** → `05_GJ_04_PROVIDERS.md`
- **GJ-05 Unified search UI and in-app listing details** → `06_GJ_05_SEARCH_UI.md`
- **GJ-06 Saved searches, alerts, matches and tracking** → `07_GJ_06_ALERTS_MATCHING.md`
- **GJ-07 Admin operations, hardening and resilience** → `08_GJ_07_ADMIN_SECURITY.md`
- **GJ-08 Full QA and release qualification** → `09_GJ_08_ACCEPTANCE.md`
For each stage, read its full prompt, respect the shared rules, begin from the accepted predecessor baseline, implement safe checkpoints, commit/push each, provide actual acceptance verdict and **stop without starting the next stage automatically**. Stage 00 may run before Phase 10 only as non-mutating research. Later stages require the documented Phase 10 and product-review handoff and owner-approved scope. If an API requires commercial permission, generate exact operator steps and leave its connector blocked; do not misrepresent external links or sample data as full integration.
### Provider research to verify anew at execution
- NAV feed and terms: https://navikt.github.io/pam-stilling-feed/ and https://arbeidsplassen.nav.no/vilkar-api
- FINN API eligibility: https://www.finn.no/api/
- Sweden JobAd Links: https://links.api.jobtechdev.se/ and JobTech official APIs (verify current names/availability)
- Adzuna search/terms: https://developer.adzuna.com/docs/search and https://developer.adzuna.com/docs/terms_of_service
- ESCO multilingual occupations: https://esco.ec.europa.eu/en/use-esco/use-esco-services-api
- UK geographic datasets: https://geoportal.statistics.gov.uk/
### Cross-industry benchmark
Every major behaviour must be tested with examples spanning teacher/teaching assistant, chef/kitchen assistant, nurse/care worker, electrician/plumber/carpenter, driver/warehouse operator, shop assistant, cleaner, accountant/administrator, farm worker, and software developer. Include English, Bokmål, Swedish and other supported source terms; qualifications, shifts and multiple employment modes; do not optimise relevance exclusively for tech roles.
### Final output
Report completed stage list, exact Git ancestry/commits, live-vs-fixture provider coverage by country, current user-facing capabilities, translation completeness, geography correctness, occupation coverage, data provenance/expiry, alerts and dedupe, performance, security and accessibility, all remaining external approvals/human tests, independent GO decisions and **production withholding status**. Never merge main or deploy. Stop after the approved stage.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,38 @@
# GJ-00 — Product and Provider Discovery / Architecture Sign-off
**Suggested branch:** `planning/int-global-jobs-00` | **Mode: planning/research ONLY**
## Objective
Establish an evidence-backed implementation plan for international, multilingual, cross-industry job discovery, integrating with the real Jobjakt code and accepted roadmap, without changing application behaviour. You may perform this planning stage before Phase 10 completion, but code implementation requires later confirmed development entry.
## Mandatory investigations
1. Verify current Git baseline and latest Phase 10 / PRODUCT-REVIEW-01 handoff. Inspect code for existing job imports, Kanban, Career Profile, search, preferences, geolocation, translations, notifications, dashboard, admin monitor, user/tenant authorization and MariaDB entities.
2. Map user journeys for new resident, multi-country mover, returning jobseeker, jobseeker with no formal occupation, and mixed jobs/shift work. Distinguish country of residence, nationality, desired search countries, ad language(s), UI language and document language.
3. Review current official API terms and capabilities for Norway (NAV feed including tombstones and consumer token; FINN partner-restricted), Sweden (JobTech JobSearch and JobAd Links, including truncation/attribution and classification), UK (Adzuna commercial agreement and description snippets; government/public-sector APIs where legitimately available); optionally Denmark/Germany/France/Netherlands as future prospects. Cite URL/access date. No claiming API access without proof.
4. Review authoritative geography: Norway county/municipality and locality; UK country/county/unitary authority/district/built-up area/town and overlap issues; Swedish län/kommun/tätort. Include immutable IDs, dataset version, ancestry, geometry/licence, alias/local names, postal code mapping where licensed, and provider data quality. Explicitly verify Warwickshire/Warwick/Leamington Spa, Vestfold/Tønsberg, Vestland/Bergen and Oslo edge cases.
5. Research ESCO multilingual occupation taxonomy, mappings to Swedish SSYK and UK SOC where viable; distinction among occupation, keyword, industry, required credential, skill, employer title and free text. Do not limit taxonomy to IT.
6. Determine options and costs for local index vs live provider queries, data updates/deletes and cache limits, rate limiting and search technology compatible with actual stack. Avoid adding Elasticsearch/third-party SaaS automatically.
7. Model source-rights metadata and per-provider capability gating. Document what can be fully displayed in-app vs preview-only vs external-link-only. Detail redirects/apply links, licences, price, approval needs.
8. Define privacy, PII retention, alert frequency and anti-spam, admin diagnostics, multi-tenant threats, disabled-source fallback and data lineage.
## Produce
`docs/global-jobs/00/PRODUCT_REQUIREMENTS.md`, `PROVIDER_RIGHTS_MATRIX.md`, `CROSS_INDUSTRY_TAXONOMY.md`, `GEOGRAPHY_DECISIONS.md`, `ARCHITECTURE_ADRS.md`, `DEPENDENCIES_AND_GATES.md`, `IMPLEMENTATION_CHECKPOINTS.md`, `ACCEPTANCE_MATRIX.md`, `OWNER_ACTIONS.md`.
Architecture must include canonical `SearchPreference`, `LocationNode`, `LocationScope`, `OccupationConcept`, `ProviderCapability`, `SourceAdvertisement`, `SourceLifecycleEvent`, `SavedJob`, `SavedSearch`, and `SearchAlert` concepts, reconciled against existing names—not a mandate to create all those tables. Define data contracts and safe migration strategy, not code changes.
## Sign-off
Identify GO / CONDITIONAL GO / NO-GO for design completeness and each proposed provider. Separate paid/approval/deployment gates. Commit and push planning docs. Do not implement features, register consumers on behalf of the user, or start GJ-01.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,43 @@
# GJ-01 — Country Preferences, Multilingual UI and Search Defaults
**Suggested branch:** `feature/gj01-country-language-preferences`
## Objective
Give every user an easy first-run and Settings experience that saves where they live, where they want to work, what roles they seek and in which languages. Automatically prefill job discovery from persisted preferences without locking the search page to a single country.
## Implement
1. Audit auth/onboarding/Settings, localization and existing preference persistence. Add migration-safe per-user search preferences using established MariaDB and revision semantics; retain backwards compatibility.
2. Model **country of residence** separately from nationality/home country, **desired work countries** (0..N), preferred regions/places (0..N), advertised-job languages (0..N), interface locale (single), each CV/cover letter's language (document-specific), occupations/skills, work arrangements and saved profiles. Defaults should be understandable and editable; no forced demographic collection.
3. Provide country selector with ISO country identifiers, display names localized where supported, searchable options, multi-select for job-search countries, optional city/region suggestions and clear default/override semantics. A user can live in Norway and search Sweden + UK.
4. **Language menu:** show `English` and official/local language(s) relevant to residence/selected region at the top, rendered as native label + English label (e.g. `Norsk bokmål — Norwegian Bokmål`, `Svenska — Swedish`, `Deutsch — German`). Account for multilingual countries, region-specific Welsh in Wales, Bokmål vs Nynorsk in Norway, and user-chosen overrides. Do NOT equate ESCO translations with complete Jobjakt UI support. Unsupported UI locales may appear under Coming Later, not as functional toggles.
5. Use BCP 47 language tags and explicit fallback rules (e.g. `en-GB`, `nb-NO`, `nn-NO`, `sv-SE`; verify support). Keep UI locale independent of ad-language filtering and document language. Persist across sessions/devices; initial suggestion may use browser locale but never lock it.
6. Localise all new Settings, onboarding, search-preference controls, validation, empty/error/help text in the actually approved interface languages; avoid machine-translated legal/professional terminology without review. Provide translation completeness report and automated missing-key checks.
7. Search page loads saved defaults; user can override for one query, save overrides to a named search profile, or explicitly Update defaults. Never silently overwrite permanent settings from temporary filter changes.
## Acceptance examples
- Norwegian-resident user, English UI, Norwegian + English ad languages, searches both Norway and UK.
- Swedish-resident user with Swedish UI and English ad results; switch UI English without changing query language.
- Welsh user sees Welsh and English prominently if Welsh UI is supported; non-Wales UK user isn't forced into Welsh.
- Reopen account on another device and recover default countries, occupations, languages and regions.
- Empty preference fallback: usable search with explicit country-choice invitation rather than empty broken state.
- Tenant isolation, concurrent editing, revision conflict, account deletion/data export, migration and reset.
## Checkpoints
`GJ01-A` schema/compatibility, `GJ01-B` onboarding/Settings, `GJ01-C` selector/localisation, `GJ01-D` search defaults/overrides, `GJ01-E` full regression and handoff. Commit/push validated checkpoints.
## Deliverables
`docs/global-jobs/01/` for preference schema, screen states, local-language mapping, supported-vs-planned locales, localisation checks, permissions, regressions and acceptance. End with GO/CONDITIONAL GO/NO-GO and GJ-02 readiness; stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,48 @@
# GJ-02 — Hierarchical Geographic Filters, Scopes and Optional Radius
**Suggested branch:** `feature/gj02-geographic-search`
## Objective
Implement accurate, discoverable and accessible country/region/county/municipality/town filtering across Norway, Sweden and UK without relying on false flat hierarchies or substring matching.
## User interaction contract (must not be changed silently)
- Selecting **Warwickshire** alone: query the **whole Warwickshire scope**; all child checkboxes remain visually unchecked.
- Expanding and selecting **Warwick** under selected Warwickshire: query **Warwick only** in that branch, not every other town in Warwickshire. Warwickshire remains shown as the selected *scope being refined*, with a clear summary `Warwick only`.
- Add **Royal Leamington Spa**: query Warwick **OR** Royal Leamington Spa, excluding other Warwickshire areas.
- Remove both towns while leaving Warwickshire selected: revert to the **whole Warwickshire** scope.
- Separate selected branches (e.g. Warwick plus Bergen) combine as **OR**. Different filter dimensions (e.g. location + profession + languages) normally combine as **AND**.
- Deselect a parent: remove or resolve its descendant filters visibly, with accessible announcement, never leave invisible active child selections.
- A checked parent with refinements is **not** the conventional 'all descendants checked' tri-state checkbox. Design accessible controls as **scope checkbox/selection plus separate refinement state**; avoid misleading mixed-state semantics. Include explicit `Whole area` / `Specific places` options and a clear effective-filter summary if needed.
## Engineering requirements
1. Inspect genuine geography sources and licences/versions. For Norway use authoritative county/municipality and locality datasets; Sweden län/kommun/settlements; UK ONS geography and appropriate settlement data. Handle UK county vs district vs built-up area accurately and atypical administrative cases (Oslo both county/municipality, UK unitary authority, etc.). Store canonical identifiers, types, country, parent relations, names/aliases, optional polygons/centroids/coordinates with provenance/version.
2. Build geographic taxonomy ingestion/update routines and mapping from provider source location codes/free text; allow unresolved/approximate classifications (never silently attach a job to the wrong town).
3. Support tree expansion without huge downloads, keyboard navigation, search-as-you-type town lookup, saved favourite locations and a compact filter summary/chips. Strictly preserve selected scopes when tree collapses.
4. Define semantic query plan translating geographic selections to filter over canonical IDs/polygons and provider-supported area fields. If provider can't do exact region filtering, use compliant local filtering or clearly disclose approximation and missing data; do not claim full provider coverage.
5. Optional town-centered radius (e.g. 10/25/50km), with geodesic distance, coordinates known/unknown rules, multi-origin OR semantics and units/localized display. Radius by distance is not a guaranteed travel-time commute. Keep a separate optional actual travel-time capability for later, only with a qualified routing provider.
6. Define remote jobs explicitly: fully remote, remote limited to country/region, hybrid/on-site and unknown. Remote must not bypass legally specified work location constraints.
7. Add indexes/performance checks for large country scopes, no 1,000-child checkbox DOM, data migration, stale taxonomy IDs and fallback on missing geographic data.
## Test corpus
- Warwickshire > Warwick and Royal Leamington Spa, deselect/reselect/expand/collapse, selected parent + descendants, sibling mixing, multiple-country OR.
- Vestfold > Tønsberg/Sandefjord; Vestland > Bergen; Oslo municipality/county dual identity; Sweden Stockholm län > Stockholm municipality/town.
- A posting geocoded only at county level must not be misreported as located within Warwick town.
- Radius from Warwick includes known nearby locations when within requested geometric distance, and doesn't claim driving time.
- Keyboard/screen-reader semantics and mobile compact filtering; frozen profile roundtrips.
## Checkpoints/deliverables
`GJ02-A` taxonomy/source decision; `GJ02-B` canonical model/import; `GJ02-C` refinement state & UI; `GJ02-D` query mapping/radius; `GJ02-E` precision/performance/acceptance. Document under `docs/global-jobs/02/`. Commit/push each. Report location precision and provider coverage honestly. Stop after acceptance.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,35 @@
# GJ-03 — Universal Occupation Autocomplete, Skills and Cross-Language Queries
**Suggested branch:** `feature/gj03-occupation-search`
## Objective
Deliver fast, useful occupation and job-title suggestions for **every sector**, available in different languages, without coding a developer-only dictionary or requiring AI inference.
## Design and build
1. Inspect search UI/backend and Career Profile, existing role categories and suggestions. Define distinction: occupation concept, advertised job title, related title, alias, skills, licence/qualification, free-text term and category. A text search like `weekend work` must remain possible.
2. Integrate a verified version of ESCO occupational concepts/labels/aliases/skills using suitable licensed API/dataset/cache. Where helpful map to national classifications including Sweden SSYK and UK SOC; maintain ID provenance/version and explicit unmapped cases. Do not assume ESCO's `no` language pack provides fully distinguished Bokmål and Nynorsk translations.
3. Build typeahead with debouncing/cancellation, typo tolerance, language-aware synonyms, ranked results, accessible keyboard selection, exact vs related query modes, optional skill chips. Present human-readable explanation for expansions across languages (e.g. `chef` ↔ `kokk`) and user control to turn them off.
4. Source signals: user-chosen occupations, verified Career Profile entries (with consent/config), common local terms, prior searches, and role terms from supported providers. Do not mine raw private CVs globally or leak user data into other tenants.
5. Support searches by job type and requirement not just formal title: part-time, evening shifts, weekend work, apprenticeships, no experience, teaching level, kitchen role, professional licensing. Filter capabilities must be source-aware, never fabricate unsupported advertised fields.
6. Multi-lingual matching can expand keywords if justified; preserve original title and advert in results. Distinguish exact-match results from related recommendations and avoid conflating unrelated occupations.
7. Generate a frozen multi-profession benchmark and evaluate suggestions for false positives, useful recall, language accuracy, query latency and index size. Include fallback to simple keyword on taxonomy outage.
## Acceptance matrix
Explicit scenarios: teacher, teaching assistant, chef, kitchen assistant, cleaner, nurse, healthcare assistant, electrician, plumber, carpenter, delivery driver, warehouse worker, shop assistant, waiter, accountant, public administrator, mechanic, software developer, agricultural labourer. Include NO/SE/UK title variants and source text. Ensure broad roles do not swamp precise title searches; 'nurse' shouldn't blindly match uncredentialed carers as an exact title.
## Checkpoints
`GJ03-A` taxonomy/licensing and mapping; `GJ03-B` search index/normalisation; `GJ03-C` suggestion UI; `GJ03-D` relevance controls and filters; `GJ03-E` benchmarks/accessibility/regression. Docs: `docs/global-jobs/03/`. Commit/push checkpoints. Report exact tested languages, source coverage and limitations. Stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,41 @@
# GJ-04 — Country Provider Connectors, Rights Enforcement and Lifecycle
**Suggested branch:** `feature/gj04-provider-connectors`
## Objective
Implement a scalable source architecture that can serve Norway, Sweden and UK while accurately representing differing API permissions and content completeness. Provider blockers must not block already-authorised sources or manual user entry.
## Mandatory source strategy
**Norway NAV:** Verify current `pam-stilling-feed` docs/terms. This is an authenticated change feed, not a free-text search endpoint; permitted local indexing/search requires consumer access and incremental updates. Verify JWT consumer registration (the public experimentation token is not a production solution), updates, tombstones/inactive masking, source application deep links, attribution and PII requirements. **FINN listings are not included in NAV's feed.**
**Norway FINN:** Official FINN API requires authorised advertiser/business relationship and ownership of accessible data; general third-party search is NOT available by default. Implement user-pasted URL recognition, deduplication, legitimate manual record entry, and original-site link. Do not silently fetch FINN pages, circumvent protection or call this 'full FINN integration'. Only implement full details if explicit suitable rights are established.
**Sweden:** Verify official JobTech JobSearch and JobAd Links capabilities and licence. JobAd Links provides truncated details/reference links, not guaranteed full vacancy content. Respect original-site link requirements, daily freshness and occupational-code mappings. If other JobTech APIs permit full descriptions, expose source-specific completeness and follow those actual terms.
**United Kingdom:** Evaluate Adzuna documented job search; results contain a **description snippet**, and ongoing commercial SaaS use requires licensing/written approval. Evaluate appropriate public-sector/other licensed sources and externally linked searches; don't assume Reed/Indeed/Totaljobs/CV-Library/Find a Job/NHS APIs allow third-party bulk search. For blocked providers, create operator instructions and a permitted fallback, not an invented API.
## Architecture
1. Provider registry with immutable ID, geo/occupation search capabilities, feed vs query mode, allowed detail level, supported filters, source link policy, commercial rights, credential status, effective dates, retention, updates/deletes, quota and telemetry. Make unavailable/blocked sources visible to admins with actionable reason but do not expose keys.
2. Typed interfaces: `Search`, `ReadDetails`, `IngestChanges`, `MapLocation`, `MapOccupation`, `Normalize`, `GetLifecycle`, `BuildApplyLink`, conditional on capabilities. Do not force every provider to implement methods it does not support.
3. Canonical SourceAdvertisement plus ProviderSourceRecord with provider ID, source ID, canonical link, employer/title, structured location, pay amount/range/currency/period, shifts, occupation, qualification, languages, contract, description format/completeness, created/modified/expiry, original apply URL, source-restriction flags and provenance. Preserve unknown values; avoid inventing salaries/hours or dropping original titles.
4. Safe ingestion/index queue with cursor/checkpoint, bounded pagination, backoff/rate limit, idempotency, incremental updates/deletes, stale-data policy, audit events, resumability, transactional MariaDB and provider-specific compliance. No infinite poll loops or API-key logs.
5. Deterministic, conservative cross-provider duplicate detection (source IDs, canonical links and supporting content/time/location) with false-positive review. Do not merge distinct teaching vacancies at same school or identical restaurant positions automatically. Display multiple sources only when identity is sufficiently proven.
6. Separate external ad cache from user-owned application tracking. Remove/mask inactive content per source terms; preserve user-owned notes/status legally and transparently, not secretly republish deleted ads.
7. Provider fixture/contracts and authorised bounded live smoke tests. Do not run live provider tests without actual access.
## Checkpoints
`GJ04-A` rights & provider registry; `GJ04-B` canonical/ad lifecycle; `GJ04-C` NAV connector if authorised; `GJ04-D` Swedish connectors; `GJ04-E` UK approved connector or external-link-only fallback; `GJ04-F` manual FINN URL workflow; `GJ04-G` dedupe, security, operational tests. Block unavailable subcheckpoint honestly; continue independent ones. Docs: `docs/global-jobs/04/`, source by source eligibility, test evidence, permissions, rate limits, update/delete compliance and owner actions. Commit/push each completed checkpoint. Stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,42 @@
# GJ-05 — Unified Discover Jobs UI and In-App Vacancy Details
**Suggested branch:** `feature/gj05-discovery-ui`
## Objective
Make searching intuitive: saved defaults, smart suggestions, fast results, hierarchical location filter, and a full permitted vacancy-detail experience within Jobjakt—not forcing a new site for every listing.
## Design
Desktop: responsive three-panel layout—collapsible filter sidebar, scrollable results list, vacancy detail. Tablet: two-pane or overlay; phone: list with full-screen detail. Offer `Expand details` route that deep-links to an individual permitted advert, with refresh, back button and pagination. Do not rely exclusively on huge modal overlays.
Use profile countries/occupation/ad languages automatically; allow temporary override and `Save this search`. Filters must be explainable and backed by provider data quality: location scopes (county→town), radius, roles/exact vs related, skills, advertised languages, contract type, full/part-time, shift/evening/weekend, apprenticeship/seasonal, remote/hybrid/on-site with country eligibility, salary currency/period and type (actual vs predicted), post date/deadline, source, and experience/licences where supplied. Mark unsupported fields as unavailable; no fabricated 'strict' enforcement.
Suggestions: accessible typeahead for any profession; recent/favourites but privacy-preserving; empty input suggestions where helpful. Provide query chips/effective selection summary including `Warwickshire: Warwick + Leamington only` without misleading parent checkbox semantics.
Results: title, employer, geographic accuracy label, source, work type, salary only where provided, last updated, active/expired status, original language, whether description is full or snippet, one canonical duplicate grouping with alternate sources when safe. Sorting/pagination/cursor; skeleton/empty/error/stale/partial-source and accessibility.
Vacancy detail: permitted original description (sanitised), duties, requirements, qualifications/licences, schedule/shifts, salary with period/currency, employer, locations, source citations/attribution, posted/updated, deadline, apply method and transparent gaps (`not provided by source`). **Never scrape the linked website to fill a provider's legally truncated excerpt.** Link to original listing and original application flow. Preserve original employer words; any translated summary must be clearly labelled and optional.
Actions: `Save Job`, `Track Application`, `Attach Existing CV`, `Create CV Variant`, `Prepare Cover Letter` (reuse current app; AI gated), original `Apply` link. Distinguish saved job from tracked application. Preserve source record vs user-owned notes.
## Testing
- Listings from multiple permitted providers, full and snippet-only previews, unavailable sources, missing salaries, expired ads, unsafe HTML and malicious links, missing coordinates, Norway/Sweden/UK location hierarchies.
- Occupations: teachers, chefs, carers, nurses, trades, transport, retail, office and IT; job details fit shift and hourly work as well as salaried roles.
- Mobile portrait/landscape, keyboard, 200% zoom, screen reader, EN/Bokmål, responsive transitions, deep-link/back navigation, filters retaining context.
- Correct URL/apply behavior; no external content loaded in an unsafe iframe by default; do not misreport full detail from snippet-only APIs.
## Checkpoints
`GJ05-A` wireframes and routes; `GJ05-B` filters/responsive UI; `GJ05-C` results/query semantics; `GJ05-D` in-app detail and source rules; `GJ05-E` tracker/doc actions; `GJ05-F` accessibility/fidelity/regression. Docs: `docs/global-jobs/05/`. Commit/push per validated checkpoint. Stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,36 @@
# GJ-06 — Saved Searches, Alerts, Skills Matching and Application Tracking
**Suggested branch:** `feature/gj06-alerts-matching`
## Objective
Help users act on relevant opportunities without overwhelming them: durable saved searches, reliable new-job alerts, explainable profession-neutral matching, and seamless use of Jobjakt's application tracker, Career Profile, CV Builder and cover letters.
## Features
1. Save and name multiple search profiles, including countries, tree scopes (not flattened children), radius, roles/skills, languages, source filters, work type and disabled-source status. Load/duplicate/edit/delete/suspend/restore; update saved profiles only when explicitly requested, with revision-safe persistence.
2. New matching jobs: scheduled bounded evaluation (according to each provider's data rights), idempotent matching by canonical provider ID + changed state, per-user subscriptions and timezone-friendly daily/weekly/in-app/email frequency; pause/unsubscribe, quiet hours where supported, anti-spam and delivery failure recovery. Don't notify about expired/deleted jobs or leak another user's preferences.
3. Occupation/skills/qualification matching: use explicitly stored Career Profile facts, source advertised requirements, ESCO relations and supported match semantics; show evidence (`CV says`, `advert requests`, `not supplied`), reasons and gaps. Never infer qualifications/regulated professional authorisation, fluency, licences or job eligibility from general similarity. No black-box score claiming hiring probability.
4. Cross-language matches: original job ad remains authoritative. Display translated title/summary only if supported, attributed and clearly labelled; distinguish exact vs related suggestions. Allow users to restrict results to ad languages they understand.
5. Saved vs tracked: `Save job` doesn't imply application submitted; `Track Application` opens established workflow, avoids duplicates and links permitted source metadata and CV/letter. Expired ad content obeys provider source lifecycle; user's own notes and status remain separate.
6. Optional relevance ranking and explanatory weights: location, title, skills, language, schedule, salary (if provided), remote eligibility and user preferences. Avoid geography bias, sensitive-class inference, nationality discrimination and misleading eligibility statements. No automatic external application submission.
7. AI inference can be added later only after approved security/semantic qualification; core matching must work with transparent rules and catalogued data.
## QA scenarios
- Teacher with pedagogy/subject preference, chef with evening shifts, nurse with mandatory authorisation, cleaner without formal qualifications, electrician with trade licence, part-time retail worker, remote English speaker, mixed-country mover and backend developer.
- Same job from multiple providers, result withdrawn before alert, saved search changed mid-run, duplicate worker delivery, locale/timezone, overlapping searches, no results, disabled API source, invalid/missing salary/location and account deletion.
## Checkpoints
`GJ06-A` saved-search storage; `GJ06-B` alert scheduling/dedupe; `GJ06-C` matching and explanations; `GJ06-D` tracker and documents; `GJ06-E` security/load/accessibility. Docs `docs/global-jobs/06/`. Commit/push checkpoints; final GO/CONDITIONAL GO/NO-GO and GJ-07 handoff. Stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,37 @@
# GJ-07 — Provider Operations, Admin Monitoring, Privacy and Resilience
**Suggested branch:** `feature/gj07-discovery-operations`
## Objective
Make multi-country job discovery auditable and sustainable to operate without exposing user CVs, raw prompts or host internals. Integrate with existing administrator processing monitor where appropriate.
## Admin view
- Provider registry: country, API eligibility/rights state, capability flags, feed/query mode, enabled status, last successful sync, health, API quotas/rate errors, source freshness, indexing backlog and next scheduled sync.
- Processing jobs/events: provider, worker, job type, cursor/checkpoint, fetched/new/updated/deleted/ignored counts, duration, retry, error code, structured stage and correlation ID; user-submitted URL import tasks show the submitting user and linked saved job/application only to properly authorised admins. Provider-wide sync jobs normally have no submitting end-user—show 'system' rather than inventing one.
- Diagnostics: filter by provider, country, status, error type, time and correlation ID; redacted timeline/log excerpt from structured application events, with bounded retention, pagination and optional authenticated SSE stream/polling. No full job-applicant CV text, prompts, OAuth tokens, provider secrets, unsafe original HTML, raw Docker logs or arbitrary shell.
- Correctness metrics: source lag, cursor age, licensing expiry/credential failure, stale/deleted advert content, dedupe rate and uncertain matches, alert queue outcomes and error clusters. Show true service health vs fixture mode.
## Security hardening
- Server-side admin role checking on list, detail, streaming and data export; tenant boundaries even if admin scope is restricted; audit privileged access and source credentials changes.
- SSRF-resistant URL import with scheme/host allowlist, DNS re-resolution/address validation and redirect policy; never fetch internal network endpoints. Treat source descriptions as untrusted HTML and avoid script execution/sandbox iframe leaks.
- Bounds: API requests, ingestion concurrency, result pagination, auto-complete rate, local index size, alert fanout, log volume, timed-out jobs, model inference remains disabled if not qualified.
- Provider lifecycle compliance tests including deletion/tombstone, changed ads, contact masking, and legal separation of external ad cache from user-owned application history.
- Privacy: document what is stored (search preferences, skills references, personal saved jobs, employer contact information), why, and deletion/retention/export rights. Don't store source data past permission; no uncontrolled tracking/marketing analytics.
- Reproducible migrations/CI, secret scanning and dependency audits, loss-of-provider/backoff/partial outage drills. Never alter production infra to get passing tests.
## Checkpoints
`GJ07-A` operational telemetry & job models; `GJ07-B` admin UI/filters/logs; `GJ07-C` authorization/privacy/redaction; `GJ07-D` resilience/performance/audits. Docs: `docs/global-jobs/07/`. Commit/push every validated checkpoint. End GO/CONDITIONAL GO/NO-GO. Stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,51 @@
# GJ-08 — Full Cross-Country, Cross-Industry Acceptance and Release Qualification
**Suggested branch:** `test/gj08-global-discovery-acceptance`
## Objective
Independently qualify the integrated feature set with full regression, licensing proof, real dataset fixtures and bounded live provider evidence. Fix bounded confirmed defects. Do not count separate passing stage suites as proof the combined product works. No production deployment.
## Verify lineage and prerequisites
Confirm accepted GJ-01 through GJ-07 stage commits are present in one integration-complete development history. Read source-rights matrix, licensing/approval evidence, country-specific data integrity contracts, Phase10 preservation baseline, security model and operator actions. Distinguish implementation complete from authorised live source operational status. Report blocked provider connectors independently.
## Mandatory acceptance journeys
1. New account chooses country of residence, multiple search countries and local/English UI; set preferred ad languages; log out, sign in on second browser, confirm defaults and no implicit preference overwrite.
2. NO: Norwegian user filters Vestfold > Tønsberg, Vestland > Bergen and Oslo; searches teacher, cook, nurse, cleaner and developer; NAV feed fixtures reflect updates/deletes, permitted in-app details and original application links. FINN URL import is manually safe if no partner access.
3. UK: Warwickshire selects whole area, Warwick then narrows to town, add Leamington gives union of two towns only, clearing children restores all Warwickshire, OR a Bergen branch; radius; county/town ambiguous source-location handling. Source snippet-only job shows honest original link, no unauthorized full content.
4. SE: Swedish title autocomplete/classification, Swedish-English ad-language filters, source truncation and proper linked-to-origin details.
5. General: teacher, chef, kitchen assistant, nurse, care worker, electrician, plumber, delivery driver, warehouse worker, shop assistant, cleaner, accountant, administrator, farmer, IT developer. Verify contract/hourly/shift/part-time, qualifications, licence and seniority not falsely inferred.
6. Full desktop 3-panel and mobile details; direct URL to in-app detail, display source metadata, safe HTML, saved jobs and tracked application integration, create CV variant and cover letter without accidental AI enablement.
7. Save search, receive deduped eligible alert, quiet/suspend/unsubscribe, provider offline/stale/deleted, delayed update, job source deduped without merging similar separate posts.
8. Admin source health, source job correlation, stale/expired processing, secured redacted diagnostic logs, non-admin denial, correlation IDs and no personal content leakage.
9. Security: SSRF/redirect/rate tests, tenant isolation, injection and malicious descriptions, PII deletion, unsafe image/link, incomplete permissions, corrupted feed states, retries, migrations and rollback.
10. Accessibility and localisation: keyboard, screen reader where actually available, 200% zoom, real phone where available, English and each shipped local UI language, no missing keys, source original text preserved; do not claim manual sign-off not performed.
## End-to-end quantitative gates
Set explicit measured gates with the owner-approved acceptance plan rather than fabricating universal numbers: critical data loss/security/misleading content = zero accepted, source terms proof for any live connector, consistent hierarchy query semantics, real provider fixtures coverage, benchmark title precision/recall across sectors, latency and index capacity with bounded load, notification dedupe and freshness, regression against preserved routes/document/PDF expectations. Document failures, skips, not run and human checks separately.
## Full suites
Backend/frontend/browser/contract/MariaDB/sidecar as applicable; content/legal-policy assertions, provider contract fixtures, feed update/delete, location mapping, all industries, prefs, localisation, role title, suggestions, filters, dedupe, CV/app integration, admin, security, TypeScript/lint/build, accessible navigation, responsive visual screenshots. Use real provider calls only with approved credentials/rate/test terms. Recheck RD21 and previous CV/PDF regression scope, do not weaken historical tests.
## Deliverables
`docs/global-jobs/08/ACCEPTANCE.md`, `PROVIDER_GO_LIVE_MATRIX.md`, `TEST_EVIDENCE.md`, `MANUAL_QA.md`, `PERFORMANCE.md`, `SECURITY_AND_PRIVACY.md`, `RELEASE_AND_ROLLBACK.md`, `OPEN_GATES.md`, `FINAL_PRODUCT_CAPABILITIES.md`. Preserve previous reports.
## Independent verdicts
- Preferences & localisation, geography, occupation taxonomy, NO, SE, UK (each connector separately), permitted full-detail viewing, source lifecycle, dedupe, saved searches/alerts, matching, admin monitor, security, multi-tenant integrity, accessibility/manual device, overall development, production.
- Mark any country/provider BLOCKED if commercial agreement, API consumer registration or access missing. `External links available` is not `Live integrated search`.
- Production GO only after independent host/backups/filesystem/ingress/CI/rollback/privacy/source-license/human acceptance criteria genuinely pass, plus explicit owner approval. No main merge or deployment here.
Commit/push verified QA checkpoints, report GO/CONDITIONAL GO/NO-GO with evidence and exact next owner actions, then stop.
## Shared rules for every INT-GLOBAL-JOBS-01 stage
- Product: **Jobjakt**, a general-purpose, commercial job-search and career-management SaaS. It is **not** an IT-only product. Current architecture includes React/TypeScript, .NET backend, MariaDB, an AI sidecar, existing Career Profile/CV/document/application features, and an established EN/NO localisation approach. Verify actual repository reality before making assumptions.
- **Entry gates:** Do not implement new global-job-discovery features until the authoritative Phase 10 and product-review/development-entry decisions permit this initiative. Planning-only reconnaissance may precede them. If an entry gate remains NO-GO, produce safe analysis and stop before code changes.
- **Baseline:** Find the latest accepted, integration-complete development commit by reading Git history and the latest authoritative acceptance reports. Do not assume `main` is current. `main` deploys production: NEVER merge to `main` or trigger production. Never change live MariaDB, Docker, Ollama, storage, secrets or other production services.
- **Branches:** Use the specified independent stage branch from the latest accepted development integration history including the preceding accepted stage. Verify ancestry, remote, clean tree and existing branch collisions. Use checkpoint commits; push each validated checkpoint; verify remote HEAD equals local HEAD. Do not force-push. Do not advance to another stage without its actual entry criteria.
- **Autonomy:** Investigate, design, implement, add tests, document, commit and push safe work without pausing for routine decisions. Stop only for external API agreements, new paid resources, security-risk acceptance, destructive changes, privileged infrastructure actions and production operations. Continue independent work when one provider or model capability is blocked.
- **Sources:** Integrate only documented, authorised sources. Verify current licensing, commercial eligibility, credentials, permitted display, storage, cache lifetime, update/delete/tombstone, attribution, user-click-through and rate-limit rules. No covert scraping, private APIs, stolen cookies, bypassing anti-bot controls or fetching protected listing content without authorisation. An external-link-only source is not a direct searchable provider.
- **Data trust:** All provider data, job descriptions and user-provided URLs are untrusted. Guard SSRF, XSS/unsafe HTML, prompt injection, HTML injection, unsafe redirects, credential leakage, denial of service and cross-user/tenant access. Do not present missing facts, inferred salaries, regulated-profession eligibility, or AI-generated job details as verified employer facts.
- **Availability:** Preserve offline/disabled provider states. Use realistic fixtures in automated tests; any live contract test needs authorised test credentials and bounded rates. Never claim unavailable external API functionality passed.
- **User-facing quality:** Desktop/tablet/phone, keyboard and screen-reader access, light/dark, EN/Bokmål plus future qualified locales, loading/empty/error states, responsive design, sensible defaults, performance, source transparency, internationalisation and accessible non-drag alternatives.
- **Data integrity:** Source advertisements and user-owned saved jobs/applications are separate records and lifecycles. Preserve existing Career Profile, historical CVs, cover letters, saved applications, revisions, RD21 protections and MariaDB compatibility. Follow source rules for removing or masking withdrawn advertisements, while handling user-owned records under a separately justified retention policy.
- **AI:** Existing live-model inference and semantic quality must remain gated unless separately qualified. Rule-based/ESCO-backed title suggestions and auditable matching should function without a generative model. Do not invent user qualifications or automate application submissions.
- **Evidence:** At every checkpoint provide exact tests (pass/fail/skip/not run), security/coverage evidence, dated docs, commit hashes, branch and remote verification. Report individual stage GO / CONDITIONAL GO / NO-GO; separate production NO-GO from development readiness.
@@ -0,0 +1,43 @@
# Jobjakt — International Job Discovery Codex Prompt Pack
This package contains **one master prompt** and **nine standalone staged prompts** for `INT-GLOBAL-JOBS-01`. It is for a **cross-industry**, country-aware, multilingual discovery system for Norway, Sweden and the UK initially, with explicit extension paths.
## Recommended use
1. Use `00_MASTER_ORCHESTRATOR.md` when you want Codex/Astra to understand the full roadmap and overall constraints.
2. Start with `01_GJ_00_DISCOVERY.md` for planning, architecture, provider-rights audit and source access checks. Planning can be done before Phase 10 completion; **do not implement** until Phase 10/Product Review entry gates actually permit it.
3. Then run files `02` to `09` separately **in order**, only after the previous stage's acceptance and latest development baseline are verified. All implementation branches build on accepted predecessor development history, not on outdated `main`.
4. Each stage's instruction to commit and push is restricted to its feature/planning/test branch. **No production/main merge or deployment.**
## Contents
| File | Purpose |
| --- | --- |
| 00_MASTER_ORCHESTRATOR.md | Overall master orchestration and universal requirements |
| 01_GJ_00_DISCOVERY.md | Architecture, APIs, source rights, acceptance plan |
| 02_GJ_01_PREFERENCES_I18N.md | Country, language and saved search defaults |
| 03_GJ_02_GEOGRAPHY.md | Region/town refinements and optional radius |
| 04_GJ_03_OCCUPATIONS.md | All-profession multilingual autocomplete and search |
| 05_GJ_04_PROVIDERS.md | NAV/Swedish/UK sources, FINN manual fallback, rights |
| 06_GJ_05_SEARCH_UI.md | Three-pane search, full permitted job description, actions |
| 07_GJ_06_ALERTS_MATCHING.md | Saved searches, alerts, relevance and job-tracker integration |
| 08_GJ_07_ADMIN_SECURITY.md | Provider/job sync monitoring, failures and secure logs |
| 09_GJ_08_ACCEPTANCE.md | Final combined QA, source rights and rollout gating |
## What is deliberately not assumed
- No general FINN job-search API access without an approved business relationship.
- No ongoing commercial Adzuna integration before approval/licensing.
- No guaranteed full in-app descriptions from snippet-only providers.
- No production-grade NAV consumer token unless the consumer has actually registered.
- No new UI language shown as fully supported without complete translation and QA.
- No automatically enabled live AI model, scraping, auto job applications, merge to main or production deployment.
## Official starting points (reverify when executing)
- NAV: https://navikt.github.io/pam-stilling-feed/ ; https://arbeidsplassen.nav.no/vilkar-api
- FINN: https://www.finn.no/api/
- Sweden JobAd Links: https://links.api.jobtechdev.se/
- Adzuna: https://developer.adzuna.com/docs/search ; https://developer.adzuna.com/docs/terms_of_service
- ESCO: https://esco.ec.europa.eu/en/use-esco/use-esco-services-api
- UK ONS: https://geoportal.statistics.gov.uk/
-38
View File
@@ -1,38 +0,0 @@
# A: blank CV and editor history acceptance
27 October 2026. Corrective branch: `codex/cv-builder-blank-undo`, from completed CV-QA `4a8c907`. Audit/plan checkpoint: `5d4b8f2`. This is development qualification; it does not reopen production or live inference.
## Implemented
- New Blank creation contains zero sections and no inherited identity/contact/photo/headline. Profile creation requires explicit section/contact selection. Existing settings without nullable inclusion flags retain legacy behaviour.
- Editor, shared render resolver, document AI and application matcher agree on selected content. AI rejects an empty source before provider access. No live inference was performed.
- Empty resolved export returns `cv_empty_content` before allocating a PDF worker, with English/Bokmål recovery copy. Design changes, save/reload, duplicate and historical restore preserve explicit selection.
- Session undo/redo includes name and settings, groups focused typing bursts, records structural/design actions, invalidates redo after new edits, preserves history across autosave and rejects history actions during revision conflict. Native text undo remains native; document shortcuts support Ctrl/Cmd+Z, Ctrl+Y and Ctrl/Cmd+Shift+Z outside editable controls.
- Document loads clear old controls and previews. Late save, preview, restore and history responses are scoped to their editor session. Explicitly selected profile `other:n` sections are reachable through Add content.
## Evidence
Evidence root: `.artifacts/pre-phase10-ux/`. Original failing runs are retained; overlapping runs are never added together.
| Lane | Result | Evidence / scope |
|---|---|---|
| Actual Next development reproduction | Both reported defects reproduced | `runtime-red/`; blank inherited four canonical markers; document Ctrl+Z failed for non-text section reorder |
| Focused backend | 108 passed, 0 failed/skipped | `blank-green/blank-green.trx`; resolver, legacy serialization, save/duplicate/restore, empty export allocation, bound AI and matching |
| Frontend | 98 passed in 4 suites, 0 failed/skipped | `a-ui-qualified.json`; includes Strict Mode typing, naming, branch redo, conflicts, document switch, restore isolation and explicit additional profile sections |
| Development browser | 4 passed, 0 failed/skipped/flaky | `a-browser/results-final.json`; EN1440/NB390 blank save/reload/template Apply, UI+API empty-export rejection, unchanged canonical source, persisted section-order shortcuts and native text undo |
| Accessibility automation | Four scoped scans: 0 violations | Each retains one serious contrast **incomplete**; this does not pass manual contrast/AT acceptance |
| TypeScript | Exit 0 | `tsc --noEmit --incremental false`, current source |
| Focused ESLint | Exit 0 | Editor/library/types/tests/translations; full lint remains final integration work |
| MariaDB + RD21 | 82 passed, 0 failed/skipped | `a-mariadb-final/mariadb.trx`; full repository helper, generated loopback MariaDB12.2.2 schemas, locked restore and cleanup exit0. Includes2 new blank persistence/design/restore, stale-writer and ownership cases |
Browser evidence precedes the final additional-section title fallback by one localized catalogue-line change; that addition has its own failing-then-passing frontend test and is also included in the final integrated browser source. No claim of human testing follows from automated widths or screenshot inspection.
Fresh review identified late restore/history isolation and additional profile-section reachability. Both received new RED tests before correction. The catalogue finding was narrowed to actual nonstandard `other:n` keys: the standard list already includes awards/publications/organisations/references. No standard section was missing. A previous assertion that renaming creates no undo state was updated to verify the new requested transaction and that rejected AI adds no extra history step.
## Decisions and limits
Blank creation and undo/redo: development GO. CV integration preservation: conditional; focused legacy and revision paths pass, full regression/PDF qualification remains scheduled on the combined integration branch. B may start after this checkpoint is committed/pushed/remote-verified.
The initial MariaDB run also passed82, but its test binary preceded the fixture correction from an unknown design ID to actual `minimal`. The final run above rebuilt that source and is authoritative; these runs are not additive. No provider fallback or production connection was used.
Session history is bounded to60 steps and is not restored after refresh; the editor explains this. Historical settings revisions remain available separately. Human accessibility, physical devices, native-language review, contrast and printing remain open. CV-model compatibility/accuracy remain BLOCKED. Production remains NO-GO. Phase10 cleanup has not begun.
-73
View File
@@ -1,73 +0,0 @@
# B and owner additions: development acceptance
28 October 2026. Branch `feature/cv-template-library-review`, cumulatively based on accepted A commit `663023a` and local-runtime checkpoint `73ea888`. Qualified source checkpoint: `3f4db8ca8183704945915ae4d2053e641bafa5b2`, pushed with remote/local equality verified. Source/image hashes are retained in the build records. No main merge or production deployment is authorised.
**Decision: CONDITIONAL GO for continued development and workstream C. Production: NO-GO.** The application changes and finite supported-document checks pass. The unchanged large-name coordinate oracle remains partial, human/device/print qualifications remain open, and the consolidated PRE-P10 regression is still required after C/D.
## Delivered
- Version-two designs for the existing nine compatible CV IDs: title/header separation in Modern/Classic/Code, full-height physical sidebar backgrounds, actual sidebar palettes, wider identities and measured name typography. Existing absent/version-one designs retain their original rendering.
- Actual server-rendered synthetic template cards, lazy loading and sandboxing; explicit candidate Apply preserves CV content and canonical profile data.
- Nine matching cover-letter designs and chooser, current-authored-text preview, explicit snapshot application and existing shared revision/history persistence. Server-side premium entitlement enforcement covers save/preview/restore, while preserving legacy and existing same-theme designs after downgrade.
- Optional CV portrait controls using the existing shared profile/crop/storage owner; keyboard zoom/pan, bounded decoded-image validation, metadata stripping, durable save acknowledgements, staged-file reconciliation and owner-only retention/account inventory.
- Document-only durable queue execution while generative work remains disabled. Disabled workers reject new PDF/DOCX imports with actionable503 rather than acknowledging an unprocessable queue. English/Bokmål recovery guidance is covered.
- Isolated verified local Ollama, approved read-only trained summariser checkpoint and parser-capable sidecar. Development workers are enabled, external AI is off and email is routed to Mailpit. The configurable backend timeout supports slower local CPU inference. Source image activation is a separate operational step, recorded in the local-runtime documentation.
## Exact evidence
Counts below are separate lanes. Focused reruns are not added to full-suite totals.
| Lane | Result | Raw evidence under `.artifacts/` |
|---|---|---|
| Full .NET backend | 1,394 passed,0 failed,0 skipped | `pre-phase10-ux/b-backend-04/b-backend.trx` |
| Full frontend | 1,014 passed across110 suites,0 failed | `pre-phase10-ux/b-frontend-full-02.json` |
| Real MariaDB12.2.2 | 84 passed,0 skipped; generated schemas cleaned | `pre-phase10-ux/b-mariadb-03/` |
| B interactive browser | 6 passed: actual gallery, actual letter preview, keyboard crop/optional photo; EN1440/NB390 | `pre-phase10-ux/b-selection-browser-03.json` |
| Real Docling import | 2 passed: upload→actual extraction→refresh→review/correct/apply→profile→CV→PDF; EN/light and NB/dark | `pre-phase10-ux/queue-real-docling-02.json` |
| Windows revised CV producer | 36 documents/122 pages; marker/page/bounds36/36; whole-name36/36; sidebar43/43 pages | `pre-phase10-ux/templates-revised-02/` |
| Windows matching letters | 18 documents/38 pages; full source retention/count/bounds18/18 | `pre-phase10-ux/matching-letters-02/` |
| Current v1 HTML preservation | 36/36 exact CSS/fonts, body/order and image-normalized HTML; raw12/36 exact | `pre-phase10-ux/templates-revised-02/legacy-preservation.json` |
| Linux producer/browser | 8 passed,0 failed/skipped/flaky; all owned resources cleaned | `cv-ux-01/pre-p10-b-linux-run-02/process.json` |
| Linux original CV fidelity | 24 documents/60 pages; unchanged oracle PASS | `cv-ux-01/pre-p10-b-linux-run-02/linux-pdf.json` |
| Linux original letter fidelity | 4 documents/14 pages; unchanged oracle4/4 PASS | `cv-ux-01/pre-p10-b-linux-run-02/letter-fidelity/fidelity-results.json` |
| Linux revised CVs | 36 documents/122 pages; content/bounds36/36, whole-name36/36, sidebar43/43 pages | `cv-ux-01/pre-p10-b-linux-run-02/template-library/` |
| Linux matching letters | 18 documents/38 pages; full source retention/count/bounds18/18 | `cv-ux-01/pre-p10-b-linux-run-02/letter-library/pdf-proof.json` |
| TypeScript/lint/static build | Native exits0; corrected static API target explicitly verified | `pre-phase10-ux/b-{typecheck-final,lint-final,build-02}.log` |
| NuGet vulnerability query | Exit0; no vulnerable packages reported, including transitives | `pre-phase10-ux/b-dependency-audit.json` |
| Local model integrity/runtime | All pinned blobs reverified28 October; earlier actual JSON/summary/rewrite smokes pass | `pre-phase10-ux/local-ai/` and `dev/README.md` |
The original Windows24-CV/four-letter corpus and the complete ordinary/contract/security/Python/preservation lanes must be rerun in consolidated integration. Prior accepted results are historical and are not relabelled as fresh B checks. Backend/frontend totals include their existing regression cases; they are not a substitute for browser or provider lanes.
## Retained failures and limits
The revised-name unchanged oracle remains **Windows8/36 PASS with28 S01-only failures; Linux16/36 PASS with20 S01-only failures**. No other marker/font/colour/membership failures occur. Separate captured-ink diagnostics show a maximum3px top difference on each final platform. The original2mm character-cell/DOM Range tolerance remains unchanged; the diagnostic is not a replacement acceptance criterion. Source completeness, physical bounds and full-height sidebar pixel checks independently pass. See TEMPLATE_AUDIT.md.
Earlier failures remain available: signature-only avatar RED;27-case legacy/removal RED;9-case entitlement RED;8 surname-fragment benchmark failures; a worker test stopped before the durable operation acknowledgement (fixed to await both review and completed operation); an initial static build pointed at the wrong test API (six UI failures, corrected by rebuilding with the explicit test endpoint and adding a preflight guard). Docker being stopped prevented one new MariaDB run from starting; no tests ran in that attempt. The subsequent84-test run passed after local IPC recovery. No failed attempt is counted as a skip or a pass.
The checked browser journeys emit pre-existing disabled-tooltip warnings and a handled checklist seed uniqueness race in the isolated SQLite host; their assertions pass. No general checklist rewrite is included. The MariaDB lane validates real persistence separately.
Automated JPEG/WebP/EXIF decoder cases ran on Windows; the actual Linux import/gallery corpus exercises the normal cropped-PNG upload path. Human assistive technology, physical devices, native language review and manual printing remain unperformed. Sparse final pages can remain for sparse source content; arbitrary names and custom font sizes cannot be guaranteed to fit without user adjustment.
Local `qwen2.5:7b` matches the repository's recorded production model name. Exact deployed artifact parity is unverified. Runtime smoke success does not qualify factual CV extraction accuracy or all queued generative workflows. Generative CV-import assistance remains disabled pending its separate grounded-output/model-quality qualification. No production cache, service, database or filesystem concern is treated as resolved.
## Independent decisions
| Area | Decision |
|---|---|
| Template implementation, finite content retention and v1 compatibility | PASS |
| Original Linux CV/letter fidelity | PASS |
| Revised full-height sidebars and benchmark name wrapping | PASS |
| Revised large-name coordinate oracle | PARTIAL; original failures retained |
| Actual gallery and explicit matching-letter selection | PASS, finite automation |
| Optional photo workflow/security | CONDITIONAL GO; stated platform/human limits |
| Upload queue recovery / actual deterministic Docling journey | PASS, finite supported fixtures |
| MariaDB / RD21 preservation | PASS in84-test provider lane |
| Local AI runtime / worker configuration | PASS for integrity, connectivity and stated smokes; source activation separately recorded |
| Real generative CV extraction accuracy / deployed model parity | NOT QUALIFIED |
| Workstream C entry | CONDITIONAL GO; continue safe development |
| Consolidated PRE-P10 acceptance / Phase10 cleanup | NOT YET READY |
| Production | NO-GO |
Qualified source images were activated only in the existing local development stack on28 October. Actual HTTP/byte identity, healthy images, authenticated-proxy boundary, seven worker flags and240-second timeout checks passed; see `dev/README.md` and `.artifacts/pre-phase10-ux/local-ai/source-3f4db8c/http-check.json`. The canonical local frontend host is `jobbjakt.local`; an unmatched localhost Host header is intentionally rejected. This operational check is separate from model-quality acceptance.
Next: create `feature/dashboard-interactive-grid` from this accepted cumulative checkpoint. Keep all C/D and consolidated integration requirements in PLAN.md. No additional owner approval is needed for these already-authorised safe development steps.
@@ -1,29 +0,0 @@
# CV Builder reproduction and correction
Date: 2026-10-27. Baseline: accepted CV-QA `4a8c907`; corrective branch `codex/cv-builder-blank-undo`. Audit checkpoint `5d4b8f2` is pushed. Production and live-model gates remain closed.
## Observed failures before application changes
Actual development browser used the repository Playwright configuration: Next development server (`next dev --webpack`), local Release API, isolated synthetic SQLite data, disabled AI/background workers, and loopback-only browser requests. No production resources or personal documents were used.
- Creating a new CV inherited four independent canonical markers: name, headline, summary and employment role. The creation dialog had no explicit Blank choice. Career Profile itself remained unchanged.
- After a section reorder was saved and read back, Ctrl+Z with the Undo toolbar button focused left the sections reordered. Expected document undo did not occur.
- An initial headline-only keyboard check passed through native browser text undo. This is retained as an observation, not used to prove document shortcuts worked. The authoritative failure uses a non-text reorder.
Raw runtime evidence: `.artifacts/pre-phase10-ux/runtime-red/results.json` (original one failure and one native-text pass); `reorder-results.json` (one focused failure), with screenshots and Playwright traces in their result directories. These are separate runs, not additive suite totals.
Backend RED: six requirement failures and one legacy-preservation pass in `blank-red/blank-red.trx`. Library RED: two failures in `blank-ui-red.json`. The initial four-suite frontend run included those two newly added failures: 87 passed/2 failed, so it is not a fully green starting baseline.
Editor RED reproduced typing-burst history, missing document shortcuts/name history, enabled Undo after revision conflict, previous document controls remaining visible while the next document loads, and automatic section hydration despite explicit empty settings. A subsequent historical-restore test reproduced a late response setting a conflict on another document. Harness syntax/navigation setup failures are retained but do not count as product reproductions.
## Correction contract
New creation defaults to Blank: explicit empty section selection and excluded profile contact. From Career Profile offers actual available sections and optional contact, initially unselected. Existing documents without the new nullable flags retain historical inheritance. Design selection changes presentation; duplication and restore preserve the stored selection. Empty export fails with a translated explanation before allocating a PDF worker.
Interactive history records immutable name/settings snapshots, bounded to 60 undo steps within the current editor session. Changes in one focused text field within one second form a typing burst; focus changes and separate controls end that burst. Structural actions are individual steps. New edits clear redo. Autosave acknowledgements preserve history; reload, historical restore and opening another document reset it. History is not a frozen document revision.
Outside editable controls, Ctrl/Cmd+Z undoes, Ctrl+Y or Ctrl/Cmd+Shift+Z redoes. Native input/textarea/contenteditable shortcuts remain native. Revision conflicts disable document history and retain the draft for explicit recovery. Save success still requires the established version-aware server acknowledgement; no history operation bypasses RD21.
## Qualification in progress
Focused backend: 108 passed, zero failed/skipped (`blank-green/blank-green.trx`). TypeScript: exit zero. This does not substitute for real MariaDB, RD21, the browser result, full regression or human/device acceptance. Final checkpoint evidence and independent verdicts will be recorded after those applicable lanes complete.
-40
View File
@@ -1,40 +0,0 @@
# C: interactive dashboard acceptance
28 October2026. Branch `feature/dashboard-interactive-grid`, cumulative base `aea7c29` includes accepted B, A, CV-QA and CV-UX history. Source checkpoint is the commit containing this report. **CONDITIONAL GO for continued development and D entry; production NO-GO.**
The dashboard now arranges the actual reports using drag handles and corner resizing on wide containers. A compact visibility catalogue and per-widget keyboard movement/width/height/hide controls replace the former nine-row arrangement form. Save, discard, reset, conflict recovery and navigation guards retain the existing preference owner. Analytics retains its original layout.
Schema2 uses bounded12-column geometry, preserving legacy order/visibility/revision through an in-memory migration. Loading, mounting or crossing a breakpoint never saves. Hidden geometry survives. The existing authenticated, byte-bounded, owner-filtered revision compare-and-swap remains the only persistence path. Old clients cannot downgrade a stored schema2 layout. Visible overlapping rectangles are rejected; hidden positions may overlap. Collision resolution uses the installed grid library, preserving gaps when no collision exists. No database migration or extra preference store.
## Evidence
Artifacts are under `.artifacts/pre-phase10-ux/`; these are independent lanes, not additive totals.
| Lane | Result | Evidence |
| --- | --- | --- |
| Full backend | **1,405 passed**, zero failed/skipped | `c-backend-02/c-backend.trx` |
| Full frontend | **1,019 passed/111 suites** before the final extra collision case | `c-frontend-full-01.json` |
| Final focused frontend | **33 passed/5 suites**, including the extra collision case and final implementation | `c-focused-final-03.json` |
| Real MariaDB12.2.2 | **86 passed**, zero failed/skipped; generated resources cleaned | `c-mariadb-01/{mariadb.trx,run.json}` |
| Dashboard browsers | **12 passed**, zero failed/skipped/retries | `c-browser-full-03.json` |
| Static build/typecheck | Native exits0; final build includes TypeScript | `c-build-05.log`, `c-typecheck-final.log` |
| Full lint | Native exit0 | `c-lint-final.log` |
| Production npm audit | Native exit0, zero advisories | `c-npm-production-audit.json` |
| Full npm audit | Native exit1, five low dev-tool entries in existing Jest/deepmerge dependencies | `c-npm-audit.json` |
The full frontend total does not include the subsequently added collision case; do not add overlapping focused reruns to that total. The final integrated regression must rediscover and run the entire resulting suite.
The twelve browser cases retain native report values/actions, fresh browser and logout/login readback, draft/cancel/reset, offline/503 recovery, delayed acknowledgements, two-session conflicts, ordinary/admin/anonymous isolation, legacy hidden flags, all-hidden recovery, actual pointer drag/resize, keyboard focus/status, emulated touch, stored gaps/hidden geometry and zero viewport-induced writes. Reflow covers EN/Bokmål, light/dark, widths320/390/768/1024/1440/1920 across the presentation scenarios; automated axe checks pass in their exercised scopes. The original data and persistence assertions remain. Screenshots/traces are attached to the latest browser results.
Initial RED migration and HTTP cases are retained. Browser failures exposed a real announcement-element horizontal overflow and a resize overlap in the library's no-compaction mode. Both are fixed, with unchanged outer-overflow assertions, a new pure collision regression and actual browser save/readback. The server's rejection prevented persisting the overlap. Earlier failed JSON/log artifacts remain; they are not counted as passing evidence.
All exported JS artifacts increase from3,499,170 to3,572,883 raw bytes, or1,032,319 to1,055,955 individually gzip-compressed bytes: **+73,713 raw/+23,636 gzip**. `c-bundle-final.json` measures the complete static artifact set, not a route download or runtime speed. `react-grid-layout` is pinned2.3.0/MIT; no Jest/deepmerge dependency was changed by its installation. A proposed audit fix downgrading Jest25 was not applied; the five tooling entries remain acceptance debt.
## Decisions and limits
- Interactive arrangement, preference compatibility, authenticated persistence and automated responsive/accessibility scope: **PASS in the evidence above**.
- C development/D readiness: **CONDITIONAL GO**. Human screen-reader, physical-touch, software-keyboard and subjective usability acceptance remain open. No physical-device or human language acceptance is claimed.
- Full integrated browser/contracts/Python/Docling/security/preservation/PDF lanes: **pending the final integration checkpoint**; C did not modify parser, Career Profile, CV/letter rendering or their ownership contracts. Existing B PDF/name-oracle/model debt remains open and is not waived.
- Existing local runtime still serves qualified B source3f4db8c; this C checkpoint has **not** been activated there. Production, main merge and Phase10 cleanup remain **NO-GO/not performed**.
See [implementation contract](C_IMPLEMENTATION.md) for architecture, limits and reproduction evidence. Proceed to the administrator processing monitor from this cumulative checkpoint.
-47
View File
@@ -1,47 +0,0 @@
# C: interactive dashboard implementation contract
28 October 2026. Entry: accepted cumulative B history through `aea7c29` (source3f4db8c), verified pushed. Branch `feature/dashboard-interactive-grid`. Existing local runtime continues to serve B until C is separately qualified.
Keep the existing widget registry, reports, preference JSON/revision columns, authentication boundaries, draft/save/cancel hook and navigation guard. Analytics/reporting retains its current layout. No dashboard database migration or parallel preference store is needed.
Use pinned `react-grid-layout@2.3.0`, modern typed API. Official package metadata declares MIT and React/ReactDOM>=16.3; installed exports and declarations were inspected. Its grouped grid/drag/resize configuration and existing compactor avoid a new collision engine. Actual React19 pointer/resize compatibility, accessibility alternatives and bundle impact must be measured, not inferred from peer ranges. Sources: [upstream package](https://github.com/react-grid-layout/react-grid-layout/blob/master/package.json), [modern API](https://github.com/react-grid-layout/react-grid-layout#whats-new-in-v2).
## Geometry and compatibility
- Canonical schema2 stores bounded integer `x,y,w,h` per known widget on12 columns. Retain legacy `size` as a consistent full/standard alias. Bounds: x>=0,y>=0,w4–12,h4–24,x+w<=12,y+h<=256; summary cards remain12 columns. Hidden geometry remains stored.
- Deterministically migrate schema1 order/visibility/full-or-standard widths in memory. New missing widgets remain hidden for existing accounts. Loading or changing viewport never persists a reset or migration.
- Preserve valid schema2 geometry/revision. Unsupported future schemas remain blocked. Schema1 clients cannot overwrite stored schema2, even with a current revision. Retain request-byte bounds and atomic owner/revision compare-and-swap.
- Visible rectangles cannot overlap on write; adjacent edges and overlapping hidden rectangles are allowed. The grid retains gaps using the library's `noCompactor` when there are no collisions, and delegates actual overlap resolution to `verticalCompactor` using `getAllCollisions`. A RED resize test exposed that `noCompactor` alone permits overlapping rectangles during resizing; the server rejected the write. Completed pointer operations order visible records by row/column, retaining hidden slots and all geometry.
- Desktop uses measured container width and real drag/resize handles. Mobile/tablet narrow containers use a linear content layout, retaining desktop geometry without writes. Content remains scrollable/readable at bounded sizes.
## Interaction
Edit the actual report widgets. The existing toolbar retains explicit Save, Discard, Reset and conflict/reload behaviour; a compact catalogue replaces the large per-widget configuration form. Drag handles and widget options supply keyboard movement, width/height and visibility alternatives. Keep focus after moves, announce changes, respect reduced motion and preserve ordinary widget links/inputs and touch scrolling.
Only actual drag/resize completion or explicit controls change the draft. Ignore mount/compaction/breakpoint callbacks as persistence events. No timers or automatic saves. A save success follows confirmed durable acknowledgement; conflicts retain the draft and require deliberate recovery.
## Qualification
1. RED/GREEN pure migration/geometry and backend HTTP schema/stale-write tests, retaining existing owner/revision/corruption/request-bound checks. Future-schema fixtures advance to3 because2 is now supported.
2. Real widget pointer drag/resize, keyboard/menu/catalogue, save/reload, discard/reset, failed save and two-tab conflict; existing report actions/data and hidden preferences survive.
3. EN/Bokmål, light/dark, desktop/tablet/mobile, reduced motion and accessibility automation. Physical device/assistive-technology acceptance remains separate.
4. Real MariaDB revision/ownership/preservation coverage, focused/full appropriate regressions, measured aggregate compiled bundle impact and browser screenshots. Commit/push/remote verification before D entry.
No production deployment, main merge, Phase10 cleanup or unrelated integrations.
## Implementation and evidence, 28 October
Implemented in `DashboardGrid.tsx`, the existing editor/catalogue, `dashboardLayout.ts`, preference hook and existing AuthController/JSON model. No preference table, queue, schema migration or new data owner. Actual report components and their links/tables remain intact. Narrow containers use content height and desktop geometry survives without writes. Scrollable desktop content has keyboard focus; controls include native width/height inputs, movement, visibility, announcements and focus recovery. The reduced-motion rule disables item transitions.
Evidence under `.artifacts/pre-phase10-ux/`:
- Initial migration RED: `c-layout-red.log`, 2 failed/6 passed. Initial HTTP geometry RED: `c-layout-red/c-layout-red.trx`, 1 failed/31 passed. GREEN foundation: 8 pure tests and32 API tests. Added visible-overlap/adjacency/hidden cases subsequently pass in the full backend suite.
- Full backend `c-backend-02/c-backend.trx`: **1,405 passed, zero failed/skipped**, including35 dashboard HTTP cases, existing revision/authentication/preservation tests.
- Real MariaDB12.2.2 `c-mariadb-01/`: **86 passed, zero failed/skipped**, including new durable geometry/owner/legacy/stale protection and concurrent one-winner tests. Generated schema/container cleanup exited0. No production database access.
- Final focused frontend `c-focused-final-03.json`: **33 passed/5 suites**; includes4 grid collision/callback/narrow viewport/conflict cases. Test-double callbacks qualify surrounding logic only; actual pointer qualification uses Playwright. Collision RED `c-collision-red.json`:1 failed/3 passed; GREEN `c-collision-green.json`:4 passed.
- Browser qualification and full frontend totals are recorded in C_ACCEPTANCE.md when complete. Failed runs are retained. A screen-reader announcement used MUI numeric width1 (100%), causing outer overflow; corrected to explicit1px and the existing reflow assertion passes. Resize simulation now starts at the actual handle centre, avoiding a clipped rounded corner. Tests now open the compact catalogue and compare pointer-induced order to actual geometry; no original ownership, persistence, failure or accessibility assertions were removed.
- Final static build `c-build-05.log` exits0; exact React19 runtime tested by actual browser cases. Full lint/typecheck native exits are recorded alongside final acceptance.
- `c-bundle-final.json`: all exported JS artifacts, B57 files3,499,170 raw/1,032,319 gzip bytes; C57 files3,572,883 raw/1,055,955 gzip. Delta **73,713 raw/23,636 gzip bytes**. This measures aggregate artifacts, not route download or runtime performance. CSS is separate. Dependency is pinned2.3.0/MIT; official peer range permits React>=16.3, actual React19 functionality requires the browser evidence.
- `c-npm-production-audit.json`: exit0, zero production advisories. `c-npm-audit.json`: exit1, five low dev-tool dependency entries around existing Jest/deepmerge; no package-lock changes to those existing packages. No new grid dependency is flagged. A suggested downgrade to Jest25 is not applied. Carry these tooling advisories to integrated security qualification; this is not a clean full audit.
No human screen-reader, physical-touch or subjective design acceptance is inferred from automation. Existing B rendering/model qualification debt remains open. C has not been deployed to the local B runtime or production.
-45
View File
@@ -1,45 +0,0 @@
# PRE-P10-UX design and scope
27 October 2026. Owner specification: CV-QA Addendum/PRE-P10-UX and appended administrator attribution/failure-diagnostics requirements, supplied in the attachment `03bb825f-4d45-40ea-8574-548831f24611/Pasted text.txt`.
The owner explicitly authorises autonomous safe implementation, validation, commits and pushes in the stated sequence. Routine design choices below are engineering decisions under that authorisation, not a claim that the owner separately reviewed this document. No additional design approval loop is required. Production, main merge, live-model activation and original Phase10 cleanup remain excluded.
## Verified entry
The completed CV-QA baseline is `4a8c907d70cbed235728f7d690b5409e72b93abe`, with remote equality verified. Its conditional acceptance permits development/QA; extraction, human/device/print and production qualifications remain limited. It contains `ef425c2` and CV-UX `4a0af62`. An attached managed worktree isolates unrelated local Docker/tooling/security work. Corrective branch: `codex/cv-builder-blank-undo`.
## A: blank creation and interactive history
Root causes found in source: `sections=[]` means inherit defaults on both server resolver and editor hydration; contact is always inherited; document AI/application matching also treat absent rows as visible. Undo mutates history inside React state updater callbacks, contains settings but not the document name, and has no document-level keyboard handling. Runtime reproduction and failing tests must precede fixes.
Use nullable settings-JSON flags `sectionsExplicit` and `includeProfileContact`. Missing flags retain legacy behavior and serialization/no-op compatibility. Blank explicitly selects zero sections and suppresses inherited contact/header/photo. Manual custom sections remain CV-specific content; adding existing Career Profile content is a deliberate action. From Career Profile exposes actual available-section/contact choices. A template changes presentation; duplicate preserves content and flags. Every resolver/context consumer must use the same inclusion semantics. Empty resolved content receives clear export validation before PDF allocation; no fictional placeholders are inserted.
Alternatives rejected: changing the meaning of every existing empty list would rewrite old CVs; hiding seeded defaults only in the UI would leave render/AI/matching leaks. A new master-profile or revision store is unnecessary.
Interactive undo/redo uses immutable document snapshots, pure state updates and bounded session-only stacks. Text editing groups by focus/edit burst; structural/design actions are individual transactions. New edits discard redo. Autosave uses the existing queue and expected server revision; success is acknowledged only after durable save. Conflict reload/restore/new-document hydration deliberately resets session history, while ordinary saves preserve it. Native input/contenteditable undo remains native; document shortcuts operate outside editable controls. History cannot cross document IDs, and delayed saves/previews must not rehydrate another document.
## B: versioned template quality and optional photographs
Keep all nine existing IDs for compatibility; this is the justified alternative to a numerical6–8 target. The catalogue already spans the requested layout families. Improve actual distinctness/spacing rather than add near-duplicates. Audit short/medium/long/dense equivalents across all nine, render PDF page images and inspect them.
Known source defects/limits: sidebar CSS ignores configured sidebar palette; Technical's usable identity width is30mm after padding, causing aggressive word wrapping; conservative section keep-together rules can produce sparse pages. Measure before changing shared pagination. Introduce an opt-in design revision if new styling would alter legacy lens renders; missing revision stays legacy. New creation/explicit template Apply may opt into revised styling. Duplicate/restore retain the chosen revision; frozen stored HTML/bytes remain untouched. Reuse licensed embedded fonts and existing photo upload/storage/crop pipeline; add accessible crop alternatives and explicit CV entry points where needed. No generated portraits or second image store.
## C: interactive dashboard
Replace form-heavy arrangement with an edit mode on the actual widgets: drag handles, resizing, accessible visibility/catalogue controls, save/discard/reset, keyboard movement/resizing and status announcements. Keep widget data/actions and the existing preference hook/owner/revision contract. Assess the current official React-Grid-Layout API/licence/React compatibility/bundle cost before selecting it; avoid reimplementing a collision engine without evidence.
Schema v2 stays in existing preference JSON/revision columns. Deterministic migration preserves v1 stored order, size and visibility; no automatic reset or write on viewport change. Persist bounded geometry with responsive behavior and per-widget minima; retain hidden geometry and future-widget rules. Old-schema clients cannot overwrite newer stored layout data. Phone/touch interactions preserve scrolling and functional widget controls; drag is never the only authoring method. No dashboard SQL migration is needed.
## D: administrator processing monitor
Reuse real `UserOperation`, queue/leases, CV runs and actual task-specific job-subject decoding. Current rows provide latest state, not historical transitions. Add a bounded owned operation-event table with monotonic cursor, UTC time, attempt and allowlisted event/status/stage/error codes. Append only successful transitions atomically; do not save unrelated tracked entities or log every heartbeat. Do not invent history for old rows. Include additive migration, retention, account erasure/export and actual MariaDB tests.
Dedicated server-admin-authorised list/detail/event APIs project safe metadata. Existing admin audit permits cross-owner identity/job metadata; ordinary APIs remain owner-scoped. Every association is checked against the operation owner. Display original user ID plus current permitted name/email; CV imports show no linked application, safe retained file metadata and known page count only. Deleted/mismatched relationships remain unavailable. No existing user-detail route is assumed.
Use bounded cursor polling with pause/resume/reconnect, filters, pagination and finite retention; SSE is optional, not required infrastructure. No raw document/prompt/completion/request/exception/stack/connection/lease data enters events or responses. Sanitisation occurs before storage/transmission. Unknown failures remain unknown. Operation GUID is the correlation key; propagate safe correlation across actual worker/backend/sidecar boundaries. Read-only monitoring avoids new privileged cancellation/retry paths unless separately proven safe. Audit any sensitive administrative action through existing owners. Disabled/disconnected model assistance is shown honestly; no live inference is started.
## Publication, preservation and acceptance
After validated A, create `feature/cv-template-library-review` from accepted A; then `feature/dashboard-interactive-grid` from accepted B; then `feature/admin-processing-monitor` from accepted C. Consolidate on `test/pre-phase10-ux-acceptance` from accepted D. No disconnected branch omits earlier work. Each checkpoint has focused RED/GREEN evidence, review, documentation, commit/push and remote check. Final regression serialises native build/browser/provider lanes and retains independent counts/source/runtime identities.
Preserve all57 capability groups/33 routes, canonical Career Profile, overrides, document/history/letter/application revisions, ownership/authentication, v1 dashboard data and licensed physical rendering. Keep old evidence/goldens; if intentional new design output differs, use separate versioned specimens rather than conceal regressions. Human/device/native-language/print/live-model/deployed acceptance is only recorded when actually performed. Final independent verdicts cover all requested areas, with owner actions and Phase10-vs-production scopes. Stop after the new Phase10 handoff; do not start cleanup.
-39
View File
@@ -1,39 +0,0 @@
# D: administrator processing monitor acceptance
28 October 2026. Conditional GO for cumulative development and integration QA. Production remains NO-GO. Source follows history foundation `1f87301ee9a38889c012c4fbe1c53a5b80fb9f6a` on `feature/admin-processing-monitor`; the publication commit containing this report identifies the qualified implementation.
## Delivered behaviour
The Admin-only Processing page shows actual durable operations, status counts, bounded owner/status/task filters, verified job associations and bounded source-file metadata. Detail shows UTC timestamps, elapsed duration, attempts, sanitized failure codes, a copyable operation GUID and ordered retained events. Worker configuration is explicitly configuration, not a health probe. Workers without individual durable operations are identified; historical events are not fabricated.
The existing operation store owns transactional history. Successful state changes and their events commit together; failed event insertion rolls back the transition. Same-stage heartbeats do not create events. Owner filtering, stale-lease checks, CAS recovery and terminal notifications remain intact. Events expire after 30 days, are excluded immediately at read time, are removed in bounded existing retention sweeps and participate in account export/deletion. The foundation checkpoint contains migration/startup/rollback evidence.
Read-only APIs require the server Admin role. Ordinary users retain owner-only operation/source access. Projections omit document text, raw result/failure/subject/lease values, paths, prompts, credentials and model information. Joined job/company/source ownership is checked. Names are bounded and filenames strip path/control characters. Event cursors remain decimal strings, including values above JavaScript's safe integer range. Correlation headers accept only an actual execution GUID; the sidecar validates and echoes that GUID after authentication without logging document content.
Polling is sequential, aborts on navigation or identity changes, uses a 15-second request timeout and failure backoff, and supports pause/resume, refresh and bounded older-event pages. Forbidden responses clear cached data and stop automatic retries. Errors cannot display arbitrary server content. EN/Bokmål, light/dark, keyboard focus, responsive tables and accessible status messages use existing application owners.
## Independent evidence
All paths below are relative to `.artifacts/pre-phase10-ux/`. Focused reruns are not added to full-suite totals.
| Lane | Result | Evidence |
| --- | --- | --- |
| Full .NET backend, final source | 1,424 passed, zero failed/skipped | `d-backend-final/d-backend.trx` |
| Full frontend | 1,027 passed across 112 suites | `d-frontend-full-01.json` |
| Focused final API/security/metadata bounds | 21 passed | `d-api-final/d-api-final.trx` |
| Real MariaDB, including final bounded SQL projections | 89 passed, disposable schema cleanup confirmed | `d-mariadb-04/` |
| Admin browser authentication/isolation/history/errors/pause, responsive themes/languages and axe | 2 passed | `d-browser-02.json`, `d-browser-02-results/` |
| Real Docling browser lifecycle plus PDF/DOCX review/apply/profile/editor/PDF | 3 passed | `d-docling-browser-01.json` |
| Offline sidecar including correlation validation | 81 passed; model loading disabled | `d-sidecar-offline-01.log` |
| Production frontend build, TypeScript | exit 0 | `d-build-03.log` |
| Full frontend lint | exit 0 | `d-lint-final-02.log` |
The Docling browser run uses real extraction, deterministic interpretation and synthetic documents. Its separate parser container has an internal-only network, bounded resources, read-only source/root and model loading disabled. A bounded fixed-upstream gateway provides host-loopback access; the gateway's own bridge is not an egress firewall. No production model or service was changed. The browser run preceded the final metadata-label clipping change; final API and MariaDB tests cover that change, and cumulative browser qualification remains required.
Initial failing checks remain evidence: invalid correlation headers, missing event storage, stale tracked retry, oversized identity labels, duplicate browser alert/region selectors and migration-version expectations were reproduced and corrected without weakening persistence/isolation assertions.
## Decisions and remaining qualification
History/transaction safety, bounded read-only Admin API, monitor UI, ordinary-user isolation and real deterministic import lifecycle: PASS in the recorded automated scopes. D overall: CONDITIONAL GO for integration QA. Formal security review and the complete cumulative regression/preservation/PDF lanes remain pending at this checkpoint. This does not replace the final acceptance report.
Human accessibility, physical devices, language review and manual printing remain open. B's revised large-name PDF coordinate-oracle failures remain unresolved and must be carried forward explicitly. Local synthetic model smoke tests do not qualify extraction accuracy or deployed-production model parity. Existing low-severity development-tool dependency advisories remain separate from production dependency results. No main merge, production deployment or Phase 10 cleanup is authorized by this decision.
@@ -1,35 +0,0 @@
# D owner-addendum qualification
29 October 2026. This adds evidence to the preserved initial D acceptance; final cumulative acceptance remains pending.
## Completed requirements
The administrator list now filters before paging by UTC creation interval (inclusive start, exclusive end), actual owner-verified job, configured processing family, fixed failure code and bounded search. Search covers safe account identity, owned job title/company and exact operation GUID only. Invalid identifiers, intervals, worker names and failure codes return 400. The UI explains invalid filters and stops automatic retries until a correction. Dates earlier than MariaDB's supported range are rejected. Existing owner/status/task filters and bounded paging remain intact.
Details expose the internal owner identifier, actual extraction-run identifier and explicit absence of a linked job. Job navigation is offered only when the authenticated administrator also owns that job; administrative metadata access grants no new ordinary-user job-reading permission. The list displays elapsed duration and current failure state. A named account-partitioned read limit permits 120 requests/minute without queueing; normal polling uses approximately 36/minute. Existing authorization remains mandatory.
Failure metadata uses the latest retained, owner-bound typed failure event with a sanitized matching code. Successful/retrying operations label retained failures as previous failures. Missing historical timestamps stay unknown; a later success timestamp is never presented as the failure time. Only an actually failed terminal operation may fall back to its recorded completion time. No arbitrary messages, payloads or source text enter this projection.
## Verification
Evidence paths are relative to `.artifacts/pre-phase10-ux/`. Focused reruns are not added to full-suite totals.
| Check | Result | Evidence |
| --- | --- | --- |
| Full backend | 1,447 passed, zero failed/skipped | `d-addendum-backend-final/backend.trx` |
| Full frontend | 1,031 passed across 112 suites, zero failed/pending | `d-addendum-frontend-full-02.json` |
| TypeScript, full lint, production frontend build | exit 0 each | `d-addendum-{typecheck,lint,build}-final.log` |
| Admin boundaries, filters, recovery, responsive EN/NB/themes | two passed | `d-addendum-browser-02.json` |
| Real browser zoom 100/200/400%, EN/light and NB/dark | two passed | same browser report; original geometry assertions retained |
| Real MariaDB, including final failure-event projection | 90 passed, zero failed/skipped; cleanup exit 0 | `d-addendum-mariadb-final-02/` |
| Follow-up static review | timestamp finding resolved; no further blocker | existing server review worker, 29 October |
The initial RED API/UI cases remain in `d-addendum-api-red/` and `d-addendum-ui-red.json`. The timestamp regression was independently reproduced with the former completion-time binding (`d-failure-time-red.json`) before restoring the corrected binding. The first provider attempt found an actual collation mismatch in the new cross-table ownership filters. Explicit provider-appropriate binary collation fixed those comparisons without rewriting data or schemas; `d-addendum-mariadb-02/` passed 90 tests before the final failure-time query was added. A subsequent final attempt could not start because Docker Desktop was stopped (`d-addendum-mariadb-final/`); it did not reach tests or MariaDB. This infrastructure failure is not a test pass.
The initial zoom recovery run passed both admin cases but was stopped while waiting for extension startup; retain `d-addendum-browser-final.log` as an incomplete attempt. Isolated diagnostic launches ruled out the long worktree path. The test context now enables its deliberate local extension and captures its service worker before navigation; the fresh four-case report passes. Application code, zoom percentages, timeout and geometry assertions were not relaxed.
## Scope and gates
The sealed security scan through `d52403d` does not include this later delta. Its bounded filters, rate policy, failure-event query and UI were reviewed separately; no new security blocker was identified. This is source review, not a new sealed scan or production acceptance.
Full cumulative ordinary/specialized browser, final Docling, PDF fidelity, source/runtime closure and local C/D activation remain pending. Historical PDF coordinate-oracle failures, human/device/language/print checks, real AI extraction qualification and production infrastructure debt remain open. No production operation, main merge, deployment or Phase 10 cleanup occurred.
@@ -1,19 +0,0 @@
# D history foundation checkpoint
28 October2026. Cumulative branch `feature/admin-processing-monitor`, based on C0bd290e; architecture checkpoint5f26108 was pushed with remote equality verified. This report accompanies the event/persistence checkpoint. **This is not acceptance of the admin API/UI, which remains in progress.**
Implemented migration-owned typed operation history, strict safe stage/status/failure projection at insertion, event owner filtering and operation-cascade erasure. Queue creation, successful claims, actual stage changes, completion, retry/failure, cancellation and lease/deadline recovery append events within their state transition transaction. Periodic same-stage/empty heartbeats do not manufacture history. New relational event writes use parameterized insertion without flushing unrelated tracked objects. Existing terminal notification transactions remain intact.
Document stage reporting retains the original owner, lease, cancellation and expiry checks and now uses the same store. Retention uses the existing hourly owner, excludes no current document cleanup and removes up to5,000 events older than30 days per sweep. Account export adds only owner-scoped event metadata; operation cascade and explicit owner erasure remove history. No event contains raw CVs/prompts/results, free-form exceptions, lease tokens, paths or model/provider strings. Existing operation records retain their prior contract; future admin projections must independently redact their legacy free-text fields.
Qualification, independent lanes under `.artifacts/pre-phase10-ux/`:
- `d-events-red/d-events-red.trx`: missing-history regression failed as expected before implementation.
- `d-retry-red/d-retry-red.trx`: confirmed an existing tracked-stale-operation retry failure. Retry now reads durable state and conditionally resets the observed terminal state/completion timestamp, atomically deleting its notification and adding one retry event.
- `d-backend-01/d-backend.trx`: **1,413 passed, zero failed/skipped** full backend. Subsequent additional history cases are checked in the focused lane, not added to this total.
- `d-events-04/d-events-04.trx`: **53 passed, zero failed/skipped**, including claim/stage/completion/retry insertion-failure rollback, stage deduplication/redaction/owner isolation, no unrelated tracked-user flush, inactive lease rejection, recovery ordering/attempts, retention/cascade, existing account rights, startup ownership, CV workers and avatar/document retention.
- `d-mariadb-02/{mariadb.trx,run.json}`: **88 passed, zero failed/skipped** against real MariaDB12.2.2. New tests prove one winning claim/event, concurrent same-stage deduplication, owner isolation, and event-insertion failure rolling back completion and notifications. Migration/startup, account/import/revision tests also pass. All generated resources cleaned; no existing/production database selected.
Retained findings: the first MariaDB run had87 passes/one outdated latest-migration assertion. The test now names the pre-RD21 migration explicitly, retaining every original content/version preservation assertion and actually exercising revision backfill. A shared in-memory SQLite connection allowed a worker test to observe an uncommitted success before stopping the worker; its fixture now uses independent connections to an isolated temporary WAL database, preserving the requirement that success survives the worker stop. `d-events-01` retains that failure; later focused/full runs pass.
The additive migration and snapshot were updated together; no production schema or runtime was changed. Event-history acceptance is limited to the checks above. Admin-only APIs, exact string cursors, owner-verified associations, safe browser presentation, polling/recovery, correlation headers and integrated acceptance remain the next work. No retrospective history is invented for old operation rows.

Some files were not shown because too many files have changed in this diff Show More