Compare commits
31 Commits
4db8c08958
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| de937d25dc | |||
| 06518a7d52 | |||
| 955182b7c2 | |||
| ce76046a29 | |||
| a23c3dfc97 | |||
| 6ed56fd493 | |||
| 235d22c059 | |||
| af3cf1cdaa | |||
| 10d548f799 | |||
| 9f16a5675a | |||
| 2126a2db5c | |||
| 988a91a151 | |||
| efc9d83c25 | |||
| 792814b04e | |||
| 09fc2b03f7 | |||
| fd2c60e70f | |||
| 158970fa01 | |||
| 9cd2e5c2e3 | |||
| 6382e83e28 | |||
| 405e6d833c | |||
| 7fab996407 | |||
| c08232b9d7 | |||
| f8466c2ebc | |||
| e4acfbd0bf | |||
| 4cf26405f6 | |||
| f8a7cf5205 | |||
| 56fed05d70 | |||
| 173187dcbb | |||
| fe9cd4dda1 | |||
| 63473bae85 | |||
| 4f69d395be |
+11
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,6 +36,8 @@ node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.next/
|
||||
.cache/
|
||||
|
||||
|
||||
+51
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
2015–2023System 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 2015–2023", repaired);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Earlier_part_time_roles_become_separate_experience_entries()
|
||||
{
|
||||
var profile = InvokeProfileBuilder("""
|
||||
# Work Experience
|
||||
System Developer
|
||||
Warwickshire County Council
|
||||
2015–2023
|
||||
- Built APIs
|
||||
|
||||
Earlier roles (part-time)
|
||||
- Sales Assistant — Royal Vapes | 2013–2015
|
||||
- Labourer — The Hodcarrier | 2014–2016
|
||||
- Lifeguard — Nuffield Health | 2012–2014
|
||||
""");
|
||||
|
||||
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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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] + "…");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user