feat: persist job source and market
This commit is contained in:
@@ -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
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -493,6 +493,8 @@ export interface JobImportResult {
|
||||
language?: string;
|
||||
tags: string[];
|
||||
sourceUrl: string;
|
||||
source?: string;
|
||||
countryCode?: string;
|
||||
deadline?: string;
|
||||
success: boolean;
|
||||
parser?: string;
|
||||
|
||||
Reference in New Issue
Block a user