diff --git a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs index b55eb66..5020edb 100644 --- a/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs +++ b/JobTrackerApi.Tests/JobApplicationsEndpointBehaviorTests.cs @@ -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] diff --git a/JobTrackerApi/Controllers/JobApplicationDtos.cs b/JobTrackerApi/Controllers/JobApplicationDtos.cs index 777d679..9626be8 100644 --- a/JobTrackerApi/Controllers/JobApplicationDtos.cs +++ b/JobTrackerApi/Controllers/JobApplicationDtos.cs @@ -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( diff --git a/JobTrackerApi/Controllers/JobApplicationsController.cs b/JobTrackerApi/Controllers/JobApplicationsController.cs index c96a48c..8641ea9 100644 --- a/JobTrackerApi/Controllers/JobApplicationsController.cs +++ b/JobTrackerApi/Controllers/JobApplicationsController.cs @@ -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 { diff --git a/JobTrackerApi/Services/JobImport/JobImportResult.cs b/JobTrackerApi/Services/JobImport/JobImportResult.cs index 7bc99ef..51ec325 100644 --- a/JobTrackerApi/Services/JobImport/JobImportResult.cs +++ b/JobTrackerApi/Services/JobImport/JobImportResult.cs @@ -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(); 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; } diff --git a/JobTrackerApi/Services/JobImport/Plugins/FinnPlugin.cs b/JobTrackerApi/Services/JobImport/Plugins/FinnPlugin.cs index 2b06a91..7cdf710 100644 --- a/JobTrackerApi/Services/JobImport/Plugins/FinnPlugin.cs +++ b/JobTrackerApi/Services/JobImport/Plugins/FinnPlugin.cs @@ -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), }; } diff --git a/JobTrackerApi/Services/JobImport/Plugins/JobbnorgePlugin.cs b/JobTrackerApi/Services/JobImport/Plugins/JobbnorgePlugin.cs index 9bbea75..880b375 100644 --- a/JobTrackerApi/Services/JobImport/Plugins/JobbnorgePlugin.cs +++ b/JobTrackerApi/Services/JobImport/Plugins/JobbnorgePlugin.cs @@ -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), }; } diff --git a/JobTrackerApi/Services/JobImport/Plugins/LinkedInPlugin.cs b/JobTrackerApi/Services/JobImport/Plugins/LinkedInPlugin.cs index f2f7816..534b2c3 100644 --- a/JobTrackerApi/Services/JobImport/Plugins/LinkedInPlugin.cs +++ b/JobTrackerApi/Services/JobImport/Plugins/LinkedInPlugin.cs @@ -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), }; } diff --git a/JobTrackerApi/Services/JobImport/Plugins/NavPlugin.cs b/JobTrackerApi/Services/JobImport/Plugins/NavPlugin.cs index 8473518..c18ea0a 100644 --- a/JobTrackerApi/Services/JobImport/Plugins/NavPlugin.cs +++ b/JobTrackerApi/Services/JobImport/Plugins/NavPlugin.cs @@ -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), }; } diff --git a/Models/Job.cs b/Models/Job.cs index 4334402..30ddd2e 100644 --- a/Models/Job.cs +++ b/Models/Job.cs @@ -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: - /// 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 + /// remains the read source during the incremental cutover. /// /// See docs/decisions/ADR-002-job-application-model.md. /// diff --git a/docs/implementation-roadmap.md b/docs/implementation-roadmap.md index 3f1c4ff..4dc7a21 100644 --- a/docs/implementation-roadmap.md +++ b/docs/implementation-roadmap.md @@ -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"). | --- diff --git a/job-tracker-ui/src/components/AddJobModal.tsx b/job-tracker-ui/src/components/AddJobModal.tsx index 15df2b1..1c603ff 100644 --- a/job-tracker-ui/src/components/AddJobModal.tsx +++ b/job-tracker-ui/src/components/AddJobModal.tsx @@ -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, diff --git a/job-tracker-ui/src/types.ts b/job-tracker-ui/src/types.ts index 9955dfa..b95cb5e 100644 --- a/job-tracker-ui/src/types.ts +++ b/job-tracker-ui/src/types.ts @@ -493,6 +493,8 @@ export interface JobImportResult { language?: string; tags: string[]; sourceUrl: string; + source?: string; + countryCode?: string; deadline?: string; success: boolean; parser?: string;