feat: persist job source and market
CI and Deploy / test (push) Successful in 2m34s
CI and Deploy / deploy (push) Successful in 56s

This commit is contained in:
cesnimda
2026-07-30 23:38:22 +02:00
parent efc9d83c25
commit 988a91a151
12 changed files with 62 additions and 9 deletions
@@ -202,7 +202,9 @@ public sealed class JobApplicationsEndpointBehaviorTests
CoverLetterText: null, CoverLetterText: null,
JobUrl: null, JobUrl: null,
DateApplied: null, DateApplied: null,
FeedbackRequestedAt: null); FeedbackRequestedAt: null,
Source: "NAV",
CountryCode: "no");
var result = await controller.Create(request, CancellationToken.None); var result = await controller.Create(request, CancellationToken.None);
@@ -213,6 +215,11 @@ public sealed class JobApplicationsEndpointBehaviorTests
Assert.Equal("NOK", saved.SalaryCurrency); Assert.Equal("NOK", saved.SalaryCurrency);
Assert.Equal("year", saved.SalaryPeriod); Assert.Equal("year", saved.SalaryPeriod);
Assert.Equal("60-70k", saved.Salary); Assert.Equal("60-70k", saved.Salary);
var opportunity = await db.Jobs.SingleAsync();
Assert.Equal(opportunity.Id, saved.JobId);
Assert.Equal("nav", opportunity.Source);
Assert.Equal("NO", opportunity.CountryCode);
Assert.Equal(saved.JobTitle, opportunity.JobTitle);
} }
[Fact] [Fact]
@@ -102,7 +102,9 @@ namespace JobTrackerApi.Controllers
string? CoverLetterText, string? CoverLetterText,
string? JobUrl, string? JobUrl,
DateTime? DateApplied, DateTime? DateApplied,
DateTime? FeedbackRequestedAt DateTime? FeedbackRequestedAt,
string? Source = null,
string? CountryCode = null
); );
public sealed record UpdateJobApplicationRequest( public sealed record UpdateJobApplicationRequest(
@@ -762,6 +762,28 @@ Canonical profile:
ResponseDate = null, ResponseDate = null,
}; };
var source = string.IsNullOrWhiteSpace(request.Source) ? null : request.Source.Trim().ToLowerInvariant();
if (source?.Length > 32) source = source[..32];
var countryCode = string.IsNullOrWhiteSpace(request.CountryCode) ? null : request.CountryCode.Trim().ToUpperInvariant();
if (countryCode?.Length != 2) countryCode = null;
job.Job = new Job
{
OwnerUserId = job.OwnerUserId,
CompanyId = job.CompanyId,
JobTitle = job.JobTitle,
Location = job.Location,
JobUrl = job.JobUrl,
Description = job.Description,
TranslatedDescription = job.TranslatedDescription,
DescriptionLanguage = job.DescriptionLanguage,
Tags = job.Tags,
Deadline = job.Deadline,
Salary = job.Salary,
Source = source,
CountryCode = countryCode,
SavedAt = job.SavedAt,
};
// A job created straight into a pre-application stage has not been applied to, so it // A job created straight into a pre-application stage has not been applied to, so it
// must not carry an applied date. SyncAppliedDate also covers the reverse: a create // must not carry an applied date. SyncAppliedDate also covers the reverse: a create
// that omits DateApplied but names a real stage still gets stamped. // that omits DateApplied but names a real stage still gets stamped.
@@ -769,12 +791,17 @@ Canonical profile:
(job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) = (job.SalaryMin, job.SalaryMax, job.SalaryCurrency, job.SalaryPeriod) =
NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod); NormalizeSalary(request.SalaryMin, request.SalaryMax, request.SalaryCurrency, request.SalaryPeriod);
job.Job.SalaryMin = job.SalaryMin;
job.Job.SalaryMax = job.SalaryMax;
job.Job.SalaryCurrency = job.SalaryCurrency;
job.Job.SalaryPeriod = job.SalaryPeriod;
// Generate and persist a short summary at creation time to avoid repeated model calls. // Generate and persist a short summary at creation time to avoid repeated model calls.
try try
{ {
var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60); var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
job.ShortSummary = shortSum; job.ShortSummary = shortSum;
job.Job.ShortSummary = shortSum;
} }
catch catch
{ {
@@ -12,6 +12,8 @@ public sealed record JobImportResult
public string? Language { get; init; } // ISO-ish, e.g. "en", "no" public string? Language { get; init; } // ISO-ish, e.g. "en", "no"
public string[] Tags { get; init; } = Array.Empty<string>(); public string[] Tags { get; init; } = Array.Empty<string>();
public string SourceUrl { get; init; } = ""; public string SourceUrl { get; init; } = "";
public string? Source { get; init; }
public string? CountryCode { get; init; }
public DateTime? Deadline { get; init; } public DateTime? Deadline { get; init; }
public bool Success { get; init; } public bool Success { get; init; }
@@ -21,6 +21,8 @@ public sealed class FinnPlugin : IJobSitePlugin
Location = meta.TryGetValue("job:location", out var loc) ? loc : null, Location = meta.TryGetValue("job:location", out var loc) ? loc : null,
Description = HtmlExtract.ToPlainText(desc), Description = HtmlExtract.ToPlainText(desc),
Parser = "finn", Parser = "finn",
Source = "finn",
CountryCode = "NO",
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc), Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
}; };
} }
@@ -18,6 +18,8 @@ public sealed class JobbnorgePlugin : IJobSitePlugin
Title = title, Title = title,
Description = HtmlExtract.ToPlainText(desc), Description = HtmlExtract.ToPlainText(desc),
Parser = "jobbnorge", Parser = "jobbnorge",
Source = "jobbnorge",
CountryCode = "NO",
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc), Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
}; };
} }
@@ -20,6 +20,7 @@ public sealed class LinkedInPlugin : IJobSitePlugin
Company = meta.TryGetValue("og:site_name", out var sn) ? sn : null, Company = meta.TryGetValue("og:site_name", out var sn) ? sn : null,
Description = HtmlExtract.ToPlainText(desc), Description = HtmlExtract.ToPlainText(desc),
Parser = "linkedin", Parser = "linkedin",
Source = "linkedin",
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc), Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
}; };
} }
@@ -22,6 +22,8 @@ public sealed class NavPlugin : IJobSitePlugin
Company = siteName, // better than nothing; universal parser often gets this anyway. Company = siteName, // better than nothing; universal parser often gets this anyway.
Description = HtmlExtract.ToPlainText(desc), Description = HtmlExtract.ToPlainText(desc),
Parser = "nav", Parser = "nav",
Source = "nav",
CountryCode = "NO",
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc), Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
}; };
} }
+2 -4
View File
@@ -9,10 +9,8 @@ namespace JobTrackerApi.Models
/// - a job can be tracked before it is applied to (see JobPipeline's Prospect stages), and /// - a job can be tracked before it is applied to (see JobPipeline's Prospect stages), and
/// - applying twice to the same role does not duplicate the whole job description. /// - applying twice to the same role does not duplicate the whole job description.
/// ///
/// Introduced in Phase 0 (2026-07-17) as an additive step. Nothing reads from this table yet: /// Introduced in Phase 0 (2026-07-17). New applications dual-write this opportunity row while
/// <see cref="JobApplication"/> still carries its own copy of these columns and remains the /// <see cref="JobApplication"/> remains the read source during the incremental cutover.
/// source of truth for all current reads and writes. The cutover (dual-write, then flip reads,
/// then drop the legacy columns) is Phase 1 work.
/// ///
/// See docs/decisions/ADR-002-job-application-model.md. /// See docs/decisions/ADR-002-job-application-model.md.
/// </summary> /// </summary>
+2 -2
View File
@@ -37,7 +37,7 @@ Goal: finish surfacing the pre-application workflow in the UI, and close the sec
|---|---|---|---|---|---| |---|---|---|---|---|---|
| 1.1 | ✅ **DONE**`Saved`, `Interested`, and `Preparing` are exposed through the shared pipeline model, grouped Kanban, filters, and status menus. | **P0** | **M** | Phase 0 | Prospect workflow is visible end to end. | | 1.1 | ✅ **DONE**`Saved`, `Interested`, and `Preparing` are exposed through the shared pipeline model, grouped Kanban, filters, and status menus. | **P0** | **M** | Phase 0 | Prospect workflow is visible end to end. |
| 1.2 | ✅ **DONE (2026-07-30)** — new jobs default to `Saved`; users can choose any later stage in the wizard. | **P0** | **XS** | 1.1 | New opportunities no longer imply an application was already submitted. | | 1.2 | ✅ **DONE (2026-07-30)** — new jobs default to `Saved`; users can choose any later stage in the wizard. | **P0** | **XS** | 1.1 | New opportunities no longer imply an application was already submitted. |
| 1.3 | **Drag-and-drop on the Kanban** | **P2** | **M** | 1.1 | Board is drop-target-only today. Table stakes at every competitor. Deferred: not blocking. | | 1.3 | **DONE** — draggable cards move between grouped Kanban columns, persist the destination entry stage, and retain precise stage selection in the card menu. | **P2** | **M** | 1.1 | Covered by grouped-board drag/drop regression tests. |
| 1.4 | **Rotate DataProtection keys** | **P1** | **XS** | none | **Needs an operator — cannot be done from here.** Keys remain recoverable from git history (`519c32e`, `955cae6`). Open since 2026-07-03. | | 1.4 | **Rotate DataProtection keys** | **P1** | **XS** | none | **Needs an operator — cannot be done from here.** Keys remain recoverable from git history (`519c32e`, `955cae6`). Open since 2026-07-03. |
| 1.5 | ✅ **DONE (2026-07-30)** — startup rejects wildcard CORS origins when credentialed requests are enabled. | **P1** | **XS** | none | Unsafe configuration now fails closed. | | 1.5 | ✅ **DONE (2026-07-30)** — startup rejects wildcard CORS origins when credentialed requests are enabled. | **P1** | **XS** | none | Unsafe configuration now fails closed. |
| 1.6 | ✅ **DONE** — the dead `careerView` prop/tab is gone; the implemented CV Builder has its own routed workspace. | **P1** | **XS** | none | No dead navigation remains. | | 1.6 | ✅ **DONE** — the dead `careerView` prop/tab is gone; the implemented CV Builder has its own routed workspace. | **P1** | **XS** | none | No dead navigation remains. |
@@ -173,7 +173,7 @@ Goal: help users find opportunities. **URL preview and bookmarklet/PWA capture a
| 6.3 | **DONE (2026-07-30)** — NAV Job Vacancy Feed integration uses the official authenticated API and rotating public experiment token; `NavJobs:Token` supports a stable private token later. FINN requires a business agreement, while Indeed and LinkedIn do not expose open discovery feeds. | **P3** | **L** | 6.2, 6.6 | Legal official Norway-first discovery without scraping. | | 6.3 | **DONE (2026-07-30)** — NAV Job Vacancy Feed integration uses the official authenticated API and rotating public experiment token; `NavJobs:Token` supports a stable private token later. FINN requires a business agreement, while Indeed and LinkedIn do not expose open discovery feeds. | **P3** | **L** | 6.2, 6.6 | Legal official Norway-first discovery without scraping. |
| 6.4 | **DONE (2026-07-30)** — discovery UI searches recent active NAV events by title/company and municipality. | **P3** | **M** | 6.3 | Provides a usable official-feed search surface. | | 6.4 | **DONE (2026-07-30)** — discovery UI searches recent active NAV events by title/company and municipality. | **P3** | **M** | 6.3 | Provides a usable official-feed search surface. |
| 6.5 | **DONE (2026-07-30)** — “Save to tracker” routes discoveries through the existing reviewed URL-import wizard and lands them in `Saved`. | **P3** | **S** | 6.3 | Reuses the established import and duplicate-warning flow. | | 6.5 | **DONE (2026-07-30)** — “Save to tracker” routes discoveries through the existing reviewed URL-import wizard and lands them in `Saved`. | **P3** | **S** | 6.3 | Reuses the established import and duplicate-warning flow. |
| 6.6 | **PARTIAL**`Job.CountryCode` and `Job.Source` exist, but the live `JobApplication` create flow does not yet dual-write `Job`, and import results do not carry market metadata. **Decided: Norway first, but no hardcoding Norway.** | **P2** | **M** | none | Complete this with the first official API so its provider market contract drives the write path. | | 6.6 | **DONE (2026-07-30)** — application creation dual-writes the `Job` opportunity; importer results carry provider source and market metadata, with NAV/FINN/Jobbnorge declaring `NO`. | **P2** | **M** | none | Norway-first provider data now drives the write path without making Norway a global default. |
| 6.7 | **Explicitly out of scope: scraping.** | — | — | — | Recorded so it is not re-proposed. Also out: auto-apply bots (ToS/ethics/quality; contradicts "apply to more *suitable* jobs"). | | 6.7 | **Explicitly out of scope: scraping.** | — | — | — | Recorded so it is not re-proposed. Also out: auto-apply bots (ToS/ethics/quality; contradicts "apply to more *suitable* jobs"). |
--- ---
@@ -130,6 +130,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
const [salaryCurrency, setSalaryCurrency] = useState(""); const [salaryCurrency, setSalaryCurrency] = useState("");
const [salaryPeriod, setSalaryPeriod] = useState(""); const [salaryPeriod, setSalaryPeriod] = useState("");
const [jobUrl, setJobUrl] = useState(""); const [jobUrl, setJobUrl] = useState("");
const [jobSource, setJobSource] = useState("");
const [countryCode, setCountryCode] = useState("");
const [deadline, setDeadline] = useState(""); const [deadline, setDeadline] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
@@ -168,7 +170,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
setNewCompanySource(""); setNewCompanySource("");
setDateApplied(getTodayIso()); setDateApplied(getTodayIso());
setJobTitle(""); setJobTitle("");
setStatus("Applied"); setStatus("Saved");
setLocation(""); setLocation("");
setSalary(""); setSalary("");
setSalaryMin(""); setSalaryMin("");
@@ -176,6 +178,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
setSalaryCurrency(""); setSalaryCurrency("");
setSalaryPeriod(""); setSalaryPeriod("");
setJobUrl(""); setJobUrl("");
setJobSource("");
setCountryCode("");
setDeadline(""); setDeadline("");
setDescription(""); setDescription("");
setTranslatedDescription(""); setTranslatedDescription("");
@@ -278,6 +282,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
} }
} }
setJobSource(r.source || "");
setCountryCode(r.countryCode || "");
setDescription(r.description || ""); setDescription(r.description || "");
setTranslatedDescription(r.translatedDescription || ""); setTranslatedDescription(r.translatedDescription || "");
setDescriptionLanguage(r.language || ""); setDescriptionLanguage(r.language || "");
@@ -333,6 +339,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
nextAction: null, nextAction: null,
followUpAt: null, followUpAt: null,
jobUrl, jobUrl,
source: jobSource || null,
countryCode: countryCode || null,
description: description || null, description: description || null,
translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null, translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null,
descriptionLanguage: descriptionLanguage || null, descriptionLanguage: descriptionLanguage || null,
+2
View File
@@ -493,6 +493,8 @@ export interface JobImportResult {
language?: string; language?: string;
tags: string[]; tags: string[];
sourceUrl: string; sourceUrl: string;
source?: string;
countryCode?: string;
deadline?: string; deadline?: string;
success: boolean; success: boolean;
parser?: string; parser?: string;