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:
+5
-1
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -36,6 +36,8 @@ node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
.next/
|
||||
.cache/
|
||||
|
||||
|
||||
+54
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,11 +3,7 @@ FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
|
||||
WORKDIR /src
|
||||
|
||||
COPY JobTrackerApi/JobTrackerApi.csproj JobTrackerApi/
|
||||
COPY JobTrackerBackend/JobTrackerBackend.csproj JobTrackerBackend/
|
||||
COPY Data/ Data/
|
||||
COPY Models/ Models/
|
||||
COPY JobTrackerApi/ JobTrackerApi/
|
||||
COPY JobTrackerBackend/ JobTrackerBackend/
|
||||
|
||||
# Retry once after clearing NuGet caches. Transient download corruption on the
|
||||
# build host can trip NU3008 ("package integrity check failed / has changed since
|
||||
|
||||
@@ -8,21 +8,22 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Controllers\**\*.cs" />
|
||||
<Compile Remove="Services\**\*.cs" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Identity.EntityFrameworkCore" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<!-- dotnet-ef design-time tooling requires this on the startup project (not just
|
||||
JobTrackerBackend, where the DbContext actually lives) since EF Core 6+. -->
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\JobTrackerBackend\JobTrackerBackend.csproj" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
|
||||
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Otp.NET" Version="1.4.1" />
|
||||
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
|
||||
<PackageReference Include="QRCoder" Version="1.8.0" />
|
||||
<PackageReference Include="SQLitePCLRaw.lib.e_sqlite3" Version="2.1.12" />
|
||||
<PackageReference Include="Stripe.net" Version="52.2.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
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; }
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 it’s 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 (0–100) 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 (0–100) 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
@@ -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
@@ -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
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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 600–1400-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.
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user