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:
@@ -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.
|
||||
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}`.
|
||||
|
||||
@@ -239,7 +239,7 @@ erDiagram
|
||||
|
||||
**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.
|
||||
- **`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`).
|
||||
- `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`.
|
||||
@@ -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.
|
||||
- **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.
|
||||
- **`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 |
|
||||
|---|---|---|
|
||||
| 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. |
|
||||
| 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. |
|
||||
|
||||
@@ -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.
|
||||
|
||||
### 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
|
||||
|
||||
> `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
|
||||
|
||||
1. Dual-write `Job` on every create path (`JobApplicationsController.Create`, `GmailController`'s job creation, CSV import).
|
||||
2. Backfill one `Job` per existing `JobApplication`; link via `JobId`.
|
||||
1. **Done:** dual-write `Job` on every persistence path (`JobApplicationsController` and Gmail suggestion creation; CSV import is preview-only).
|
||||
2. **Done:** backfill one `Job` per existing `JobApplication`; link via `JobId`.
|
||||
3. Flip reads to `Job`, one endpoint at a time.
|
||||
4. Drop the duplicated opportunity columns from `JobApplication`.
|
||||
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.
|
||||
Reference in New Issue
Block a user