fix: close release preflight gaps
Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
This commit is contained in:
+8
-4
@@ -9,6 +9,7 @@ Updated: 2026-07-31
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **Runbook:** Follow `docs/operations/stripe-activation.md`, completing test mode before creating or installing live-mode values.
|
||||||
|
|
||||||
## Public registration verification
|
## Public registration verification
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ Updated: 2026-07-31
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **Recommended:** Monitor Turnstile and rate-limit failures during the first public rollout; decide whether free-only onboarding remains open before Stripe activation.
|
||||||
|
- **Current status:** Production returns `allowRegistration=true`, `turnstileEnabled=true`, `googleEnabled=true`, and `microsoftEnabled=false`. A registration request without a Turnstile token is rejected with HTTP 400. `requireEmailVerification=false`, so the operator must explicitly accept that policy or enable and verify email delivery before rollout. A successful Turnstile/browser registration still requires an interactive session.
|
||||||
|
|
||||||
## CI runner verification
|
## CI runner verification
|
||||||
|
|
||||||
@@ -23,6 +25,7 @@ Updated: 2026-07-31
|
|||||||
- **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.
|
- **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.
|
- **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.
|
- **Recommended:** Keep the full gate intact; fix the runner instead of skipping or filtering tests.
|
||||||
|
- **Current status:** The `release-readiness` branch is pushed to origin. Creating the pull request at `https://git.cesnimda.uk/cesnimda/jobtrackingapp/pulls/new/release-readiness` still requires an authenticated Gitea browser or CLI session; neither is available in this workspace.
|
||||||
|
|
||||||
## React Router security release
|
## React Router security release
|
||||||
|
|
||||||
@@ -35,8 +38,8 @@ Updated: 2026-07-31
|
|||||||
|
|
||||||
- **Blocked:** Closing roadmap item 1.4.
|
- **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.
|
- **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.
|
- **Required:** Follow `docs/operations/data-protection-key-rotation.md`. First compare production key ids with the single exposed id; rotate only if production actually used it.
|
||||||
- **Recommended:** Back up the current key ring securely, rotate during a short maintenance window, then verify login, reset links, and encrypted application data.
|
- **Recommended:** If the exposed id is active, back up and scratch-restore the database, inventory TOTP/mail credentials protected by the ring, and choose a re-protection cutover instead of blindly deleting keys.
|
||||||
|
|
||||||
## Production verification and deployment
|
## Production verification and deployment
|
||||||
|
|
||||||
@@ -45,10 +48,11 @@ Updated: 2026-07-31
|
|||||||
- **Required:** Follow `docs/release-candidate-review.md` and `docs/release-checklist.md` on the production host.
|
- **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.
|
- **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.
|
- **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.
|
||||||
|
- **Current status:** Anonymous production checks confirm the frontend and `/api/auth/config` return HTTP 200. The public `/health` path currently returns the SPA HTML shell; the release branch now proxies that exact path to the backend and includes a regression test.
|
||||||
|
|
||||||
## Legacy job/application column cutover
|
## Legacy job/application column cutover
|
||||||
|
|
||||||
- **Blocked:** Removing the opportunity columns duplicated between `JobApplication` and `Job`.
|
- **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.
|
- **Why:** The compatibility dual-write protects existing production rows and older clients. The release branch now backfills missing opportunities on startup, synchronizes both creation paths, and leaves all legacy columns intact. Dropping columns still requires production validation and an observation release.
|
||||||
- **Required:** Provide/authorize a recent production backup restore, verify the backfill report, and confirm that backward API compatibility is no longer required.
|
- **Required:** After deployment, run the read-only report in `docs/operations/job-opportunity-cutover.md` against production and a restored backup, then confirm that backward API compatibility is no longer required.
|
||||||
- **Recommended:** Use an expand/contract release: first stop legacy reads after a verified backfill, observe one release, then drop the duplicate columns in the following migration.
|
- **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.
|
||||||
|
|||||||
@@ -816,6 +816,10 @@ public sealed class GmailControllerTests
|
|||||||
Assert.Equal(1, created.Imported);
|
Assert.Equal(1, created.Imported);
|
||||||
Assert.Equal("thread-suggested", created.ThreadId);
|
Assert.Equal("thread-suggested", created.ThreadId);
|
||||||
Assert.Equal(1, await db.JobApplications.CountAsync());
|
Assert.Equal(1, await db.JobApplications.CountAsync());
|
||||||
|
var linkedOpportunity = await db.JobApplications.Include(application => application.Job).SingleAsync();
|
||||||
|
Assert.NotNull(linkedOpportunity.JobId);
|
||||||
|
Assert.Equal("Platform Engineer", linkedOpportunity.Job!.JobTitle);
|
||||||
|
Assert.Equal("gmail", linkedOpportunity.Job.Source);
|
||||||
Assert.Equal(1, await db.Correspondences.CountAsync());
|
Assert.Equal(1, await db.Correspondences.CountAsync());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using JobTrackerApi.Models;
|
||||||
|
using JobTrackerApi.Services;
|
||||||
|
using JobTrackerApi.Tests.TestSupport;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Tests;
|
||||||
|
|
||||||
|
public sealed class JobOpportunitySyncTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task Backfill_links_each_legacy_application_once()
|
||||||
|
{
|
||||||
|
await using var db = TestHostFactory.CreateInMemoryDb();
|
||||||
|
var company = new Company { OwnerUserId = "user-1", Name = "Acme" };
|
||||||
|
db.Companies.Add(company);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
db.JobApplications.Add(new JobApplication
|
||||||
|
{
|
||||||
|
OwnerUserId = "user-1",
|
||||||
|
CompanyId = company.Id,
|
||||||
|
JobTitle = "Platform Engineer",
|
||||||
|
Location = "Oslo",
|
||||||
|
SavedAt = new DateTime(2026, 7, 1, 10, 0, 0, DateTimeKind.Utc),
|
||||||
|
});
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
|
Assert.Equal(1, await JobOpportunitySync.BackfillLegacyAsync(db));
|
||||||
|
Assert.Equal(0, await JobOpportunitySync.BackfillLegacyAsync(db));
|
||||||
|
|
||||||
|
var application = await db.JobApplications.IgnoreQueryFilters().Include(item => item.Job).SingleAsync();
|
||||||
|
Assert.NotNull(application.JobId);
|
||||||
|
Assert.Equal("Platform Engineer", application.Job!.JobTitle);
|
||||||
|
Assert.Equal("Oslo", application.Job.Location);
|
||||||
|
Assert.Equal("legacy", application.Job.Source);
|
||||||
|
Assert.Equal(1, await db.Jobs.IgnoreQueryFilters().CountAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -567,6 +567,7 @@ public sealed class GmailController : ControllerBase
|
|||||||
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
|
if (string.IsNullOrWhiteSpace(company.RecruiterEmail) && !string.IsNullOrWhiteSpace(request.RecruiterEmail)) company.RecruiterEmail = request.RecruiterEmail.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var savedAt = DateTime.UtcNow;
|
||||||
var job = new JobApplication
|
var job = new JobApplication
|
||||||
{
|
{
|
||||||
OwnerUserId = ownerUserId,
|
OwnerUserId = ownerUserId,
|
||||||
@@ -574,8 +575,10 @@ public sealed class GmailController : ControllerBase
|
|||||||
JobTitle = jobTitle,
|
JobTitle = jobTitle,
|
||||||
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
|
Status = string.IsNullOrWhiteSpace(request.Status) ? "Applied" : request.Status.Trim(),
|
||||||
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
Notes = string.IsNullOrWhiteSpace(request.Notes) ? null : request.Notes.Trim(),
|
||||||
DateApplied = DateTime.UtcNow,
|
DateApplied = savedAt,
|
||||||
|
SavedAt = savedAt,
|
||||||
};
|
};
|
||||||
|
job.Job = JobOpportunitySync.Create(job, "gmail");
|
||||||
_db.JobApplications.Add(job);
|
_db.JobApplications.Add(job);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -702,27 +702,6 @@ Canonical profile:
|
|||||||
return Ok(dtos);
|
return Ok(dtos);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SyncOpportunity(JobApplication application, Job opportunity)
|
|
||||||
{
|
|
||||||
opportunity.OwnerUserId = application.OwnerUserId;
|
|
||||||
opportunity.CompanyId = application.CompanyId;
|
|
||||||
opportunity.JobTitle = application.JobTitle;
|
|
||||||
opportunity.Location = application.Location;
|
|
||||||
opportunity.JobUrl = application.JobUrl;
|
|
||||||
opportunity.Description = application.Description;
|
|
||||||
opportunity.TranslatedDescription = application.TranslatedDescription;
|
|
||||||
opportunity.DescriptionLanguage = application.DescriptionLanguage;
|
|
||||||
opportunity.ShortSummary = application.ShortSummary;
|
|
||||||
opportunity.Tags = application.Tags;
|
|
||||||
opportunity.Deadline = application.Deadline;
|
|
||||||
opportunity.Salary = application.Salary;
|
|
||||||
opportunity.SalaryMin = application.SalaryMin;
|
|
||||||
opportunity.SalaryMax = application.SalaryMax;
|
|
||||||
opportunity.SalaryCurrency = application.SalaryCurrency;
|
|
||||||
opportunity.SalaryPeriod = application.SalaryPeriod;
|
|
||||||
opportunity.SavedAt = application.SavedAt;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
private static (decimal? Min, decimal? Max, string? Currency, string? Period) NormalizeSalary(
|
||||||
decimal? min, decimal? max, string? currency, string? period)
|
decimal? min, decimal? max, string? currency, string? period)
|
||||||
{
|
{
|
||||||
@@ -782,7 +761,7 @@ Canonical profile:
|
|||||||
if (source?.Length > 32) source = source[..32];
|
if (source?.Length > 32) source = source[..32];
|
||||||
var countryCode = string.IsNullOrWhiteSpace(request.CountryCode) ? null : request.CountryCode.Trim().ToUpperInvariant();
|
var countryCode = string.IsNullOrWhiteSpace(request.CountryCode) ? null : request.CountryCode.Trim().ToUpperInvariant();
|
||||||
if (countryCode?.Length != 2) countryCode = null;
|
if (countryCode?.Length != 2) countryCode = null;
|
||||||
job.Job = new Job { Source = source, CountryCode = countryCode };
|
job.Job = JobOpportunitySync.Create(job, source, countryCode);
|
||||||
|
|
||||||
// A job created straight into a pre-application stage has not been applied to, so it
|
// A job created straight into a pre-application stage has not been applied to, so it
|
||||||
// must not carry an applied date. SyncAppliedDate also covers the reverse: a create
|
// must not carry an applied date. SyncAppliedDate also covers the reverse: a create
|
||||||
@@ -803,7 +782,7 @@ Canonical profile:
|
|||||||
// ignore summarizer failures at create time
|
// ignore summarizer failures at create time
|
||||||
}
|
}
|
||||||
|
|
||||||
SyncOpportunity(job, job.Job);
|
JobOpportunitySync.Apply(job, job.Job);
|
||||||
_db.JobApplications.Add(job);
|
_db.JobApplications.Add(job);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -879,7 +858,7 @@ Canonical profile:
|
|||||||
// Records StatusChanged plus any lifecycle event the transition implies.
|
// Records StatusChanged plus any lifecycle event the transition implies.
|
||||||
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
|
JobLifecycleEvents.RecordStatusChange(_db, job, oldStatus, request.StatusChangedAt ?? DateTime.Now);
|
||||||
|
|
||||||
if (job.Job is not null) SyncOpportunity(job, job.Job);
|
if (job.Job is not null) JobOpportunitySync.Apply(job, job.Job);
|
||||||
await _db.SaveChangesAsync(cancellationToken);
|
await _db.SaveChangesAsync(cancellationToken);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
using JobTrackerApi.Data;
|
||||||
|
using JobTrackerApi.Models;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace JobTrackerApi.Services;
|
||||||
|
|
||||||
|
public static class JobOpportunitySync
|
||||||
|
{
|
||||||
|
public static Job Create(JobApplication application, string? source = null, string? countryCode = null)
|
||||||
|
{
|
||||||
|
var opportunity = new Job { Source = source, CountryCode = countryCode };
|
||||||
|
Apply(application, opportunity);
|
||||||
|
return opportunity;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void Apply(JobApplication application, Job opportunity)
|
||||||
|
{
|
||||||
|
opportunity.OwnerUserId = application.OwnerUserId;
|
||||||
|
opportunity.CompanyId = application.CompanyId;
|
||||||
|
opportunity.JobTitle = application.JobTitle;
|
||||||
|
opportunity.Location = application.Location;
|
||||||
|
opportunity.JobUrl = application.JobUrl;
|
||||||
|
opportunity.Description = application.Description;
|
||||||
|
opportunity.TranslatedDescription = application.TranslatedDescription;
|
||||||
|
opportunity.DescriptionLanguage = application.DescriptionLanguage;
|
||||||
|
opportunity.ShortSummary = application.ShortSummary;
|
||||||
|
opportunity.Tags = application.Tags;
|
||||||
|
opportunity.Deadline = application.Deadline;
|
||||||
|
opportunity.Salary = application.Salary;
|
||||||
|
opportunity.SalaryMin = application.SalaryMin;
|
||||||
|
opportunity.SalaryMax = application.SalaryMax;
|
||||||
|
opportunity.SalaryCurrency = application.SalaryCurrency;
|
||||||
|
opportunity.SalaryPeriod = application.SalaryPeriod;
|
||||||
|
opportunity.SavedAt = application.SavedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static async Task<int> BackfillLegacyAsync(JobTrackerContext db, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var applications = await db.JobApplications
|
||||||
|
.IgnoreQueryFilters()
|
||||||
|
.Where(application => application.JobId == null)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
foreach (var application in applications)
|
||||||
|
application.Job = Create(application, "legacy");
|
||||||
|
|
||||||
|
if (applications.Count > 0)
|
||||||
|
await db.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
return applications.Count;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2111,8 +2111,9 @@ public static class StartupInitializationExtensions
|
|||||||
|
|
||||||
var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId");
|
var companyOwnershipExists = ColumnExists(conn, provider, "Companies", "OwnerUserId");
|
||||||
var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId");
|
var jobOwnershipExists = ColumnExists(conn, provider, "JobApplications", "OwnerUserId");
|
||||||
|
var opportunityOwnershipExists = ColumnExists(conn, provider, "Jobs", "OwnerUserId");
|
||||||
|
|
||||||
if (companyOwnershipExists || jobOwnershipExists)
|
if (companyOwnershipExists || jobOwnershipExists || opportunityOwnershipExists)
|
||||||
{
|
{
|
||||||
if (companyOwnershipExists)
|
if (companyOwnershipExists)
|
||||||
{
|
{
|
||||||
@@ -2123,6 +2124,11 @@ public static class StartupInitializationExtensions
|
|||||||
{
|
{
|
||||||
adminDb.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
adminDb.Database.ExecuteSqlRaw("UPDATE JobApplications SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (opportunityOwnershipExists)
|
||||||
|
{
|
||||||
|
adminDb.Database.ExecuteSqlRaw("UPDATE Jobs SET OwnerUserId = {0} WHERE OwnerUserId IS NULL;", admin.Id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -2173,6 +2179,14 @@ public static class StartupInitializationExtensions
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
using (var scope = app.Services.CreateScope())
|
||||||
|
{
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<JobTrackerContext>();
|
||||||
|
var backfilled = JobOpportunitySync.BackfillLegacyAsync(db).GetAwaiter().GetResult();
|
||||||
|
if (backfilled > 0)
|
||||||
|
app.Logger.LogInformation("Backfilled {Count} legacy job opportunities.", backfilled);
|
||||||
|
}
|
||||||
|
|
||||||
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
|
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
|
||||||
readiness.MarkReady();
|
readiness.MarkReady();
|
||||||
|
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ flowchart LR
|
|||||||
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.
|
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.
|
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.
|
||||||
|
|
||||||
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.
|
Development leaves static-export mode disabled and rewrites deep links to the root shell; production exports that 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}`.
|
**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}`.
|
||||||
|
|
||||||
@@ -239,7 +239,7 @@ erDiagram
|
|||||||
|
|
||||||
**Key notes:**
|
**Key notes:**
|
||||||
- `ApplicationUser` (IdentityUser) also stores `ProfileCvText`, **`ProfileCvStructureJson`** (the master career profile — a JSON blob, not relational), `AvatarImageDataUrl` (base64 in a column, on the `/auth/me` hot path), Google/Microsoft link info, TOTP secrets, current CV artifact/run pointers.
|
- `ApplicationUser` (IdentityUser) also stores `ProfileCvText`, **`ProfileCvStructureJson`** (the master career profile — a JSON blob, not relational), `AvatarImageDataUrl` (base64 in a column, on the `/auth/me` hot path), Google/Microsoft link info, TOTP secrets, current CV artifact/run pointers.
|
||||||
- **`Job` vs `JobApplication`** — `Job` is the opportunity (title, company, description, URL, salary, location, deadline, tags); `JobApplication` is the user's pursuit of it (status, dates, follow-ups, correspondence, attachments). Introduced in Phase 0 as an **additive** step: `JobApplication.JobId` is a nullable FK and `JobApplication` still carries its original opportunity columns for backwards compatibility. See §16 and `docs/decisions/ADR-002-job-application-model.md`.
|
- **`Job` vs `JobApplication`** — `Job` is the opportunity (title, company, description, URL, salary, location, deadline, tags); `JobApplication` is the user's pursuit of it (status, dates, follow-ups, correspondence, attachments). `JobApplication.JobId` remains nullable for compatibility, but startup backfills missing links and persistence paths synchronize both copies. Legacy reads and columns remain until the production cutover report passes. See §16 and `docs/decisions/ADR-002-job-application-model.md`.
|
||||||
- Salary is **structured**: `SalaryMin`, `SalaryMax`, `SalaryCurrency`, `SalaryPeriod` (plus a legacy free-text `Salary`).
|
- 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.
|
- `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`.
|
- Denormalized `HasResume`/`HasCoverLetter`/`HasPortfolio`/`HasOtherAttachment` flags duplicate `Attachments`; kept honest by `AttachmentFlagsRecomputeTests`.
|
||||||
@@ -410,7 +410,7 @@ Full record: `docs/phase-0-foundation-report.md`. What changed architecturally:
|
|||||||
> If you point `OLLAMA_BASE_URL` at an Ollama in **another** compose stack, address it by host IP (e.g. `http://<host-ip>:11435`) — `ai-service` can no longer resolve container names on `shared_services`, by design. The bundled `ollama` profile is on `ai_internal` and still works by name.
|
> If you point `OLLAMA_BASE_URL` at an Ollama in **another** compose stack, address it by host IP (e.g. `http://<host-ip>:11435`) — `ai-service` can no longer resolve container names on `shared_services`, by design. The bundled `ollama` profile is on `ai_internal` and still works by name.
|
||||||
- **Pipeline expanded beyond `Applied`** — `JobPipeline` now models pre-application stages (`Saved`, `Interested`, `Preparing`) in a new `PipelineCategory.Prospect`, so a job can be tracked before it is applied to. `Saved` is the new default for wizard-created jobs; `Applied` remains the default for the legacy create path.
|
- **Pipeline expanded beyond `Applied`** — `JobPipeline` now models pre-application stages (`Saved`, `Interested`, `Preparing`) in a new `PipelineCategory.Prospect`, so a job can be tracked before it is applied to. `Saved` is the new default for wizard-created jobs; `Applied` remains the default for the legacy create path.
|
||||||
- **`DateApplied` is nullable** + `SavedAt` added — a saved job no longer carries a fabricated application date.
|
- **`DateApplied` is nullable** + `SavedAt` added — a saved job no longer carries a fabricated application date.
|
||||||
- **`Job` entity introduced** alongside `JobApplication` (additive; `JobApplication.JobId` nullable FK). No behaviour moved yet — this only makes the split possible.
|
- **`Job` entity introduced** alongside `JobApplication`; linked rows are now created and backfilled, while reads remain on the legacy columns pending production cutover validation.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ operator/external dependencies belong in `BLOCKERS.md`.
|
|||||||
|
|
||||||
| Priority | Debt | Current decision / trigger |
|
| 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 | `JobApplication` still duplicates opportunity data now owned by `Job`. | Startup now backfills missing `Job` rows and both create paths dual-write. Keep compatibility reads until the production report and restore rehearsal pass; observe one release, then remove the legacy columns. |
|
||||||
| 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. |
|
| 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`. |
|
| 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 | `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. |
|
||||||
|
|||||||
@@ -50,6 +50,10 @@ Split `Job` from `JobApplication`, **additively and in stages**. Phase 0 lays th
|
|||||||
|
|
||||||
This keeps Phase 0 a pure schema-and-vocabulary change with **zero behavioural change** to existing workflows, which is what "unblock future phases safely" requires.
|
This keeps Phase 0 a pure schema-and-vocabulary change with **zero behavioural change** to existing workflows, which is what "unblock future phases safely" requires.
|
||||||
|
|
||||||
|
### Phase 1 status (2026-07-31)
|
||||||
|
|
||||||
|
Create and update paths now synchronize `Job`, and startup idempotently backfills a linked `Job` for every legacy application missing one. Reads and legacy columns remain unchanged until the production validation and observation release in `docs/operations/job-opportunity-cutover.md` complete.
|
||||||
|
|
||||||
### The invariant
|
### The invariant
|
||||||
|
|
||||||
> `DateApplied` is set **if and only if** the job has left the pre-application stages.
|
> `DateApplied` is set **if and only if** the job has left the pre-application stages.
|
||||||
@@ -94,8 +98,8 @@ EF emits a warning that the `SavedAt` backfill `UPDATE` runs while a rebuild of
|
|||||||
|
|
||||||
## Phase 1 cutover plan
|
## Phase 1 cutover plan
|
||||||
|
|
||||||
1. Dual-write `Job` on every create path (`JobApplicationsController.Create`, `GmailController`'s job creation, CSV import).
|
1. **Done:** dual-write `Job` on every persistence path (`JobApplicationsController` and Gmail suggestion creation; CSV import is preview-only).
|
||||||
2. Backfill one `Job` per existing `JobApplication`; link via `JobId`.
|
2. **Done:** backfill one `Job` per existing `JobApplication`; link via `JobId`.
|
||||||
3. Flip reads to `Job`, one endpoint at a time.
|
3. Flip reads to `Job`, one endpoint at a time.
|
||||||
4. Drop the duplicated opportunity columns from `JobApplication`.
|
4. Drop the duplicated opportunity columns from `JobApplication`.
|
||||||
5. Make `JobId` non-nullable.
|
5. Make `JobId` non-nullable.
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Production DataProtection key rotation
|
||||||
|
|
||||||
|
The repository history contains one DataProtection key:
|
||||||
|
|
||||||
|
- exposed key id: `9a89a42c-d2bd-4770-83fb-5930685432db`
|
||||||
|
- historical path: `JobTrackerApi/keys/key-9a89a42c-d2bd-4770-83fb-5930685432db.xml`
|
||||||
|
|
||||||
|
Do not rotate blindly. Production stores its key ring in `/data/keys` inside the backend volume. First
|
||||||
|
prove whether production ever used the exposed key.
|
||||||
|
|
||||||
|
## 1. Non-destructive fingerprint check
|
||||||
|
|
||||||
|
From `/opt/job-tracker/app` on the production host:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec -T backend sh -lc \
|
||||||
|
'for key in /data/keys/key-*.xml; do [ -f "$key" ] && basename "$key"; done'
|
||||||
|
```
|
||||||
|
|
||||||
|
- If the exposed key id is **absent**, save the command output as release evidence and close the
|
||||||
|
rotation blocker. Production keys were not the committed key; deleting a healthy ring would only
|
||||||
|
cause an outage.
|
||||||
|
- If the exposed key id is **present**, continue below. Do not delete or move anything yet.
|
||||||
|
|
||||||
|
## 2. Back up before a confirmed rotation
|
||||||
|
|
||||||
|
First complete the database backup and scratch-restore checklist in `docs/deployment/backup-restore.md`.
|
||||||
|
Then take a restricted backup of the key ring:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /opt/job-tracker/app
|
||||||
|
umask 077
|
||||||
|
docker compose exec -T backend tar -C /data -czf - keys \
|
||||||
|
> "/opt/job-tracker/backups/dataprotection-keys-$(date -u +%Y%m%dT%H%M%SZ).tar.gz"
|
||||||
|
```
|
||||||
|
|
||||||
|
Store that archive offline with production secrets. It contains decryption keys and must not enter
|
||||||
|
Git, chat, CI artifacts, or ordinary application logs.
|
||||||
|
|
||||||
|
## 3. Inventory the impact
|
||||||
|
|
||||||
|
This ring protects more than browser cookies. Before revoking an exposed key, count affected records
|
||||||
|
without printing their encrypted values:
|
||||||
|
|
||||||
|
- users with `TotpSecretEncrypted` or `TotpPendingSecretEncrypted`;
|
||||||
|
- Gmail connections with encrypted access/refresh tokens;
|
||||||
|
- Microsoft Graph connections with encrypted access/refresh tokens;
|
||||||
|
- IMAP connections with encrypted passwords;
|
||||||
|
- encrypted application-export files that still need to remain restorable.
|
||||||
|
|
||||||
|
Password-reset/email-confirmation tokens and active authentication cookies will also stop validating.
|
||||||
|
|
||||||
|
## 4. Choose the cutover
|
||||||
|
|
||||||
|
### No protected records
|
||||||
|
|
||||||
|
Use a short maintenance window, replace the `/data/keys` ring, restart the backend, then verify login,
|
||||||
|
password reset, email confirmation, and a new encrypted backup. Existing sessions will be signed out.
|
||||||
|
|
||||||
|
### Protected records exist
|
||||||
|
|
||||||
|
Do not simply delete the old ring: that would strand TOTP secrets and mail credentials. Keep the old
|
||||||
|
ring available only for a one-time re-protection operation, create a new active key, re-protect every
|
||||||
|
persistent secret, verify the affected integrations, then remove the exposed key and restart. The
|
||||||
|
repository does not yet include that production-only migration because it is unnecessary unless the
|
||||||
|
fingerprint check proves the exposed key is active.
|
||||||
|
|
||||||
|
## Completion evidence
|
||||||
|
|
||||||
|
- production key-id listing captured without key contents;
|
||||||
|
- database scratch restore passed;
|
||||||
|
- key-ring backup stored outside the repository;
|
||||||
|
- affected-record counts recorded without encrypted values;
|
||||||
|
- login, password reset, TOTP, Gmail/Graph/IMAP, and encrypted backup behavior verified as applicable;
|
||||||
|
- exposed key id absent from the active `/data/keys` directory.
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# Job opportunity cutover
|
||||||
|
|
||||||
|
The release-readiness build closes the write-side gap:
|
||||||
|
|
||||||
|
- normal application creation and Gmail suggested-job creation both create a linked `Job`;
|
||||||
|
- edits synchronize opportunity fields through `JobOpportunitySync`;
|
||||||
|
- startup idempotently creates one `Job` for every legacy `JobApplication` whose `JobId` is null.
|
||||||
|
|
||||||
|
The startup backfill is additive and runs after the deployment backup. It does not drop or overwrite
|
||||||
|
legacy application columns.
|
||||||
|
|
||||||
|
## Production validation
|
||||||
|
|
||||||
|
Run these read-only checks after deployment. Expected result for every count is `0`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT COUNT(*) AS missing_job
|
||||||
|
FROM JobApplications
|
||||||
|
WHERE JobId IS NULL;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS missing_target
|
||||||
|
FROM JobApplications a
|
||||||
|
LEFT JOIN Jobs j ON j.Id = a.JobId
|
||||||
|
WHERE a.JobId IS NOT NULL AND j.Id IS NULL;
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS owner_mismatch
|
||||||
|
FROM JobApplications a
|
||||||
|
JOIN Jobs j ON j.Id = a.JobId
|
||||||
|
WHERE NOT (a.OwnerUserId <=> j.OwnerUserId);
|
||||||
|
|
||||||
|
SELECT COUNT(*) AS field_mismatch
|
||||||
|
FROM JobApplications a
|
||||||
|
JOIN Jobs j ON j.Id = a.JobId
|
||||||
|
WHERE NOT (a.CompanyId <=> j.CompanyId)
|
||||||
|
OR NOT (a.JobTitle <=> j.JobTitle)
|
||||||
|
OR NOT (a.Location <=> j.Location)
|
||||||
|
OR NOT (a.JobUrl <=> j.JobUrl)
|
||||||
|
OR NOT (a.Description <=> j.Description)
|
||||||
|
OR NOT (a.Salary <=> j.Salary);
|
||||||
|
```
|
||||||
|
|
||||||
|
`<=>` is MariaDB/MySQL's null-safe equality operator. Do not use these statements as an update script.
|
||||||
|
|
||||||
|
## Expand/contract release order
|
||||||
|
|
||||||
|
1. Deploy the additive backfill and synchronized writers.
|
||||||
|
2. Capture the validation counts above and observe one release.
|
||||||
|
3. Change reads to use `Job`; keep compatibility columns during that release.
|
||||||
|
4. Re-run validation against a fresh backup restore.
|
||||||
|
5. Only then make `JobId` required and drop duplicated opportunity columns in a later migration.
|
||||||
|
|
||||||
|
Do not combine the destructive column drop with the first production backfill.
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# Stripe activation
|
||||||
|
|
||||||
|
The application code is complete. Activate it in Stripe test mode first; test and live mode have
|
||||||
|
different API keys, price ids, webhook endpoints/signing secrets, and customer data.
|
||||||
|
|
||||||
|
Official references:
|
||||||
|
|
||||||
|
- https://docs.stripe.com/subscriptions
|
||||||
|
- https://docs.stripe.com/customer-management/integrate-customer-portal
|
||||||
|
- https://docs.stripe.com/webhooks?lang=dotnet
|
||||||
|
|
||||||
|
## Test-mode setup
|
||||||
|
|
||||||
|
1. Create one recurring monthly Premium product/price.
|
||||||
|
2. Enable the customer portal for subscription management and cancellation.
|
||||||
|
3. Register `https://jobs.cesnimda.uk/api/billing/webhook` as an HTTPS webhook endpoint for:
|
||||||
|
- `customer.subscription.created`
|
||||||
|
- `customer.subscription.updated`
|
||||||
|
- `customer.subscription.deleted`
|
||||||
|
4. Add the test-mode values to `/opt/job-tracker/shared/.env`:
|
||||||
|
|
||||||
|
```text
|
||||||
|
STRIPE_SECRET_KEY=<test secret key>
|
||||||
|
STRIPE_PRICE_PREMIUM=<test recurring price id>
|
||||||
|
STRIPE_WEBHOOK_SECRET=<test endpoint signing secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Restart/redeploy the backend without printing those values.
|
||||||
|
|
||||||
|
Never put the values in Git, chat, screenshots, issue text, or CI logs. Stripe requires the raw
|
||||||
|
request body for signature verification; the implemented endpoint reads it before constructing the
|
||||||
|
signed event.
|
||||||
|
|
||||||
|
## Acceptance check
|
||||||
|
|
||||||
|
Use a disposable, authenticated Jobbjakt account:
|
||||||
|
|
||||||
|
1. Account settings reports billing enabled and offers Upgrade.
|
||||||
|
2. Upgrade opens Stripe-hosted Checkout for the configured monthly price.
|
||||||
|
3. Completing a Stripe test subscription returns to `/settings?billing=success`.
|
||||||
|
4. The signed subscription webhook stores the Stripe customer/subscription/status and grants the
|
||||||
|
`Premium` role.
|
||||||
|
5. `/api/auth/me` exposes Premium entitlements and the UI unlocks Premium capabilities.
|
||||||
|
6. Manage billing opens the Stripe customer portal.
|
||||||
|
7. Cancel in the portal and confirm an updated/deleted subscription webhook removes Premium when the
|
||||||
|
subscription is no longer active or trialing.
|
||||||
|
8. Replay one event from Stripe Workbench and confirm role/state remain idempotent.
|
||||||
|
|
||||||
|
## Live-mode cutover
|
||||||
|
|
||||||
|
Repeat the product/price, portal, and webhook setup in live mode. Replace all three environment values
|
||||||
|
as one change; never mix a test price or endpoint secret with a live API key. Complete one real
|
||||||
|
low-risk subscription and cancellation, confirm the webhook delivery log is green, then remove the
|
||||||
|
disposable account if it is no longer needed.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Removing all three environment values disables new Checkout/portal sessions without deleting billing
|
||||||
|
history. Do not manually remove Premium roles while Stripe still reports an active subscription; fix
|
||||||
|
webhook delivery and replay the authoritative subscription event instead.
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import ClientShell from "./ClientShell";
|
|
||||||
|
|
||||||
// Optional catch-all so EVERY path (/, /cv/{slug}, /career/builder/…) resolves to this same shell on
|
|
||||||
// a hard load. Without it, `output: export` only generates `/`, and Next's client renders its
|
|
||||||
// not-found for any deep URL before React Router can route it — breaking refresh and shared links.
|
|
||||||
// generateStaticParams emits just index.html (the empty slug); nginx (try_files $uri /index.html)
|
|
||||||
// serves that shell for every unknown path, and this catch-all matches it client-side so React
|
|
||||||
// Router takes over. See docs/architecture/frontend.md (SPA routing).
|
|
||||||
export function generateStaticParams() {
|
|
||||||
return [{ slug: [] }];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
return <ClientShell />;
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import ClientShell from "./[[...slug]]/ClientShell";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <ClientShell />;
|
||||||
|
}
|
||||||
@@ -2,9 +2,14 @@
|
|||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
// The whole app is client-rendered React Router behind auth (see app/page.tsx) -- static
|
// The whole app is client-rendered React Router behind auth (see app/page.tsx) -- static
|
||||||
// export keeps the same "one index.html + JS bundle, served by nginx" deploy as CRA had.
|
// export keeps the same "one index.html + JS bundle, served by nginx" deploy as CRA had.
|
||||||
// `next dev` cannot generate arbitrary optional-catch-all paths under static-export mode.
|
// Development mirrors nginx's SPA fallback with a rewrite to the root shell.
|
||||||
output: process.env.NODE_ENV === "development" ? undefined : "export",
|
output: process.env.NODE_ENV === "development" ? undefined : "export",
|
||||||
reactStrictMode: true,
|
reactStrictMode: true,
|
||||||
|
...(process.env.NODE_ENV === "development" && {
|
||||||
|
async rewrites() {
|
||||||
|
return [{ source: "/:path*", destination: "/" }];
|
||||||
|
},
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = nextConfig;
|
module.exports = nextConfig;
|
||||||
|
|||||||
@@ -14,6 +14,16 @@ server {
|
|||||||
try_files $uri /index.html;
|
try_files $uri /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /health {
|
||||||
|
proxy_pass http://backend:8080/health;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://backend:8080;
|
proxy_pass http://backend:8080;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
test("public health reaches the backend instead of the SPA fallback", () => {
|
||||||
|
const config = fs.readFileSync(path.join(process.cwd(), "nginx.conf"), "utf8");
|
||||||
|
|
||||||
|
expect(config).toMatch(/location = \/health\s*{[^}]*proxy_pass http:\/\/backend:8080\/health;/s);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user