Compare commits

...

31 Commits

Author SHA1 Message Date
cesnimda de937d25dc fix(db): repair Jobs identity
CI and Deploy / test (pull_request) Successful in 4m40s
CI and Deploy / deploy (pull_request) Has been skipped
CI and Deploy / test (push) Successful in 4m11s
CI and Deploy / deploy (push) Successful in 1m36s
Production's SQLite-shaped Jobs table lacks AUTO_INCREMENT, causing the legacy opportunity backfill to abort startup. Reuse the existing idempotent MariaDB primary-key repair before backfill runs.
2026-08-02 19:22:23 +02:00
cesnimda 06518a7d52 fix: require verified production email
CI and Deploy / test (pull_request) Successful in 4m42s
CI and Deploy / deploy (pull_request) Has been skipped
CI and Deploy / test (push) Successful in 4m2s
CI and Deploy / deploy (push) Failing after 1m15s
Expose the existing email-verification setting through Compose and close the completed DataProtection rotation blocker.
2026-08-02 17:59:05 +02:00
cesnimda 955182b7c2 fix: close release preflight gaps
CI and Deploy / test (pull_request) Failing after 1m22s
CI and Deploy / deploy (pull_request) Has been skipped
Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
2026-07-31 20:18:30 +02:00
cesnimda ce76046a29 feat: complete release readiness work
- consolidate API ownership and remove dead vendor code

- add Stripe billing, learning paths, and public CV hardening

- add migration, recovery, security, audit, and browser gates
2026-07-31 16:54:16 +02:00
cesnimda a23c3dfc97 docs: record remaining product decisions
CI and Deploy / test (push) Successful in 2m34s
CI and Deploy / deploy (push) Successful in 28s
2026-07-31 00:18:32 +02:00
cesnimda 6ed56fd493 fix: localize usage and salary insights
CI and Deploy / test (push) Successful in 2m40s
CI and Deploy / deploy (push) Successful in 56s
2026-07-31 00:17:33 +02:00
cesnimda 235d22c059 feat: add salary insights
CI and Deploy / test (push) Successful in 2m41s
CI and Deploy / deploy (push) Successful in 58s
2026-07-31 00:15:38 +02:00
cesnimda af3cf1cdaa fix: keep opportunity data synchronized
CI and Deploy / test (push) Successful in 2m46s
CI and Deploy / deploy (push) Successful in 54s
2026-07-31 00:12:10 +02:00
cesnimda 10d548f799 feat: show account storage usage
CI and Deploy / test (push) Successful in 2m37s
CI and Deploy / deploy (push) Successful in 58s
2026-07-31 00:10:15 +02:00
cesnimda 9f16a5675a feat: show account AI usage
CI and Deploy / test (push) Successful in 2m40s
CI and Deploy / deploy (push) Successful in 50s
2026-07-31 00:08:41 +02:00
cesnimda 2126a2db5c fix: reconcile orphan migration history
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Successful in 55s
2026-07-30 23:51:08 +02:00
cesnimda 988a91a151 feat: persist job source and market
CI and Deploy / test (push) Successful in 2m34s
CI and Deploy / deploy (push) Successful in 56s
2026-07-30 23:38:22 +02:00
cesnimda efc9d83c25 fix: finish prospect workflow safeguards
CI and Deploy / test (push) Successful in 2m37s
CI and Deploy / deploy (push) Successful in 54s
2026-07-30 23:34:42 +02:00
cesnimda 792814b04e feat: cap per-user AI token spend
CI and Deploy / test (push) Successful in 2m43s
CI and Deploy / deploy (push) Successful in 57s
2026-07-30 23:32:13 +02:00
cesnimda 09fc2b03f7 feat: gate premium CV themes
CI and Deploy / test (push) Successful in 2m35s
CI and Deploy / deploy (push) Successful in 1m1s
2026-07-30 23:24:19 +02:00
cesnimda fd2c60e70f feat: complete public CV sharing
CI and Deploy / test (push) Successful in 2m34s
CI and Deploy / deploy (push) Successful in 1m0s
2026-07-30 23:19:17 +02:00
cesnimda 158970fa01 feat: enforce account usage limits
CI and Deploy / test (push) Successful in 2m39s
CI and Deploy / deploy (push) Successful in 55s
2026-07-30 23:14:42 +02:00
cesnimda 9cd2e5c2e3 feat: protect auth with Turnstile
CI and Deploy / test (push) Successful in 2m41s
CI and Deploy / deploy (push) Successful in 59s
2026-07-30 23:08:36 +02:00
cesnimda 6382e83e28 ci: report dependency vulnerabilities
CI and Deploy / test (push) Successful in 2m34s
CI and Deploy / deploy (push) Successful in 29s
2026-07-30 22:58:59 +02:00
cesnimda 405e6d833c feat: add NAV job discovery
CI and Deploy / test (push) Successful in 2m27s
CI and Deploy / deploy (push) Successful in 1m0s
2026-07-30 22:57:17 +02:00
cesnimda 7fab996407 docs: reconcile phase 6 status 2026-07-30 22:42:49 +02:00
cesnimda c08232b9d7 feat: meter AI usage
CI and Deploy / test (push) Failing after 58s
CI and Deploy / deploy (push) Has been skipped
2026-07-30 22:39:24 +02:00
cesnimda f8466c2ebc feat: advance phase 5 AI workflow 2026-07-30 22:27:10 +02:00
cesnimda e4acfbd0bf refactor: complete phase 4 builder cleanup 2026-07-30 22:23:55 +02:00
cesnimda 4cf26405f6 feat: complete phase 3 career workspace
CI and Deploy / test (push) Failing after 1m6s
CI and Deploy / deploy (push) Has been skipped
2026-07-30 22:19:13 +02:00
cesnimda f8a7cf5205 fix(deploy): repair backup directory permissions
CI and Deploy / test (push) Successful in 2m24s
CI and Deploy / deploy (push) Successful in 2m14s
2026-07-30 21:42:51 +02:00
cesnimda 56fed05d70 feat: complete phase 2 UX improvements
CI and Deploy / test (push) Successful in 2m42s
CI and Deploy / deploy (push) Failing after 9s
2026-07-30 21:35:52 +02:00
cesnimda 173187dcbb feat(cv): Phase 2.1-b — extract Projects, Certifications, and languages-from-prose
The structured model and StructuredCvProfileJson.FromSections already map
Projects/Certifications/Languages headings, but the AI normalize prompt
never emitted them, so on the benchmark CV the entire Projects section and
the in-summary languages (English Native, Norwegian B1) were silently
dropped. This closes that gap upstream — no backend schema or data change.

ai-service (tools/summarizer/app.py):
- /cv/normalize: added # Projects and # Certifications headings; a
  languages-from-prose rule (pull "native English", "Norwegian at B1" out
  of the summary even with no Languages section; ignore programming
  languages); and skill-group prefix stripping ("Development:",
  "DevOps & Infrastructure:", "Practices:" dropped, only the skills kept).
- /cv/classify-block: Projects and Certifications added to the section
  enum + rules (fallback path).

Backend:
- LooksLikeNormalizedMarkdownCv now recognises # Projects / # Certifications
  so those CVs still take the markdown assembly path.

Tests:
- CvExtractionCoverageTests (4) lock the C# mapping of Projects,
  Certifications and Languages sections into the structured profile.
- ai-service test_classify_block_supports_projects_section (1).
426 backend tests, 17 ai-service tests pass; app.py compiles.

The LLM behaviour (prompt -> headings) needs Ollama to observe and was not
run here; the C# side that consumes the headings is proven and the prompt
change is additive. Merge-not-replace + the review screen are the next
increment (2.1-a, approved: always-review, conservative merge).

Deployment: these prompts live in the ai-service container, which
deploy.sh does not rebuild by default -- deploy with
DEPLOY_BUILD_AI_SERVICE=true or the change won't take effect.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:40:04 +02:00
cesnimda fe9cd4dda1 refactor(career): Phase 1 increment 2 — extract editor sections, hide duplicate CV concepts
UI-only. No change to APIs, save payloads, extraction behaviour, or data
models. The parent CareerProfilePage still owns loading, state, saving,
and all extraction/import actions; the new sections are presentational
(value + onChange, plus a getMetadata callback for review chips).

Extracted into src/views/career/CareerProfileSections.tsx:
PersonalInformation, ProfessionalSummary, Skills, Interests, Languages,
WorkExperience, Education, OtherSections. FieldReviewNote + confidenceTone
moved there verbatim and shared with the parent. CareerProfilePage went
from 1376 to ~1200 lines.

No Projects/Certifications sections were created -- the editor never had
them (they are not editable structured fields here). Inventing them would
add functionality, which this refactor avoids; noted for a product
decision later.

Hid the duplicate CV concepts behind an "Advanced CV tools" toggle,
collapsed by default: the CV Structure Overview parse block and the
Template-driven CV builder. Both stay mounted and functional (gated with
display:none), so no tested functionality is removed -- the real CV
Builder at /career/builder is the single generation surface. Future
removal plan documented.

Tests: added "editing a field in an extracted section updates parent
state and flows into save" (render -> edit -> PUT /career/profile
{profile,cvText}); existing parse/rewrite tests reveal the advanced tools
first. The increment-1 save-invariant test still pins the payload.

Verified: tsc clean, production build clean, 137 frontend tests pass.
Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:00:26 +02:00
cesnimda 63473bae85 refactor(career): Phase 1 increment — user-facing terminology + component split
UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.

Terminology -> user-facing (i18n strings):
- "Structured CV editor"     -> "Career information"
- "CV structure overview"    -> "Profile sections"
- "Summary bullets"          -> "Professional summary"
- "Core skills"              -> "Skills"
- "Analyze sections"         -> "Read sections"
- "Original extraction"      -> "Original import"
- hardcoded "Master career profile" -> "Career profile"
Help text de-jargoned; the Career information help now frames it as the
source the CV Builder consumes.

Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.

Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.

Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.

The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:50:52 +02:00
cesnimda 4f69d395be fix(nav): only the most-specific sidebar item is active
On /career/builder/{id} both "Career Workspace" (/career) and "CV Builder"
(/career/builder) highlighted, because AppShell tested each item with
`pathname === to || pathname.startsWith(to + "/")` — so /career matched
every /career/... child. No "most specific wins" rule.

Add AppShell.activeNavTo(pathname, tos): the longest `to` that the path is
at or under wins, across both nav lists; every other item is inactive. A
child route never lights up a parent nav item. `selected` now compares
against that single computed activeTo. Exported as a pure function so the
ownership rule is unit-tested directly (sidebar-active-nav.test.ts):
exactly one active item for /career, /career/builder and
/career/builder/{id}, and no double-highlight.

Also give the breadcrumb/title in App.tsx explicit /career/builder ->
"CV Builder" ownership (it previously showed "Career Workspace"), and
reframe the Career Workspace header to the "Career Profile" product
framing: "This information powers your CVs, applications, cover letters
and AI assistance."

Frontend only — no change to CareerProfiles, CvVariants, CV generation,
extraction APIs, AI, permissions or tenant isolation. Plan for the deeper
information-architecture work is in docs/career-workspace-ux-refactor.md,
staged so the 1376-line CareerProfilePage and the live CV/extraction
pipeline are refactored incrementally with verification, not in one risky
rewrite.

