feat: complete release readiness work

- consolidate API ownership and remove dead vendor code

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

- add migration, recovery, security, audit, and browser gates
This commit is contained in:
cesnimda
2026-07-31 16:54:16 +02:00
parent a23c3dfc97
commit ce76046a29
1634 changed files with 6889 additions and 135429 deletions
+5 -1
View File
@@ -20,6 +20,10 @@ AUTH_ADMIN_PASSWORD=CHANGE_ME_STRONG_PASSWORD
AUTH_ALLOW_REGISTRATION=false
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).
@@ -53,7 +57,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
+12 -5
View File
@@ -87,12 +87,11 @@ jobs:
&& rm -rf node_modules \
&& npm ci --no-audit --no-fund )
# Report the existing advisory baseline without blocking unrelated deploys. Promote this to a
# hard gate after the upstream no-fix advisories in the current toolchain are cleared.
- name: Audit frontend production dependencies
continue-on-error: true
# 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 --omit=dev --audit-level=high
run: npm audit --audit-level=high
- name: Test frontend
working-directory: job-tracker-ui
@@ -100,6 +99,14 @@ jobs:
# whitelist silently skipped new suites and let two regressions reach main.
run: npm test -- --watchAll=false --runInBand
- name: Install browser smoke runtime
working-directory: job-tracker-ui
run: npx playwright install --with-deps chromium
- name: Test browser smoke flows
working-directory: job-tracker-ui
run: npm run test:e2e
- name: Build frontend
working-directory: job-tracker-ui
env:
+2
View File
@@ -36,6 +36,8 @@ node_modules/
build/
dist/
coverage/
playwright-report/
test-results/
.next/
.cache/
+54
View File
@@ -0,0 +1,54 @@
# 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:** No Stripe CLI or `STRIPE_SECRET_KEY`, `STRIPE_PRICE_PREMIUM`, or `STRIPE_WEBHOOK_SECRET` environment configuration is available in this workspace, so activation cannot be performed safely here.
## 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; decide whether free-only onboarding remains open before Stripe activation.
## 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.
## 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 DataProtection key rotation
- **Blocked:** Closing roadmap item 1.4.
- **Why:** Keys remain recoverable from git history and can only be rotated on the production host. Rotation invalidates protected payloads/cookies and must be coordinated by the operator.
- **Required:** Rotate the production DataProtection key ring and confirm completion.
- **Recommended:** Back up the current key ring securely, rotate during a short maintenance window, then verify login, reset links, and encrypted application data.
## 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:** No `PROD_HOST`, `PROD_USER`, or `PROD_SSH_KEY` environment configuration is available. A read-only batch SSH probe to `jobs.cesnimda.uk:22` timed out, so no production operation was attempted.
## 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. Dropping columns safely requires proving every production application has a correct `JobId` and rehearsing the migration against a restored backup.
- **Required:** Provide/authorize a recent production backup restore, verify the backfill report, and confirm that backward API compatibility is no longer required.
- **Recommended:** Use an expand/contract release: first stop legacy reads after a verified backfill, observe one release, then drop the duplicate columns in the following migration.
-14
View File
@@ -7,8 +7,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi", "JobTracker
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerApi.Tests", "JobTrackerApi.Tests\JobTrackerApi.Tests.csproj", "{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "JobTrackerBackend", "JobTrackerBackend\JobTrackerBackend.csproj", "{709F069F-DD13-42CC-9C5E-99923A545790}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -43,18 +41,6 @@ Global
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x64.Build.0 = Release|Any CPU
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.ActiveCfg = Release|Any CPU
{4AA1218D-B33E-4E8B-8C46-EB85A5FE615C}.Release|x86.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|Any CPU.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x64.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.ActiveCfg = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Debug|x86.Build.0 = Debug|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|Any CPU.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x64.Build.0 = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.ActiveCfg = Release|Any CPU
{709F069F-DD13-42CC-9C5E-99923A545790}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
+11
View File
@@ -5,6 +5,17 @@ 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()
{
@@ -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]
@@ -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);
}
}
@@ -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);
}
}
}
@@ -209,6 +209,8 @@ public sealed class JobApplicationsEndpointBehaviorTests
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);
@@ -20,6 +20,6 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
<ProjectReference Include="..\JobTrackerApi\JobTrackerApi.csproj" />
</ItemGroup>
</Project>
@@ -226,6 +226,74 @@ public sealed class ProfileCvControllerTests
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]
public async Task Upload_reconstructs_flattened_pdf_cv_before_save()
{
@@ -1255,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,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";
}
}
@@ -1,4 +1,5 @@
using JobTrackerApi.Models;
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers
{
@@ -232,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));
}
@@ -745,7 +740,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();
@@ -825,7 +820,9 @@ 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}")]
@@ -1432,6 +1429,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,
@@ -1441,7 +1440,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")]
@@ -315,7 +315,7 @@ public sealed partial class ProfileCvController : ControllerBase
[NonAction]
public async Task ProcessQueuedRunAsync(int runId, CancellationToken cancellationToken)
{
var run = await _db.CvExtractionRuns.FirstOrDefaultAsync(x => x.Id == runId, 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)
@@ -367,7 +367,7 @@ public sealed partial class ProfileCvController : ControllerBase
}
case "reprocess":
{
var artifact = await _db.CvUploadArtifacts.AsNoTracking().FirstOrDefaultAsync(x => x.Id == run.ArtifactId && x.OwnerUserId == user.Id, cancellationToken);
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))
{
@@ -416,14 +416,46 @@ public sealed partial class ProfileCvController : ControllerBase
private async Task PruneExtractionRunsAsync(string ownerUserId, CancellationToken cancellationToken)
{
var expired = await _db.CvExtractionRuns
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) return;
_db.CvExtractionRuns.RemoveRange(expired);
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)
@@ -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);
-4
View File
@@ -3,11 +3,7 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /src
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
COPY Data/ Data/
COPY Models/ Models/
COPY JobTrackerApi/ JobTrackerApi/
COPY JobTrackerBackend/ JobTrackerBackend/
# Retry once after clearing NuGet caches. Transient download corruption on the
# build host can trip NU3008 ("package integrity check failed / has changed since
+10 -9
View File
@@ -8,21 +8,22 @@
</PropertyGroup>
<ItemGroup>
<Compile Remove="Controllers\**\*.cs" />
<Compile Remove="Services\**\*.cs" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<!-- dotnet-ef design-time tooling requires this on the startup project (not just
JobTrackerBackend, where the DbContext actually lives) since EF Core 6+. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
<PackageReference Include="Stripe.net" Version="52.2.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
</ItemGroup>
</Project>
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)
{
}
}
}
@@ -272,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");
@@ -4,6 +4,9 @@ public sealed record AccountEntitlements(bool AdvancedAi, bool PremiumThemes, bo
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);
@@ -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; }
}
@@ -12,7 +12,7 @@ public class JobApplication
// 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 Models/Job.cs and docs/decisions/ADR-002-job-application-model.md.
// See JobTrackerApi/Models/Job.cs and docs/decisions/ADR-002-job-application-model.md.
public int? JobId { get; set; }
public Job? Job { get; set; }
+37 -2
View File
@@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.AspNetCore.HttpOverrides;
using Microsoft.AspNetCore.RateLimiting;
using Microsoft.IdentityModel.Tokens;
using JobTrackerApi.Models;
@@ -26,8 +27,12 @@ var builder = WebApplication.CreateBuilder(args);
// Avoid Windows EventLog provider issues in local dev environments.
builder.Logging.ClearProviders();
builder.Logging.AddConsole();
builder.Logging.AddDebug();
if (builder.Environment.IsProduction()) builder.Logging.AddJsonConsole();
else
{
builder.Logging.AddSimpleConsole();
builder.Logging.AddDebug();
}
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
@@ -116,6 +121,11 @@ builder.Services.AddCors(options =>
// 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))
@@ -407,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 =>
@@ -430,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) =>
{
@@ -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) =>
+29 -3
View File
@@ -1,5 +1,7 @@
using System.Threading.Channels;
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using Microsoft.EntityFrameworkCore;
namespace JobTrackerApi.Services;
@@ -50,13 +52,13 @@ public sealed class CvProcessingHostedService : BackgroundService
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await ProcessInterruptedRunsAsync(stoppingToken);
await foreach (var runId in _queue.DequeueAllAsync(stoppingToken))
{
try
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, stoppingToken);
await ProcessRunAsync(runId, stoppingToken);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
@@ -68,4 +70,28 @@ public sealed class CvProcessingHostedService : BackgroundService
}
}
}
private async Task ProcessInterruptedRunsAsync(CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
var interruptedRuns = await db.CvExtractionRuns.IgnoreQueryFilters()
.Where(x => x.Status == "queued" || x.Status == "running")
.Select(x => new { x.Id, x.StartedAtUtc })
.ToListAsync(cancellationToken);
// ponytail: single-instance recovery; use row leasing if multiple workers are ever deployed.
// SQLite cannot ORDER BY DateTimeOffset, so the small interrupted-work set is ordered locally.
foreach (var run in interruptedRuns.OrderBy(x => x.StartedAtUtc))
{
await ProcessRunAsync(run.Id, cancellationToken);
}
}
private async Task ProcessRunAsync(int runId, CancellationToken cancellationToken)
{
await using var scope = _scopeFactory.CreateAsyncScope();
var controller = scope.ServiceProvider.GetRequiredService<ProfileCvController>();
await controller.ProcessQueuedRunAsync(runId, cancellationToken);
}
}
+1 -1
View File
@@ -148,7 +148,7 @@ namespace JobTrackerApi.Services
var frequencies = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
foreach (var token in Tokenize(jobText))
{
if (token.Length < 3 || StopWords.Contains(token) || IsNumeric(token)) continue;
if (token.Length is < 3 or > 64 || StopWords.Contains(token) || IsNumeric(token)) continue;
frequencies[token] = frequencies.TryGetValue(token, out var c) ? c + 1 : 1;
}
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Globalization;
using System.Text;
namespace JobTrackerApi.Services;
@@ -26,16 +27,19 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
private readonly AppPaths _paths;
private readonly ILogger<PlaywrightCvPdfExporter> _logger;
private readonly int _retentionDays;
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger)
public PlaywrightCvPdfExporter(AppPaths paths, ILogger<PlaywrightCvPdfExporter> logger, IConfiguration configuration)
{
_paths = paths;
_logger = logger;
_retentionDays = Math.Clamp(configuration.GetValue("CvExports:RetainDays", 30), 1, 365);
}
public async Task<CvPdfArtifact> ExportAsync(TailoredCvRenderResult renderResult, CancellationToken cancellationToken)
{
var now = DateTimeOffset.UtcNow;
PruneExpiredExports(DateOnly.FromDateTime(now.UtcDateTime).AddDays(-_retentionDays));
var folder = Path.Combine(_paths.CvExportsRoot, now.ToString("yyyyMMdd"));
Directory.CreateDirectory(folder);
@@ -46,10 +50,8 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
var tempRoot = Path.Combine(Path.GetTempPath(), "jobtracker-cv-pdf", Guid.NewGuid().ToString("n"));
var htmlPath = Path.Combine(tempRoot, "document.html");
var userDataDir = Path.Combine(tempRoot, "profile");
Directory.CreateDirectory(tempRoot);
Directory.CreateDirectory(userDataDir);
try
{
@@ -61,10 +63,12 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
throw new InvalidOperationException("CV PDF export is unavailable. Install Chromium/Google Chrome or set CV_PDF_BROWSER_PATH.");
}
var arguments = BuildArguments(userDataDir, storagePath, htmlPath);
var startInfo = new ProcessStartInfo();
startInfo.FileName = browserPath;
startInfo.Arguments = arguments;
foreach (var argument in BuildArguments(storagePath, htmlPath))
{
startInfo.ArgumentList.Add(argument);
}
startInfo.RedirectStandardOutput = true;
startInfo.RedirectStandardError = true;
startInfo.UseShellExecute = false;
@@ -73,7 +77,14 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
using var process = new Process();
process.StartInfo = startInfo;
process.Start();
await process.WaitForExitAsync(cancellationToken);
try
{
await process.WaitForExitAsync(cancellationToken).WaitAsync(TimeSpan.FromSeconds(60), cancellationToken);
}
finally
{
if (!process.HasExited) process.Kill(entireProcessTree: true);
}
var stdout = await process.StandardOutput.ReadToEndAsync();
var stderr = await process.StandardError.ReadToEndAsync();
@@ -102,9 +113,27 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
}
}
private static string BuildArguments(string userDataDir, string storagePath, string htmlPath)
private void PruneExpiredExports(DateOnly cutoff)
{
var parts = new List<string>
foreach (var directory in Directory.EnumerateDirectories(_paths.CvExportsRoot))
{
var name = Path.GetFileName(directory);
if (!DateOnly.TryParseExact(name, "yyyyMMdd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var date) || date >= cutoff) continue;
try
{
Directory.Delete(directory, recursive: true);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not prune expired CV export directory {Directory}", directory);
}
}
}
private static IReadOnlyList<string> BuildArguments(string storagePath, string htmlPath)
{
return new[]
{
"--headless=new",
"--disable-gpu",
@@ -112,12 +141,9 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
"--disable-dev-shm-usage",
"--allow-file-access-from-files",
"--enable-local-file-accesses",
"--user-data-dir=" + Quote(userDataDir),
"--print-to-pdf=" + Quote(storagePath),
Quote(htmlPath)
$"--print-to-pdf={storagePath}",
new Uri(htmlPath).AbsoluteUri
};
return string.Join(' ', parts);
}
private static string? ResolveBrowserPath()
@@ -171,8 +197,4 @@ public sealed class PlaywrightCvPdfExporter : ICvPdfExporter
}
}
private static string Quote(string value)
{
return '"' + value.Replace("\\", "\\\\").Replace("\"", "\\\"") + '"';
}
}
@@ -4,6 +4,8 @@ using JobTrackerApi.Models;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
namespace JobTrackerApi.Services;
@@ -529,6 +531,10 @@ public static class StartupInitializationExtensions
EnsureColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE AspNetUsers ADD COLUMN TotpPendingSecretEncrypted TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE AspNetUsers ADD COLUMN TotpEnabledAtUtc TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE AspNetUsers ADD COLUMN StripeCustomerId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionId TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE AspNetUsers ADD COLUMN StripeSubscriptionStatus TEXT NULL;");
EnsureColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE AspNetUsers ADD COLUMN StripeLastEventCreatedUtc TEXT NULL;");
static void EnsureUserRuleSettingsTable(DbConnection c)
{
@@ -1115,12 +1121,11 @@ public static class StartupInitializationExtensions
EnsureCoverLetterVersionsTable(conn);
EnsureInterviewPrepItemsTable(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
// and at least one of the new columns already exists.
// Once the base app tables exist, provision this reconciler-owned schema set and
// stamp its historical migration before later migrations rebuild JobApplications.
var isLegacy =
HasMigration(conn, "20260310174114_AddCorrespondence") &&
!HasMigration(conn, legacyMigrationId) &&
(HasColumn(conn, "Companies", "Source") || HasColumn(conn, "JobApplications", "IsDeleted"));
!HasMigration(conn, legacyMigrationId);
if (isLegacy)
{
@@ -1427,6 +1432,10 @@ public static class StartupInitializationExtensions
EnsureMySqlColumn(conn, "AspNetUsers", "TotpSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpPendingSecretEncrypted", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpPendingSecretEncrypted` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "TotpEnabledAtUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `TotpEnabledAtUtc` datetime NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeCustomerId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeCustomerId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionId", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeSubscriptionStatus", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeSubscriptionStatus` varchar(64) NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "StripeLastEventCreatedUtc", "ALTER TABLE `AspNetUsers` ADD COLUMN `StripeLastEventCreatedUtc` datetime(6) NULL;");
// RuleSettings is MIGRATION-owned — the initial migration creates it. The reconciler
// used to create it too, which made a clean install fail with "Table 'RuleSettings'
@@ -1997,13 +2006,19 @@ public static class StartupInitializationExtensions
// table is skipped here (the parent does not exist yet) and picked up in pass 3.
ReconcileSchema();
// 2. Migrations create every migration-owned table. On a brand-new database this is what
// actually builds the schema; the pass above found nothing to reconcile.
// 2. Apply one migration at a time, reconciling after each. Some historical migrations
// rebuild tables using columns owned by the reconciler, so a fresh database needs
// those columns added after the base table appears and before a later migration reads it.
try
{
using var migrationScope = app.Services.CreateScope();
var migrationDb = migrationScope.ServiceProvider.GetRequiredService<JobTrackerContext>();
migrationDb.Database.Migrate();
var migrator = migrationDb.Database.GetService<IMigrator>();
while (migrationDb.Database.GetPendingMigrations().FirstOrDefault() is { } migration)
{
migrator.Migrate(migration);
ReconcileSchema();
}
}
catch (Exception ex)
{
@@ -1,36 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<!--
Transitional shared-backend project.
The API host and test project both reference this library so controller/service code can
be exercised without dragging the web-entry project into every test build.
Files still live in their original folders for now; a later refactor can move them physically.
-->
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<RestoreIgnoreFailedSources>true</RestoreIgnoreFailedSources>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<Compile Include="..\Data\**\*.cs" />
<Compile Include="..\Models\**\*.cs" />
<Compile Include="..\JobTrackerApi\Controllers\**\*.cs" />
<Compile Include="..\JobTrackerApi\Services\**\*.cs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="Otp.NET" Version="1.4.1" />
<PackageReference Include="QRCoder" Version="1.8.0" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
</ItemGroup>
</Project>
+20 -4
View File
@@ -5,6 +5,7 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
## Features (high level)
- Track job applications (status, applied date, notes, tags, follow-up dates, deadlines, salary, links, etc.)
- Share opt-in public CV links with recruiter-friendly PDF download
- Company management (location/source, recruiter details, pipeline stage, next contact date)
- Correspondence log per application (email/messages with subject/channel/date)
- Attachments per application (upload, list, download, rename, delete)
@@ -16,6 +17,7 @@ Job Tracker is a simple, self-hosted app for tracking job applications with a Re
- Note: no offline service-worker cache is bundled by design (the app is deployed frequently; an aggressive cache would risk serving stale builds). The manifest provides installability and share-to-capture without it.
- Optional local AI service for short/full descriptions
- Optional Google sign-in (Google ID tokens) to protect the API
- Standard Problem Details responses with trace IDs for unhandled API errors
## Architecture
@@ -103,7 +105,18 @@ npm start
The UI defaults to calling `http://localhost:5202/api` when running on localhost (see `job-tracker-ui/src/api.ts`).
### 3) (Optional) Run the AI service
### 3) Run the browser smoke suite
```bash
cd job-tracker-ui
npx playwright install chromium
npm run test:e2e
```
The suite starts isolated API/SQLite and Next.js processes, then covers login, saved-job creation,
Career Workspace, and anonymous public-CV/PDF access. It is also a required CI gate.
### 4) (Optional) Run the AI service
The API calls a local FastAPI service to generate summaries. If its not running, the app still works (summary generation may be empty / best-effort).
@@ -130,7 +143,8 @@ Common keys:
- `ConnectionStrings:JobTracker`: overrides SQLite location (otherwise uses `DataRoot/jobtracker.db`)
- `Data:Root`: folder for the SQLite DB + exports (defaults to API content root)
- `Data:AttachmentsRoot`: override attachments folder (defaults to `<Data:Root>/Attachments`)
- `Cors:Origins`: list of allowed origins (defaults to `http://localhost:3000`; use `"*"` to allow all)
- `CvExports:RetainDays`: generated PDF retention in days (default `30`, clamped to `1``365`)
- `Cors:Origins`: list of allowed origins (defaults to `http://localhost:3000`; wildcard origins are rejected because requests use credentials)
- `Ai:BaseUrl`: AI service base URL (default `http://127.0.0.1:8001`)
- `Exports:DailyEnabled`: enable/disable daily export background job
- `Exports:DailyFolder`: export destination (relative to `Data:Root` if not absolute)
@@ -147,6 +161,8 @@ Common keys:
- `Auth:AdminEmail` / `Auth:AdminPassword`: optional seed admin user (created on startup if missing)
- `Auth:AllowRegistration`: allow self-service registration via `POST /api/auth/register` (default `false`)
- `Auth:Require`: if `true`, all endpoints require auth (except endpoints explicitly marked anonymous)
- `Stripe:SecretKey`, `Stripe:PricePremium`, `Stripe:WebhookSecret`: enable hosted Premium checkout,
the customer portal, and signed subscription webhooks when all are configured
- `Translation:Provider`: `none` (default) or `libretranslate`
- `Translation:LibreTranslate:BaseUrl`: base URL for LibreTranslate (only if provider enabled)
- `Translation:LibreTranslate:ApiKey`: optional API key for LibreTranslate
@@ -163,7 +179,7 @@ Common keys:
### UI settings
- `REACT_APP_API_BASE_URL`: override the API base URL (example: `http://localhost:5202/api`)
- `NEXT_PUBLIC_API_BASE_URL`: override the API base URL (example: `http://localhost:5202/api`)
## API endpoint reference
@@ -206,7 +222,7 @@ Authentication:
- `GET /api/jobapplications/stats`
- Returns totals, counts by status, applied-last-30-days, and average days since applied.
- `GET /api/jobapplications/{id}/match-score`
- Deterministic CV↔job keyword-coverage score (0100) with matched/missing keywords and per-CV-section coverage. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
- Deterministic CV↔job keyword-coverage score (0100) with matched/missing keywords and per-CV-section coverage. Missing skills also seed a checklist-backed job-specific learning path, so users can mark them learned or dismiss them. No AI calls: results are instant and reproducible. Requires profile CV text/structure and a job description. (The AI narrative equivalent is `GET /api/jobapplications/{id}/candidate-fit`.)
- `GET /api/jobapplications/{id}/status-suggestion`
- Deterministic status suggestion derived from the job's most recent inbound message (interview invite / offer / rejection). Returns a forward-only suggestion (`hasSuggestion`, `suggestedStatus`, `signal`, …) or `hasSuggestion: false`. Applying it is a normal `PATCH .../status` — always user-confirmed.
- `DELETE /api/jobapplications/{id}`
+5 -2
View File
@@ -39,9 +39,9 @@ This keeps secrets outside the uploaded repo checkout so they are not wiped by C
The production frontend already proxies `/api` to the backend container via Nginx.
Recommended default:
- leave `REACT_APP_API_BASE_URL` unset/empty in production
- leave `NEXT_PUBLIC_API_BASE_URL` unset/empty in production
Only set `REACT_APP_API_BASE_URL` if the UI must call a different external API origin on purpose.
Only set `NEXT_PUBLIC_API_BASE_URL` if the UI must call a different external API origin on purpose.
## Example production `.env`
```env
@@ -51,6 +51,9 @@ AUTH_JWT_KEY=replace_with_long_random_secret
AUTH_ADMIN_EMAIL=you@example.com
AUTH_ADMIN_PASSWORD=replace_with_strong_password
APP_PUBLIC_BASE_URL=https://your-domain.example
STRIPE_SECRET_KEY=sk_live_...
STRIPE_PRICE_PREMIUM=price_...
STRIPE_WEBHOOK_SECRET=whsec_...
AI_SERVICE_BASE_URL=http://ai-service:8001
OLLAMA_BASE_URL=http://ollama:11434
OLLAMA_MODEL=qwen2.5:7b
+22 -1
View File
@@ -14,6 +14,8 @@ services:
- ConnectionStrings__JobTracker=${JOBTRACKER_CONNECTION_STRING}
# If you enable HTTPS at a reverse proxy (recommended), handle redirects there.
- HttpsRedirection__Enabled=false
# Backend is internal-only here; nginx is the sole trusted ingress.
- Proxy__TrustForwardedHeaders=true
# Authentication (recommended for any non-local deployment)
- Auth__Require=true
- Auth__JwtKey=${AUTH_JWT_KEY}
@@ -22,6 +24,9 @@ services:
- Auth__AllowRegistration=${AUTH_ALLOW_REGISTRATION:-false}
- Turnstile__SiteKey=${TURNSTILE_SITE_KEY}
- Turnstile__SecretKey=${TURNSTILE_SECRET_KEY}
- Stripe__SecretKey=${STRIPE_SECRET_KEY}
- Stripe__PricePremium=${STRIPE_PRICE_PREMIUM}
- Stripe__WebhookSecret=${STRIPE_WEBHOOK_SECRET}
# Optional: allow Google / Microsoft ID-token bearer auth (sign-in, not mail access)
- Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID}
- Auth__MicrosoftClientId=${AUTH_MICROSOFT_CLIENT_ID}
@@ -65,6 +70,10 @@ services:
# ai-service.
- ai_internal
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
# Liveness only. /health does not touch the database on purpose: a health check that queried
# MariaDB would restart a healthy backend whenever the database blipped.
# start_period covers first-boot schema reconciliation, which can take a while on a new database.
@@ -85,7 +94,7 @@ services:
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=${AUTH_GOOGLE_CLIENT_ID}
- NEXT_PUBLIC_MICROSOFT_CLIENT_ID=${AUTH_MICROSOFT_CLIENT_ID}
# Optional override; default in production is `/api`
- NEXT_PUBLIC_API_BASE_URL=${REACT_APP_API_BASE_URL}
- NEXT_PUBLIC_API_BASE_URL=${NEXT_PUBLIC_API_BASE_URL}
ports:
- "3000:80"
depends_on:
@@ -95,6 +104,10 @@ services:
- default
- shared_services
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
# Cheap liveness: nginx answering on its own port. wget ships with the alpine base.
healthcheck:
test: ["CMD", "wget", "-q", "-O", "/dev/null", "http://127.0.0.1:80/"]
@@ -141,6 +154,10 @@ services:
networks:
- ai_internal
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health', timeout=5).read()"]
interval: 30s
@@ -170,6 +187,10 @@ services:
- shared_services
- ai_internal
restart: unless-stopped
logging:
options:
max-size: "10m"
max-file: "3"
gpus: all
healthcheck:
test: ["CMD", "ollama", "list"]
+6 -1
View File
@@ -1,3 +1,8 @@
# safety
TODO: Complete documentation.
AI output is suggestion-only and cannot overwrite the Career Profile automatically. Prompts delimit
untrusted source text, require preservation of factual claims, and instruct providers not to invent
employers, titles, dates, or metrics. Attachments are included only when explicitly selected.
Provider outages degrade AI features without blocking core tracking. Calls and tokens are metered and
limited by plan. See `docs/ai/security.md` and `docs/ai/privacy.md`.
+1 -1
View File
@@ -51,7 +51,7 @@ master profile text ────────────┘ │
## History model
`AiInteraction` (`Models/AiInteraction.cs`) is **append-only** — one row per generation, never
`AiInteraction` (`JobTrackerApi/Models/AiInteraction.cs`) is **append-only** — one row per generation, never
overwritten. This is deliberately distinct from `AiWorkspaceNote` (a one-row-per-type *cache* for
candidate-fit/focus-plan). History gives the user restore/reuse (re-surface a past result), compare
(view two side by side), copy, and delete. `ResultJson` is `{ text, meta? }`; `Provider` records which
+7 -2
View File
@@ -93,6 +93,11 @@ Seeding is idempotent per `(JobApplicationId, SystemKey)` — enforced by a uniq
never duplicates. Custom items have a `NULL` `SystemKey`; both SQLite and MariaDB treat NULLs as
distinct in a unique index, so a user can add as many as they like.
The deterministic match-score endpoint also synchronises `learning:{hash}` system items from its
current missing skills. They form the first job-specific learning path without another table or an
external course catalogue. Manual learned/dismissed decisions persist; only recommendations that
were auto-completed because a gap disappeared reopen when the same gap returns.
Deleting a **system** item dismisses it (a hard delete would be undone by the next seed); deleting a
**custom** item removes the row. Dismissed items leave the progress denominator entirely.
@@ -378,6 +383,6 @@ belongs to the AI cache — a reminder that the two systems are genuinely differ
6. ✅ Completion and product readiness — ownership audit, security review, full local verification
(Phase 5.6). See `docs/phase-5-completion-report.md`.
**Phase 5 is feature-complete locally.** It is not deployed: CI is red for a documented environmental
reason and deployment is gated on it. See the completion report's *Remaining risks*.
**Phase 5 is feature-complete locally.** Current release and deployment dependencies are tracked in
`BLOCKERS.md`; the completion report is a historical snapshot.
7. Cover letter workflow. 8. Documents. 9. Interview preparation. 10. Dashboard improvements.
+2 -2
View File
@@ -10,7 +10,7 @@
> ## Implementation status (2026-07-18) — SHIPPED
>
> The model below is implemented on `main` and verified against the live database:
> - **Entities** (`Models/CareerEntities.cs`): `CareerExperience`, `CareerEducation`, `CareerSkill`,
> - **Entities** (`JobTrackerApi/Models/CareerEntities.cs`): `CareerExperience`, `CareerEducation`, `CareerSkill`,
> `CareerProject`, `CareerCertification`, `CareerLanguage` — relational children of `CareerProfile`;
> long tail in `CareerProfile.LongTailJson`. Migration `AddCareerProfileRelationalChildren`
> (applied cleanly on the real dev DB and the running container).
@@ -40,7 +40,7 @@ The Career Workspace foundation (`992f89e`) stores the whole profile as **one JS
`CareerProfileService.SaveVersionAsync` (dual-write). One row per user.
- `CareerProfileVersion` — append-only history: one row per save, with a `Source` discriminator.
The blob shape (`Models/StructuredCvProfile.cs`) already has structured items for **Jobs, Education,
The blob shape (`JobTrackerApi/Models/StructuredCvProfile.cs`) already has structured items for **Jobs, Education,
Certifications, Projects, Languages**, plus `Skills`/`Summary`/`Interests` (string lists) and
`OtherSections` (title + items). `CareerProfileService` already assigns **stable item IDs** to
Jobs/Education/Certifications/Projects and normalizes free-text dates to `YYYY-MM`.
+36 -51
View File
@@ -1,10 +1,11 @@
# Jobjakt — Current Architecture
> **This document describes the system as it actually is.** Every claim was verified against code.
> Last verified: 2026-07-17 (Phase 0). Supersedes the archived `docs/_archive/SYSTEM_OVERVIEW.md` (2026-07-02).
> Last verified: 2026-07-31. Supersedes the archived `docs/_archive/SYSTEM_OVERVIEW.md` (2026-07-02).
>
> **Rule:** if this document and the code disagree, the code wins — and this document is a bug. Fix it.
> Do not trust other files under `docs/` over this one; most are stubs.
> Topic documentation is maintained alongside this file; dated completion and review reports remain
> historical snapshots. When documentation conflicts, prefer this file and the code.
---
@@ -69,22 +70,19 @@ flowchart LR
BG --> DB
```
### Solution layout (unusual — read this first)
### Solution layout
| Project | Role |
|---|---|
| `JobTrackerApi/` | Web **host only**: `Program.cs`, appsettings, migrations, Dockerfile. Its csproj **excludes** `Controllers/**` and `Services/**` from its own compilation. |
| `JobTrackerBackend/` | "Transitional shared-backend" **library** that link-compiles, via `<Compile Include>`, files physically located in `../Data`, `../Models`, `../JobTrackerApi/Controllers`, `../JobTrackerApi/Services`. Exists so tests can reference controllers/services without the web host. |
| `JobTrackerApi/` | ASP.NET Core host plus its controllers, services, EF models, `JobTrackerContext`, migrations, and Dockerfile. |
| `JobTrackerApi.Tests/` | xUnit, 36 test files incl. authorization + hostile-fixture suites. |
| `Models/`, `Data/` (repo root) | The *real* EF models and `JobTrackerContext`. |
| `job-tracker-ui/` | React SPA inside a Next.js shell. |
| `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). |
| `tools/hostile-fixture-db/` | Security test fixture generator. |
| `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD. |
**Source lives in one place and compiles from another.** Any tool assuming csproj-adjacent source will mislead you. Check `JobTrackerBackend.csproj` before adding files or projects.
> Corrected 2026-07-17: the dead root `Controller/` (singular) folder described in the archived overview **no longer exists** — removed in `519c32e`.
> Corrected 2026-07-31: the transitional `JobTrackerBackend` link-compilation project and root
> `Models/`/`Data/` directories were retired. Source now compiles from the project that owns it.
---
@@ -94,7 +92,7 @@ flowchart LR
**Frontend:** **Next.js 16** + React 19 + **TypeScript 5.9** + MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, i18n EN + NB (custom provider), Jest/RTL.
> Corrected 2026-07-17: the archived overview said "CRA/react-scripts 5, TypeScript 4.9". The CRA→Next.js migration has happened. See §4 for what that migration did and did not do.
> Corrected 2026-07-31: the CRA migration is complete; direct Jest/Babel configuration replaced `react-scripts`.
**AI:** FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; **one** generation provider selected by the `AI_PROVIDER` env var ∈ {`ollama` (default, `qwen2.5:7b`), `gemini`, `groq`}; TTL cache.
@@ -104,17 +102,16 @@ flowchart LR
## 4. Frontend architecture
**Three toolchains coexist. This is the single most confusing thing about the frontend.**
**Two routing layers coexist intentionally:**
1. **Next.js 16 App Router** (`app/layout.tsx`, `app/page.tsx`) — a thin shell that mounts a client-side app. The CRA→Next migration was a **CSR lift-and-shift**: no SSR, no server components, no Next routing, no data fetching. Next is effectively a build tool here. Static export → nginx.
2. **react-router-dom v6** — does the actual routing, in **two different patterns inside one file** (`src/App.tsx`): `createBrowserRouter` for public routes (`/`, `/login`, `/forgot-password`, `/reset-password`, `/verify-email`) and a nested `<Routes>` inside a catch-all `Shell` for authenticated routes.
3. **react-scripts 5.0.1** — still a dependency, used **only** as the test runner (`"test": "react-scripts test"`).
Known consequence: a **dev-only 404 on deep links** follows directly from the Next shell + client router combination.
Development leaves static-export mode disabled so deep links reach the client router; production exports one shell and nginx falls back to `index.html` for unknown paths.
**Routes** (`src/App.tsx`): public — `/`, `/login`, `/forgot-password`, `/reset-password`, `/verify-email`. Authenticated — `/dashboard`, `/jobs`, `/reminders`, `/kanban`, `/companies`, `/correspondence`, `/correspondence/review`, `/profile`, `/career`, `/trash`, `/settings`, `/settings/connected-accounts`, `/admin/{audit,users,system}`.
> **No `/register` route exists.** Sign-up is folded into `LoginPage.tsx`, and the endpoint is disabled by default (§5).
> `/register` reuses the hardened auth form. Submission remains disabled until registration is enabled by production configuration (§5).
**State management: none.** No Redux/Zustand/React Query. Local `useState` + `axios` per component, with a hand-rolled `refreshToken` counter threaded through props. Two workspace-cache hooks exist (`components/job-workspace/useWorkspaceTabCache.ts`, `useJobWorkspaceBaseData.ts`). This is the root cause of the oversized components below.
@@ -138,7 +135,7 @@ the reference model for how account identity and the master career profile relat
| `/career` | `views/CareerWorkspacePage.tsx``views/CareerProfilePage.tsx` (~1293 lines) | The **master career profile** — the single editable source of truth |
`CareerWorkspacePage` is a thin shell (heading + source-of-truth notice) around `CareerProfilePage`.
The CV Builder is **not** built yet (Phase 4); `CareerProfilePage` is where it will live.
The CV Builder is a separate routed workspace and consumes the Career Profile as its source of truth.
### Request flow
@@ -209,7 +206,7 @@ null-leaves).
- **Sessions:** `UserSession` entity + `SessionsController` — list/revoke active sessions.
- Password policy: min 8, digit + lowercase. Reset via emailed token (SMTP required).
- **Registration is disabled by default** — `AuthController.cs:135` reads `Auth:AllowRegistration` defaulting to `false` and returns HTTP 403. There is no CAPTCHA anywhere.
- **Rate limiting (3 fixed-window policies):** `auth-login` 10/window, `auth-email` 5/window, `auth-2fa-challenge` 5/window. **AI and other expensive endpoints are unthrottled.**
- **Rate limiting:** login, auth email, 2FA challenge, and anonymous public-CV PDF export have dedicated fixed-window policies. AI usage is bounded by per-account monthly generation and token ceilings rather than request-window throttling.
---
@@ -246,12 +243,12 @@ erDiagram
- Salary is **structured**: `SalaryMin`, `SalaryMax`, `SalaryCurrency`, `SalaryPeriod` (plus a legacy free-text `Salary`).
- `Tags` is a **JSON array in a string column** — not queryable; `/tags` and `/tag-trends` must scan.
- Denormalized `HasResume`/`HasCoverLetter`/`HasPortfolio`/`HasOtherAttachment` flags duplicate `Attachments`; kept honest by `AttachmentFlagsRecomputeTests`.
- CV text is stored **three times** (`CvExtractionRun.RawExtractedText`, `.NormalizedText`, `.StructuredProfileJson`) plus twice on the user. Deliberate audit trail, but **no retention policy**.
- CV extraction runs retain the newest 20 completed runs per user; expired runs and unreferenced upload artifacts are pruned while the current artifact is preserved.
- `Status` is free-text at the DB level; canonicalized only in the application layer by `JobPipeline.Normalize` — deliberately, so custom user values are never destroyed.
- Indexes: `OwnerUserId` on Company/Job/JobApplication/GmailConnection; composites `(OwnerUserId, UploadedAtUtc)`, `(OwnerUserId, StartedAtUtc)`; unique `(OwnerUserId, JobApplicationId)` on draft, unique `(OwnerUserId, GmailAddress)`. EF auto-indexes FKs by convention. **Genuinely missing:** owner-prefixed composites `(OwnerUserId, IsDeleted, Status)` and `(OwnerUserId, FollowUpAt)`.
- SQLite at `DataRoot/jobtracker.db` (WAL); migrations applied at startup by `StartupInitializationExtensions` (1356 lines — also seeds admin, creates Identity tables where `dotnet ef` is unavailable, ignores `PendingModelChangesWarning`).
- Indexes include owner-prefixed list/board/reminder composites `(OwnerUserId, IsDeleted)`, `(OwnerUserId, IsDeleted, Status)`, and `(OwnerUserId, FollowUpAt)`, plus CV and provider-specific indexes.
- SQLite lives at `DataRoot/jobtracker.db` (WAL); migrations and legacy-schema reconciliation run at startup through `StartupInitializationExtensions`.
> Corrected 2026-07-17: prior session notes recorded the EF model snapshot as **broken/empty**. It was **resynced** in `20260711181039_SyncModelSnapshot` — the snapshot now covers the full model and incremental `dotnet ef migrations add` works normally. Note `dotnet ef` still needs the `Design` package temporarily added to `JobTrackerApi` (the `MigrationsAssembly`), since it lives in `JobTrackerBackend` with `PrivateAssets=all`.
> Corrected 2026-07-31: the model snapshot is current and the API directly carries the EF Design package and `JobTrackerContext`; no temporary project edit is required for `dotnet ef`.
---
@@ -294,10 +291,10 @@ erDiagram
| `DailyExportHostedService` | Daily JSON export at a configured local hour |
| `JobEnrichmentHostedService` | Backfills summaries/enrichment |
| `SummarizerProbeHostedService` | Probes AI service readiness |
| `CvProcessingHostedService` + `CvProcessingQueue` | In-memory queue for CV extraction |
| `CvProcessingHostedService` + `CvProcessingQueue` | Process-local wake-up queue for CV extraction; queued/running database work is recovered at startup |
| `DatabaseBackupHostedService``DatabaseBackupRunner` | Automated DB backup (`VACUUM INTO`, server-derived path) |
**All state is in-process** (`IMemoryCache`, in-memory queue) — single-instance assumption, no distributed locks, **queued CV jobs are lost on restart**.
Caches and worker coordination are process-local. CV work itself is durable and recovered after restart, but the worker remains a deliberate single-instance design without database leasing.
---
@@ -361,15 +358,15 @@ Inbound: `GmailOAuthService` (655), `MicrosoftGraphOAuthService` (507), `ImapSer
- **Backend:** xUnit integration-style via `TestHostFactory`. 36 test files. Notable: `JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, `ImapServiceSsrfGuardTests`, `ProductionConfigTests`, `AttachmentFlagsRecomputeTests`, `CvCorpusHarnessTests`, `SqliteMigrationHelperTests`, `JobPipelineTests`, plus a `tools/hostile-fixture-db` project.
- **Frontend:** ~20 Jest/RTL files — **all run in CI**.
- **AI service:** pytest (`tools/summarizer/tests/`).
- **Gaps:** no true end-to-end browser tests; no load/perf tests; **no dependency CVE scanning** (CI explicitly sets `npm_config_audit: 'false'`).
- **Browser smoke:** Playwright drives login/session cookies, saved-job creation, Career Workspace routing, and anonymous public-CV rendering/PDF download against isolated API/SQLite and Next.js processes. CI installs Chromium and runs all four flows.
- **Gap:** no load/performance suite. CI reports NuGet transitive vulnerabilities and production npm audit findings.
---
## 14. Logging & error handling
Console/debug logging; middleware logs every request (method, path, status, ms, traceId, sub claim). Unhandled exceptions logged then rethrown (500). Client errors POST to `/api/client-errors`. React `ErrorBoundary` + route error page.
No structured sink (Seq/OTLP), no in-app log rotation, no ProblemDetails standardization.
Development uses simple console/debug logging; production emits structured JSON. Middleware records method, path, status, duration, trace ID, and subject. Unhandled errors return Problem Details with the same trace ID. Client errors POST to `/api/client-errors`; the frontend has an `ErrorBoundary` and route error page.
Compose bounds each container's local logs to three 10 MB files. There is still no external sink (Seq/OTLP) or cross-host aggregation.
---
@@ -385,17 +382,17 @@ No structured sink (Seq/OTLP), no in-app log rotation, no ProblemDetails standar
- 2FA + recovery codes + trusted devices + session revocation.
- **AI sidecar: backend-only.** Unpublished, on a private two-member network, and token-authenticated (§16). Verified against the running stack, not just configured.
**Open findings** (detail in `docs/application-discovery-report.md` §12 and `docs/phase-0-foundation-report.md`):
**Findings status** (detail in `docs/application-discovery-report.md` §12 and `docs/phase-0-foundation-report.md`):
| Sev | Finding | Status |
|---|---|---|
| Medium | DataProtection keys recoverable from git history (`519c32e`, `955cae6`) | **Open — rotation required, needs an operator** |
| Medium | **CORS: `Cors:Origins="*"` triggers `SetIsOriginAllowed(_ => true)` + `AllowCredentials()`** (`Program.cs:96-102`) — reflected-origin with cookies = session theft from any site. Not currently active (compose never sets `Cors__Origins`, so it defaults to `localhost:3000`), but it is one config value away. | **Open — landmine** |
| Medium | AI cost ceiling | Metering shipped in Phase 5; enforce quotas before open registration (Phase 7). |
| Low | No CAPTCHA (rate limiting only) | Open — blocks public signup |
| Low | Unbounded storage: attachments, CV artifacts, extraction runs, base64 avatars | Open |
| Medium | Wildcard credentialed CORS configuration | **Closed — startup rejects it** |
| Medium | AI cost ceiling | **Closed — monthly generation/token limits are enforced by plan** |
| Low | Public-registration abuse control | Implemented with Turnstile; production keys/configuration still required |
| Low | Unbounded storage | **Closed — attachment quotas, extraction/artifact pruning, file-backed avatars, and PDF-export retention are implemented** |
| Low | Backup / DPAPI is Windows-oriented — verify behaviour on Linux prod | Unverified |
| Low | No dependency CVE scanning in CI | Open |
| Low | No dependency CVE scanning in CI | **Closed — NuGet and production npm audit reporting are in CI** |
---
@@ -417,20 +414,9 @@ Full record: `docs/phase-0-foundation-report.md`. What changed architecturally:
---
## 17. Known debt (ranked)
## 17. Known debt
1. **`JobApplication` still carries opportunity columns** — the `Job` split is started but not completed. Reads/writes still use the legacy columns.
2. **God controllers**`JobApplicationsController` 2313/38 endpoints, `ProfileCvController` 2249, `StartupInitializationExtensions` 1356, `GmailController` 1023, `AuthController` 879.
3. **Hardcoded CV templates** — dead end for the CV Builder.
4. **Three frontend toolchains** — Next shell + react-router (×2 patterns) + react-scripts test runner.
5. **No frontend data layer** — root cause of the 6001400-line components.
6. **`ProfilePage` (1368 lines) serves two routes** behind a boolean; the `careerView` prop it accepts is **never read** (the "CV Builder" tab is inert).
7. **Denormalized `Has*` flags**; **`Tags` as a JSON string column**; **unbounded CV storage**; **base64 avatars in a DB column**.
8. **`JobTrackerBackend` link-compilation** — self-described "transitional".
9. **In-memory queue/cache** — single-instance coupling; restart loses queued CV jobs.
10. **No OpenAPI in prod, no ProblemDetails, no structured logging sink.**
11. **Root-level clutter**: `temp_job.json`, `temp_post_job.py`, `todo jobtracker.txt`, `test/`, `tmp/`, `vendor/`, `docs.7z`, `CV_Changes.md`, `SMART_GMAIL_PROGRESS.md`.
12. **Norway-only import plugins** (Finn/NAV/Jobbnorge). Product decision 2026-07-17: **Norway first, but no hardcoding Norway** — market must become a data dimension, not an assumption.
The live, prioritized ledger is `docs/architecture/technical-debt.md`. The principal remaining items are the legacy `JobApplication` opportunity columns, single-replica worker coordination, and optional cross-host log aggregation. Large orchestration files are refactored only when a cohesive behavior change provides a safe seam.
---
@@ -438,9 +424,8 @@ Full record: `docs/phase-0-foundation-report.md`. What changed architecturally:
Recorded nowhere else in active docs:
1. `JobTrackerBackend` link-compilation exists so tests can reach controllers without the web host — deliberate, self-described "transitional".
2. `Status` is free-text and canonicalized in the application layer **specifically so custom user values are never destroyed** (`JobPipeline.cs` docstring). Deliberate; do not "fix" it with a DB enum.
3. The CI frontend-test whitelist was removed after it "silently skipped new suites and let two regressions reach main."
4. Ollama is intentionally not bundled by default so deploys reuse a shared instance.
5. `AI_PROVIDER=gemini` exists to offload a weak local GPU in prod.
6. `TailoredCvDraft` is a separate entity specifically to guarantee the master CV is never auto-modified.
1. `Status` is free-text and canonicalized in the application layer **specifically so custom user values are never destroyed** (`JobPipeline.cs` docstring). Deliberate; do not "fix" it with a DB enum.
2. The CI frontend-test whitelist was removed after it "silently skipped new suites and let two regressions reach main."
3. Ollama is intentionally not bundled by default so deploys reuse a shared instance.
4. `AI_PROVIDER=gemini` exists to offload a weak local GPU in prod.
5. `TailoredCvDraft` is a separate entity specifically to guarantee the master CV is never auto-modified.
+7 -2
View File
@@ -25,8 +25,8 @@ The master profile is never written by the builder. A variant references career
## Variant model
`CvVariant` (`Models/CvVariant.cs`) + `CvVariantVersion` (autosave history). The whole lens lives in one
`SettingsJson` blob (`CvVariantSettings`, `Models/CvVariantSettings.cs`) because it is edited and saved
`CvVariant` (`JobTrackerApi/Models/CvVariant.cs`) + `CvVariantVersion` (autosave history). The whole lens lives in one
`SettingsJson` blob (`CvVariantSettings`, `JobTrackerApi/Models/CvVariantSettings.cs`) because it is edited and saved
as a unit — never queried field-by-field. A variant stores:
- `ThemeId` + overrides: accent, heading/body font, density, page size, photo/icons/page-numbers.
@@ -56,6 +56,11 @@ Anonymous (`/api/public-cv/{slug}`, `PublicCvController`): serves a public varia
tenant filter — there is no current user). Unknown/private slug → 404
(`CvBuilderTests.Private_variant_is_not_served_publicly`).
Recruiters can download the same public render as PDF through `GET /api/public-cv/{slug}/pdf`.
The endpoint applies the identical public/private slug check; unknown, revoked, or private links return
404 without invoking the exporter. Anonymous PDF generation is limited to three requests per minute
per public link because it launches Chromium. `PublicCvPage` exposes it as a native **Download PDF** link.
## Builder workflow (frontend)
`/career/builder` lists variants (`CvBuilderPage`); the editor (`CvBuilderEditor`) is three tabs —
+2 -2
View File
@@ -7,7 +7,7 @@
**A theme is data, not code.** There is exactly one renderer — `ThemedCvRenderer`
(`JobTrackerApi/Services/ThemedCvRenderer.cs`) — with a single render path. Every visual difference
between themes is expressed by the fields of a `CvTheme` record (`Models/CvTheme.cs`). Adding a theme
between themes is expressed by the fields of a `CvTheme` record (`JobTrackerApi/Models/CvTheme.cs`). Adding a theme
never touches the renderer.
This replaces the previous approach (`CvTemplateRenderer`, one hand-written HTML method per template),
@@ -30,7 +30,7 @@ section keys move to the sidebar for the two-column layouts.
## Adding a theme
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`Models/CvTheme.cs`). Only override the
1. Append one `CvTheme { … }` to `CvThemeCatalog.Themes` (`JobTrackerApi/Models/CvTheme.cs`). Only override the
fields that differ from the defaults.
2. Nothing else. It appears in `GET /api/cv/themes`, the Customize tab picker, and renders.
+14 -1
View File
@@ -1,3 +1,16 @@
# deployment
TODO: Complete documentation.
Production runs the frontend/nginx, ASP.NET API, AI sidecar, and configured database through Docker
Compose. The backend is not published directly; nginx proxies `/api`. `deploy/deploy.sh` validates
configuration, takes and verifies a provider-appropriate backup before replacement, builds/restarts the
stack, and performs health checks.
Production compose enables `Proxy:TrustForwardedHeaders` because nginx is the sole ingress, allowing
HTTPS scheme and client-IP rate limits to use one trusted forwarded hop. The development override
publishes the API directly and disables forwarded-header trust.
Each service rotates local Docker logs at 10 MB and retains three files. Add a central sink only if
cross-host search or longer retention becomes necessary.
Use `deploy/README.md`, `deploy/first-production-deployment.md`, and
`docs/deployment/backup-restore.md` as the operational runbooks.
+9 -1
View File
@@ -1,3 +1,11 @@
# future
TODO: Complete documentation.
Architecture evolves incrementally rather than through a rewrite. Current priorities are reducing large
controller/component orchestration boundaries, replacing the transitional link-compiled backend project,
adding durable background work if multi-instance deployment becomes necessary, and introducing browser
end-to-end coverage for critical user journeys.
Job-specific learning recommendations reuse deterministic match gaps and checklist state. Portfolio
content stays in public CVs. Broader course integrations or separate portfolio hosting remain out of
scope until concrete requirements justify new storage or service boundaries. Active items are tracked
in `docs/architecture/technical-debt.md` and `BLOCKERS.md`.
+48 -48
View File
@@ -1,56 +1,56 @@
# Technical Debt Management
# Technical debt
## Purpose
Last reconciled: 2026-07-31
Track known issues.
This ledger contains verified engineering debt only. Product ideas belong in the roadmaps and
operator/external dependencies belong in `BLOCKERS.md`.
---
## Resolved in the 2026-07-31 debt pass
# Categories
- Removed the 232 MB unused `vendor/saasable-ui-main` snapshot.
- Retired the link-compiling `JobTrackerBackend` project. The API now owns its controllers,
services, models, and `JobTrackerContext`; tests and tools reference the API directly.
- Added standard Problem Details responses with trace IDs and structured JSON production logs.
- Made trusted reverse-proxy headers explicit so rate limits see the real client address in the
production nginx topology.
- Made queued CV processing recover queued/running database work after restart and bypass the
request-only tenant filter with explicit owner checks.
- Pruned expired CV extraction runs and orphaned upload artifacts while preserving the current CV.
- Added configurable PDF export retention (`CvExports:RetainDays`, default 30 days).
- Corrected the remaining CRA-era environment-variable names after the Next.js migration.
- Removed React Router and `act(...)` warnings from the frontend tests.
- Replaced placeholder documentation stubs with concise, code-linked guidance.
- Removed stale locals and reconciled old TODO claims against the implemented code.
- Added a CI-gated Playwright smoke suite for login/session cookies, saved-job creation, Career
Workspace routing, and anonymous public-CV rendering/PDF download.
- Updated Axios and Next.js and overrode Next's vulnerable bundled Sharp/PostCSS versions; the only
remaining npm findings are the non-applicable/mitigated React Router items in `BLOCKERS.md`.
- Pinned SQLitePCLRaw 2.1.12 so the native SQLite runtime is no longer in the high-severity
CVE-2025-6965 range; the NuGet transitive vulnerability audit is clean.
- Made fresh SQLite startup reconcile schema-owned columns between historical EF migrations, so
strict SQLite identifier handling no longer breaks the later table rebuild.
- Updated Jest and Playwright, pinned the patched transitive brace-expansion package, and promoted
the full npm high-severity audit to a blocking CI gate.
- Corrected Chromium PDF export argument handling, bounded hung exports, and verified the returned
public artifact is a real PDF in the browser smoke suite.
- Removed unfinished Portfolio/Notes workspace navigation promises; existing project, attachment,
and application-note surfaces remain authoritative, and stale section links fall back to Overview.
## Architecture
## Remaining engineering debt
Examples:
| Priority | Debt | Current decision / trigger |
|---|---|---|
| P1 | `JobApplication` still duplicates opportunity data now owned by `Job`. | Keep the compatibility dual-write until a production-data backfill and restore rehearsal prove every application has a valid `JobId`; then remove the legacy columns in one migration. |
| P1 | Background workers assume one API instance. Restart recovery is durable, but there is no row lease for concurrent workers. | Add database leasing only before deploying more than one backend replica. |
| P2 | Production log aggregation is still deployment-owned; Compose now bounds each container's local logs to 3 × 10 MB. | Add an OTLP/Seq sink only before multi-host operation or when incident-response needs exceed `docker logs`. |
| P3 | `Tags` remains a JSON string and attachment-purpose booleans remain compatibility columns. | Normalize tags only when server-side tag querying becomes slow. Remove attachment flags only with an API/schema compatibility release; recomputation tests currently prevent drift. |
| P3 | Checklist reads seed and synchronize system-generated items, so the GET is intentionally non-cacheable. | Split read/write paths only if workspace read volume or caching makes the current idempotent behavior measurably costly. |
| P3 | Several orchestration files are large (`JobApplicationsController`, `StartupInitializationExtensions`, `JobDetailsDialog`). | File length alone is not a defect. Extract a cohesive slice only when the next behavior change touches it; avoid a standalone rewrite. |
- Large services.
- Tight coupling.
## Deliberate non-debt
---
## Frontend
Examples:
- Duplicate components.
- Complex state.
---
## Backend
Examples:
- Large controllers.
- Missing abstractions.
---
## Testing
Examples:
- Missing coverage.
---
# Rules
Do not fix everything immediately.
Prioritise:
Impact.
Risk.
User value.
- Free-text pipeline status is intentional so custom values are not destroyed.
- Next.js is the static build shell and React Router owns client navigation; changing routers has no
demonstrated user benefit.
- The CV queue is intentionally process-local for the current single-replica deployment.
- User-local untracked files at the repository root were not removed or modified.
+6 -1
View File
@@ -1,3 +1,8 @@
# 2fa
TODO: Complete documentation.
Local accounts can enable TOTP from Profile settings. Setup requires the current password, verification
of the first six-digit code, and acknowledgement of one-time recovery codes. Users can disable TOTP,
regenerate recovery codes, list/revoke trusted devices, and revoke every trusted device.
Challenge attempts are rate-limited. Recovery codes are hashed at rest and shown only when generated.
See `docs/security/two-factor-authentication.md` for the detailed trust model.
+7 -1
View File
@@ -1,3 +1,9 @@
# login
TODO: Complete documentation.
`POST /api/auth/login` accepts email, password, and remember-me preference. Successful local login
issues an HttpOnly session cookie plus a readable CSRF cookie. Accounts with two-factor authentication
receive a short-lived pending token and must complete `POST /api/auth/2fa/challenge` before a session is
issued. Login and challenge endpoints are rate-limited.
The frontend route is `/login`; registration has its own `/register` route. Authentication errors are
shown without exposing whether an unknown account exists.
+7 -1
View File
@@ -1,3 +1,9 @@
# oauth
TODO: Complete documentation.
Google and Microsoft sign-in exchange provider identity tokens for a normal Jobjakt local session.
Verified provider email addresses may link to the matching local account; provider subject identifiers
are then retained for stable future sign-in. Gmail and Microsoft Graph mailbox connections use separate
OAuth flows and state validation because they grant mailbox permissions, not application login.
Provider client IDs and secrets belong in environment configuration, never the repository. See
`docs/architecture/authentication.md` and the connected-account settings UI.
+8 -1
View File
@@ -1,3 +1,10 @@
# overview
TODO: Complete documentation.
Jobjakt supports local email/password sessions plus optional Google and Microsoft identity exchange.
Local access tokens are stored in an HttpOnly `jobtracker_session` cookie; mutating cookie-authenticated
requests also require the double-submit CSRF token. Identity, sessions, two-factor authentication,
trusted devices, email verification, and password reset are implemented by the controllers and services
under `JobTrackerApi/Controllers` and `JobTrackerApi/Services`.
Production authentication fails closed when required configuration is missing. See
`docs/architecture/authentication.md` and `docs/security/authentication.md` for the security model.
+6 -1
View File
@@ -1,3 +1,8 @@
# signup
TODO: Complete documentation.
Self-service signup uses `POST /api/auth/register` and the `/register` frontend route. It is disabled
unless `Auth:AllowRegistration`/`AUTH_ALLOW_REGISTRATION` is enabled. When Cloudflare Turnstile keys
are configured, the server validates the widget token through Siteverify before creating the account.
Production activation is intentionally blocked until the operator supplies Turnstile configuration;
see `BLOCKERS.md`. Email verification can also be required before the first login.
+1 -1
View File
@@ -96,7 +96,7 @@ It also independently states the same product boundary you have been enforcing:
Branch is **10 commits ahead, 29 commits behind** `main`. Seven files were touched on both sides since the fork:
```
Data/JobTrackerContext.cs
JobTrackerApi/Data/JobTrackerContext.cs
JobTrackerApi/Controllers/JobApplicationsController.cs
JobTrackerApi/Controllers/ProfileCvController.cs
JobTrackerApi/Program.cs
+5 -1
View File
@@ -1,3 +1,7 @@
# education
TODO: Complete documentation.
Education entries are relational `CareerEducation` children with stable item keys. The Career Profile is
the editable source of truth; CV variants control only selection, order, labels, and presentation-time
overrides. Imported CV data is reviewed before it is merged.
See `docs/architecture/career-profile-model.md`.
+5 -1
View File
@@ -1,3 +1,7 @@
# experience
TODO: Complete documentation.
Experience entries are relational `CareerExperience` children owned by one Career Profile. They carry
stable item keys so CV variants can reference, hide, reorder, or override presentation without copying
the underlying facts. Career Profile edits update the source; variants resolve the latest source data.
See `docs/architecture/career-profile-model.md`.
+4 -1
View File
@@ -1,3 +1,6 @@
# projects
TODO: Complete documentation.
Projects are structured Career Profile records and can appear in any CV variant. Project facts remain
in the profile while each variant chooses visibility, order, and presentation overrides. Public CV
custom sections are the portfolio surface. A separate hosting product is intentionally out of scope
until a concrete need such as media galleries or custom domains appears.
+5 -1
View File
@@ -1,3 +1,7 @@
# skills
TODO: Complete documentation.
Skills are structured Career Profile records used by CV rendering, deterministic job matching, keyword
gap analysis, and application readiness. They are never inferred into the profile without review.
Variant-specific hiding or ordering belongs to `CvVariantSettings`, not the Career Profile.
See `docs/career/career-profile.md` and `docs/architecture/career-profile-model.md`.
+5 -1
View File
@@ -1,3 +1,7 @@
# ai
TODO: Complete documentation.
AI cover-letter generation is suggestion-only. Prompts delimit untrusted job content, require factual
claims to come from the Career Profile or application context, and return text for user review. Calls
are recorded by the shared AI interaction meter and constrained by plan request/token limits.
See `docs/ai/cover-letter-ai.md` and `docs/ai/safety.md`.
+3 -1
View File
@@ -1,3 +1,5 @@
# builder
TODO: Complete documentation.
The application workspace exposes cover-letter drafting alongside tailored CV and recruiter-message
tools. Users can generate a suggestion, edit it, save deliberately, and restore prior versions. Context
comes from the owned application and Career Profile; attachments are included only when selected for AI.
+5 -1
View File
@@ -1,3 +1,7 @@
# overview
TODO: Complete documentation.
Cover letters are application-owned documents generated from the Career Profile, job description, and
selected application context. They are not part of the master profile. Saved versions retain explicit
history so generation never silently overwrites an accepted draft.
See `docs/career/cover-letters.md` and `docs/architecture/application-workspace.md`.
+5 -1
View File
@@ -1,3 +1,7 @@
# ai
TODO: Complete documentation.
`POST /api/cv/ai/assist` supports focused rewrite actions such as shorten, grammar, bullets, summary,
ATS phrasing, and tailoring. Requests always instruct the provider to preserve facts. The response is a
suggestion: the user must apply it to a variant field explicitly.
Usage is counted by the shared AI metering and monthly plan limits.
+6 -1
View File
@@ -1,3 +1,8 @@
# content
TODO: Complete documentation.
The Content tab reads the master profile through `GET /api/cv/outline`. Section order, visibility,
renamed headings, item order, and per-item bullet overrides are saved in `CvVariantSettings`; profile
records remain unchanged. Custom sections belong only to the variant.
Reordering supports native drag-and-drop plus arrow-button keyboard controls. Rich-text input stores a
small plain-text markup whitelist that the server renders safely.
+6 -1
View File
@@ -1,3 +1,8 @@
# customisation
TODO: Complete documentation.
The Customize tab selects a catalog theme and changes the supported theme tokens: accent, fonts,
density, page size, photo treatment, icons, and page-number preference. `CvThemeCatalog` defines the
available first-party themes and plan entitlement. Existing Premium-theme documents remain readable
and exportable after downgrade.
See `docs/architecture/cv-theme-engine.md` for the data model.
+6 -1
View File
@@ -1,3 +1,8 @@
# exports
TODO: Complete documentation.
Authenticated variant export uses `POST /api/cv/variants/{id}/export-pdf`. The server resolves the
variant against the current Career Profile, renders HTML through `ThemedCvRenderer`, and invokes
headless Chromium through `PlaywrightCvPdfExporter`.
Public variants expose the same render through `GET /api/public-cv/{slug}/pdf`; visibility is checked
before rendering and anonymous generation is limited to three requests per public link per minute.
+6 -1
View File
@@ -1,3 +1,8 @@
# overview
TODO: Complete documentation.
The CV Builder turns the Career Profile into named, themed CV variants. A variant stores presentation
settings and stable item references; it never copies or overwrites the master profile. Users edit
content overrides, customize a data-driven theme, preview continuously, retain version history, export
PDF, and optionally publish an unguessable read-only link.
The authoritative design is `docs/architecture/cv-builder.md`.
+5 -1
View File
@@ -1,3 +1,7 @@
# rendering
TODO: Complete documentation.
Rendering is a projection pipeline: Career Profile plus `CvVariantSettings` becomes `CvRenderModel`,
then `ThemedCvRenderer` applies a declarative `CvTheme` to produce HTML. Preview, authenticated PDF,
and public sharing all use this path, preventing format-specific content drift.
See `docs/architecture/cv-rendering.md` and `docs/architecture/cv-theme-engine.md`.
+5 -1
View File
@@ -1,3 +1,7 @@
# themes
TODO: Complete documentation.
Themes are immutable catalog data rather than separate renderer implementations. Each theme declares
layout, colors, typography, spacing, header/photo treatment, ATS suitability, and whether Premium is
required. User overrides are stored on the variant and normalized before rendering.
Add a theme to `CvThemeCatalog`; do not add a new HTML renderer.

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