Route public health checks to the API, backfill and synchronize job opportunities, stabilize SPA smoke tests, and document operator-only production steps.
- 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
The structured model and StructuredCvProfileJson.FromSections already map
Projects/Certifications/Languages headings, but the AI normalize prompt
never emitted them, so on the benchmark CV the entire Projects section and
the in-summary languages (English Native, Norwegian B1) were silently
dropped. This closes that gap upstream — no backend schema or data change.
ai-service (tools/summarizer/app.py):
- /cv/normalize: added # Projects and # Certifications headings; a
languages-from-prose rule (pull "native English", "Norwegian at B1" out
of the summary even with no Languages section; ignore programming
languages); and skill-group prefix stripping ("Development:",
"DevOps & Infrastructure:", "Practices:" dropped, only the skills kept).
- /cv/classify-block: Projects and Certifications added to the section
enum + rules (fallback path).
Backend:
- LooksLikeNormalizedMarkdownCv now recognises # Projects / # Certifications
so those CVs still take the markdown assembly path.
Tests:
- CvExtractionCoverageTests (4) lock the C# mapping of Projects,
Certifications and Languages sections into the structured profile.
- ai-service test_classify_block_supports_projects_section (1).
426 backend tests, 17 ai-service tests pass; app.py compiles.
The LLM behaviour (prompt -> headings) needs Ollama to observe and was not
run here; the C# side that consumes the headings is proven and the prompt
change is additive. Merge-not-replace + the review screen are the next
increment (2.1-a, approved: always-review, conservative merge).
Deployment: these prompts live in the ai-service container, which
deploy.sh does not rebuild by default -- deploy with
DEPLOY_BUILD_AI_SERVICE=true or the change won't take effect.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-only. No change to APIs, save payloads, extraction behaviour, or data
models. The parent CareerProfilePage still owns loading, state, saving,
and all extraction/import actions; the new sections are presentational
(value + onChange, plus a getMetadata callback for review chips).
Extracted into src/views/career/CareerProfileSections.tsx:
PersonalInformation, ProfessionalSummary, Skills, Interests, Languages,
WorkExperience, Education, OtherSections. FieldReviewNote + confidenceTone
moved there verbatim and shared with the parent. CareerProfilePage went
from 1376 to ~1200 lines.
No Projects/Certifications sections were created -- the editor never had
them (they are not editable structured fields here). Inventing them would
add functionality, which this refactor avoids; noted for a product
decision later.
Hid the duplicate CV concepts behind an "Advanced CV tools" toggle,
collapsed by default: the CV Structure Overview parse block and the
Template-driven CV builder. Both stay mounted and functional (gated with
display:none), so no tested functionality is removed -- the real CV
Builder at /career/builder is the single generation surface. Future
removal plan documented.
Tests: added "editing a field in an extracted section updates parent
state and flows into save" (render -> edit -> PUT /career/profile
{profile,cvText}); existing parse/rewrite tests reveal the advanced tools
first. The increment-1 save-invariant test still pins the payload.
Verified: tsc clean, production build clean, 137 frontend tests pass.
Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
UI-only restructuring of the Career Profile surface. No change to
database models, CareerProfiles schema, CvVariants, extraction APIs, AI
services, CV rendering, or public CV.
Terminology -> user-facing (i18n strings):
- "Structured CV editor" -> "Career information"
- "CV structure overview" -> "Profile sections"
- "Summary bullets" -> "Professional summary"
- "Core skills" -> "Skills"
- "Analyze sections" -> "Read sections"
- "Original extraction" -> "Original import"
- hardcoded "Master career profile" -> "Career profile"
Help text de-jargoned; the Career information help now frames it as the
source the CV Builder consumes.
Component split (first step): extract ProfileCompleteness (completeness
meter + missing chips + version history) into src/views/career/. Display
only, props in, no state or API.
Save path untouched: api.put("/career/profile", { profile, cvText }). A
new test pins that exact call as the refactor invariant so the remaining
section extraction cannot silently change save behaviour. Existing
profile-page tests re-pointed to the new labels; every behavioural
assertion (save, parse, field values) kept.
Verified: tsc clean, production build clean, 136 frontend tests pass
(135 + 1 invariant). Sidebar fix from the previous task still passes.
Backend untouched.
The remaining Phase 1 work (per-section editor components, hiding the
template-driven builder and structure-overview blocks, actionable
per-section empty states) is staged in docs/career-workspace-ux-refactor.md
because it touches the live extraction test surface and is best verified
by driving the authenticated UI. This increment is a clean, non-regressing
checkpoint.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
On /career/builder/{id} both "Career Workspace" (/career) and "CV Builder"
(/career/builder) highlighted, because AppShell tested each item with
`pathname === to || pathname.startsWith(to + "/")` — so /career matched
every /career/... child. No "most specific wins" rule.
Add AppShell.activeNavTo(pathname, tos): the longest `to` that the path is
at or under wins, across both nav lists; every other item is inactive. A
child route never lights up a parent nav item. `selected` now compares
against that single computed activeTo. Exported as a pure function so the
ownership rule is unit-tested directly (sidebar-active-nav.test.ts):
exactly one active item for /career, /career/builder and
/career/builder/{id}, and no double-highlight.
Also give the breadcrumb/title in App.tsx explicit /career/builder ->
"CV Builder" ownership (it previously showed "Career Workspace"), and
reframe the Career Workspace header to the "Career Profile" product
framing: "This information powers your CVs, applications, cover letters
and AI assistance."
Frontend only — no change to CareerProfiles, CvVariants, CV generation,
extraction APIs, AI, permissions or tenant isolation. Plan for the deeper
information-architecture work is in docs/career-workspace-ux-refactor.md,
staged so the 1376-line CareerProfilePage and the live CV/extraction
pipeline are refactored incrementally with verification, not in one risky
rewrite.
Verified: tsc clean, frontend build clean, 135 frontend tests pass
(128 + 7 new nav tests). Backend untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Consolidates release-candidate verification: backend 420 (incl. the CI
runner's exact Ubuntu 20.04/libicu66 environment), frontend 128, all
four DB scenarios, backup/restore with a byte-exact æøå round trip,
health and auth checks.
Status: READY WITH DOCUMENTED RISKS. No open code blocker. Remaining
risks separated into code (none), infrastructure (runner instability
A/B, still unconfirmed-fixed; old runner ICU), and manual owner
verification (sign-in, production backup, production scale).
Recommendation: deploy with documented risks -- re-run CI with the ICU
fix, owner runs a real backup + scratch restore, deploy, then run the
manual smoke test.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- docs/deployment/backup-restore.md: production backup checklist; documents
that deploy.sh loads the env, validates before backup, and validates the
dump. Adds VERIFIED UTF-8/Norwegian-character round trip (æ ø å survive a
real deploy.sh backup -> restore byte-exact; HEX compared). States plainly
that no production database was reached and the owner must run one real
backup + scratch restore.
- docs/deployment/manual-smoke-test.md: owner-run post-deploy checklist
(auth, applications, career profile, CV builder, AI, files). Each item
names what "wrong" looks like. Documents that login requires the owner.
- runner-investigation.md: Finding C -- the latest CI red was a real ICU
code bug the runner caught correctly, not instability. Amends the blanket
"outside the repository" conclusion. A and B stand as separate env issues.
- release-candidate-review.md: corrected drifted line refs after the index
fix; noted the CI ICU finding so the "purely external" verdict is honest.
All claims reflect behaviour verified this session.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy.sh and the other scripts run on the Linux deploy host and in
Docker. A CRLF checkout breaks them with "bad interpreter: bash\r".
The committed blobs are already LF, but nothing guaranteed it against a
host with core.autocrlf=true. `*.sh text eol=lf` makes it explicit.
Verified: git ls-files --eol shows attr/text eol=lf on all .sh files;
renormalize produced no index churn (already LF).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Adds the two issues found and fixed during the release-candidate audit to
release-candidate-review.md: the follow-up reminder index that never
created on MariaDB (fix in the preceding commit), and the
nondeterministic timeline day-grouping test.
Corrects database-ownership.md drift: the MariaDB startup scenarios now
report 42 tables (measured in every scenario this audit), not the stale
40 from before the last Phase 5 tables were added, and adds the
partially-migrated heal scenario (35 -> 42) that was verified.
All claims reflect behaviour verified in this audit: 420 tests on Windows
and Linux in both ICU modes, all three Docker images built, four database
startup scenarios against live MariaDB 11 and SQLite, and a full
backup -> restore -> app-start cycle.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Validation only. No application behaviour changed.
Verified against the code rather than the prose, by line number:
Before deployment -- validate_deploy_config (deploy.sh:343) and
backup_database (:347) both precede the build (:375) and the container
replacement (:382), so nothing is built, stopped or replaced without a
verified restore point.
During deployment -- startup runs ReconcileSchema (:1965),
Database.Migrate (:1973), ReconcileSchema (:1984). All seven Phase 4/5
migrations confirmed to have a literally empty Up body, which is what
makes a code rollback safe.
Health checks and rollback -- backend and frontend healthchecks present,
frontend gated on backend health, rollback documented in two places with
the code-vs-database distinction.
After deployment -- added an eight-point owner checklist covering login,
existing applications, workspace, career profile, CV builder, public CV,
AI features and attachments. Each item names what wrong looks like,
because "it loaded" is not a check. Merged the previous overlapping
"After deploying" list into it rather than leaving two competing
checklists.
Recorded the CV language ICU defect as closed, with the note that it was
invisible to a normal local test run -- the clearest evidence in this
review that passing locally and correct in the deployed container are
different claims.
Sections are now READY / BLOCKED / MANUAL VERIFICATION, with accepted
limitations kept separate. Test count updated to 420.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Full backup -> verify -> restore -> start-the-app rehearsal of the
deploy.sh backup path against MariaDB 11.
Verified: the real backup_database function selected the MariaDB path
from DATABASE_PROVIDER=mariadb, produced a valid .sql.gz with 42
CREATE TABLE statements and an intact "Dump completed" trailer, restored
into a separate empty MariaDB container, and the application then
started healthy against the restored database with the reconciler
finding nothing to do. All 42 tables matched on row count, and content
survived including foreign key relationships and career profile JSON.
This is a rehearsal, NOT a verification of production data. No
production host was contacted and no production data was read. This
machine has no route to production: no /opt/job-tracker, no
DATABASE_PROVIDER or connection string in its .env, and the local stack
runs SQLite. Production host, user and key are CI secrets not available
here.
The document leads with that scope limit, records the commands to run
against production with values substituted, and ends with the checklist
that actually closes the gap -- including checking that non-ASCII CV
text survives the round trip, which the ASCII-heavy seed data did not
prove.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Restructured into READY / BLOCKED / MANUAL, with accepted limitations
kept separate.
B1 (backup selected the wrong provider and reported success) and N2
(/health always reported version: unknown) are both closed and verified;
their original findings are kept because the failure modes are worth
understanding. N1 closed with the .env.example additions.
One blocker remains and it is external: the CI runner. Stated with what
it needs from the owner, and with the decision it forces -- fix the
runner, or deploy deliberately from a locally verified commit knowing CI
is red.
MANUAL now leads with backup and restore readiness: the mechanism is
verified against containers, but only the owner can prove it works on
production data. Added a note that authenticated smoke testing is not an
automation gap that more work would close -- sign-in needs a password,
and no automated step here should handle one.
Validation only. No application behaviour changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
deploy.sh symlinked /opt/job-tracker/shared/.env for docker compose but
never loaded it into its own shell. Its own decisions therefore ran
against an empty environment: DATABASE_PROVIDER fell back to sqlite on a
MariaDB host, so the deploy tarred the data volume, printed "Backup
verified" and continued with no database dump. The operator saw a green
backup line and a new file in the backups directory, and had no restore
point.
Load the shared env before any decision. Parsed line by line rather than
sourced, because a compose .env is not a shell script and an unquoted
value containing spaces would execute as a command. Values already in
the environment win, so CI-provided APP_VERSION and friends still
override the file. No value is echoed.
Remove the sqlite default. DATABASE_PROVIDER must be stated; missing or
unrecognised aborts the deploy.
Validate deployment configuration before the backup, and so before
anything is built, stopped or replaced: the connection string when the
provider needs one, AI_SERVICE_TOKEN (compose declares it with :?) and
AUTH_JWT_KEY (the backend throws on a blank key). Names in the output,
never values.
Verify each backup against its own format. A dump must be valid gzip,
contain CREATE TABLE, and carry the "Dump completed" trailer, so a dump
that died partway through is rejected. An archive must contain
jobtracker.db. A tar can no longer pass the dump check.
Also resolve the SQLite volume by its project-prefixed name and fail if
absent. The bare jobtracker_data name would have silently created an
empty volume and backed that up -- the same class of bug, found while
testing this fix.
Verified against a seeded MariaDB 11 container and real Docker volumes:
provider selection, all four validation failures, both backup formats
and their failure paths, truncated and trailer-stripped dumps, and zero
secret occurrences across every test's output.
Docs updated for the drift: deploy/README.md, deploy/first-production-
deployment.md, docs/release-candidate-review.md (B1 closed) and
.env.example, which now names the two database variables.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Final validation pass before the first production deployment. Findings
only; no application code changed.
Verified against the implementation rather than the other documents:
migration and reconciler ownership, startup order, authorization
coverage, tenant isolation, AI service protection, file access, the
five architecture rules, container dependency ordering and failure
behaviour.
Two blocking items, one new: deploy.sh symlinks the shared .env for
docker compose but never sources it, so DATABASE_PROVIDER is unset in
the script's own shell and backup_database takes the SQLite branch on a
MariaDB host. It tars the data volume, verify_backup only checks the
file is non-empty on that path, and the deploy reports a verified
backup that contains no database dump. Same root cause silently
disables the APP_PUBLIC_BASE_URL smoke check and the Ollama warmup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Written against the actual implementation rather than the existing docs, and
validated locally against MariaDB 11 containers. No application behaviour
changed — this commit adds two documents.
deploy/first-production-deployment.md covers pre-deployment checks, the eight
deployment steps, smoke tests for backend, database and application, and
rollback. It documents what deploy.sh really does: it backs up first and aborts
on failure, and it replaces containers with up -d --force-recreate rather than
running compose down, so the window is container start time. It also records the
startup sequence as implemented — reconcile, migrate, reconcile — and that
Database.Migrate() throws rather than limping on.
Validation surfaced things worth writing down. The connection string resolves
from inside the backend container, so Server=127.0.0.1 means the container and
not the host; this broke a validation run before it could have broken a deploy.
DATABASE_PROVIDER defaults to sqlite, and if it goes missing the backend does not
quietly serve an empty database — it exits with "no such table:
INFORMATION_SCHEMA.TABLES", which is loud but baffling if unexplained. A blank
AUTH_JWT_KEY throws at startup when auth is required, which is the right
behaviour. The runbook maps each of these log lines to its cause.
Rollback is documented with the distinction stated plainly: a code rollback
keeps all data and is almost always the whole fix, while a database restore
discards everything written since the dump. Restore only when the data itself is
wrong.
docs/release-checklist.md records the completed architecture work, local
verification results, known risks with severities, the unresolved CI runner
blocker and what would unblock it, and seven first-deployment warnings.
Validated: compose build; compose up on a fresh MariaDB (42 tables, backend
healthy); the depends_on health gate holding the frontend until the backend is
healthy; restart against the populated database with rows preserved; backup and
restore; and the failure paths. Not validated, and said so in both documents: the
authenticated end-to-end journey, because signing in needs a password.
393 backend tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Closes the three operational blockers from the production readiness review.
deploy.sh now takes a database backup before it builds, stops or replaces
anything, and aborts the deploy if the backup fails — so no deploy proceeds
without a restore point. Dumps are gzipped and timestamped into
/opt/job-tracker/backups (override with BACKUP_DIR), so one deploy never
overwrites an earlier backup. Credentials come from the existing connection
string and travel via MYSQL_PWD, never on the command line, so they cannot reach
the process list or the deploy log. A dump that is empty or missing CREATE TABLE
is rejected, because a truncated file that looks like a restore point is worse
than none. SQLite deployments get their data volume tarred instead. Nothing is
ever deleted automatically; retention is documented as manual.
deploy/README.md documents backup creation, location, retention, database
restore, application rollback, and when to use which — restore and rollback kept
distinct, because a bad deploy usually needs only the rollback and restoring
would discard everything written since the dump.
Health checks now cover backend and frontend, which previously had none. GET
/health is anonymous, cheap, and deliberately does not touch the database: a
health check that queried MariaDB would restart a healthy backend whenever the
database blipped, and would hand out an unauthenticated way to probe database
availability. The backend image gains curl on the existing chromium apt layer,
since the aspnet runtime ships neither curl nor wget. frontend now waits for
backend to be healthy rather than merely started, because nginx proxies /api and
refuses to start when the upstream cannot be resolved.
Verified against real containers, no production data: backup from a seeded
MariaDB 11; restore into a clean MariaDB 11 with rows identical; bad credentials
and a missing connection string both abort non-zero and leave no partial file;
SQLite volume backup produces a readable archive; backend and frontend both
reach healthy; and a backend pointed at an unreachable database exits and is
reported unhealthy, so a broken deploy cannot present as a running stack.
Incidentally confirmed the earlier authorization work: with Auth:Require unset,
/health returns 200 while /api/jobapplications returns 401.
393 backend tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>