Verified: tsc clean, frontend build clean, 135 frontend tests pass
(128 + 7 new nav tests). Backend untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 02:29:50 +02:00
1709 changed files with 15159 additions and 149461 deletions
+11 -1
View File
@@ -16,6 +16,16 @@ JOBTRACKER_CONNECTION_STRING=
AUTH_JWT_KEY=CHANGE_ME_LONG_RANDOM_SECRET
AUTH_ADMIN_EMAIL=admin@example.com
AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# Public signup remains closed until explicitly enabled. Configure both Turnstile keys first.
AUTH_ALLOW_REGISTRATION=false
# Require local accounts to confirm ownership of their email address before signing in.
AUTH_REQUIRE_EMAIL_VERIFICATION=true
TURNSTILE_SITE_KEY=
TURNSTILE_SECRET_KEY=
# Optional hosted Stripe Checkout. Configure all three values and the customer portal before enabling billing.
STRIPE_SECRET_KEY=
STRIPE_PRICE_PREMIUM=
STRIPE_WEBHOOK_SECRET=
AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID
# Optional: enables the "Continue with Microsoft" sign-in tab (separate from the
# MICROSOFT_CLIENT_ID below, which is for Outlook mail linking, not sign-in).
@@ -49,7 +59,7 @@ GROQ_MODEL=llama-3.3-70b-versatile
# Optional: only needed if you want the UI to call a non-default API base URL.
# In production the UI defaults to `/api`.
REACT_APP_API_BASE_URL=
NEXT_PUBLIC_API_BASE_URL=
# Used by docker-compose.yml (email / password resets / notifications)
APP_PUBLIC_BASE_URL=https://jobs.cesnimda.uk
+17
View File
@@ -59,6 +59,9 @@ jobs:
# specific to this runner. This one-test smoke separates "the test host cannot start at all"
# from "something in the suite takes the host down"; the log is not readable via the API, so the
# step boundary is the signal.
- name: Audit backend dependencies
run: dotnet list JobTrackerApi/JobTrackerApi.csproj package --vulnerable --include-transitive
- name: Test backend (host smoke)
run: dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --configuration Release --no-build --filter "FullyQualifiedName~CvBuilderTests.Every_catalog_theme_renders_valid_html"
@@ -84,12 +87,26 @@ jobs:
&& rm -rf node_modules \
&& npm ci --no-audit --no-fund )
# Moderate React Router advisories remain documented and mitigated; high/critical findings
# in either production or test/browser tooling block the build.
- name: Audit frontend dependencies
working-directory: job-tracker-ui
run: npm audit --audit-level=high
- name: Test frontend
working-directory: job-tracker-ui
# Run the WHOLE suite. Never whitelist test files here again: the previous
# whitelist silently skipped new suites and let two regressions reach main.
run: npm test -- --watchAll=false --runInBand
- name: Install browser smoke runtime
working-directory: job-tracker-ui
run: npx playwright install --with-deps chromium
- name: Test browser smoke flows
working-directory: job-tracker-ui
run: npm run test:e2e
- name: Build frontend
working-directory: job-tracker-ui
env:
+2
View File
@@ -36,6 +36,8 @@ node_modules/
build/
dist/
coverage/
playwright-report/
test-results/
.next/
.cache/
+51
View File
@@ -0,0 +1,51 @@
# Blockers
Updated: 2026-07-31
## Stripe billing
- **Blocked:** Activating roadmap item 7.5 in production.
- **Why:** Hosted Checkout, customer-portal sessions, signed subscription webhooks, persisted billing state, and Premium-role provisioning are implemented. The Stripe product, recurring price, portal, webhook registration, and production credentials must be created outside the repository.
- **Required:** Configure the Premium recurring price, enable the Stripe customer portal, register `/api/billing/webhook` for `customer.subscription.created`, `customer.subscription.updated`, and `customer.subscription.deleted`, then supply `STRIPE_SECRET_KEY`, `STRIPE_PRICE_PREMIUM`, and `STRIPE_WEBHOOK_SECRET` through the deployment environment. Do not place secret values in source control or chat.
- **Recommended:** One monthly Premium price first; add annual billing only after the monthly flow is operating.
- **Current access check:** Production has test-mode secret and webhook values, but `STRIPE_PRICE_PREMIUM` currently contains a `prod_...` Product ID. Checkout requires the recurring `price_...` Price ID. The publishable key is not used by hosted Checkout.
- **Runbook:** Follow `docs/operations/stripe-activation.md`, completing test mode before creating or installing live-mode values.
## Public registration verification
- **Blocked:** Completing a real-browser production signup check.
- **Why:** The 2026-07-31 anonymous production check confirms `allowRegistration=true`, `turnstileEnabled=true`, and Google sign-in enabled. Completing Turnstile and creating a disposable account requires an interactive production browser session.
- **Required:** Register one disposable account through Turnstile, verify email/sign-in/rate-limit behavior, then remove the account if it is not needed.
- **Recommended:** Monitor Turnstile and rate-limit failures during the first public rollout; keep email verification required.
- **Current status:** Production returns `allowRegistration=true`, `turnstileEnabled=true`, `googleEnabled=true`, and `microsoftEnabled=false`. A registration request without a Turnstile token is rejected with HTTP 400. SMTP is configured and enabled. The release branch now maps `AUTH_REQUIRE_EMAIL_VERIFICATION`; production must set it to `true` before the interactive signup test.
## CI runner verification
- **Blocked:** Proving that the current release gate completes on the self-hosted runner.
- **Why:** The workflow now runs the complete backend, frontend, dependency-audit, browser, and production-build checks, but historical runner failures were intermittent and the current working tree has not been submitted to remote CI. Local success cannot prove runner health.
- **Required:** Submit the reviewed changes and run the Gitea workflow. If it still fails early, inspect the job log and `journalctl -u act_runner`/runner resources on the host.
- **Recommended:** Keep the full gate intact; fix the runner instead of skipping or filtering tests.
- **Current status:** The `release-readiness` branch is pushed to origin. Creating the pull request at `https://git.cesnimda.uk/cesnimda/jobtrackingapp/pulls/new/release-readiness` still requires an authenticated Gitea browser or CLI session; neither is available in this workspace.
## React Router security release
- **Blocked:** Clearing the final two moderate React Router package findings without introducing a higher-severity advisory.
- **Why:** The reported paths affect redirects and SSR hydration. This application uses declarative `BrowserRouter` (not SSR/RSC), and post-login redirects reject protocol-relative and backslash paths. The redirect-fixed React Router 7.18.2 release is itself covered by a high-severity RSC advisory; npm's suggested high-severity fix downgrades to a release that reintroduces the moderate redirect findings. No published version clears both sets.
- **Required:** Upgrade React Router when a release clears both the redirect/SSR findings and the RSC advisory, then rerun Jest, production build, and Playwright.
- **Recommended:** Keep 6.30.3 plus the explicit redirect allowlist until that release; do not force an audit-driven major downgrade/upgrade that leaves tests unable to load.
## Production verification and deployment
- **Blocked:** Authenticated production smoke tests, backup restore verification against real data, OAuth-provider checks, and deployment.
- **Why:** These require production access, real credentials, and operator authorization.
- **Required:** Follow `docs/release-candidate-review.md` and `docs/release-checklist.md` on the production host.
- **Recommended:** Verify backup/restore before deployment, then exercise login, existing application counts, Career Workspace, public CV refresh/download, AI, and attachments in order.
- **Current access check:** Read-only SSH access is confirmed to the LAN production host as both `root` and `pi` using the existing `id_ed25519` identity. All four containers are healthy and the host has 44 GB free. No production change or deployment was attempted.
- **Current status:** Anonymous production checks confirm the frontend and `/api/auth/config` return HTTP 200. The public `/health` path currently returns the SPA HTML shell; the release branch now proxies that exact path to the backend and includes a regression test.
## Legacy job/application column cutover
- **Blocked:** Removing the opportunity columns duplicated between `JobApplication` and `Job`.
- **Why:** The compatibility dual-write protects existing production rows and older clients. The release branch now backfills missing opportunities on startup, synchronizes both creation paths, and leaves all legacy columns intact. Dropping columns still requires production validation and an observation release.
- **Required:** After deployment, run the read-only report in `docs/operations/job-opportunity-cutover.md` against production and a restored backup, then confirm that backward API compatibility is no longer required.
- **Recommended:** Use an expand/contract release: first stop legacy reads after a verified backfill, observe one release, then drop the duplicate columns in the following migration.
-14
View File
@@ -7,8 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi", "JobTracker
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi.Tests", "JobTrackerApi.Tests\JobTrackerApi.Tests.csproj", "{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerBackend", "JobTrackerBackend\JobTrackerBackend.csproj", "{709F069F-DD13-42CC-9C5E-99923A545790}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -43,18 +41,6 @@ Global
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x64.Build.0 = Release|Any CPU
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.ActiveCfg = Release|Any CPU
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+31
View File
@@ -0,0 +1,31 @@
using JobTrackerApi.Models;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AccountPlansTests
{
[Theory]
[InlineData("active", true)]
[InlineData("trialing", true)]
[InlineData("past_due", false)]
[InlineData("canceled", false)]
[InlineData(null, false)]
public void Premium_subscription_status_requires_current_access(string? status, bool expected)
{
Assert.Equal(expected, AccountPlans.IsPremiumSubscriptionStatus(status));
}
[Fact]
public void Premium_role_receives_higher_cost_capabilities()
{
var free = AccountPlans.ForRoles(Array.Empty<string>());
var premium = AccountPlans.ForRoles(new[] { "Premium" });
Assert.False(free.AdvancedAi);
Assert.True(premium.AdvancedAi);
Assert.True(premium.MonthlyAiCalls > free.MonthlyAiCalls);
Assert.True(premium.MonthlyAiTokens > free.MonthlyAiTokens);
Assert.True(premium.StorageBytes > free.StorageBytes);
}
}
+3
View File
@@ -66,6 +66,9 @@ public sealed class AiWorkspaceTests
Assert.Equal("job-analysis", res!.Module);
Assert.Equal("gemini", res.Provider);
Assert.Contains("Generated suggestion", res.ResultJson);
Assert.True(res.InputCharacterCount > 0);
Assert.Equal("## Result\nGenerated suggestion.".Length, res.OutputCharacterCount);
Assert.Equal((res.InputCharacterCount + res.OutputCharacterCount + 3) / 4, res.EstimatedTokenCount);
Assert.Single(await db.AiInteractions.IgnoreQueryFilters().Where(x => x.JobApplicationId == jobId).ToListAsync());
}
@@ -0,0 +1,31 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AnalyticsSalaryTests
{
[Fact]
public async Task Salary_insights_keep_currency_and_period_groups_separate()
{
await using var db = TestHostFactory.CreateInMemoryDb("user-1");
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
db.JobApplications.AddRange(
new JobApplication { OwnerUserId = "user-1", Company = company, JobTitle = "A", Status = "Applied", SalaryMin = 600_000, SalaryMax = 800_000, SalaryCurrency = "NOK", SalaryPeriod = "year" },
new JobApplication { OwnerUserId = "user-1", Company = company, JobTitle = "B", Status = "Applied", SalaryMin = 700_000, SalaryMax = 900_000, SalaryCurrency = "NOK", SalaryPeriod = "year" },
new JobApplication { OwnerUserId = "user-1", Company = company, JobTitle = "C", Status = "Applied", SalaryMin = 50, SalaryMax = 70, SalaryCurrency = "EUR", SalaryPeriod = "hour" });
await db.SaveChangesAsync();
var result = await new AnalyticsService(db).GetAnalyticsOverviewAsync(default);
Assert.Equal(2, result.SalaryInsights.Count);
var nok = Assert.Single(result.SalaryInsights, x => x.Currency == "NOK" && x.Period == "year");
Assert.Equal(2, nok.Count);
Assert.Equal(600_000, nok.Minimum);
Assert.Equal(900_000, nok.Maximum);
Assert.Equal(750_000, nok.AverageMidpoint);
}
}
@@ -228,6 +228,25 @@ public sealed class ApplicationChecklistTests
Assert.Equal(custom!.Id, next!.Id);
}
[Fact]
public async Task Learning_recommendations_preserve_user_decisions_and_reopen_only_auto_completed_items()
{
var (db, svc) = New("user-1");
await using var _ = db;
var job = await SeedAsync(db, "user-1");
var first = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
var kubernetes = first.Single(item => item.Keyword == "Kubernetes");
await svc.UpdateAsync("user-1", job.Id, kubernetes.Id,
new ChecklistItemInput(null, null, null, ChecklistStatuses.Done, null), default);
await svc.SyncLearningRecommendationsAsync("user-1", job.Id, [], default);
var reopened = await svc.SyncLearningRecommendationsAsync("user-1", job.Id, ["Kubernetes", "AWS"], default);
Assert.Equal(ChecklistStatuses.Done, reopened.Single(item => item.Keyword == "Kubernetes").Status);
Assert.Equal(ChecklistStatuses.Pending, reopened.Single(item => item.Keyword == "AWS").Status);
}
[Fact]
public async Task Interview_prep_is_only_outstanding_at_the_interview_stage()
{
@@ -252,6 +271,7 @@ public sealed class ApplicationChecklistTests
Assert.Null(await svc.GetAsync("user-1", other.Id, default));
Assert.Null(await svc.AddAsync("user-1", other.Id, new ChecklistItemInput("Sneak", null, null, null, null), default));
Assert.False(await svc.DeleteAsync("user-1", other.Id, 1, default));
Assert.Empty(await svc.SyncLearningRecommendationsAsync("user-1", other.Id, ["Kubernetes"], default));
}
[Fact]
@@ -724,6 +724,24 @@ public sealed class AuthAndSystemControllerTests
summarizer.Verify(x => x.RunProbeAsync(It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Login_rejects_missing_turnstile_token_when_configured()
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Turnstile:SiteKey"] = "site-key",
["Turnstile:SecretKey"] = "secret-key",
}).Build();
var users = CreateUserManager();
var controller = new AuthController(config, users.Object, Mock.Of<ITokenService>(), Mock.Of<IAppEmailSender>(), Mock.Of<IGoogleTokenValidator>(), Mock.Of<IMicrosoftTokenValidator>(), NullLogger<AuthController>.Instance, Mock.Of<ITwoFactorPendingTokenService>(), TestHostFactory.CreateInMemoryDb(), httpClients: Mock.Of<IHttpClientFactory>());
var result = await controller.Login(new AuthController.LoginRequest("person@example.com", "password"), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal("Security verification failed. Please try again.", badRequest.Value);
users.Verify(x => x.FindByEmailAsync(It.IsAny<string>()), Times.Never);
}
private static IConfiguration BuildConfig()
{
return new ConfigurationBuilder()
+26
View File
@@ -0,0 +1,26 @@
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class AvatarStorageTests
{
[Fact]
public async Task Store_resolve_delete_round_trip_keeps_image_out_of_database_value()
{
var root = Path.Combine(Path.GetTempPath(), "jobtracker-avatar-test-" + Guid.NewGuid().ToString("N"));
try
{
var stored = await AvatarStorage.StoreAsync(root, "user-1", new byte[] { 1, 2, 3 }, "image/png", default);
Assert.StartsWith("file:", stored);
Assert.Equal("data:image/png;base64,AQID", AvatarStorage.Resolve(stored));
AvatarStorage.Delete(stored);
Assert.Null(AvatarStorage.Resolve(stored));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
}
@@ -0,0 +1,49 @@
using System.Text;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class BillingControllerTests
{
[Fact]
public async Task Webhook_rejects_an_invalid_Stripe_signature()
{
var configuration = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?>
{
["Stripe:SecretKey"] = "sk_test_fake",
["Stripe:PricePremium"] = "price_fake",
["Stripe:WebhookSecret"] = "whsec_fake",
["App:PublicBaseUrl"] = "https://example.test",
}).Build();
var userStore = new Mock<IUserStore<ApplicationUser>>();
var users = new Mock<UserManager<ApplicationUser>>(
userStore.Object, Options.Create(new IdentityOptions()), new PasswordHasher<ApplicationUser>(),
Array.Empty<IUserValidator<ApplicationUser>>(), Array.Empty<IPasswordValidator<ApplicationUser>>(),
new UpperInvariantLookupNormalizer(), new IdentityErrorDescriber(), null!, NullLogger<UserManager<ApplicationUser>>.Instance);
var roleStore = new Mock<IRoleStore<IdentityRole>>();
var roles = new Mock<RoleManager<IdentityRole>>(
roleStore.Object, Array.Empty<IRoleValidator<IdentityRole>>(), new UpperInvariantLookupNormalizer(),
new IdentityErrorDescriber(), NullLogger<RoleManager<IdentityRole>>.Instance);
var controller = new BillingController(configuration, users.Object, roles.Object, NullLogger<BillingController>.Instance)
{
ControllerContext = new ControllerContext { HttpContext = new DefaultHttpContext() },
};
controller.Request.Body = new MemoryStream(Encoding.UTF8.GetBytes("{}"));
controller.Request.Headers["Stripe-Signature"] = "invalid";
var result = await controller.Webhook(CancellationToken.None);
Assert.IsType<BadRequestObjectResult>(result);
}
}
@@ -106,6 +106,10 @@ public sealed class CareerProfileServiceTests
Contact = { FullName = "Ada Lovelace", Email = "ada@example.com", Headline = "Engineer" },
Summary = { "First", "Second" },
Interests = { "Chess" },
Awards = { "Engineering prize" },
Publications = { "Reliable systems" },
Organisations = { "ACM" },
References = { "Available on request" },
Jobs =
{
new StructuredCvJob { Title = "Senior Eng", Company = "Acme", Start = "Jan 2020", End = "Present", IsCurrent = true, Bullets = { "Built X" }, Skills = { "C#" } },
@@ -130,6 +134,10 @@ public sealed class CareerProfileServiceTests
Assert.Equal("Ada Lovelace", loaded.Contact.FullName);
Assert.Equal(new[] { "First", "Second" }, loaded.Summary);
Assert.Equal(new[] { "Chess" }, loaded.Interests);
Assert.Equal(new[] { "Engineering prize" }, loaded.Awards);
Assert.Equal(new[] { "Reliable systems" }, loaded.Publications);
Assert.Equal(new[] { "ACM" }, loaded.Organisations);
Assert.Equal(new[] { "Available on request" }, loaded.References);
Assert.Equal(2, loaded.Jobs.Count);
Assert.Equal("Senior Eng", loaded.Jobs[0].Title); // order preserved
Assert.True(loaded.Jobs[0].IsCurrent);
+26
View File
@@ -280,4 +280,30 @@ public sealed class CvBuilderTests
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
Assert.Null(await svc.GetPublicOwnerAsync(v.PublicSlug, default));
}
[Fact]
public async Task Re_enabling_public_access_rotates_the_revoked_link()
{
var (db, svc) = NewService("user-1");
await using var _ = db;
var v = await svc.CreateAsync("user-1", "CV", null, null, default);
await svc.SetPublicAsync("user-1", v.Id, true, default);
var revokedSlug = v.PublicSlug;
await svc.SetPublicAsync("user-1", v.Id, false, default);
await svc.SetPublicAsync("user-1", v.Id, true, default);
Assert.NotEqual(revokedSlug, v.PublicSlug);
Assert.Null(await svc.GetPublicOwnerAsync(revokedSlug, default));
Assert.Equal("user-1", await svc.GetPublicOwnerAsync(v.PublicSlug, default));
}
[Fact]
public void Premium_theme_policy_keeps_three_free_themes_available()
{
Assert.Equal(3, CvThemeCatalog.Themes.Count(t => CvThemeCatalog.CanUse(t.Id, false)));
Assert.Equal(5, CvThemeCatalog.Themes.Count(t => t.Premium));
Assert.All(CvThemeCatalog.Themes, t => Assert.True(CvThemeCatalog.CanUse(t.Id, true)));
Assert.False(CvThemeCatalog.CanUse("unknown", true));
}
}
@@ -0,0 +1,44 @@
using System.Reflection;
using JobTrackerApi.Services;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging.Abstractions;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CvExportRetentionTests
{
[Fact]
public void Export_pruning_removes_only_expired_date_directories()
{
var root = Path.Combine(Path.GetTempPath(), $"jobtracker-export-retention-{Guid.NewGuid():N}");
var exportsRoot = Path.Combine(root, "CvExports");
var old = Path.Combine(exportsRoot, "20260101");
var keep = Path.Combine(exportsRoot, "20260731");
var unrelated = Path.Combine(exportsRoot, "manual");
Directory.CreateDirectory(old);
Directory.CreateDirectory(keep);
Directory.CreateDirectory(unrelated);
try
{
var config = new ConfigurationBuilder().AddInMemoryCollection(new Dictionary<string, string?> { ["Data:Root"] = root }).Build();
var environment = new Mock<IHostEnvironment>();
environment.SetupGet(x => x.ContentRootPath).Returns(root);
var exporter = new PlaywrightCvPdfExporter(new AppPaths(config, environment.Object), NullLogger<PlaywrightCvPdfExporter>.Instance, config);
var prune = typeof(PlaywrightCvPdfExporter).GetMethod("PruneExpiredExports", BindingFlags.Instance | BindingFlags.NonPublic)!;
prune.Invoke(exporter, [new DateOnly(2026, 7, 1)]);
Assert.False(Directory.Exists(old));
Assert.True(Directory.Exists(keep));
Assert.True(Directory.Exists(unrelated));
}
finally
{
if (Directory.Exists(root)) Directory.Delete(root, recursive: true);
}
}
}
@@ -0,0 +1,146 @@
using System.Reflection;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using Xunit;
namespace JobTrackerApi.Tests;
// Phase 2.1-b — CV extraction coverage. The structured model and StructuredCvProfileJson.FromSections
// already map Projects, Certifications and Languages headings into the profile; the gap was upstream
// (the AI /cv/normalize prompt never emitted those headings, so the sections were dropped). These
// tests lock the C# side so that once the normalized markdown carries # Projects / # Certifications /
// # Languages, they reach the structured profile — and so a future change can't silently regress it.
//
// Content shapes mirror what the (fixed) normalizer produces for the benchmark CV
// (Connor Babbington): a Projects section, and languages stated as "Name: Level".
public sealed class CvExtractionCoverageTests
{
private static StructuredCvSection Section(string name, string content) =>
new() { Name = name, Content = content };
[Fact]
public void FromSections_maps_a_Projects_heading_into_structured_projects()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Projects",
"JobTrack\nFull-stack job-application tracker (React, ASP.NET Core, SQLite, Docker).\n\n" +
"InboxIntel\nGmail analytics and safe bulk-cleanup tool in .NET 8 with PostgreSQL."),
});
Assert.Equal(2, profile.Projects.Count);
Assert.Contains(profile.Projects, p => p.Name == "JobTrack");
Assert.Contains(profile.Projects, p => p.Name == "InboxIntel");
}
[Fact]
public void FromSections_maps_a_Certifications_heading_into_structured_certifications()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Certifications",
"Extended Diploma NVQ Level 3 in ICT\n\nAZ-900 Azure Fundamentals"),
});
Assert.NotEmpty(profile.Certifications);
Assert.Contains(profile.Certifications, c => (c.Name ?? "").Contains("NVQ", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void FromSections_maps_a_Languages_heading_with_levels()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Languages", "English: Native\nNorwegian: B1"),
});
Assert.Equal(2, profile.Languages.Count);
Assert.Contains(profile.Languages, l => l.Name == "English" && (l.Level ?? "").Contains("Native"));
Assert.Contains(profile.Languages, l => l.Name == "Norwegian" && (l.Level ?? "").Contains("B1"));
}
// The benchmark CV has all four rich sections; confirm they coexist without one clobbering another.
[Fact]
public void FromSections_populates_projects_certifications_and_languages_together()
{
var profile = StructuredCvProfileJson.FromSections(new[]
{
Section("Skills", "C#\n.NET\nDocker"),
Section("Projects", "JobTrack\nJob-application tracker."),
Section("Certifications", "NVQ Level 3 in ICT"),
Section("Languages", "English: Native\nNorwegian: B1"),
});
Assert.NotEmpty(profile.Skills);
Assert.Single(profile.Projects);
Assert.NotEmpty(profile.Certifications);
Assert.Equal(2, profile.Languages.Count);
}
[Fact]
public void Normalized_markdown_strips_skill_group_labels()
{
var profile = InvokeProfileBuilder("""
# Skills
Development: C#
DevOps & Infrastructure: Docker
Practices: CI/CD
""");
Assert.Equal(new[] { "C#", "CI/CD", "Docker" }, profile.Skills);
Assert.DoesNotContain(profile.Skills, skill => skill.StartsWith("Development", System.StringComparison.OrdinalIgnoreCase));
}
[Fact]
public void Normalized_markdown_separates_glued_dates_from_job_title()
{
var profile = InvokeProfileBuilder("""
# Work Experience
20152023System Developer
Warwickshire County Council, UK
- Built APIs
""");
var job = Assert.Single(profile.Jobs);
Assert.Equal("2015", job.Start);
Assert.Equal("2023", job.End);
Assert.Contains("System Developer", job.Title ?? string.Empty);
}
[Fact]
public void Extraction_repairs_common_utf8_as_latin1_mojibake()
{
var method = typeof(ProfileCvController).GetMethod("RepairKnownMojibake", BindingFlags.NonPublic | BindingFlags.Static)!;
var repaired = Assert.IsType<string>(method.Invoke(null, new object[] { "Tønsberg 2015–2023" }));
Assert.Equal("Tønsberg 20152023", repaired);
}
[Fact]
public void Earlier_part_time_roles_become_separate_experience_entries()
{
var profile = InvokeProfileBuilder("""
# Work Experience
System Developer
Warwickshire County Council
20152023
- Built APIs
Earlier roles (part-time)
- Sales Assistant Royal Vapes | 20132015
- Labourer The Hodcarrier | 20142016
- Lifeguard Nuffield Health | 20122014
""");
Assert.Contains(profile.Jobs, job => job.Title == "Sales Assistant" && job.Company == "Royal Vapes");
Assert.Contains(profile.Jobs, job => job.Title == "Labourer" && job.Company == "The Hodcarrier");
Assert.Contains(profile.Jobs, job => job.Title == "Lifeguard" && job.Company == "Nuffield Health");
Assert.Equal(4, profile.Jobs.Count);
}
private static StructuredCvProfile InvokeProfileBuilder(string markdown)
{
var method = typeof(ProfileCvController).GetMethod("BuildStructuredCvFromNormalizedMarkdown", BindingFlags.NonPublic | BindingFlags.Static)!;
return Assert.IsType<StructuredCvProfile>(method.Invoke(null, new object[] { markdown }));
}
}
@@ -0,0 +1,219 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Xunit;
namespace JobTrackerApi.Tests;
// Phase 2.1-a — the diff engine. Pure comparison; applies nothing. These pin the conservative policy:
// clear matches only, updates only on non-empty differing values, no deletions, dedup, and the
// High/Medium/Low confidence markers that drive the review screen.
public sealed class CvProfileDiffServiceTests
{
private static readonly CvProfileDiffService Svc = new();
private static CvCategoryDiff Cat(CvImportDiff d, string name) => d.Categories.First(c => c.Category == name);
private static StructuredCvJob Job(string title, string company, string? start = null, string? end = null, params string[] bullets)
=> new() { Title = title, Company = company, Start = start, End = end, Bullets = bullets.ToList() };
[Fact]
public void Empty_current_makes_everything_an_addition()
{
var current = new StructuredCvProfile();
var extracted = new StructuredCvProfile
{
Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built full-stack apps.") },
Projects = { new StructuredCvProject { Name = "JobTrack", Bullets = { "Job tracker." } } },
Skills = { "C#", ".NET", "Docker" },
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" }, new StructuredCvLanguage { Name = "Norwegian", Level = "B1" } },
};
var diff = Svc.Diff(current, extracted);
Assert.True(diff.HasChanges);
Assert.Single(Cat(diff, "Experience").Added);
Assert.Single(Cat(diff, "Projects").Added);
Assert.Equal(3, Cat(diff, "Skills").Added.Count);
Assert.Equal(2, Cat(diff, "Languages").Added.Count);
Assert.Empty(Cat(diff, "Experience").Updated);
}
[Fact]
public void A_matched_job_with_no_new_information_is_unchanged_not_duplicated()
{
var job = Job("System Developer", "Warwickshire County Council", "2015", "2023", "Built apps.");
var current = new StructuredCvProfile { Jobs = { job } };
var extracted = new StructuredCvProfile { Jobs = { Job("system developer", "WARWICKSHIRE COUNTY COUNCIL", "2015", "2023", "Built apps.") } };
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Empty(exp.Added); // same company+title -> not a new entry
Assert.Empty(exp.Updated); // same dates + bullets -> nothing to update
Assert.Equal(1, exp.UnchangedCount);
}
[Fact]
public void A_matched_job_with_new_dates_is_an_update_not_an_add()
{
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
var extracted = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council", "2015", "2023") } };
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Empty(exp.Added);
Assert.Single(exp.Updated);
Assert.Contains(exp.Updated[0].FieldChanges, f => f.Field == "Dates");
}
[Fact]
public void A_genuinely_new_job_is_an_addition()
{
var current = new StructuredCvProfile { Jobs = { Job("System Developer", "Warwickshire County Council") } };
var extracted = new StructuredCvProfile
{
Jobs = { Job("System Developer", "Warwickshire County Council"), Job("Bartender", "The Hodcarrier", "2016", "2018") },
};
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Single(exp.Added);
Assert.Equal("Bartender — The Hodcarrier", exp.Added[0].Label);
}
[Fact]
public void Skills_dedup_case_insensitively_and_only_new_ones_are_added()
{
var current = new StructuredCvProfile { Skills = { "C#", "SQL" } };
var extracted = new StructuredCvProfile { Skills = { "c#", ".NET", "sql", "Docker", "docker" } };
var skills = Cat(Svc.Diff(current, extracted), "Skills");
Assert.Equal(2, skills.Added.Count); // .NET and Docker only, deduped
Assert.Contains(skills.Added, s => s.Label == ".NET");
Assert.Contains(skills.Added, s => s.Label == "Docker");
}
[Fact]
public void A_language_level_change_is_an_update_a_new_language_is_an_add()
{
var current = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } } };
var extracted = new StructuredCvProfile
{
Languages =
{
new StructuredCvLanguage { Name = "English", Level = "Native" },
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
},
};
var langs = Cat(Svc.Diff(current, extracted), "Languages");
Assert.Single(langs.Added);
Assert.Equal("Norwegian (B1)", langs.Added[0].Label);
Assert.Equal(1, langs.UnchangedCount);
}
[Fact]
public void A_language_without_a_level_is_low_confidence()
{
var extracted = new StructuredCvProfile { Languages = { new StructuredCvLanguage { Name = "French" } } };
var langs = Cat(Svc.Diff(new StructuredCvProfile(), extracted), "Languages");
Assert.Equal("Low", langs.Added[0].Confidence);
Assert.Equal(1, diffLow(langs));
}
private static int diffLow(CvCategoryDiff c) => c.LowConfidenceCount;
[Fact]
public void Extraction_never_proposes_blanking_an_existing_value_and_never_deletes()
{
// current has a rich job; extraction found the same job but with an empty location and no bullets.
var current = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022", "Did things.") } };
current.Jobs[0].Location = "Oslo";
var extracted = new StructuredCvProfile { Jobs = { Job("Dev", "Acme", "2019", "2022") } }; // no location, no bullets
var exp = Cat(Svc.Diff(current, extracted), "Experience");
Assert.Empty(exp.Added);
Assert.Empty(exp.Updated); // empty extracted fields never overwrite
Assert.Equal(1, exp.UnchangedCount);
// And a job the extraction did NOT mention simply doesn't appear in the diff (never deleted).
var extracted2 = new StructuredCvProfile();
Assert.False(Svc.Diff(current, extracted2).HasChanges);
}
[Fact]
public void Merge_preserves_existing_items_and_adds_new_information()
{
var current = new StructuredCvProfile
{
Jobs = { Job("Developer", "Acme", "2020", "2022", "Curated bullet") },
Skills = { "C#" },
};
current.Jobs[0].Id = "keep-me";
current.Jobs[0].Location = "Oslo";
var extracted = new StructuredCvProfile
{
Jobs =
{
Job("developer", "ACME", "2020", "2024", "Curated bullet", "New extracted bullet"),
Job("Engineer", "New Co", "2024", "Present", "Built systems"),
},
Skills = { "c#", "Docker" },
};
var merged = Svc.Merge(current, extracted);
Assert.Equal(2, merged.Jobs.Count);
Assert.Equal("keep-me", merged.Jobs[0].Id);
Assert.Equal("Oslo", merged.Jobs[0].Location);
Assert.Equal("2024", merged.Jobs[0].End);
Assert.Equal(new[] { "Curated bullet", "New extracted bullet" }, merged.Jobs[0].Bullets);
Assert.Equal(new[] { "C#", "Docker" }, merged.Skills);
}
[Fact]
public void Merge_never_blanks_or_deletes_existing_data()
{
var current = new StructuredCvProfile
{
Contact = new StructuredCvContact { FullName = "Demo User", Email = "demo@example.com" },
Jobs = { Job("Developer", "Acme", "2020", "2024", "Keep this") },
Languages = { new StructuredCvLanguage { Name = "English", Level = "Native" } },
};
var extracted = new StructuredCvProfile
{
Contact = new StructuredCvContact { FullName = "Demo User" },
Languages = { new StructuredCvLanguage { Name = "English" } },
};
var merged = Svc.Merge(current, extracted);
Assert.Equal("demo@example.com", merged.Contact.Email);
Assert.Single(merged.Jobs);
Assert.Equal("Native", merged.Languages[0].Level);
}
[Fact]
public void Merge_requires_explicit_acceptance_for_each_low_confidence_change()
{
var extracted = new StructuredCvProfile
{
Languages =
{
new StructuredCvLanguage { Name = "French" },
new StructuredCvLanguage { Name = "Norwegian", Level = "B1" },
},
};
var diff = Svc.Diff(new StructuredCvProfile(), extracted);
var french = Cat(diff, "Languages").Added.Single(x => x.Label == "French");
Assert.Equal("Languages|french", french.Id);
var defaultMerge = Svc.Merge(new StructuredCvProfile(), extracted);
var confirmedMerge = Svc.Merge(new StructuredCvProfile(), extracted, new HashSet<string> { french.Id });
Assert.DoesNotContain(defaultMerge.Languages, x => x.Name == "French");
Assert.Contains(defaultMerge.Languages, x => x.Name == "Norwegian");
Assert.Contains(confirmedMerge.Languages, x => x.Name == "French");
}
}
@@ -816,6 +816,10 @@ public sealed class GmailControllerTests
Assert.Equal(1, created.Imported);
Assert.Equal("thread-suggested", created.ThreadId);
Assert.Equal(1, await db.JobApplications.CountAsync());
var linkedOpportunity = await db.JobApplications.Include(application => application.Job).SingleAsync();
Assert.NotNull(linkedOpportunity.JobId);
Assert.Equal("Platform Engineer", linkedOpportunity.Job!.JobTitle);
Assert.Equal("gmail", linkedOpportunity.Job.Source);
Assert.Equal(1, await db.Correspondences.CountAsync());
}
@@ -202,17 +202,26 @@ 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);
Assert.NotNull(result);
var created = Assert.IsType<CreatedAtActionResult>(result.Result);
Assert.IsType<JobApplicationDto>(created.Value);
var saved = await db.JobApplications.FirstAsync();
Assert.Equal(60000m, saved.SalaryMin);
Assert.Equal(70000m, saved.SalaryMax);
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]
@@ -232,6 +241,7 @@ public sealed class JobApplicationsEndpointBehaviorTests
SalaryMax = 60000m,
SalaryCurrency = "NOK",
SalaryPeriod = "year",
Job = new Job { CompanyId = company.Id, JobTitle = "Backend Dev", Source = "nav", CountryCode = "NO" },
};
db.JobApplications.Add(job);
await db.SaveChangesAsync();
@@ -271,6 +281,12 @@ public sealed class JobApplicationsEndpointBehaviorTests
Assert.Null(saved.SalaryMax);
Assert.Null(saved.SalaryCurrency);
Assert.Null(saved.SalaryPeriod);
var opportunity = await db.Jobs.SingleAsync();
Assert.Equal(saved.JobTitle, opportunity.JobTitle);
Assert.Null(opportunity.SalaryMin);
Assert.Null(opportunity.SalaryPeriod);
Assert.Equal("nav", opportunity.Source);
Assert.Equal("NO", opportunity.CountryCode);
}
private static JobApplicationsController CreateController(JobTrackerContext db, string userId)
@@ -0,0 +1,39 @@
using Xunit;
using System.Net;
using System.Text;
using JobTrackerApi.Controllers;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Configuration;
namespace JobTrackerApi.Tests;
public sealed class JobDiscoveryControllerTests
{
[Fact]
public async Task Search_filters_recent_active_nav_jobs()
{
const string token = "eyJ.test.token";
var feed = """{"next_url":"","items":[{"date_modified":"2026-07-30T10:00:00Z","_feed_entry":{"uuid":"1","status":"ACTIVE","title":"Backend Developer","businessName":"Acme","municipal":"OSLO"}},{"date_modified":"2026-07-30T11:00:00Z","_feed_entry":{"uuid":"2","status":"ACTIVE","title":"Nurse","businessName":"Hospital","municipal":"BERGEN"}}]}""";
var client = new HttpClient(new Handler(request => request.RequestUri!.AbsolutePath.EndsWith("publicToken") ? token : feed));
var controller = new JobDiscoveryController(new ClientFactory(client), new ConfigurationBuilder().Build(), new MemoryCache(new MemoryCacheOptions()));
var result = await controller.Search("backend", "oslo", CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var job = Assert.Single(Assert.IsAssignableFrom<IReadOnlyList<JobDiscoveryController.DiscoveredJob>>(ok.Value));
Assert.Equal("Backend Developer", job.Title);
Assert.Equal("NO", job.CountryCode);
}
private sealed class ClientFactory(HttpClient client) : IHttpClientFactory
{
public HttpClient CreateClient(string name) => client;
}
private sealed class Handler(Func<HttpRequestMessage, string> response) : HttpMessageHandler
{
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) =>
Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) { Content = new StringContent(response(request), Encoding.UTF8, "application/json") });
}
}
@@ -0,0 +1,38 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class JobOpportunitySyncTests
{
[Fact]
public async Task Backfill_links_each_legacy_application_once()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var company = new Company { OwnerUserId = "user-1", Name = "Acme" };
db.Companies.Add(company);
await db.SaveChangesAsync();
db.JobApplications.Add(new JobApplication
{
OwnerUserId = "user-1",
CompanyId = company.Id,
JobTitle = "Platform Engineer",
Location = "Oslo",
SavedAt = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc),
});
await db.SaveChangesAsync();
Assert.Equal(1, await JobOpportunitySync.BackfillLegacyAsync(db));
Assert.Equal(0, await JobOpportunitySync.BackfillLegacyAsync(db));
var application = await db.JobApplications.IgnoreQueryFilters().Include(item => item.Job).SingleAsync();
Assert.NotNull(application.JobId);
Assert.Equal("Platform Engineer", application.Job!.JobTitle);
Assert.Equal("Oslo", application.Job.Location);
Assert.Equal("legacy", application.Job.Source);
Assert.Equal(1, await db.Jobs.IgnoreQueryFilters().CountAsync());
}
}
@@ -20,6 +20,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
</ItemGroup>
</Project>
+124 -17
View File
@@ -40,7 +40,7 @@ public sealed class ProfileCvControllerTests
}
[Fact]
public async Task Upload_stores_cv_artifact_and_extraction_run_metadata()
public async Task Upload_waits_for_review_then_accept_merges_and_applies()
{
var user = new ApplicationUser { Id = "user-1" };
var userManager = CreateUserManager();
@@ -81,21 +81,55 @@ public sealed class ProfileCvControllerTests
Assert.IsType<OkObjectResult>(result);
var artifact = await db.CvUploadArtifacts.SingleAsync();
var run = await db.CvExtractionRuns.SingleAsync();
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("user-1", artifact.OwnerUserId);
Assert.Equal("resume.md", artifact.OriginalFileName);
Assert.True(System.IO.File.Exists(artifact.StoragePath));
Assert.Equal("applied", run.Status);
Assert.Equal("pending_review", run.Status);
Assert.Equal("upload", run.Trigger);
Assert.Equal(artifact.Id, run.ArtifactId);
Assert.Null(user.ProfileCvStructureJson);
Assert.Null(user.CurrentCvExtractionRunId);
Assert.IsType<OkObjectResult>(await controller.GetRunDiff(run.Id));
Assert.IsType<OkObjectResult>(await controller.AcceptRun(run.Id));
var parsed = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
Assert.Equal("applied", run.Status);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal(artifact.Id, user.CurrentCvUploadArtifactId);
Assert.Equal(1, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, parsed.Metadata.AppliedExtractionRunId);
Assert.True(parsed.Metadata.ProfileVersion >= 1);
Assert.Contains(parsed.Metadata.Fields.Keys, key => key == "contact.fullName" || key == "summary");
}
[Fact]
public async Task DiscardRun_leaves_profile_unchanged()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvStructureJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "C#" } }) };
var userManager = CreateUserManager();
userManager.Setup(x => x.GetUserAsync(It.IsAny<ClaimsPrincipal>())).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
await using var db = CreateDb();
db.CvExtractionRuns.Add(new CvExtractionRun
{
OwnerUserId = user.Id,
Trigger = "upload",
Status = "pending_review",
ParserVersion = "test",
NormalizerVersion = "test",
LlmPromptVersion = "test",
StructuredProfileJson = StructuredCvProfileJson.Serialize(new StructuredCvProfile { Skills = { "Docker" } }),
});
await db.SaveChangesAsync();
var run = await db.CvExtractionRuns.SingleAsync();
var controller = CreateController(userManager.Object, aiService.Object, db, CreatePaths());
Assert.IsType<NoContentResult>(await controller.DiscardRun(run.Id));
Assert.Equal("discarded", run.Status);
Assert.Equal(new[] { "C#" }, StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Skills);
}
[Fact]
public async Task GetRuns_returns_latest_extraction_runs()
{
@@ -186,10 +220,78 @@ public sealed class ProfileCvControllerTests
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Equal("reprocess", run.Trigger);
Assert.Equal("applied", run.Status);
Assert.Equal(2, user.CurrentCvProfileVersion);
Assert.Equal(run.Id, user.CurrentCvExtractionRunId);
Assert.Equal("# Connor Babbington\n\n## Professional Summary\nRefined profile", user.ProfileCvText);
Assert.Equal("pending_review", run.Status);
Assert.Equal(1, user.CurrentCvProfileVersion);
Assert.Null(user.CurrentCvExtractionRunId);
Assert.Null(user.ProfileCvText);
}
[Fact]
public async Task Background_processing_bypasses_the_http_user_filter_for_the_owned_run()
{
var user = new ApplicationUser { Id = "user-1", ProfileCvText = "# Ada Lovelace\n\n## Skills\nC#" };
var userManager = CreateUserManager();
userManager.Setup(x => x.FindByIdAsync(user.Id)).ReturnsAsync(user);
var aiService = new Mock<ISummarizerService>();
aiService.Setup(x => x.SummarizeSectionAsync(
It.Is<string>(instruction => instruction.StartsWith("Rewrite this CV", StringComparison.Ordinal)),
It.IsAny<string>(), 1800, 500))
.ReturnsAsync(user.ProfileCvText);
aiService.Setup(x => x.SummarizeSectionAsync(
It.Is<string>(instruction => instruction.Contains("Extract this CV into structured JSON", StringComparison.Ordinal)),
It.IsAny<string>(), 3200, 900))
.ReturnsAsync("""
{"version":"1","contact":{"fullName":"Ada Lovelace"},"summary":[],"jobs":[],"education":[],"skills":["C#"],"languages":[],"interests":[],"otherSections":[]}
""");
await using var db = CreateDb(userId: null);
var paths = CreatePaths();
var orphanedPath = Path.Combine(paths.CvArtifactsRoot, "orphaned.pdf");
await System.IO.File.WriteAllTextAsync(orphanedPath, "obsolete");
var currentPath = Path.Combine(paths.CvArtifactsRoot, "current.pdf");
await System.IO.File.WriteAllTextAsync(currentPath, "current");
db.CvUploadArtifacts.Add(new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = "orphaned.pdf",
StoredFileName = "orphaned.pdf",
MimeType = "application/pdf",
ByteSize = 8,
Sha256 = "orphaned",
StoragePath = orphanedPath,
});
var currentArtifact = new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = "current.pdf",
StoredFileName = "current.pdf",
MimeType = "application/pdf",
ByteSize = 7,
Sha256 = "current",
StoragePath = currentPath,
};
db.CvUploadArtifacts.Add(currentArtifact);
var run = new CvExtractionRun
{
OwnerUserId = user.Id,
Trigger = "improve",
ParserVersion = "test",
NormalizerVersion = "test",
LlmPromptVersion = "test",
Status = "queued",
};
db.CvExtractionRuns.Add(run);
await db.SaveChangesAsync();
user.CurrentCvUploadArtifactId = currentArtifact.Id;
var controller = CreateController(userManager.Object, aiService.Object, db, paths);
await controller.ProcessQueuedRunAsync(run.Id, CancellationToken.None);
Assert.Equal("pending_review", run.Status);
Assert.NotNull(run.CompletedAtUtc);
Assert.Equal(currentArtifact.Id, Assert.Single(await db.CvUploadArtifacts.IgnoreQueryFilters().ToListAsync()).Id);
Assert.False(System.IO.File.Exists(orphanedPath));
Assert.True(System.IO.File.Exists(currentPath));
}
[Fact]
@@ -268,9 +370,10 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
Assert.Equal(reconstructed, user.ProfileCvText);
var savedRun = await db.CvExtractionRuns.SingleAsync();
Assert.Equal(reconstructed, savedRun.NormalizedText);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Single(structured.Summary);
Assert.Single(structured.Jobs);
@@ -326,10 +429,11 @@ public sealed class ProfileCvControllerTests
Assert.IsType<OkObjectResult>(result);
normalizer.Verify(x => x.NormalizeAsync(It.Is<string>(text => text.Contains("Warwickshire County Council", StringComparison.Ordinal)), It.IsAny<CancellationToken>()), Times.Once);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var savedRun = await db.CvExtractionRuns.SingleAsync();
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Contains("# Skills", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", user.ProfileCvText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("# Skills", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
Assert.Contains("Warwickshire County Council", savedRun.NormalizedText ?? string.Empty, StringComparison.OrdinalIgnoreCase);
}
[Fact]
@@ -366,7 +470,8 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
var structured = StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson);
var savedRun = await db.CvExtractionRuns.SingleAsync();
var structured = StructuredCvProfileJson.Deserialize(savedRun.StructuredProfileJson);
Assert.Equal("Connor Babbington", structured.Contact.FullName);
Assert.Equal("connor.babbington@cesnimda.co.uk", structured.Contact.Email);
Assert.Equal("+47 41 33 44 70", structured.Contact.Phone);
@@ -965,8 +1070,10 @@ public sealed class ProfileCvControllerTests
var result = await controller.Upload(file);
Assert.IsType<OkObjectResult>(result);
Assert.Contains("Built APIs", user.ProfileCvText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(user.ProfileCvStructureJson).Contact.FullName);
var run = await db.CvExtractionRuns.SingleAsync();
Assert.Contains("Built APIs", run.NormalizedText);
Assert.Equal("Connor Babbington", StructuredCvProfileJson.Deserialize(run.StructuredProfileJson).Contact.FullName);
Assert.Equal("pending_review", run.Status);
}
[Fact]
@@ -1216,7 +1323,7 @@ public sealed class ProfileCvControllerTests
};
}
private static JobTrackerContext CreateDb(string userId = "user-1")
private static JobTrackerContext CreateDb(string? userId = "user-1")
{
return TestHostFactory.CreateInMemoryDb(userId);
}
@@ -0,0 +1,61 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
using Moq;
using System.Reflection;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class PublicCvControllerTests
{
[Fact]
public void Pdf_download_is_rate_limited()
{
var attribute = typeof(PublicCvController).GetMethod(nameof(PublicCvController.DownloadPdf))!
.GetCustomAttribute<EnableRateLimitingAttribute>();
Assert.Equal("public-pdf", attribute?.PolicyName);
}
[Fact]
public async Task Pdf_download_uses_the_public_variant_render()
{
var user = new ApplicationUser { Id = "owner-1", FirstName = "Ada", LastName = "Lovelace" };
var variants = new Mock<ICvVariantService>();
var pdf = new Mock<ICvPdfExporter>();
var render = new ThemedCvRenderResult("modern", "ada-cv.pdf", "<html>CV</html>");
variants.Setup(x => x.GetPublicOwnerAsync("public-slug", It.IsAny<CancellationToken>())).ReturnsAsync(user.Id);
variants.Setup(x => x.RenderPublicAsync("public-slug", It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()))
.ReturnsAsync((render, user.Id));
pdf.Setup(x => x.ExportAsync(It.IsAny<TailoredCvRenderResult>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new CvPdfArtifact("ada-cv.pdf", "unused", [1, 2, 3]));
var controller = new PublicCvController(TestHostFactory.CreateUserManager(user).Object, variants.Object, pdf.Object);
var result = Assert.IsType<FileContentResult>(await controller.DownloadPdf("public-slug", CancellationToken.None));
Assert.Equal("application/pdf", result.ContentType);
Assert.Equal("ada-cv.pdf", result.FileDownloadName);
Assert.Equal([1, 2, 3], result.FileContents);
pdf.Verify(x => x.ExportAsync(
It.Is<TailoredCvRenderResult>(value => value.TemplateId == "modern" && value.Html == "<html>CV</html>"),
It.IsAny<CancellationToken>()), Times.Once);
}
[Fact]
public async Task Pdf_download_does_not_render_an_unknown_or_private_slug()
{
var variants = new Mock<ICvVariantService>();
variants.Setup(x => x.GetPublicOwnerAsync("private-slug", It.IsAny<CancellationToken>())).ReturnsAsync((string?)null);
var pdf = new Mock<ICvPdfExporter>();
var controller = new PublicCvController(TestHostFactory.CreateUserManager().Object, variants.Object, pdf.Object);
Assert.IsType<NotFoundResult>(await controller.DownloadPdf("private-slug", CancellationToken.None));
variants.Verify(x => x.RenderPublicAsync(It.IsAny<string>(), It.IsAny<CvRenderPerson>(), It.IsAny<CancellationToken>()), Times.Never);
pdf.VerifyNoOtherCalls();
}
}
@@ -0,0 +1,55 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/ai/usage")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class AiUsageController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly JobTrackerContext _db;
public AiUsageController(UserManager<ApplicationUser> users, JobTrackerContext db)
{
_users = users;
_db = db;
}
public sealed record UsagePeriodDto(int Calls, long InputCharacters, long OutputCharacters, long EstimatedTokens);
public sealed record UsageDto(UsagePeriodDto CurrentMonth, UsagePeriodDto AllTime, string Plan, int MonthlyCallLimit, long MonthlyTokenLimit, long StorageUsedBytes, long StorageLimitBytes);
[HttpGet]
public async Task<ActionResult<UsageDto>> Get(CancellationToken cancellationToken)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var roles = await _users.GetRolesAsync(user);
var entitlements = AccountPlans.ForRoles(roles);
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
return Ok(new UsageDto(
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart), cancellationToken),
await SumAsync(_db.AiInteractions.Where(x => x.OwnerUserId == user.Id), cancellationToken),
entitlements.AdvancedAi ? "premium" : "free",
entitlements.MonthlyAiCalls,
entitlements.MonthlyAiTokens,
await _db.Attachments.Where(x => x.JobApplication.OwnerUserId == user.Id).SumAsync(x => (long?)x.FileSize, cancellationToken) ?? 0,
entitlements.StorageBytes));
}
private static async Task<UsagePeriodDto> SumAsync(IQueryable<AiInteraction> query, CancellationToken cancellationToken)
{
var totals = await query.GroupBy(_ => 1).Select(group => new UsagePeriodDto(
group.Count(),
group.Sum(x => (long)x.InputCharacterCount),
group.Sum(x => (long)x.OutputCharacterCount),
group.Sum(x => (long)x.EstimatedTokenCount))).FirstOrDefaultAsync(cancellationToken);
return totals ?? new UsagePeriodDto(0, 0, 0, 0);
}
}
@@ -4,6 +4,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
@@ -17,16 +18,18 @@ public sealed class AiWorkspaceController : ControllerBase
private readonly UserManager<ApplicationUser> _users;
private readonly IAiWorkspaceService _workspace;
private readonly IConfiguration _config;
private readonly JobTrackerApi.Data.JobTrackerContext? _db;
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config)
public AiWorkspaceController(UserManager<ApplicationUser> users, IAiWorkspaceService workspace, IConfiguration config, JobTrackerApi.Data.JobTrackerContext? db = null)
{
_users = users;
_workspace = workspace;
_config = config;
_db = db;
}
public sealed record GenerateRequest(string Module, string? Mode, string? ExtraContext);
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, DateTimeOffset CreatedAtUtc);
public sealed record InteractionDto(int Id, string Module, string? Mode, string Title, string Provider, JsonElement Result, int InputCharacterCount, int OutputCharacterCount, int EstimatedTokenCount, DateTimeOffset CreatedAtUtc);
[HttpGet("modules")]
public ActionResult<object> Modules() => Ok(new { modules = _workspace.Modules, provider = ResolveProvider() });
@@ -38,6 +41,22 @@ public sealed class AiWorkspaceController : ControllerBase
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(request?.Module)) return BadRequest("Choose an AI module.");
if (_db is not null)
{
var roles = await _users.GetRolesAsync(user);
var entitlements = AccountPlans.ForRoles(roles);
var monthStart = new DateTimeOffset(DateTime.UtcNow.Year, DateTime.UtcNow.Month, 1, 0, 0, 0, TimeSpan.Zero);
var used = await _db.AiInteractions
.Where(x => x.OwnerUserId == user.Id && x.CreatedAtUtc >= monthStart)
.GroupBy(_ => 1)
.Select(g => new { Calls = g.Count(), Tokens = g.Sum(x => (long)x.EstimatedTokenCount) })
.FirstOrDefaultAsync(ct);
if ((used?.Calls ?? 0) >= entitlements.MonthlyAiCalls)
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI limit reached ({entitlements.MonthlyAiCalls} generations). Upgrade your plan or try again next month.");
if ((used?.Tokens ?? 0) >= entitlements.MonthlyAiTokens)
return StatusCode(StatusCodes.Status429TooManyRequests, $"Monthly AI cost limit reached ({entitlements.MonthlyAiTokens:N0} estimated tokens). Upgrade your plan or try again next month.");
}
try
{
var interaction = await _workspace.GenerateAsync(
@@ -86,5 +105,5 @@ public sealed class AiWorkspaceController : ControllerBase
private static InteractionDto ToDto(AiInteraction x) => new(
x.Id, x.Module, x.Mode, x.Title, x.Provider,
JsonSerializer.Deserialize<JsonElement>(string.IsNullOrWhiteSpace(x.ResultJson) ? "{}" : x.ResultJson),
x.CreatedAtUtc);
x.InputCharacterCount, x.OutputCharacterCount, x.EstimatedTokenCount, x.CreatedAtUtc);
}
@@ -1,6 +1,7 @@
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Microsoft.AspNetCore.Identity;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
@@ -22,11 +23,13 @@ namespace JobTrackerApi.Controllers
private readonly AppPaths _paths;
private readonly JobTrackerContext _db;
private readonly UserManager<ApplicationUser>? _users;
public AttachmentsController(AppPaths paths, JobTrackerContext db)
public AttachmentsController(AppPaths paths, JobTrackerContext db, UserManager<ApplicationUser>? users = null)
{
_paths = paths;
_db = db;
_users = users;
}
public sealed record AttachmentDto(int Id, string FileName, DateTime UploadDate, string FileType, long FileSize, string? Purpose, bool UseForAi);
@@ -202,6 +205,19 @@ namespace JobTrackerApi.Controllers
var jobExists = await _db.JobApplications.AnyAsync(j => j.Id == jobId, cancellationToken);
if (!jobExists) return BadRequest("jobId does not exist.");
if (_users is not null)
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var roles = await _users.GetRolesAsync(user);
var limit = AccountPlans.ForRoles(roles).StorageBytes;
var used = await _db.Attachments.Where(a => a.JobApplication.OwnerUserId == user.Id).SumAsync(a => (long?)a.FileSize, cancellationToken) ?? 0;
var incoming = files.Sum(file => file.Length);
if (incoming > limit - used)
return StatusCode(StatusCodes.Status413PayloadTooLarge, $"Storage limit reached ({limit / 1_000_000} MB). Remove files or upgrade your plan.");
}
var folder = Path.Combine(_paths.AttachmentsRoot, jobId.ToString());
Directory.CreateDirectory(folder);
+55 -7
View File
@@ -24,8 +24,10 @@ public sealed class AuthController : ControllerBase
private readonly ILogger<AuthController> _logger;
private readonly ITwoFactorPendingTokenService _twoFactorPending;
private readonly JobTrackerContext _db;
private readonly string _avatarDataRoot;
private readonly IHttpClientFactory? _httpClients;
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db)
public AuthController(IConfiguration cfg, UserManager<ApplicationUser> users, ITokenService tokens, IAppEmailSender email, IGoogleTokenValidator googleTokens, IMicrosoftTokenValidator microsoftTokens, ILogger<AuthController> logger, ITwoFactorPendingTokenService twoFactorPending, JobTrackerContext db, IHostEnvironment? environment = null, IHttpClientFactory? httpClients = null)
{
_cfg = cfg;
_users = users;
@@ -36,6 +38,8 @@ public sealed class AuthController : ControllerBase
_logger = logger;
_twoFactorPending = twoFactorPending;
_db = db;
_httpClients = httpClients;
_avatarDataRoot = Path.GetFullPath((_cfg["Data:Root"] ?? environment?.ContentRootPath ?? AppContext.BaseDirectory).Trim());
}
[HttpGet("config")]
@@ -47,6 +51,8 @@ public sealed class AuthController : ControllerBase
var microsoftEnabled = !string.IsNullOrWhiteSpace((_cfg["Auth:MicrosoftClientId"] ?? string.Empty).Trim());
var allowRegistration = _cfg.GetValue("Auth:AllowRegistration", false);
var requireEmailVerification = _cfg.GetValue("Auth:RequireEmailVerification", false);
var turnstileSiteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim();
var turnstileEnabled = turnstileSiteKey.Length > 0 && !string.IsNullOrWhiteSpace(_cfg["Turnstile:SecretKey"]);
return Ok(new
{
@@ -56,11 +62,13 @@ public sealed class AuthController : ControllerBase
localEnabled = true,
allowRegistration,
requireEmailVerification,
turnstileEnabled,
turnstileSiteKey = turnstileEnabled ? turnstileSiteKey : null,
});
}
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true);
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true);
public sealed record LoginRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
public sealed record RegisterRequest(string Email, string Password, bool RememberMe = true, string? TurnstileToken = null);
public sealed record AuthSessionResult(bool Authenticated, string Provider);
public sealed record TwoFactorRequiredResult(bool RequiresTwoFactor, string PendingToken);
public sealed record GoogleLinkDto(bool Linked, string? Email, DateTimeOffset? LinkedAt);
@@ -77,6 +85,8 @@ public sealed class AuthController : ControllerBase
string? ProfileCvStructureJson,
string? AvatarImageDataUrl,
IList<string> Roles,
string Plan,
AccountEntitlements Entitlements,
GoogleLinkDto? GoogleLink,
MicrosoftLinkDto? MicrosoftLink);
private const int MaxAvatarBytes = 1_000_000;
@@ -98,6 +108,7 @@ public sealed class AuthController : ControllerBase
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
if (!await VerifyTurnstileAsync(request.TurnstileToken, "login", cancellationToken)) return BadRequest("Security verification failed. Please try again.");
var user = await _users.FindByEmailAsync(email) ?? await _users.FindByNameAsync(email);
if (user is null) return Unauthorized();
@@ -139,6 +150,7 @@ public sealed class AuthController : ControllerBase
if (email.Length == 0) return BadRequest("Email is required.");
if (password.Length == 0) return BadRequest("Password is required.");
if (!await VerifyTurnstileAsync(request.TurnstileToken, "register", cancellationToken)) return BadRequest("Security verification failed. Please try again.");
var existing = await _users.FindByEmailAsync(email);
if (existing is not null) return BadRequest("User already exists.");
@@ -168,6 +180,36 @@ public sealed class AuthController : ControllerBase
return await CompleteSignInAsync(user, request.RememberMe, "local", cancellationToken);
}
private async Task<bool> VerifyTurnstileAsync(string? token, string expectedAction, CancellationToken cancellationToken)
{
var secret = (_cfg["Turnstile:SecretKey"] ?? string.Empty).Trim();
var siteKey = (_cfg["Turnstile:SiteKey"] ?? string.Empty).Trim();
if (secret.Length == 0 && siteKey.Length == 0) return true;
if (secret.Length == 0 || siteKey.Length == 0) return false;
if (string.IsNullOrWhiteSpace(token) || token.Length > 2048 || _httpClients is null) return false;
try
{
using var content = new FormUrlEncodedContent(new Dictionary<string, string>
{
["secret"] = secret,
["response"] = token.Trim(),
["remoteip"] = HttpContext.Connection.RemoteIpAddress?.ToString() ?? string.Empty,
});
using var response = await _httpClients.CreateClient().PostAsync("https://challenges.cloudflare.com/turnstile/v0/siteverify", content, cancellationToken);
if (!response.IsSuccessStatusCode) return false;
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
return json.RootElement.TryGetProperty("success", out var success) && success.GetBoolean()
&& json.RootElement.TryGetProperty("action", out var action)
&& string.Equals(action.GetString(), expectedAction, StringComparison.Ordinal);
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException)
{
_logger.LogWarning(ex, "Turnstile verification failed");
return false;
}
}
[HttpPost("google/exchange")]
[AllowAnonymous]
[EnableRateLimiting("auth-login")]
@@ -353,6 +395,8 @@ public sealed class AuthController : ControllerBase
ProfileCvStructureJson: null,
AvatarImageDataUrl: null,
Roles: Array.Empty<string>(),
Plan: "free",
Entitlements: AccountPlans.ForRoles(Array.Empty<string>()),
GoogleLink: provider == "google" ? new GoogleLinkDto(false, email, null) : null,
MicrosoftLink: provider == "microsoft" ? new MicrosoftLinkDto(false, email, null) : null));
}
@@ -575,8 +619,7 @@ public sealed class AuthController : ControllerBase
return BadRequest("Only PNG, JPEG, or WebP images are supported.");
}
var base64 = Convert.ToBase64String(bytes);
user.AvatarImageDataUrl = $"data:{detectedContentType};base64,{base64}";
user.AvatarImageDataUrl = await AvatarStorage.StoreAsync(_avatarDataRoot, user.Id, bytes, detectedContentType, HttpContext.RequestAborted);
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
@@ -584,7 +627,7 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
return Ok(new { avatarImageDataUrl = user.AvatarImageDataUrl });
return Ok(new { avatarImageDataUrl = AvatarStorage.Resolve(user.AvatarImageDataUrl) });
}
[HttpDelete("avatar")]
@@ -597,6 +640,7 @@ public sealed class AuthController : ControllerBase
return Unauthorized();
}
var storedAvatar = user.AvatarImageDataUrl;
user.AvatarImageDataUrl = null;
var result = await _users.UpdateAsync(user);
if (!result.Succeeded)
@@ -604,6 +648,7 @@ public sealed class AuthController : ControllerBase
return BadRequest(string.Join("; ", result.Errors.Select(e => e.Description)));
}
AvatarStorage.Delete(storedAvatar);
return NoContent();
}
@@ -866,6 +911,7 @@ public sealed class AuthController : ControllerBase
private static MeResult ToMeResult(ApplicationUser user, IList<string> roles)
{
var entitlements = AccountPlans.ForRoles(roles);
return new MeResult(
Provider: "local",
Id: user.Id,
@@ -876,8 +922,10 @@ public sealed class AuthController : ControllerBase
DisplayName: user.DisplayName,
ProfileCvText: user.ProfileCvText,
ProfileCvStructureJson: user.ProfileCvStructureJson,
AvatarImageDataUrl: user.AvatarImageDataUrl,
AvatarImageDataUrl: AvatarStorage.Resolve(user.AvatarImageDataUrl),
Roles: roles,
Plan: entitlements.AdvancedAi ? "premium" : "free",
Entitlements: entitlements,
GoogleLink: new GoogleLinkDto(
Linked: !string.IsNullOrWhiteSpace(user.GoogleSubject),
Email: user.GoogleEmail,
@@ -0,0 +1,232 @@
using System.Security.Claims;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Stripe;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/billing")]
public sealed class BillingController : ControllerBase
{
private const string UserMetadataKey = "jobtracker_user_id";
private readonly IConfiguration _configuration;
private readonly UserManager<ApplicationUser> _users;
private readonly RoleManager<IdentityRole> _roles;
private readonly ILogger<BillingController> _logger;
public BillingController(
IConfiguration configuration,
UserManager<ApplicationUser> users,
RoleManager<IdentityRole> roles,
ILogger<BillingController> logger)
{
_configuration = configuration;
_users = users;
_roles = roles;
_logger = logger;
}
public sealed record BillingRedirectDto(string Url);
public sealed record BillingStatusDto(bool Enabled, bool CanCheckout, bool CanManage);
[HttpGet("status")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingStatusDto>> Status(CancellationToken cancellationToken)
{
var enabled = TryGetConfiguration(out _, out _, out _, out _);
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
var entitlements = AccountPlans.ForRoles(await _users.GetRolesAsync(user));
return Ok(new BillingStatusDto(
enabled,
enabled && !entitlements.AdvancedAi,
enabled && !string.IsNullOrWhiteSpace(user.StripeCustomerId)));
}
[HttpPost("checkout")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingRedirectDto>> Checkout(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out var premiumPrice, out _, out var publicBaseUrl))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
var currentRoles = await _users.GetRolesAsync(user);
if (AccountPlans.ForRoles(currentRoles).AdvancedAi)
return Conflict("This account already has Premium access.");
var metadata = new Dictionary<string, string> { [UserMetadataKey] = user.Id };
var options = new Stripe.Checkout.SessionCreateOptions
{
Mode = "subscription",
SuccessUrl = $"{publicBaseUrl}/settings?billing=success",
CancelUrl = $"{publicBaseUrl}/settings?billing=cancelled",
ClientReferenceId = user.Id,
Customer = user.StripeCustomerId,
CustomerEmail = string.IsNullOrWhiteSpace(user.StripeCustomerId) ? user.Email : null,
Metadata = metadata,
SubscriptionData = new Stripe.Checkout.SessionSubscriptionDataOptions { Metadata = metadata },
LineItems = new List<Stripe.Checkout.SessionLineItemOptions>
{
new() { Price = premiumPrice, Quantity = 1 },
},
};
try
{
var session = await new Stripe.Checkout.SessionService(new StripeClient(secretKey))
.CreateAsync(options, cancellationToken: cancellationToken);
if (string.IsNullOrWhiteSpace(session.Url))
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Stripe did not return a checkout URL.");
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe checkout creation failed for user {UserId}", user.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Billing checkout is temporarily unavailable.");
}
}
[HttpPost("portal")]
[Authorize(AuthenticationSchemes = "local")]
public async Task<ActionResult<BillingRedirectDto>> Portal(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out _, out _, out var publicBaseUrl))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
var userId = User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub");
var user = userId is null ? null : await _users.FindByIdAsync(userId);
if (user is null) return Unauthorized();
if (string.IsNullOrWhiteSpace(user.StripeCustomerId)) return NotFound("No Stripe customer exists for this account.");
try
{
var session = await new Stripe.BillingPortal.SessionService(new StripeClient(secretKey))
.CreateAsync(new Stripe.BillingPortal.SessionCreateOptions
{
Customer = user.StripeCustomerId,
ReturnUrl = $"{publicBaseUrl}/settings",
}, cancellationToken: cancellationToken);
return Ok(new BillingRedirectDto(session.Url));
}
catch (StripeException ex)
{
_logger.LogError(ex, "Stripe billing portal creation failed for user {UserId}", user.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Billing management is temporarily unavailable.");
}
}
[HttpPost("webhook")]
[AllowAnonymous]
[RequestSizeLimit(1_000_000)]
public async Task<IActionResult> Webhook(CancellationToken cancellationToken)
{
if (!TryGetConfiguration(out var secretKey, out var premiumPrice, out var webhookSecret, out _))
return Problem(statusCode: StatusCodes.Status503ServiceUnavailable, title: "Billing is not configured.");
string json;
using (var reader = new StreamReader(Request.Body))
json = await reader.ReadToEndAsync(cancellationToken);
Event stripeEvent;
try
{
stripeEvent = EventUtility.ConstructEvent(json, Request.Headers["Stripe-Signature"].ToString(), webhookSecret);
}
catch (StripeException ex)
{
_logger.LogWarning(ex, "Rejected a Stripe webhook with an invalid signature");
return BadRequest("Invalid Stripe signature.");
}
if (stripeEvent.Type is not (EventTypes.CustomerSubscriptionCreated
or EventTypes.CustomerSubscriptionUpdated
or EventTypes.CustomerSubscriptionDeleted))
return Ok();
if (stripeEvent.Data.Object is not Subscription eventSubscription)
return BadRequest("Stripe subscription payload was missing.");
Subscription subscription;
try
{
// Stripe does not guarantee webhook delivery order. Re-read the subscription so a late
// event cannot restore access after a newer cancellation or payment failure.
subscription = await new SubscriptionService(new StripeClient(secretKey))
.GetAsync(eventSubscription.Id, cancellationToken: cancellationToken);
}
catch (StripeException ex)
{
_logger.LogError(ex, "Could not refresh Stripe subscription {SubscriptionId}", eventSubscription.Id);
return Problem(statusCode: StatusCodes.Status502BadGateway, title: "Could not verify the current subscription state.");
}
if (subscription.Items?.Data?.Any(item => item.Price?.Id == premiumPrice) != true)
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because it does not contain the configured Premium price", subscription.Id);
return Ok();
}
if (!subscription.Metadata.TryGetValue(UserMetadataKey, out var userId) || string.IsNullOrWhiteSpace(userId))
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because Jobbjakt user metadata is missing", subscription.Id);
return Ok();
}
var user = await _users.FindByIdAsync(userId);
if (user is null)
{
_logger.LogWarning("Ignoring Stripe subscription {SubscriptionId} because user {UserId} no longer exists", subscription.Id, userId);
return Ok();
}
user.StripeCustomerId = subscription.CustomerId;
user.StripeSubscriptionId = subscription.Id;
user.StripeSubscriptionStatus = subscription.Status;
user.StripeLastEventCreatedUtc = stripeEvent.Created;
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not persist billing state.");
const string premiumRole = "Premium";
if (!await _roles.RoleExistsAsync(premiumRole))
{
var roleResult = await _roles.CreateAsync(new IdentityRole(premiumRole));
if (!roleResult.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not provision the Premium role.");
}
var hasRole = await _users.IsInRoleAsync(user, premiumRole);
var shouldHaveRole = AccountPlans.IsPremiumSubscriptionStatus(subscription.Status);
var roleUpdate = shouldHaveRole && !hasRole
? await _users.AddToRoleAsync(user, premiumRole)
: !shouldHaveRole && hasRole
? await _users.RemoveFromRoleAsync(user, premiumRole)
: IdentityResult.Success;
if (!roleUpdate.Succeeded)
return Problem(statusCode: StatusCodes.Status500InternalServerError, title: "Could not update Premium access.");
return Ok();
}
private bool TryGetConfiguration(out string secretKey, out string premiumPrice, out string webhookSecret, out string publicBaseUrl)
{
secretKey = (_configuration["Stripe:SecretKey"] ?? string.Empty).Trim();
premiumPrice = (_configuration["Stripe:PricePremium"] ?? string.Empty).Trim();
webhookSecret = (_configuration["Stripe:WebhookSecret"] ?? string.Empty).Trim();
publicBaseUrl = (_configuration["App:PublicBaseUrl"] ?? string.Empty).Trim().TrimEnd('/');
return secretKey.Length > 0 && premiumPrice.Length > 0 && webhookSecret.Length > 0
&& Uri.TryCreate(publicBaseUrl, UriKind.Absolute, out var uri)
&& uri.Scheme is "http" or "https";
}
}
@@ -38,8 +38,11 @@ public sealed class CvVariantController : ControllerBase
public sealed record AiAssistResult(string Original, string Result);
[HttpGet("themes")]
public ActionResult<IEnumerable<object>> GetThemes()
public async Task<ActionResult<IEnumerable<object>>> GetThemes()
{
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var premiumThemes = AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes;
var themes = CvThemeCatalog.Themes.Select(t => new
{
id = t.Id,
@@ -51,6 +54,8 @@ public sealed class CvVariantController : ControllerBase
photoShape = t.PhotoShape,
supportsIcons = t.DefaultIcons,
atsFriendly = t.AtsFriendly,
premium = t.Premium,
available = premiumThemes || !t.Premium,
swatches = new[] { t.Accent, t.SidebarBg, t.Paper },
});
return Ok(themes);
@@ -81,6 +86,8 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
if (request?.Settings is not null && !CvThemeCatalog.Exists(request.Settings.ThemeId))
return BadRequest("Unknown theme.");
if (request?.Settings is not null && !await CanUseThemeAsync(user, request.Settings.ThemeId))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
var variant = await _variants.CreateAsync(user.Id, request?.Name, request?.JobApplicationId, request?.Settings, ct);
return Ok(ToDto(variant));
}
@@ -101,6 +108,13 @@ public sealed class CvVariantController : ControllerBase
if (user is null) return Unauthorized();
var settings = CvVariantSettingsJson.Normalize(request.Settings);
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
if (!await CanUseThemeAsync(user, settings.ThemeId))
{
var current = await _variants.GetAsync(user.Id, id, ct);
if (current is null) return NotFound();
if (!string.Equals(CvVariantSettingsJson.Deserialize(current.SettingsJson).ThemeId, settings.ThemeId, StringComparison.OrdinalIgnoreCase))
return StatusCode(StatusCodes.Status403Forbidden, "This theme requires Premium.");
}
var variant = await _variants.SaveAsync(user.Id, id, request.Name, settings, request.Source ?? "autosave", ct);
return variant is null ? NotFound() : Ok(ToDto(variant));
}
@@ -164,6 +178,7 @@ public sealed class CvVariantController : ControllerBase
var user = await _users.GetUserAsync(User);
if (user is null) return Unauthorized();
var settings = CvVariantSettingsJson.Normalize(request.Settings);
if (!CvThemeCatalog.Exists(settings.ThemeId)) return BadRequest("Unknown theme.");
var render = await _variants.RenderSettingsAsync(user.Id, settings, Person(user), ct);
return Ok(new RenderDto(render.ThemeId, render.Html, render.SuggestedFileName));
}
@@ -222,6 +237,9 @@ public sealed class CvVariantController : ControllerBase
return $"{task} Preserve every factual claim — never invent employers, titles, dates, or metrics. Write in {lang}. Return only the rewritten text with no preamble.{extra}";
}
private async Task<bool> CanUseThemeAsync(ApplicationUser user, string? themeId) =>
CvThemeCatalog.CanUse(themeId, AccountPlans.ForRoles(await _users.GetRolesAsync(user)).PremiumThemes);
private static CvRenderPerson Person(ApplicationUser user)
{
var name = string.Join(" ", new[] { user.FirstName?.Trim(), user.LastName?.Trim() }.Where(x => !string.IsNullOrWhiteSpace(x)));
@@ -229,7 +247,7 @@ public sealed class CvVariantController : ControllerBase
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.Email?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = "Your Name";
return new CvRenderPerson(name!, user.AvatarImageDataUrl);
return new CvRenderPerson(name!, AvatarStorage.Resolve(user.AvatarImageDataUrl));
}
private static VariantDto ToDto(CvVariant v) => new(
+4 -1
View File
@@ -567,6 +567,7 @@ public sealed class GmailController : ControllerBase
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
}
var savedAt = DateTime.UtcNow;
var job = new JobApplication
{
OwnerUserId = ownerUserId,
@@ -574,8 +575,10 @@ public sealed class GmailController : ControllerBase
JobTitle = jobTitle,
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
DateApplied = DateTime.UtcNow,
DateApplied = savedAt,
SavedAt = savedAt,
};
job.Job = JobOpportunitySync.Create(job, "gmail");
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
@@ -1,4 +1,5 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers
{
@@ -102,7 +103,9 @@ namespace JobTrackerApi.Controllers
string? CoverLetterText,
string? JobUrl,
DateTime? DateApplied,
DateTime? FeedbackRequestedAt
DateTime? FeedbackRequestedAt,
string? Source = null,
string? CountryCode = null
);
public sealed record UpdateJobApplicationRequest(
@@ -230,7 +233,8 @@ namespace JobTrackerApi.Controllers
List<string> MatchedKeywords,
List<string> MissingKeywords,
List<MatchSectionCoverageDto> SectionCoverage,
bool HasEnoughSignal);
bool HasEnoughSignal,
IReadOnlyList<LearningRecommendationDto> LearningRecommendations);
public sealed record MatchSectionCoverageDto(string Section, int Matched, int Total);
}
@@ -567,9 +567,6 @@ Canonical profile:
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
// Use persisted short summary when available to avoid repeated model calls.
var shortSummary = j.ShortSummary;
var summary = shortSummary; // list endpoints return the short summary only
dtoItems.Add(BuildJobApplicationDto(j, d));
}
@@ -599,8 +596,6 @@ Canonical profile:
{
lastMsg.TryGetValue(j.Id, out var lm);
var d = RulesEngine.Evaluate(settings, j, now, lm);
var shortSummary = j.ShortSummary;
var summary = shortSummary;
dtos.Add(BuildJobApplicationDto(j, d));
}
@@ -724,7 +719,7 @@ Canonical profile:
}
[HttpPost]
public async Task<ActionResult<JobApplication>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
public async Task<ActionResult<JobApplicationDto>> Create([FromBody] CreateJobApplicationRequest request, CancellationToken cancellationToken)
{
var userId = CurrentUserId;
var title = (request.JobTitle ?? "").Trim();
@@ -762,6 +757,12 @@ 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 = JobOpportunitySync.Create(job, source, countryCode);
// 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.
@@ -781,6 +782,7 @@ Canonical profile:
// ignore summarizer failures at create time
}
JobOpportunitySync.Apply(job, job.Job);
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
@@ -797,13 +799,15 @@ Canonical profile:
.Include(j => j.Company)
.FirstAsync(j => j.Id == job.Id, cancellationToken);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
var settings = await GetCachedRuleSettingsAsync(cancellationToken);
var followUp = RulesEngine.Evaluate(settings, created, DateTime.Now, lastMessageAt: null);
return CreatedAtAction(nameof(GetById), new { id = created.Id }, BuildJobApplicationDto(created, followUp));
}
[HttpPut("{id:int}")]
public async Task<IActionResult> Update([FromRoute] int id, [FromBody] UpdateJobApplicationRequest request, CancellationToken cancellationToken)
{
var job = await _db.JobApplications.FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
var job = await _db.JobApplications.Include(j => j.Job).FirstOrDefaultAsync(j => j.Id == id, cancellationToken);
if (job is null) return NotFound();
var oldStatus = job.Status;
@@ -854,6 +858,7 @@ Canonical profile:
// Records StatusChanged plus any lifecycle event the transition implies.
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job);
await _db.SaveChangesAsync(cancellationToken);
return NoContent();
}
@@ -1403,6 +1408,8 @@ Canonical profile:
}
var result = _matchService.Evaluate(job.JobTitle, jobText, cvSections);
var learningRecommendations = await _checklist.SyncLearningRecommendationsAsync(
userId, id, result.MissingKeywords, cancellationToken);
return Ok(new MatchScoreDto(
Score: result.Score,
@@ -1412,7 +1419,8 @@ Canonical profile:
MatchedKeywords: result.MatchedKeywords.ToList(),
MissingKeywords: result.MissingKeywords.ToList(),
SectionCoverage: result.SectionCoverage.Select(s => new MatchSectionCoverageDto(s.Section, s.Matched, s.Total)).ToList(),
HasEnoughSignal: result.HasEnoughSignal));
HasEnoughSignal: result.HasEnoughSignal,
LearningRecommendations: learningRecommendations));
}
[HttpGet("{id:int}/candidate-fit")]
@@ -1821,7 +1829,7 @@ Candidate master CV:
? request!.PhotoDataUrl
: request?.UseProfileAvatar == false
? null
: user.AvatarImageDataUrl;
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
return Ok(new TailoredCvPreviewDto(rendered.TemplateId, rendered.Html, rendered.SuggestedFileName));
}
@@ -1852,7 +1860,7 @@ Candidate master CV:
? request!.PhotoDataUrl
: request?.UseProfileAvatar == false
? null
: user.AvatarImageDataUrl;
: AvatarStorage.Resolve(user.AvatarImageDataUrl);
var rendered = RenderTailoredCv(job, document, user, photoDataUrl);
var artifact = await _cvPdfExporter.ExportAsync(rendered, cancellationToken);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
@@ -0,0 +1,110 @@
using System.Net.Http.Headers;
using System.Text.Json;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/job-discovery")]
[Authorize(AuthenticationSchemes = "local")]
public sealed class JobDiscoveryController : ControllerBase
{
private const string BaseUrl = "https://pam-stilling-feed.nav.no";
private readonly IHttpClientFactory _clients;
private readonly IConfiguration _configuration;
private readonly IMemoryCache _cache;
public JobDiscoveryController(IHttpClientFactory clients, IConfiguration configuration, IMemoryCache cache)
{
_clients = clients;
_configuration = configuration;
_cache = cache;
}
[HttpGet("search")]
public async Task<ActionResult<IReadOnlyList<DiscoveredJob>>> Search([FromQuery] string? q, [FromQuery] string? location, CancellationToken cancellationToken)
{
try
{
var token = await GetTokenAsync(cancellationToken);
var client = _clients.CreateClient();
var entries = new Dictionary<string, DiscoveredJob>(StringComparer.OrdinalIgnoreCase);
var next = "/api/v1/feed";
// ponytail: scan the recent event window on demand; add a persisted feed cursor only when
// usage makes the bounded request noticeably slow or NAV private-token terms require it.
for (var page = 0; page < 20 && !string.IsNullOrWhiteSpace(next); page++)
{
using var request = new HttpRequestMessage(HttpMethod.Get, BaseUrl + next);
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
if (page == 0) request.Headers.IfModifiedSince = DateTimeOffset.UtcNow.AddDays(-14);
using var response = await client.SendAsync(request, cancellationToken);
response.EnsureSuccessStatusCode();
using var json = JsonDocument.Parse(await response.Content.ReadAsStreamAsync(cancellationToken));
next = json.RootElement.TryGetProperty("next_url", out var nextElement) ? nextElement.GetString() ?? "" : "";
foreach (var item in json.RootElement.GetProperty("items").EnumerateArray())
{
var feed = item.GetProperty("_feed_entry");
var id = feed.GetProperty("uuid").GetString();
if (string.IsNullOrWhiteSpace(id)) continue;
var status = feed.TryGetProperty("status", out var statusElement) ? statusElement.GetString() : null;
if (!string.Equals(status, "ACTIVE", StringComparison.OrdinalIgnoreCase))
{
entries.Remove(id);
continue;
}
entries[id] = new DiscoveredJob(
id,
feed.TryGetProperty("title", out var title) ? title.GetString() ?? "" : "",
feed.TryGetProperty("businessName", out var company) ? company.GetString() : null,
feed.TryGetProperty("municipal", out var municipal) ? municipal.GetString() : null,
item.TryGetProperty("date_modified", out var modified) && modified.TryGetDateTimeOffset(out var date) ? date : null,
$"https://arbeidsplassen.nav.no/stillinger/stilling/{id}",
"nav",
"NO");
}
}
var query = (q ?? "").Trim();
var place = (location ?? "").Trim();
return Ok(entries.Values
.Where(job => Contains(job.Title, query) || Contains(job.Company, query))
.Where(job => Contains(job.Location, place))
.OrderByDescending(job => job.ModifiedAt)
.Take(100)
.ToList());
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or InvalidOperationException)
{
return Problem("NAV job discovery is temporarily unavailable.", statusCode: StatusCodes.Status502BadGateway);
}
}
private async Task<string> GetTokenAsync(CancellationToken cancellationToken)
{
var configured = _configuration["NavJobs:Token"]?.Trim();
if (!string.IsNullOrWhiteSpace(configured)) return configured;
return await _cache.GetOrCreateAsync("nav-jobs-public-token", async entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30);
var text = await _clients.CreateClient().GetStringAsync(BaseUrl + "/api/publicToken", cancellationToken);
var start = text.IndexOf("eyJ", StringComparison.Ordinal);
if (start < 0) throw new InvalidOperationException("NAV public token was not returned.");
var token = text[start..].Trim();
var end = token.IndexOfAny(['\r', '\n', ' ', '\t']);
return end < 0 ? token : token[..end];
}) ?? throw new InvalidOperationException("NAV public token was not returned.");
}
private static bool Contains(string? value, string filter) =>
filter.Length == 0 || (value?.Contains(filter, StringComparison.OrdinalIgnoreCase) ?? false);
public sealed record DiscoveredJob(string Id, string Title, string? Company, string? Location, DateTimeOffset? ModifiedAt, string Url, string Source, string CountryCode);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,480 @@
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Data;
using JobTrackerApi.Services;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Controllers;
public sealed partial class ProfileCvController : ControllerBase
{
private static TailoredCvDocument BuildMasterCvDocument(StructuredCvProfile structuredCv, string templateId, string? targetRole, string? fallbackHeadline, string? companyName)
{
var normalized = StructuredCvProfileJson.Normalize(structuredCv);
var customSections = new List<TailoredCvCustomSection>();
if (normalized.Certifications.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Certifications",
Items = normalized.Certifications.Select(certification => string.Join(" | ", new[] { certification.Name, certification.Issuer, certification.Location, certification.Date }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
if (normalized.Projects.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Projects",
Items = normalized.Projects.Select(project => string.Join(" | ", new[] { project.Name, project.Role, project.Location, FormatDateRangeForSection(project.Start, project.End, false) }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
if (normalized.Languages.Count > 0)
{
customSections.Add(new TailoredCvCustomSection
{
Title = "Languages",
Items = normalized.Languages.Select(language => string.Join(": ", new[] { language.Name, language.Level }.Where(value => !string.IsNullOrWhiteSpace(value)))).Where(value => !string.IsNullOrWhiteSpace(value)).ToList(),
});
}
customSections.AddRange(normalized.OtherSections.Select(section => new TailoredCvCustomSection { Title = section.Title, Items = section.Items }));
return TailoredCvDraftJson.Normalize(new TailoredCvDocument
{
TemplateId = templateId,
Headline = normalized.Contact.Headline ?? targetRole ?? fallbackHeadline ?? companyName,
Summary = normalized.Summary,
SelectedSkills = normalized.Skills,
Experience = normalized.Jobs.Select(job => new TailoredCvExperienceItem
{
Title = job.Title,
Company = job.Company,
Location = job.Location,
Start = job.Start,
End = job.End,
IsCurrent = job.IsCurrent,
Bullets = job.Bullets,
}).ToList(),
Education = normalized.Education.Select(education => new TailoredCvEducationItem
{
Qualification = education.Qualification,
QualificationLevel = education.QualificationLevel,
Institution = education.Institution,
Location = education.Location,
Start = education.Start,
End = education.End,
Details = education.Details,
}).ToList(),
CustomSections = customSections,
RenderOptions = new TailoredCvRenderOptions
{
ShowPhoto = true,
AccentColor = templateId switch
{
"harvard" => "brick",
"auckland" => "emerald",
"edinburgh" => "plum",
"monarch" => "#7c2d12",
"fjord" => "#0f4c5c",
_ => "slate",
},
SectionOrder = new List<string> { "summary", "skills", "experience", "education", "custom" },
}
});
}
private async Task<StructuredCvProfile> BuildStructuredCvAsync(string text, CancellationToken cancellationToken)
{
if (LooksLikeNormalizedMarkdownCv(text))
{
var normalized = BuildStructuredCvFromNormalizedMarkdown(text);
AnnotateStructuredCv(normalized, "normalized-markdown", 0.78);
return StructuredCvProfileJson.Normalize(normalized);
}
var parseSource = NormalizeTextForStructuredParsing(text);
var parsedSections = ParseSections(parseSource)
.Select(section => new StructuredCvSection
{
Name = section.Name,
Content = section.Content,
WordCount = CountWords(section.Content),
})
.ToList();
var hasRealSections = parsedSections.Any(section => !string.Equals(section.Name, "General", StringComparison.OrdinalIgnoreCase));
List<ClassifiedCvBlock> classifiedBlocks = new();
List<StructuredCvSection> fallbackSections = parsedSections;
StructuredCvProfile? classifierFallback = null;
if (!hasRealSections)
{
classifiedBlocks = await ClassifyBlocksAsync(parseSource, cancellationToken);
var hasMeaningfulClassifierStructure = classifiedBlocks.Any(block => !string.Equals(block.SectionName, "General", StringComparison.OrdinalIgnoreCase));
if (hasMeaningfulClassifierStructure)
{
fallbackSections = BuildSectionsFromClassifiedBlocks(classifiedBlocks);
classifierFallback = BuildStructuredCvFromClassifiedBlocks(classifiedBlocks);
}
}
var sectionFallback = StructuredCvProfileJson.FromSections(fallbackSections);
AnnotateStructuredCv(sectionFallback, "repair", 0.56);
var heuristicFallback = BuildHeuristicStructuredCv(parseSource, text);
AnnotateStructuredCv(heuristicFallback, "deterministic", 0.68);
heuristicFallback.Sections = new List<StructuredCvSection>();
var fallback = StructuredCvProfileJson.Merge(heuristicFallback, sectionFallback);
if (classifierFallback is not null)
{
fallback = StructuredCvProfileJson.Merge(classifierFallback, fallback);
}
fallback.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(fallback.Contact.Email);
var extracted = await TryExtractStructuredCvAsync(parseSource, cancellationToken);
var merged = StructuredCvProfileJson.Merge(extracted, fallback);
merged.Contact.FullName ??= GuessFullName(text) ?? GuessFullNameFromEmail(merged.Contact.Email);
if (!IsPlausibleLocationValue(merged.Contact.Location, merged.Contact.FullName))
{
merged.Contact.Location = PreferDetectedLocation(text, null, merged.Contact.FullName);
}
merged.Jobs = merged.Jobs
.Where(job => !LooksLikePersonName(job.Title ?? string.Empty))
.ToList();
var reparsedJobs = ParseJobsHeuristically(text)
.Where(job => !LooksLikePersonName(job.Title ?? string.Empty))
.ToList();
var existingFirstTitle = merged.Jobs.FirstOrDefault()?.Title;
var reparsedFirstTitle = reparsedJobs.FirstOrDefault()?.Title;
if (LooksLikePersonName(existingFirstTitle ?? string.Empty)
&& LooksLikeRoleOrHeadline(reparsedFirstTitle ?? string.Empty)
&& ArePlausibleJobs(reparsedJobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
else if (ArePlausibleJobs(merged.Jobs, merged.Contact.FullName))
{
if (ScoreJobs(reparsedJobs, merged.Contact.FullName) > ScoreJobs(merged.Jobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
}
else if (ArePlausibleJobs(reparsedJobs, merged.Contact.FullName))
{
merged.Jobs = reparsedJobs;
}
return StructuredCvProfileJson.Normalize(merged);
}
private async Task<CvUploadArtifact> SaveUploadArtifactAsync(ApplicationUser user, IFormFile file, CancellationToken cancellationToken)
{
var extension = Path.GetExtension(file.FileName ?? string.Empty);
var userRoot = Path.Combine(_paths.CvArtifactsRoot, user.Id);
Directory.CreateDirectory(userRoot);
var storedFileName = $"{DateTimeOffset.UtcNow:yyyyMMddHHmmss}-{Guid.NewGuid():N}{extension}";
var storagePath = Path.Combine(userRoot, storedFileName);
await using (var target = System.IO.File.Create(storagePath))
await using (var source = file.OpenReadStream())
{
await source.CopyToAsync(target, cancellationToken);
}
await using var hashStream = System.IO.File.OpenRead(storagePath);
var shaBytes = await SHA256.HashDataAsync(hashStream, cancellationToken);
return new CvUploadArtifact
{
OwnerUserId = user.Id,
OriginalFileName = file.FileName ?? storedFileName,
StoredFileName = storedFileName,
MimeType = file.ContentType ?? "application/octet-stream",
ByteSize = file.Length,
Sha256 = Convert.ToHexString(shaBytes),
StoragePath = storagePath,
UploadedAtUtc = DateTimeOffset.UtcNow,
};
}
private async Task<ExtractionPipelineResult> ExtractStructuredCvFromFileAsync(IFormFile file, string extension, CancellationToken cancellationToken)
{
string text;
var canUseAiExtraction = string.Equals(extension, ".pdf", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".docx", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".txt", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".md", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".png", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".jpeg", StringComparison.OrdinalIgnoreCase)
|| string.Equals(extension, ".webp", StringComparison.OrdinalIgnoreCase);
if (canUseAiExtraction)
{
await using var uploadStream = file.OpenReadStream();
var extracted = await _aiService.ExtractTextAsync(uploadStream, file.FileName ?? $"cv{extension}", file.ContentType, cancellationToken);
text = extracted?.Text?.Trim() ?? string.Empty;
}
else
{
text = string.Empty;
}
if (string.IsNullOrWhiteSpace(text))
{
text = (await ExtractTextAsync(file, extension)).Trim();
}
if (string.IsNullOrWhiteSpace(text))
{
throw new InvalidOperationException("The uploaded CV file could not be read or was empty.");
}
text = RepairKnownMojibake(text);
var normalizedText = (await MaybeReconstructStructuredCvAsync(text, cancellationToken)).Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
return new ExtractionPipelineResult(text, normalizedText, structuredCv);
}
private async Task ApplyTextExtractionRunAsync(ApplicationUser user, string trigger, string rawText, string normalizedText, StructuredCvProfile structuredCv, int? artifactId, CancellationToken cancellationToken)
{
var run = new CvExtractionRun
{
OwnerUserId = user.Id,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "applied",
RawExtractedText = rawText,
NormalizedText = normalizedText,
StartedAtUtc = DateTimeOffset.UtcNow,
CompletedAtUtc = DateTimeOffset.UtcNow,
AppliedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
structuredCv.Metadata.ProfileVersion = (user.CurrentCvProfileVersion ?? 0) + 1;
structuredCv.Metadata.AppliedExtractionRunId = run.Id;
structuredCv.Metadata.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _careerProfileService.SaveVersionAsync(user.Id, structuredCv, trigger, cancellationToken);
var structuredJson = StructuredCvProfileJson.Serialize(structuredCv);
run.StructuredProfileJson = structuredJson;
user.ProfileCvText = normalizedText;
user.ProfileCvStructureJson = structuredJson;
user.CurrentCvExtractionRunId = run.Id;
user.CurrentCvProfileVersion = structuredCv.Metadata.ProfileVersion;
if (artifactId.HasValue)
{
user.CurrentCvUploadArtifactId = artifactId.Value;
}
var update = await _users.UpdateAsync(user);
if (!update.Succeeded)
{
run.Status = "failed";
run.ErrorMessage = string.Join("; ", update.Errors.Select(e => e.Description));
await _db.SaveChangesAsync(cancellationToken);
throw new InvalidOperationException(run.ErrorMessage);
}
await _db.SaveChangesAsync(cancellationToken);
await PruneExtractionRunsAsync(user.Id, cancellationToken);
}
private async Task<CvExtractionRun> CreateQueuedRunAsync(string ownerUserId, int? artifactId, string trigger, CancellationToken cancellationToken)
{
var run = new CvExtractionRun
{
OwnerUserId = ownerUserId,
ArtifactId = artifactId,
Trigger = trigger,
ParserVersion = ParserVersion,
NormalizerVersion = NormalizerVersion,
LlmPromptVersion = LlmPromptVersion,
Status = "queued",
StartedAtUtc = DateTimeOffset.UtcNow,
};
_db.CvExtractionRuns.Add(run);
await _db.SaveChangesAsync(cancellationToken);
return run;
}
// Invoked by CvProcessingHostedService (this controller is also registered as a
// transient service). NonAction keeps it off the HTTP surface: without it the
// controller-level [Route] exposes it as an any-verb endpoint.
[NonAction]
public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
{
var run = await _db.CvExtractionRuns.IgnoreQueryFilters().FirstOrDefaultAsync(x => x.Id == runId, cancellationToken);
if (run is null) return;
var user = await _users.FindByIdAsync(run.OwnerUserId);
if (user is null)
{
run.Status = "failed";
run.ErrorMessage = "CV processing user was not found.";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
return;
}
run.Status = "running";
run.ErrorMessage = null;
await _db.SaveChangesAsync(cancellationToken);
try
{
switch (run.Trigger)
{
case "rebuild":
{
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before rebuilding it.");
var rebuilt = await _aiService.SummarizeSectionAsync(
"Rewrite this CV into a stronger master CV with clear sections such as Professional Summary, Core Skills, Experience Highlights, and Selected Achievements. Preserve only factual claims, avoid inventing employers or metrics, and make the output clean and ready for tailoring to job applications. Return only the rebuilt CV text.",
user.ProfileCvText,
2200,
700);
if (string.IsNullOrWhiteSpace(rebuilt)) throw new InvalidOperationException("The AI service could not rebuild your CV text right now.");
var normalizedText = rebuilt.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "improve":
{
if (string.IsNullOrWhiteSpace(user.ProfileCvText)) throw new InvalidOperationException("Add or import CV text before improving it.");
var improved = await _aiService.SummarizeSectionAsync(
"Rewrite this CV into a cleaner, better-structured master CV profile. Preserve factual claims, employers, skills, and measurable results. Improve clarity, tighten wording, use strong bullet-style phrasing, and keep it ready for further tailoring to specific roles. Return only the improved CV text.",
user.ProfileCvText,
1800,
500);
if (string.IsNullOrWhiteSpace(improved)) throw new InvalidOperationException("The AI service could not improve your CV text right now.");
var normalizedText = improved.Trim();
var structuredCv = await BuildStructuredCvAsync(normalizedText, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, normalizedText, normalizedText, structuredCv, cancellationToken);
break;
}
case "reprocess":
{
var artifact = await _db.CvUploadArtifacts.IgnoreQueryFilters().AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
if (artifact is null) throw new InvalidOperationException("Upload a CV before reprocessing it.");
if (string.IsNullOrWhiteSpace(artifact.StoragePath) || !System.IO.File.Exists(artifact.StoragePath))
{
throw new InvalidOperationException("The stored CV artifact could not be found for reprocessing.");
}
await using var stream = System.IO.File.OpenRead(artifact.StoragePath);
var file = new FormFile(stream, 0, stream.Length, "file", artifact.OriginalFileName)
{
Headers = new HeaderDictionary(),
ContentType = artifact.MimeType
};
var extension = Path.GetExtension(artifact.OriginalFileName ?? string.Empty);
var result = await ExtractStructuredCvFromFileAsync(file, extension, cancellationToken);
await CompleteQueuedRunForReviewAsync(run, result.RawText, result.NormalizedText, result.StructuredCv, cancellationToken);
break;
}
default:
throw new InvalidOperationException($"Unsupported CV processing trigger '{run.Trigger}'.");
}
await SendRunCompletionEmailAsync(user, run, true, cancellationToken);
}
catch (Exception ex)
{
run.Status = "failed";
run.ErrorMessage = ex.Message;
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
await PruneExtractionRunsAsync(user.Id, cancellationToken);
await SendRunCompletionEmailAsync(user, run, false, cancellationToken);
_logger.LogWarning(ex, "CV processing run {RunId} failed for user {UserId}", run.Id, user.Id);
}
}
private async Task CompleteQueuedRunForReviewAsync(CvExtractionRun run, string rawText, string normalizedText, StructuredCvProfile structuredCv, CancellationToken cancellationToken)
{
run.RawExtractedText = rawText;
run.NormalizedText = normalizedText;
run.StructuredProfileJson = StructuredCvProfileJson.Serialize(structuredCv);
run.Status = "pending_review";
run.CompletedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(cancellationToken);
await PruneExtractionRunsAsync(run.OwnerUserId, cancellationToken);
}
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var expired = await _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.Status != "queued" && x.Status != "running")
.OrderByDescending(x => x.StartedAtUtc)
.Skip(ExtractionRunRetentionCount)
.ToListAsync(cancellationToken);
if (expired.Count > 0)
{
_db.CvExtractionRuns.RemoveRange(expired);
await _db.SaveChangesAsync(cancellationToken);
}
var referencedArtifactIds = await _db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && x.ArtifactId != null)
.Select(x => x.ArtifactId!.Value)
.Distinct()
.ToListAsync(cancellationToken);
var user = await _users.FindByIdAsync(ownerUserId);
if (user?.CurrentCvUploadArtifactId is int currentArtifactId)
{
referencedArtifactIds.Add(currentArtifactId);
}
var orphanedArtifacts = await _db.CvUploadArtifacts.IgnoreQueryFilters()
.Where(x => x.OwnerUserId == ownerUserId && !referencedArtifactIds.Contains(x.Id))
.ToListAsync(cancellationToken);
if (orphanedArtifacts.Count == 0) return;
_db.CvUploadArtifacts.RemoveRange(orphanedArtifacts);
await _db.SaveChangesAsync(cancellationToken);
foreach (var artifact in orphanedArtifacts)
{
try
{
if (!string.IsNullOrWhiteSpace(artifact.StoragePath)) System.IO.File.Delete(artifact.StoragePath);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not delete unreferenced CV artifact {ArtifactId} at {Path}", artifact.Id, artifact.StoragePath);
}
}
}
private async Task SendRunCompletionEmailAsync(ApplicationUser user, CvExtractionRun run, bool success, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(user.Email)) return;
var subject = success ? $"Your CV {run.Trigger} is complete" : $"Your CV {run.Trigger} failed";
var body = success
? $"Your CV {run.Trigger} request finished successfully.\n\nRun ID: {run.Id}\nStatus: {run.Status}\nCompleted: {run.CompletedAtUtc:O}\n"
: $"Your CV {run.Trigger} request failed.\n\nRun ID: {run.Id}\nStatus: {run.Status}\nError: {run.ErrorMessage}\nCompleted: {run.CompletedAtUtc:O}\n";
try
{
await _emailSender.SendAsync(user.Email, subject, body, cancellationToken);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "CV processing completion email failed for run {RunId} user {UserId}", run.Id, user.Id);
}
}
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,7 @@ using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.RateLimiting;
namespace JobTrackerApi.Controllers;
@@ -15,11 +16,13 @@ public sealed class PublicCvController : ControllerBase
{
private readonly UserManager<ApplicationUser> _users;
private readonly ICvVariantService _variants;
private readonly ICvPdfExporter _pdf;
public PublicCvController(UserManager<ApplicationUser> users, ICvVariantService variants)
public PublicCvController(UserManager<ApplicationUser> users, ICvVariantService variants, ICvPdfExporter pdf)
{
_users = users;
_variants = variants;
_pdf = pdf;
}
[HttpGet("{slug}")]
@@ -37,6 +40,22 @@ public sealed class PublicCvController : ControllerBase
return Ok(new { html = result.Value.render.Html, name = person.FallbackName });
}
[HttpGet("{slug}/pdf")]
[EnableRateLimiting("public-pdf")]
public async Task<IActionResult> DownloadPdf(string slug, CancellationToken ct)
{
var ownerId = await _variants.GetPublicOwnerAsync(slug, ct);
if (ownerId is null) return NotFound();
var person = Person(await _users.FindByIdAsync(ownerId));
var result = await _variants.RenderPublicAsync(slug, person, ct);
if (result is null) return NotFound();
var render = result.Value.render;
var artifact = await _pdf.ExportAsync(new TailoredCvRenderResult(render.ThemeId, render.SuggestedFileName, render.Html), ct);
return File(artifact.Bytes, "application/pdf", artifact.FileName);
}
private static CvRenderPerson Person(ApplicationUser? user)
{
if (user is null) return new CvRenderPerson("Candidate", null);
@@ -44,6 +63,6 @@ public sealed class PublicCvController : ControllerBase
if (string.IsNullOrWhiteSpace(name)) name = user.DisplayName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = user.UserName?.Trim();
if (string.IsNullOrWhiteSpace(name)) name = "Candidate";
return new CvRenderPerson(name!, user.AvatarImageDataUrl);
return new CvRenderPerson(name!, AvatarStorage.Resolve(user.AvatarImageDataUrl));
}
}
-4
View File
@@ -3,11 +3,7 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
COPY Data/ Data/
COPY Models/ Models/
COPY JobTrackerApi/ JobTrackerApi/
COPY JobTrackerBackend/ JobTrackerBackend/
# Retry once after clearing NuGet caches. Transient download corruption on the
# build host can trip NU3008 ("package integrity check failed / has changed since
+10 -9
View File
@@ -8,21 +8,22 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<!-- dotnet-ef design-time tooling requires this on the startup project (not just
JobTrackerBackend, where the DbContext actually lives) since EF Core 6+. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageReference Include="Stripe.net" Version="52.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
</Project>
@@ -1,30 +1,22 @@
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
[DbContext(typeof(JobTrackerContext))]
[Migration("20260311121000_AddCorrespondenceEmailFields")]
public partial class AddCorrespondenceEmailFields : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Subject",
table: "Correspondences",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Channel",
table: "Correspondences",
type: "TEXT",
nullable: true);
// Schema is provisioned idempotently by StartupInitializationExtensions.
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "Subject", table: "Correspondences");
migrationBuilder.DropColumn(name: "Channel", table: "Correspondences");
}
}
}
@@ -1,4 +1,6 @@
using System;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
@@ -6,22 +8,19 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
[DbContext(typeof(JobTrackerContext))]
[Migration("20260311180000_AddShortSummary")]
public partial class AddShortSummary : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "ShortSummary",
table: "JobApplications",
type: "TEXT",
nullable: true);
// Schema is provisioned idempotently by StartupInitializationExtensions.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(name: "ShortSummary", table: "JobApplications");
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddAiUsageMetering : Migration
{
// Intentionally a no-op: StartupInitializationExtensions owns idempotent SQLite/MariaDB
// column reconciliation and runs before EF migrations. The snapshot records the model change;
// the reconciler performs the provider-safe DDL without duplicate-column failures.
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,22 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <inheritdoc />
public partial class AddStripeBillingState : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
// StartupInitializationExtensions owns provider-safe, idempotent SQLite/MariaDB DDL.
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
@@ -26,6 +26,12 @@ namespace JobTrackerApi.Migrations
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("TEXT");
b.Property<int>("EstimatedTokenCount")
.HasColumnType("INTEGER");
b.Property<int>("InputCharacterCount")
.HasColumnType("INTEGER");
b.Property<int>("JobApplicationId")
.HasColumnType("INTEGER");
@@ -37,6 +43,9 @@ namespace JobTrackerApi.Migrations
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<int>("OutputCharacterCount")
.HasColumnType("INTEGER");
b.Property<string>("OwnerUserId")
.IsRequired()
.HasMaxLength(255)
@@ -263,6 +272,18 @@ namespace JobTrackerApi.Migrations
b.Property<string>("SecurityStamp")
.HasColumnType("TEXT");
b.Property<string>("StripeCustomerId")
.HasColumnType("TEXT");
b.Property<DateTime?>("StripeLastEventCreatedUtc")
.HasColumnType("TEXT");
b.Property<string>("StripeSubscriptionId")
.HasColumnType("TEXT");
b.Property<string>("StripeSubscriptionStatus")
.HasColumnType("TEXT");
b.Property<DateTimeOffset?>("TotpEnabledAtUtc")
.HasColumnType("TEXT");
+17
View File
@@ -0,0 +1,17 @@
namespace JobTrackerApi.Models;
public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bool Automation, bool Analytics, long StorageBytes, int MonthlyAiCalls, long MonthlyAiTokens);
public static class AccountPlans
{
public static bool IsPremiumSubscriptionStatus(string? status) =>
status is "active" or "trialing";
public static AccountEntitlements ForRoles(IList<string> roles)
{
var premium = roles.Contains("Premium", StringComparer.OrdinalIgnoreCase) || roles.Contains("Admin", StringComparer.OrdinalIgnoreCase);
return premium
? new AccountEntitlements(true, true, true, true, 5_000_000_000, 250, 1_000_000)
: new AccountEntitlements(false, false, false, false, 250_000_000, 25, 100_000);
}
}
@@ -28,5 +28,10 @@ public sealed class AiInteraction
// meta carries any structured extras (e.g. career-match percent).
public string ResultJson { get; set; } = string.Empty;
// Provider-neutral usage meter. Token count is estimated because the sidecar currently returns text only.
public int InputCharacterCount { get; set; }
public int OutputCharacterCount { get; set; }
public int EstimatedTokenCount { get; set; }
public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow;
}
@@ -21,6 +21,8 @@ namespace JobTrackerApi.Models
public sealed record StageDurationDto(string Stage, double MedianDays, int Count);
public sealed record SalaryInsightDto(string Currency, string Period, int Count, decimal Minimum, decimal Maximum, decimal AverageMidpoint);
public sealed record AnalyticsOverviewDto(
List<FunnelStagePoint> Funnel,
List<ResponseRatePoint> ResponseRateBySource,
@@ -28,6 +30,7 @@ namespace JobTrackerApi.Models
double? MedianDaysToFirstResponse,
int TotalResponses,
int TotalActive,
List<StageDurationDto> TimeInStage
List<StageDurationDto> TimeInStage,
List<SalaryInsightDto> SalaryInsights
);
}
@@ -22,4 +22,8 @@ public sealed class ApplicationUser : IdentityUser
public string? TotpSecretEncrypted { get; set; }
public string? TotpPendingSecretEncrypted { get; set; }
public DateTimeOffset? TotpEnabledAtUtc { get; set; }
public string? StripeCustomerId { get; set; }
public string? StripeSubscriptionId { get; set; }
public string? StripeSubscriptionStatus { get; set; }
public DateTime? StripeLastEventCreatedUtc { get; set; }
}
@@ -50,6 +50,7 @@ public sealed class CvTheme
// ATS-friendly = single-column, no sidebar tables/graphics that trip naive resume parsers. Set on
// the single-column themes; surfaced in GET /api/cv/themes so the picker can badge it.
public bool AtsFriendly { get; init; }
public bool Premium { get; init; }
// Which section keys render in the sidebar for two-column layouts (ignored for single/header-band).
public List<string> SidebarSections { get; init; } = new() { "contact", "skills", "languages" };
@@ -79,7 +80,7 @@ public static class CvThemeCatalog
},
new()
{
Id = "executive", Name = "Executive", Category = "Executive",
Premium = true, Id = "executive", Name = "Executive", Category = "Executive",
Description = "High-contrast, serif, leadership-weighted. For senior and client-facing roles.",
Layout = "single", HeaderStyle = "centered", HeadingStyle = "underline",
Accent = "#7c2d12", Ink = "#1c1917", Muted = "#57534e", Line = "#1c1917",
@@ -88,7 +89,7 @@ public static class CvThemeCatalog
},
new()
{
Id = "technical", Name = "Technical", Category = "Technical",
Premium = true, Id = "technical", Name = "Technical", Category = "Technical",
Description = "Dense two-column layout tuned for engineering CVs — skills and projects up front.",
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
Accent = "#0f4c5c", Ink = "#102a43", Muted = "#486581", SidebarBg = "#0f4c5c", SidebarInk = "#ffffff",
@@ -107,7 +108,7 @@ public static class CvThemeCatalog
},
new()
{
Id = "nordic", Name = "Nordic", Category = "Modern Professional",
Premium = true, Id = "nordic", Name = "Nordic", Category = "Modern Professional",
Description = "Calm cool-blue sidebar, generous whitespace, Scandinavian restraint.",
Layout = "sidebar-right", HeaderStyle = "plain", HeadingStyle = "caps-rule",
Accent = "#3b6ea5", Ink = "#1f2937", Muted = "#4b5563", SidebarBg = "#eef3f8", SidebarInk = "#1f2937",
@@ -116,7 +117,7 @@ public static class CvThemeCatalog
},
new()
{
Id = "elegant", Name = "Elegant", Category = "Creative",
Premium = true, Id = "elegant", Name = "Elegant", Category = "Creative",
Description = "Editorial serif headings over sans body, premium spacing and a plum accent.",
Layout = "single", HeaderStyle = "kicker", HeadingStyle = "underline",
Accent = "#7c3aed", Ink = "#1f2937", Muted = "#4b5563",
@@ -125,7 +126,7 @@ public static class CvThemeCatalog
},
new()
{
Id = "creative", Name = "Creative", Category = "Creative",
Premium = true, Id = "creative", Name = "Creative", Category = "Creative",
Description = "Bold accent sidebar and photo-forward header for design and product roles.",
Layout = "sidebar-left", HeaderStyle = "band", HeadingStyle = "bar",
Accent = "#db2777", Ink = "#18181b", Muted = "#52525b", SidebarBg = "#be185d", SidebarInk = "#ffffff",
@@ -141,6 +142,9 @@ public static class CvThemeCatalog
return Themes.FirstOrDefault(t => t.Id == key) ?? Themes[0];
}
public static bool CanUse(string? id, bool premiumThemes) =>
Exists(id) && (premiumThemes || !Resolve(id).Premium);
public static bool Exists(string? id)
{
var key = (id ?? string.Empty).Trim().ToLowerInvariant();
@@ -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>
@@ -10,9 +10,9 @@ public class JobApplication
public int CompanyId { get; set; }
public Company Company { get; set; } = null!;
// The opportunity this application is for. Nullable and unused for now: Phase 0 added the
// Job entity additively and JobApplication still owns the opportunity columns below.
// See Models/Job.cs and docs/decisions/ADR-002-job-application-model.md.
// The opportunity this application is for. Nullable for legacy rows; new creates and edits
// keep Job synchronized while JobApplication remains the current read model.
// See JobTrackerApi/Models/Job.cs and docs/decisions/ADR-002-job-application-model.md.
public int? JobId { get; set; }
public Job? Job { get; set; }
@@ -13,6 +13,10 @@ public sealed class StructuredCvProfile
public List<string> Skills { get; set; } = new();
public List<StructuredCvLanguage> Languages { get; set; } = new();
public List<string> Interests { get; set; } = new();
public List<string> Awards { get; set; } = new();
public List<string> Publications { get; set; } = new();
public List<string> Organisations { get; set; } = new();
public List<string> References { get; set; } = new();
public List<StructuredCvOtherSection> OtherSections { get; set; } = new();
public List<StructuredCvSection> Sections { get; set; } = new();
}
@@ -82,6 +82,10 @@ public static class StructuredCvProfileJson
primary.Interests = primary.Interests.Count == 0
? secondary.Interests
: primary.Interests.Concat(secondary.Interests).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
MergeStrings(primary.Awards, secondary.Awards);
MergeStrings(primary.Publications, secondary.Publications);
MergeStrings(primary.Organisations, secondary.Organisations);
MergeStrings(primary.References, secondary.References);
if (primary.OtherSections.Count == 0) primary.OtherSections = secondary.OtherSections;
if (primary.Sections.Count == 0) primary.Sections = secondary.Sections;
@@ -126,6 +130,22 @@ public static class StructuredCvProfileJson
case "interests":
profile.Interests = SplitList(section.Content);
break;
case "awards":
case "honours":
case "honors":
profile.Awards = SplitList(section.Content);
break;
case "publications":
profile.Publications = SplitList(section.Content);
break;
case "organisations":
case "organizations":
case "memberships":
profile.Organisations = SplitList(section.Content);
break;
case "references":
profile.References = SplitList(section.Content);
break;
case "work experience":
case "experience":
case "employment history":
@@ -193,6 +213,10 @@ public static class StructuredCvProfileJson
.Where(language => !string.IsNullOrWhiteSpace(language.Name))
.ToList();
profile.Interests = CleanList(profile.Interests);
profile.Awards = CleanList(profile.Awards);
profile.Publications = CleanList(profile.Publications);
profile.Organisations = CleanList(profile.Organisations);
profile.References = CleanList(profile.References);
profile.OtherSections = (profile.OtherSections ?? new List<StructuredCvOtherSection>())
.Select(section => new StructuredCvOtherSection
{
@@ -630,6 +654,11 @@ public static class StructuredCvProfileJson
AddSectionIfAny(sections, "Interests", profile.Interests);
AddSectionIfAny(sections, "Awards", profile.Awards);
AddSectionIfAny(sections, "Publications", profile.Publications);
AddSectionIfAny(sections, "Organisations", profile.Organisations);
AddSectionIfAny(sections, "References", profile.References);
foreach (var other in profile.OtherSections)
{
AddSectionIfAny(sections, other.Title ?? "Other", other.Items);
@@ -638,6 +667,12 @@ public static class StructuredCvProfileJson
return NormalizeSections(sections);
}
private static void MergeStrings(List<string> primary, IEnumerable<string> secondary)
{
foreach (var value in secondary)
if (!primary.Contains(value, StringComparer.OrdinalIgnoreCase)) primary.Add(value);
}
private static void AddSectionIfAny(List<StructuredCvSection> sections, string name, IEnumerable<string>? lines)
{
var content = string.Join("\n", (lines ?? Array.Empty<string>()).Where(line => !string.IsNullOrWhiteSpace(line)).Select(line => line.Trim())).Trim();
+44 -15
View File
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using JobTrackerApi.Models;
@@ -26,8 +27,12 @@ var builder = WebApplication.CreateBuilder(args);
// Avoid Windows EventLog provider issues in local dev environments.
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
if (builder.Environment.IsProduction()) builder.Logging.AddJsonConsole();
else
{
builder.Logging.AddSimpleConsole();
builder.Logging.AddDebug();
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@@ -39,6 +44,7 @@ builder.Services.AddSingleton<ICvTemplateRenderer, CvTemplateRenderer>();
builder.Services.AddSingleton<IThemedCvRenderer, ThemedCvRenderer>();
builder.Services.AddSingleton<ICvPdfExporter, PlaywrightCvPdfExporter>();
builder.Services.AddScoped<ICareerProfileService, CareerProfileService>();
builder.Services.AddSingleton<ICvProfileDiffService, CvProfileDiffService>();
builder.Services.AddScoped<ICvVariantService, CvVariantService>();
builder.Services.AddScoped<IAiWorkspaceService, AiWorkspaceService>();
builder.Services.AddScoped<IApplicationWorkspaceService, ApplicationWorkspaceService>();
@@ -104,24 +110,22 @@ builder.Services.AddCors(options =>
}
if (origins.Any(x => x.Trim() == "*"))
{
policy.SetIsOriginAllowed(_ => true)
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
else
{
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
}
throw new InvalidOperationException("Cors:Origins cannot contain wildcard when credentialed requests are enabled.");
policy.WithOrigins(origins.Select(x => x.Trim()).Where(x => x.Length > 0).ToArray())
.AllowAnyMethod()
.AllowAnyHeader()
.AllowCredentials();
});
});
// Add controllers
builder.Services.AddControllers();
builder.Services.AddProblemDetails(options =>
{
options.CustomizeProblemDetails = context =>
context.ProblemDetails.Extensions["traceId"] = context.HttpContext.TraceIdentifier;
});
builder.Services.AddOpenApi();
var dataRoot = (builder.Configuration["Data:Root"] ?? "").Trim();
if (string.IsNullOrWhiteSpace(dataRoot))
@@ -413,6 +417,17 @@ builder.Services.AddRateLimiter(options =>
QueueLimit = 0,
}));
options.AddPolicy("public-pdf", context =>
RateLimitPartition.GetFixedWindowLimiter(
partitionKey: $"public-pdf:{context.Request.RouteValues["slug"]?.ToString() ?? "unknown"}",
factory: _ => new FixedWindowRateLimiterOptions
{
PermitLimit = 3,
Window = TimeSpan.FromMinutes(1),
QueueProcessingOrder = QueueProcessingOrder.OldestFirst,
QueueLimit = 0,
}));
// Brute-forcing a 6-digit TOTP code (1e6 space) is far more feasible than a password, so
// this gets a tighter window than auth-login.
options.AddPolicy("auth-2fa-challenge", context =>
@@ -436,9 +451,23 @@ if (ephemeralJwtKey)
var enableHttpsRedirect = app.Configuration.GetValue("HttpsRedirection:Enabled", false);
var enableHsts = app.Configuration.GetValue("HttpsRedirection:Hsts", false);
if (app.Configuration.GetValue("Proxy:TrustForwardedHeaders", false))
{
var forwarded = new ForwardedHeadersOptions
{
ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
ForwardLimit = 1,
};
// This mode is enabled only when compose keeps the backend internal and nginx is the sole ingress.
forwarded.KnownNetworks.Clear();
forwarded.KnownProxies.Clear();
app.UseForwardedHeaders(forwarded);
}
if (enableHsts) app.UseHsts();
if (enableHttpsRedirect) app.UseHttpsRedirection();
app.UseExceptionHandler();
// Structured request logging for easy diagnosis.
app.Use(async (ctx, next) =>
{
+7 -1
View File
@@ -133,7 +133,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
_ => throw new ArgumentException($"Unknown AI module '{module}'."),
};
var result = await _ai.SummarizeSectionAsync($"{instruction} {Guardrail}", source, max, 120);
var prompt = $"{instruction} {Guardrail}";
var result = await _ai.SummarizeSectionAsync(prompt, source, max, 120);
if (string.IsNullOrWhiteSpace(result))
{
throw new AiUnavailableException("The AI service could not generate this right now. Please try again in a moment.");
@@ -148,6 +149,9 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
Title = title,
Provider = string.IsNullOrWhiteSpace(provider) ? "ai-service" : provider,
ResultJson = JsonSerializer.Serialize(new { text = result.Trim() }, Json),
InputCharacterCount = prompt.Length + source.Length,
OutputCharacterCount = result.Trim().Length,
EstimatedTokenCount = EstimateTokens(prompt.Length + source.Length + result.Trim().Length),
CreatedAtUtc = DateTimeOffset.UtcNow,
};
_db.AiInteractions.Add(interaction);
@@ -174,6 +178,8 @@ public sealed class AiWorkspaceService : IAiWorkspaceService
return true;
}
internal static int EstimateTokens(int characterCount) => Math.Max(0, (characterCount + 3) / 4);
private static string? NormalizeMode(string module, string? mode)
{
if (module != "cover-letter") return null;
+24 -2
View File
@@ -85,7 +85,11 @@ namespace JobTrackerApi.Services
j.SavedAt,
j.CompanyId,
CompanyName = j.Company.Name,
CompanySource = j.Company.Source
CompanySource = j.Company.Source,
j.SalaryMin,
j.SalaryMax,
j.SalaryCurrency,
j.SalaryPeriod
})
.ToListAsync(cancellationToken);
@@ -176,6 +180,23 @@ namespace JobTrackerApi.Services
.Select(p => new StageDurationDto(p.Stage, p.MedianDays, p.Count))
.ToList();
var salaryInsights = activeJobs
.Where(j => (j.SalaryMin is not null || j.SalaryMax is not null)
&& !string.IsNullOrWhiteSpace(j.SalaryCurrency)
&& !string.IsNullOrWhiteSpace(j.SalaryPeriod))
.GroupBy(j => new { Currency = j.SalaryCurrency!.ToUpperInvariant(), Period = j.SalaryPeriod!.ToLowerInvariant() })
.Select(g => new SalaryInsightDto(
g.Key.Currency,
g.Key.Period,
g.Count(),
g.Min(j => j.SalaryMin ?? j.SalaryMax!.Value),
g.Max(j => j.SalaryMax ?? j.SalaryMin!.Value),
Math.Round(g.Average(j => ((j.SalaryMin ?? j.SalaryMax!.Value) + (j.SalaryMax ?? j.SalaryMin!.Value)) / 2m), 0)))
.OrderByDescending(x => x.Count)
.ThenBy(x => x.Currency)
.ThenBy(x => x.Period)
.ToList();
return new AnalyticsOverviewDto(
Funnel: funnel,
ResponseRateBySource: responseRateBySource,
@@ -183,7 +204,8 @@ namespace JobTrackerApi.Services
MedianDaysToFirstResponse: medianDays,
TotalResponses: activeJobs.Count(j => j.ResponseReceived || j.ResponseDate is not null),
TotalActive: activeJobs.Count,
TimeInStage: timeInStage
TimeInStage: timeInStage,
SalaryInsights: salaryInsights
);
}
}
@@ -1,6 +1,8 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.EntityFrameworkCore;
using System.Security.Cryptography;
using System.Text;
namespace JobTrackerApi.Services;
@@ -28,6 +30,8 @@ public sealed record ChecklistProgressDto(int Total, int Completed, int Dismisse
public sealed record ChecklistDto(IReadOnlyList<ChecklistItemDto> Items, ChecklistProgressDto Progress);
public sealed record LearningRecommendationDto(int Id, string Keyword, string Status);
public sealed record ChecklistItemInput(string? Title, string? Description, string? Category, string? Status, string? Section);
// The signals a checklist item can auto-complete from. Computed once per read.
@@ -86,10 +90,13 @@ public interface IApplicationChecklistService
Task<ChecklistItemDto?> UpdateAsync(string ownerUserId, int jobApplicationId, int itemId, ChecklistItemInput input, CancellationToken ct);
Task<bool> DeleteAsync(string ownerUserId, int jobApplicationId, int itemId, CancellationToken ct);
Task<ChecklistDto?> ReorderAsync(string ownerUserId, int jobApplicationId, IReadOnlyList<int> orderedIds, CancellationToken ct);
Task<IReadOnlyList<LearningRecommendationDto>> SyncLearningRecommendationsAsync(
string ownerUserId, int jobApplicationId, IReadOnlyList<string> missingKeywords, CancellationToken ct);
}
public sealed class ApplicationChecklistService : IApplicationChecklistService
{
private const string LearningKeyPrefix = "learning:";
// The default system checklist. Stable keys — renaming a title must never orphan a user's item.
private sealed record Template(string Key, string Title, string Description, string Category, string? Signal, string? Section);
@@ -246,6 +253,83 @@ public sealed class ApplicationChecklistService : IApplicationChecklistService
return Project(items);
}
public async Task<IReadOnlyList<LearningRecommendationDto>> SyncLearningRecommendationsAsync(
string ownerUserId, int jobApplicationId, IReadOnlyList<string> missingKeywords, CancellationToken ct)
{
if (!await _db.JobApplications.AsNoTracking()
.AnyAsync(job => job.Id == jobApplicationId && job.OwnerUserId == ownerUserId, ct))
{
return [];
}
var keywords = missingKeywords
.Where(keyword => !string.IsNullOrWhiteSpace(keyword))
.Select(keyword => keyword.Trim())
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var items = await _db.ApplicationChecklistItems
.Where(item => item.OwnerUserId == ownerUserId
&& item.JobApplicationId == jobApplicationId
&& item.SystemKey != null
&& item.SystemKey.StartsWith(LearningKeyPrefix))
.ToListAsync(ct);
var byKey = items.ToDictionary(item => item.SystemKey!, StringComparer.OrdinalIgnoreCase);
var activeKeys = keywords.Select(LearningKey).ToHashSet(StringComparer.OrdinalIgnoreCase);
var changed = false;
foreach (var keyword in keywords)
{
var key = LearningKey(keyword);
if (!byKey.TryGetValue(key, out var item))
{
item = new ApplicationChecklistItem
{
OwnerUserId = ownerUserId,
JobApplicationId = jobApplicationId,
SystemKey = key,
Title = keyword,
Description = $"Build or verify evidence for {keyword} before claiming it in an application.",
Category = ChecklistCategories.Custom,
Status = ChecklistStatuses.Pending,
Section = "match",
SortOrder = items.Count,
IsSystemGenerated = true,
};
_db.ApplicationChecklistItems.Add(item);
items.Add(item);
byKey[key] = item;
changed = true;
}
else if (item.Status == ChecklistStatuses.Done && item.IsAutoCompleted)
{
item.Status = ChecklistStatuses.Pending;
item.IsAutoCompleted = false;
item.CompletedAt = null;
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
changed = true;
}
}
foreach (var item in items.Where(item => !activeKeys.Contains(item.SystemKey!)
&& item.Status == ChecklistStatuses.Pending))
{
item.Status = ChecklistStatuses.Done;
item.IsAutoCompleted = true;
item.CompletedAt = DateTimeOffset.UtcNow;
item.UpdatedAtUtc = DateTimeOffset.UtcNow;
changed = true;
}
if (changed) await _db.SaveChangesAsync(ct);
return keywords.Select(keyword => byKey[LearningKey(keyword)])
.Select(item => new LearningRecommendationDto(item.Id, item.Title, item.Status))
.ToList();
}
private static string LearningKey(string keyword) => LearningKeyPrefix
+ Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(keyword.Trim().ToLowerInvariant())))[..40];
// The next unfinished step, by category priority then the user's own ordering. This is what
// ApplicationWorkspaceService surfaces as "what do I do next" — one source, not a parallel ruleset.
public static ChecklistItemDto? NextPending(ChecklistDto checklist) =>
+37
View File
@@ -0,0 +1,37 @@
using System.Security.Cryptography;
using System.Text;
namespace JobTrackerApi.Services;
public static class AvatarStorage
{
private const string FilePrefix = "file:";
public static async Task<string> StoreAsync(string dataRoot, string userId, byte[] bytes, string contentType, CancellationToken cancellationToken)
{
var userKey = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(userId))).ToLowerInvariant();
var folder = Path.Combine(dataRoot, "Avatars", userKey);
Directory.CreateDirectory(folder);
var extension = contentType switch { "image/png" => ".png", "image/webp" => ".webp", _ => ".jpg" };
var path = Path.Combine(folder, "avatar" + extension);
foreach (var old in Directory.EnumerateFiles(folder, "avatar.*")) if (!string.Equals(old, path, StringComparison.OrdinalIgnoreCase)) File.Delete(old);
await File.WriteAllBytesAsync(path, bytes, cancellationToken);
return FilePrefix + path;
}
public static string? Resolve(string? value)
{
if (string.IsNullOrWhiteSpace(value) || !value.StartsWith(FilePrefix, StringComparison.Ordinal)) return value;
var path = value[FilePrefix.Length..];
if (!File.Exists(path)) return null;
var contentType = Path.GetExtension(path).ToLowerInvariant() switch { ".png" => "image/png", ".webp" => "image/webp", _ => "image/jpeg" };
return $"data:{contentType};base64,{Convert.ToBase64String(File.ReadAllBytes(path))}";
}
public static void Delete(string? value)
{
if (value?.StartsWith(FilePrefix, StringComparison.Ordinal) != true) return;
var path = value[FilePrefix.Length..];
if (File.Exists(path)) File.Delete(path);
}
}
@@ -23,6 +23,10 @@ public static class CareerProfileMapper
public StructuredCvContact Contact { get; set; } = new();
public List<string> Summary { get; set; } = new();
public List<string> Interests { get; set; } = new();
public List<string> Awards { get; set; } = new();
public List<string> Publications { get; set; } = new();
public List<string> Organisations { get; set; } = new();
public List<string> References { get; set; } = new();
public List<StructuredCvOtherSection> OtherSections { get; set; } = new();
public List<StructuredCvSection> Sections { get; set; } = new();
}
@@ -35,6 +39,10 @@ public static class CareerProfileMapper
Contact = p.Contact,
Summary = p.Summary,
Interests = p.Interests,
Awards = p.Awards,
Publications = p.Publications,
Organisations = p.Organisations,
References = p.References,
OtherSections = p.OtherSections,
Sections = p.Sections,
}, JsonOptions);
@@ -125,6 +133,10 @@ public static class CareerProfileMapper
Contact = tail.Contact,
Summary = tail.Summary,
Interests = tail.Interests,
Awards = tail.Awards,
Publications = tail.Publications,
Organisations = tail.Organisations,
References = tail.References,
OtherSections = tail.OtherSections,
Sections = tail.Sections,
Jobs = experiences.Select(x => new StructuredCvJob
+435
View File
@@ -0,0 +1,435 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using JobTrackerApi.Models;
namespace JobTrackerApi.Services;
// Phase 2.1-a — the diff half of the review-and-merge workflow. Pure comparison of the user's current
// Career Profile against a freshly-extracted profile; produces structured, user-facing changes. It
// applies NOTHING — the merge engine consumes an accepted diff separately. No DB, no AI, no state.
//
// Conservative by design (the approved policy): items match only when they clearly refer to the same
// thing (company+title, institution+qualification, project/certification name, language/skill text),
// a field is only ever proposed as an *update* when the extracted value is non-empty and differs, and
// nothing the user already has is ever marked for deletion.
public enum CvChangeKind { Add, Update }
public sealed record CvFieldChange(string Field, string? OldValue, string? NewValue);
public sealed class CvItemChange
{
public string Id { get; init; } = "";
public CvChangeKind Kind { get; init; }
public string Category { get; init; } = "";
public string Label { get; init; } = "";
public string Confidence { get; init; } = "Medium"; // High | Medium | Low
public List<CvFieldChange> FieldChanges { get; init; } = new();
}
public sealed class CvCategoryDiff
{
public string Category { get; init; } = "";
public List<CvItemChange> Added { get; init; } = new();
public List<CvItemChange> Updated { get; init; } = new();
public int UnchangedCount { get; init; }
public int LowConfidenceCount => Added.Concat(Updated).Count(c => c.Confidence == "Low");
}
public sealed class CvImportDiff
{
public List<CvCategoryDiff> Categories { get; init; } = new();
public int TotalAdded => Categories.Sum(c => c.Added.Count);
public int TotalUpdated => Categories.Sum(c => c.Updated.Count);
public int TotalLowConfidence => Categories.Sum(c => c.LowConfidenceCount);
public bool HasChanges => TotalAdded > 0 || TotalUpdated > 0;
}
public interface ICvProfileDiffService
{
CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted);
StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null);
}
public sealed class CvProfileDiffService : ICvProfileDiffService
{
public CvImportDiff Diff(StructuredCvProfile current, StructuredCvProfile extracted)
{
current ??= new StructuredCvProfile();
extracted ??= new StructuredCvProfile();
return new CvImportDiff
{
Categories = new List<CvCategoryDiff>
{
DiffContact(current.Contact, extracted.Contact),
DiffSummary(current.Summary, extracted.Summary),
DiffList("Experience", current.Jobs, extracted.Jobs, JobKey, JobLabel, JobFields, JobConfidence),
DiffList("Education", current.Education, extracted.Education, EduKey, EduLabel, EduFields, EduConfidence),
DiffList("Projects", current.Projects, extracted.Projects, p => Norm(p.Name), p => p.Name ?? "Project", ProjectFields, p => Confidence(p.Name, p.Bullets.Count > 0)),
DiffList("Certifications", current.Certifications, extracted.Certifications, c => Norm(c.Name), c => c.Name ?? "Certification", CertFields, c => Confidence(c.Name, !string.IsNullOrWhiteSpace(c.Issuer))),
DiffLanguages(current.Languages, extracted.Languages),
DiffScalars("Skills", current.Skills, extracted.Skills),
DiffScalars("Interests", current.Interests, extracted.Interests),
DiffScalars("Awards", current.Awards, extracted.Awards),
DiffScalars("Publications", current.Publications, extracted.Publications),
DiffScalars("Organisations", current.Organisations, extracted.Organisations),
DiffScalars("References", current.References, extracted.References),
}.Where(c => c is not null).Select(c => c!).ToList(),
};
}
public StructuredCvProfile Merge(StructuredCvProfile current, StructuredCvProfile extracted, IReadOnlySet<string>? acceptedLowConfidenceIds = null)
{
var merged = StructuredCvProfileJson.Deserialize(StructuredCvProfileJson.Serialize(current ?? new StructuredCvProfile()));
extracted = FilterLowConfidence(extracted ?? new StructuredCvProfile(), acceptedLowConfidenceIds);
MergeContact(merged.Contact, extracted.Contact);
if (extracted.Summary.Any(x => !string.IsNullOrWhiteSpace(x))) merged.Summary = extracted.Summary.Where(x => !string.IsNullOrWhiteSpace(x)).ToList();
MergeList(merged.Jobs, extracted.Jobs, JobKey, MergeJob);
MergeList(merged.Education, extracted.Education, EduKey, MergeEducation);
MergeList(merged.Projects, extracted.Projects, p => Norm(p.Name), MergeProject);
MergeList(merged.Certifications, extracted.Certifications, c => Norm(c.Name), MergeCertification);
MergeLanguages(merged.Languages, extracted.Languages);
AppendUnique(merged.Skills, extracted.Skills);
AppendUnique(merged.Interests, extracted.Interests);
AppendUnique(merged.Awards, extracted.Awards);
AppendUnique(merged.Publications, extracted.Publications);
AppendUnique(merged.Organisations, extracted.Organisations);
AppendUnique(merged.References, extracted.References);
MergeList(merged.OtherSections, extracted.OtherSections, x => Norm(x.Title), (a, b) => AppendUnique(a.Items, b.Items));
if (extracted.Sections.Count > 0) merged.Sections = extracted.Sections;
foreach (var field in extracted.Metadata.Fields) merged.Metadata.Fields[field.Key] = field.Value;
return merged;
}
private static StructuredCvProfile FilterLowConfidence(StructuredCvProfile source, IReadOnlySet<string>? accepted)
{
var filtered = JsonSerializer.Deserialize<StructuredCvProfile>(JsonSerializer.Serialize(source)) ?? new StructuredCvProfile();
bool Allowed(string category, string key, string confidence) => confidence != "Low" || (accepted?.Contains(ChangeId(category, key)) ?? false);
if (!Allowed("Contact", string.Empty, Confidence(filtered.Contact.FullName, !string.IsNullOrWhiteSpace(filtered.Contact.Email)))) filtered.Contact = new StructuredCvContact();
filtered.Jobs = filtered.Jobs.Where(x => Allowed("Experience", JobKey(x), JobConfidence(x))).ToList();
filtered.Education = filtered.Education.Where(x => Allowed("Education", EduKey(x), EduConfidence(x))).ToList();
filtered.Projects = filtered.Projects.Where(x => Allowed("Projects", Norm(x.Name), Confidence(x.Name, x.Bullets.Count > 0))).ToList();
filtered.Certifications = filtered.Certifications.Where(x => Allowed("Certifications", Norm(x.Name), Confidence(x.Name, !string.IsNullOrWhiteSpace(x.Issuer)))).ToList();
filtered.Languages = filtered.Languages.Where(x => Allowed("Languages", Norm(x.Name), string.IsNullOrWhiteSpace(x.Level) ? "Low" : "High")).ToList();
return filtered;
}
private static void MergeList<T>(List<T> current, IEnumerable<T> extracted, Func<T, string> key, Action<T, T> merge)
{
var currentByKey = current.Where(x => key(x).Length > 0).GroupBy(key).ToDictionary(g => g.Key, g => g.First());
foreach (var incoming in extracted)
{
var k = key(incoming);
if (k.Length > 0 && currentByKey.TryGetValue(k, out var existing)) merge(existing, incoming);
else if (k.Length > 0 && !currentByKey.ContainsKey(k))
{
current.Add(incoming);
currentByKey[k] = incoming;
}
}
}
private static void MergeContact(StructuredCvContact current, StructuredCvContact incoming)
{
SetIfPresent(incoming.FullName, v => current.FullName = v);
SetIfPresent(incoming.Headline, v => current.Headline = v);
SetIfPresent(incoming.Email, v => current.Email = v);
SetIfPresent(incoming.Phone, v => current.Phone = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Website, v => current.Website = v);
SetIfPresent(incoming.LinkedIn, v => current.LinkedIn = v);
}
private static void MergeJob(StructuredCvJob current, StructuredCvJob incoming)
{
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
current.IsCurrent = incoming.IsCurrent || current.IsCurrent;
AppendUnique(current.Bullets, incoming.Bullets);
AppendUnique(current.Skills, incoming.Skills);
}
private static void MergeEducation(StructuredCvEducation current, StructuredCvEducation incoming)
{
SetIfPresent(incoming.QualificationLevel, v => current.QualificationLevel = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
AppendUnique(current.Details, incoming.Details);
}
private static void MergeProject(StructuredCvProject current, StructuredCvProject incoming)
{
SetIfPresent(incoming.Role, v => current.Role = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Start, v => current.Start = v);
SetIfPresent(incoming.End, v => current.End = v);
AppendUnique(current.Bullets, incoming.Bullets);
AppendUnique(current.Skills, incoming.Skills);
}
private static void MergeCertification(StructuredCvCertification current, StructuredCvCertification incoming)
{
SetIfPresent(incoming.Issuer, v => current.Issuer = v);
SetIfPresent(incoming.Location, v => current.Location = v);
SetIfPresent(incoming.Date, v => current.Date = v);
AppendUnique(current.Details, incoming.Details);
}
private static void MergeLanguages(List<StructuredCvLanguage> current, IEnumerable<StructuredCvLanguage> extracted)
{
var currentByName = current.Where(x => !string.IsNullOrWhiteSpace(x.Name)).GroupBy(x => Norm(x.Name)).ToDictionary(g => g.Key, g => g.First());
foreach (var incoming in extracted.Where(x => !string.IsNullOrWhiteSpace(x.Name)))
{
var k = Norm(incoming.Name);
if (currentByName.TryGetValue(k, out var existing))
{
SetIfPresent(incoming.Level, v => existing.Level = v);
SetIfPresent(incoming.Notes, v => existing.Notes = v);
}
else
{
current.Add(incoming);
currentByName[k] = incoming;
}
}
}
private static void AppendUnique(List<string> current, IEnumerable<string> incoming)
{
var seen = new HashSet<string>(current.Select(Norm).Where(x => x.Length > 0));
foreach (var value in incoming.Where(x => !string.IsNullOrWhiteSpace(x)))
if (seen.Add(Norm(value))) current.Add(value.Trim());
}
private static void SetIfPresent(string? value, Action<string> set)
{
if (!string.IsNullOrWhiteSpace(value)) set(value.Trim());
}
// ---- list diff (Experience / Education / Projects / Certifications) --------------------------
private static CvCategoryDiff DiffList<T>(
string category,
List<T> current,
List<T> extracted,
Func<T, string> key,
Func<T, string> label,
Func<T, T, List<CvFieldChange>> fieldChanges,
Func<T, string> confidence)
{
var currentByKey = new Dictionary<string, T>();
foreach (var item in current)
{
var k = key(item);
if (!string.IsNullOrEmpty(k)) currentByKey[k] = item;
}
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
var unchanged = 0;
foreach (var item in extracted)
{
var k = key(item);
if (!string.IsNullOrEmpty(k) && currentByKey.TryGetValue(k, out var existing))
{
var changes = fieldChanges(existing, item);
if (changes.Count > 0)
updated.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Update, Category = category, Label = label(item), Confidence = confidence(item), FieldChanges = changes });
else
unchanged++;
}
else
{
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = label(item), Confidence = confidence(item) });
}
}
return new CvCategoryDiff { Category = category, Added = added, Updated = updated, UnchangedCount = unchanged };
}
// ---- contact (field-level) ------------------------------------------------------------------
private static CvCategoryDiff DiffContact(StructuredCvContact current, StructuredCvContact extracted)
{
var fields = new List<CvFieldChange>();
void Compare(string name, string? oldV, string? newV)
{
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
fields.Add(new CvFieldChange(name, oldV, newV));
}
Compare("Full name", current.FullName, extracted.FullName);
Compare("Headline", current.Headline, extracted.Headline);
Compare("Email", current.Email, extracted.Email);
Compare("Phone", current.Phone, extracted.Phone);
Compare("Location", current.Location, extracted.Location);
Compare("Website", current.Website, extracted.Website);
Compare("LinkedIn", current.LinkedIn, extracted.LinkedIn);
var isNew = string.IsNullOrWhiteSpace(current.FullName) && string.IsNullOrWhiteSpace(current.Email);
var changes = new List<CvItemChange>();
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
if (fields.Count > 0)
{
var change = new CvItemChange
{
Id = "Contact",
Kind = isNew ? CvChangeKind.Add : CvChangeKind.Update,
Category = "Contact",
Label = extracted.FullName ?? "Contact details",
Confidence = Confidence(extracted.FullName, !string.IsNullOrWhiteSpace(extracted.Email)),
FieldChanges = fields,
};
(isNew ? added : updated).Add(change);
}
return new CvCategoryDiff { Category = "Contact", Added = added, Updated = updated };
}
private static CvCategoryDiff DiffSummary(List<string> current, List<string> extracted)
{
var cur = JoinLines(current);
var ext = JoinLines(extracted);
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
if (!string.IsNullOrWhiteSpace(ext) && !ValuesEqual(cur, ext))
{
var change = new CvItemChange
{
Id = "Professional summary",
Kind = string.IsNullOrWhiteSpace(cur) ? CvChangeKind.Add : CvChangeKind.Update,
Category = "Professional summary",
Label = "Professional summary",
Confidence = "Medium",
FieldChanges = new List<CvFieldChange> { new("Summary", Trunc(cur), Trunc(ext)) },
};
(string.IsNullOrWhiteSpace(cur) ? added : updated).Add(change);
}
return new CvCategoryDiff { Category = "Professional summary", Added = added, Updated = updated };
}
// ---- languages & skills (scalar-ish, dedup by normalized name) -------------------------------
private static CvCategoryDiff DiffLanguages(List<StructuredCvLanguage> current, List<StructuredCvLanguage> extracted)
{
var currentByName = current.Where(l => !string.IsNullOrWhiteSpace(l.Name)).ToDictionary(l => Norm(l.Name), l => l);
var added = new List<CvItemChange>();
var updated = new List<CvItemChange>();
var unchanged = 0;
foreach (var lang in extracted)
{
if (string.IsNullOrWhiteSpace(lang.Name)) continue;
var k = Norm(lang.Name);
if (currentByName.TryGetValue(k, out var existing))
{
if (!string.IsNullOrWhiteSpace(lang.Level) && !ValuesEqual(existing.Level, lang.Level))
updated.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Update, Category = "Languages", Label = lang.Name!, Confidence = "Medium", FieldChanges = new List<CvFieldChange> { new("Level", existing.Level, lang.Level) } });
else
unchanged++;
}
else
{
added.Add(new CvItemChange { Id = ChangeId("Languages", k), Kind = CvChangeKind.Add, Category = "Languages", Label = string.IsNullOrWhiteSpace(lang.Level) ? lang.Name! : $"{lang.Name} ({lang.Level})", Confidence = string.IsNullOrWhiteSpace(lang.Level) ? "Low" : "High" });
}
}
return new CvCategoryDiff { Category = "Languages", Added = added, Updated = updated, UnchangedCount = unchanged };
}
private static CvCategoryDiff DiffScalars(string category, List<string> current, List<string> extracted)
{
var currentSet = new HashSet<string>(current.Select(Norm).Where(s => s.Length > 0));
var added = new List<CvItemChange>();
var seen = new HashSet<string>();
foreach (var item in extracted)
{
var k = Norm(item);
if (k.Length == 0 || !seen.Add(k)) continue;
if (!currentSet.Contains(k))
added.Add(new CvItemChange { Id = ChangeId(category, k), Kind = CvChangeKind.Add, Category = category, Label = item.Trim(), Confidence = "High" });
}
return new CvCategoryDiff { Category = category, Added = added, UnchangedCount = currentSet.Count };
}
// ---- keys / labels / field comparisons -------------------------------------------------------
private static string JobKey(StructuredCvJob j) => $"{Norm(j.Company)}|{Norm(j.Title)}";
private static string JobLabel(StructuredCvJob j) => string.Join(" — ", new[] { j.Title, j.Company }.Where(v => !string.IsNullOrWhiteSpace(v)));
private static List<CvFieldChange> JobFields(StructuredCvJob a, StructuredCvJob b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
AddIf(f, "Location", a.Location, b.Location);
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
f.Add(new CvFieldChange("Bullets", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
return f;
}
private static string JobConfidence(StructuredCvJob j) => Confidence(j.Title, !string.IsNullOrWhiteSpace(j.Company) && (!string.IsNullOrWhiteSpace(j.Start) || j.Bullets.Count > 0));
private static string EduKey(StructuredCvEducation e) => $"{Norm(e.Institution)}|{Norm(e.Qualification)}";
private static string EduLabel(StructuredCvEducation e) => string.Join(" — ", new[] { e.Qualification, e.Institution }.Where(v => !string.IsNullOrWhiteSpace(v)));
private static List<CvFieldChange> EduFields(StructuredCvEducation a, StructuredCvEducation b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Dates", DateRange(a.Start, a.End), DateRange(b.Start, b.End));
AddIf(f, "Location", a.Location, b.Location);
return f;
}
private static string EduConfidence(StructuredCvEducation e) => Confidence(e.Qualification, !string.IsNullOrWhiteSpace(e.Institution));
private static List<CvFieldChange> ProjectFields(StructuredCvProject a, StructuredCvProject b)
{
var f = new List<CvFieldChange>();
if (b.Bullets.Count > 0 && !ValuesEqual(JoinLines(a.Bullets), JoinLines(b.Bullets)))
f.Add(new CvFieldChange("Details", $"{a.Bullets.Count} line(s)", $"{b.Bullets.Count} line(s)"));
return f;
}
private static List<CvFieldChange> CertFields(StructuredCvCertification a, StructuredCvCertification b)
{
var f = new List<CvFieldChange>();
AddIf(f, "Issuer", a.Issuer, b.Issuer);
AddIf(f, "Date", a.Date, b.Date);
return f;
}
// ---- helpers --------------------------------------------------------------------------------
private static string ChangeId(string category, string key) => key.Length == 0 ? category : $"{category}|{key}";
private static void AddIf(List<CvFieldChange> f, string name, string? oldV, string? newV)
{
if (!string.IsNullOrWhiteSpace(newV) && !ValuesEqual(oldV, newV))
f.Add(new CvFieldChange(name, oldV, newV));
}
private static string Confidence(string? primary, bool corroborated)
{
if (string.IsNullOrWhiteSpace(primary)) return "Low";
return corroborated ? "High" : "Medium";
}
private static string DateRange(string? start, string? end)
{
var s = (start ?? "").Trim();
var e = (end ?? "").Trim();
if (s.Length == 0 && e.Length == 0) return "";
return $"{s} - {e}".Trim(' ', '-');
}
private static bool ValuesEqual(string? a, string? b) => Norm(a) == Norm(b);
private static string Norm(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return "";
var lowered = value.Trim().ToLowerInvariant();
return Regex.Replace(lowered, @"[^\p{L}\p{Nd}]+", " ").Trim();
}
private static string JoinLines(IEnumerable<string> lines) => string.Join("\n", lines.Where(l => !string.IsNullOrWhiteSpace(l)).Select(l => l.Trim()));
private static string Trunc(string? s, int max = 140) => string.IsNullOrEmpty(s) ? "" : (s.Length <= max ? s : s[..max] + "…");
}
+29 -3
View File
@@ -1,5 +1,7 @@
using System.Threading.Channels;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
@@ -50,13 +52,13 @@ public sealed class CvProcessingHostedService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ProcessInterruptedRunsAsync(stoppingToken);
await foreach (var runId in _queue.DequeueAllAsync(stoppingToken))
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, stoppingToken);
await ProcessRunAsync(runId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -68,4 +70,28 @@ public sealed class CvProcessingHostedService : BackgroundService
}
}
}
private async Task ProcessInterruptedRunsAsync(CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var interruptedRuns = await db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.Status == "queued" || x.Status == "running")
.Select(x => new { x.Id, x.StartedAtUtc })
.ToListAsync(cancellationToken);
// ponytail: single-instance recovery; use row leasing if multiple workers are ever deployed.
// SQLite cannot ORDER BY DateTimeOffset, so the small interrupted-work set is ordered locally.
foreach (var run in interruptedRuns.OrderBy(x => x.StartedAtUtc))
{
await ProcessRunAsync(run.Id, cancellationToken);
}
}
private async Task ProcessRunAsync(int runId, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, cancellationToken);
}
}
+4
View File
@@ -79,6 +79,10 @@ public static class CvVariantResolver
["certifications"] = CertificationSection(profile, settings),
["languages"] = LanguageSection(profile),
["interests"] = TagSection("interests", "Interests", profile.Interests),
["awards"] = BulletSection("awards", "Awards", profile.Awards),
["publications"] = BulletSection("publications", "Publications", profile.Publications),
["organisations"] = BulletSection("organisations", "Organisations", profile.Organisations),
["references"] = BulletSection("references", "References", profile.References),
};
// OtherSections from the master profile become body sections keyed other:<n>.
@@ -96,6 +96,7 @@ public sealed class CvVariantService : ICvVariantService
{
var variant = await GetAsync(ownerUserId, id, ct);
if (variant is null) return null;
if (isPublic && !variant.IsPublic) variant.PublicSlug = NewSlug();
variant.IsPublic = isPublic;
variant.UpdatedAtUtc = DateTimeOffset.UtcNow;
await _db.SaveChangesAsync(ct);
+1 -1
View File
@@ -148,7 +148,7 @@ namespace JobTrackerApi.Services
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var token in Tokenize(jobText))
{
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
if (token.Length is < 3 or > 64 || StopWords.Contains(token) || IsNumeric(token)) continue;
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
}
@@ -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),
};
}

Some files were not shown because too many files have changed in this diff Show More