feat: persist job source and market
This commit is contained in:
@@ -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
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user