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,
JobUrl: null,
DateApplied: null,
FeedbackRequestedAt: null);
FeedbackRequestedAt: null,
Source: "NAV",
CountryCode: "no");
var result = await controller.Create(request, CancellationToken.None);
@@ -213,6 +215,11 @@ public sealed class JobApplicationsEndpointBehaviorTests
Assert.Equal("NOK", saved.SalaryCurrency);
Assert.Equal("year", saved.SalaryPeriod);
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]
@@ -102,7 +102,9 @@ namespace JobTrackerApi.Controllers
string? CoverLetterText,
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt
DateTime? FeedbackRequestedAt,
string? Source = null,
string? CountryCode = null
);
public sealed record UpdateJobApplicationRequest(
@@ -762,6 +762,28 @@ Canonical profile:
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
// must not carry an applied date. SyncAppliedDate also covers the reverse: a create
// 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) =
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.
try
{
var shortSum = await _summarizer.SummarizeAsync(BuildSummarySource(job), 160, 60);
job.ShortSummary = shortSum;
job.Job.ShortSummary = shortSum;
}
catch
{
@@ -12,6 +12,8 @@ public sealed record JobImportResult
public string? Language { get; init; } // ISO-ish, e.g. "en", "no"
public string[] Tags { get; init; } = Array.Empty<string>();
public string SourceUrl { get; init; } = "";
public string? Source { get; init; }
public string? CountryCode { get; init; }
public DateTime? Deadline { 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,
Description = HtmlExtract.ToPlainText(desc),
Parser = "finn",
Source = "finn",
CountryCode = "NO",
Success = !string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(desc),
};
}
@@ -18,6 +18,8 @@ public sealed class JobbnorgePlugin : IJobSitePlugin
Title = title,
Description = HtmlExtract.ToPlainText(desc),
Parser = "jobbnorge",
Source = "jobbnorge",
CountryCode = "NO",
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,
Description = HtmlExtract.ToPlainText(desc),
Parser = "linkedin",
Source = "linkedin",
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.
Description = HtmlExtract.ToPlainText(desc),
Parser = "nav",
Source = "nav",
CountryCode = "NO",
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
/// - 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:
/// <see cref="JobApplication"/> still carries its own copy of these columns and remains the
/// 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.
/// Introduced in Phase 0 (2026-07-17). New applications dual-write this opportunity row while
/// <see cref="JobApplication"/> remains the read source during the incremental cutover.
///
/// See docs/decisions/ADR-002-job-application-model.md.
/// </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.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.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. |
@@ -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.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.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"). |
---
@@ -130,6 +130,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
const [salaryCurrency, setSalaryCurrency] = useState("");
const [salaryPeriod, setSalaryPeriod] = useState("");
const [jobUrl, setJobUrl] = useState("");
const [jobSource, setJobSource] = useState("");
const [countryCode, setCountryCode] = useState("");
const [deadline, setDeadline] = useState("");
const [description, setDescription] = useState("");
@@ -168,7 +170,7 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
setNewCompanySource("");
setDateApplied(getTodayIso());
setJobTitle("");
setStatus("Applied");
setStatus("Saved");
setLocation("");
setSalary("");
setSalaryMin("");
@@ -176,6 +178,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
setSalaryCurrency("");
setSalaryPeriod("");
setJobUrl("");
setJobSource("");
setCountryCode("");
setDeadline("");
setDescription("");
setTranslatedDescription("");
@@ -278,6 +282,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
}
}
setJobSource(r.source || "");
setCountryCode(r.countryCode || "");
setDescription(r.description || "");
setTranslatedDescription(r.translatedDescription || "");
setDescriptionLanguage(r.language || "");
@@ -333,6 +339,8 @@ export default function AddJobModal({ open, onClose, onCreated, initialUrl }: Pr
nextAction: null,
followUpAt: null,
jobUrl,
source: jobSource || null,
countryCode: countryCode || null,
description: description || null,
translatedDescription: shouldShowTranslatedDescription ? translatedDescription || null : null,
descriptionLanguage: descriptionLanguage || null,
+2
View File
@@ -493,6 +493,8 @@ export interface JobImportResult {
language?: string;
tags: string[];
sourceUrl: string;
source?: string;
countryCode?: string;
deadline?: string;
success: boolean;
parser?: string;