Production POST /api/cv/variants returned 200 but GET /api/cv/variants/{id}
returned 404, with the query logged as `... FROM CvVariants WHERE FALSE`
(no parameters). The created row had a correct OwnerUserId; the read was
excluded by the global query filter because CurrentUserId was null at
query time. Reproduced locally: it affected EVERY tenant-filtered read
(CV list returned 0 after creating 5, JobApplications returned total 0),
not just CV -- writes worked, reads came back empty.
Root cause: the "local" JwtBearer OnTokenValidated resolves the
request-scoped JobTrackerContext (to run LocalSessionValidator) BEFORE the
authentication middleware assigns HttpContext.User. JobTrackerContext
captured CurrentUserId in its constructor from ICurrentUserService.UserId,
which reads HttpContext.User -- still unauthenticated at that point -- so
CurrentUserId froze to null. That same scoped instance is reused by the
controller, so `CurrentUserId != null && OwnerUserId == CurrentUserId`
compiled to WHERE FALSE for the whole request. POST worked because
CreateAsync sets OwnerUserId from the controller-resolved user, and
inserts are not filtered.
Fix: make CurrentUserId a computed property that reads
ICurrentUserService.UserId live, so the query filters see the
authenticated user at query-execution time. Deny-on-null is preserved
(still null for an unauthenticated principal). LocalSessionValidator is
unaffected -- it already uses IgnoreQueryFilters and queries by explicit
sid.
Verified on a real MariaDB 11 container end to end: create then read a
variant returns 200, the variant list returns all rows, and
GET /api/jobapplications reads normally. Added
CurrentUserIdLiveEvaluationTests pinning the live-evaluation behaviour
(both fail against a constructor snapshot). 422 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5.5. Completes the lifecycle after submission: prepare, communicate, chase.
Interview preparation gets a durable, user-owned store. There were already two
per-application AI stores, InterviewPrepNote and AiWorkspaceNote, but both are
caches that regenerate when their context signature changes — anything a user
typed into them would eventually be overwritten. InterviewPrepItem is the side
nothing regenerates, covering company research, technical notes, behavioural
answers, STAR examples and the user's own questions in one table, because those
categories differ only by label and adding one must not need a migration. Each
item records whether the user wrote it or accepted a suggestion, and an
IsPrepared flag makes the section double as the preparation checklist.
Generation stays in the existing AiWorkspaceService "interview" module, appended
to AiInteraction as before. A suggestion is history until the user adds it as a
prep item; opening the section generates nothing.
Follow-up reuses what exists rather than adding a tracker. The date is
JobApplication.FollowUpAt, the same field RulesEngine and the reminder hosted
service already act on, so reminders keep working with no new wiring. The task
stays an ApplicationChecklistItem in the follow-up category — the section counts
open tasks without owning them. The record is a FollowUpSet JobEvent, the same
type the rest of the app emits.
Communication is untouched: Correspondence already owns recruiter contacts,
history and notes, and the workspace already mounted it.
The timeline interpreter learned five more types — InterviewScheduled,
InterviewCompleted and OfferReceived as milestones, FollowUpCreated and
FollowUpCompleted as routine, deliberately outside the milestone spine so it
stays a summary of what actually happened. JobEvent remains the history source.
InterviewPrepItems is reconciler-owned with a no-op migration, guarded on
JobApplications, and verified on a fresh MariaDB 11: int AUTO_INCREMENT primary
key, varchar owner and title, tinyint flag, datetime(6), composite index inside
the key limit.
371 backend tests, 128 frontend tests, Release build and the production build all
pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5.4. Connects the career outputs a user already has to one job
application, without building a second copy of any of them.
The flow is strictly one-directional — CareerProfile -> CvVariant ->
application output — and nothing writes back up. No code path in this phase
touches CareerProfile or its children.
CV integration re-points rather than duplicates. GET/PUT /{id}/cv attaches one
variant to an application via CvVariant.JobApplicationId; replacing detaches the
previous variant instead of deleting it. Creating, duplicating, editing, theming,
previewing, exporting PDF and version history all stay in the existing CV
builder, which the section links into. There is no second CV system.
Tailoring composes the Phase 5.3 analysis and match into skills to highlight,
experience to prioritise, projects to emphasise, keywords to include and gaps to
address. Deterministic and advisory: it says what the user could emphasise and
the user edits the variant themselves. Nothing auto-applies.
Cover letters gain the history they were missing. JobApplication.CoverLetterText
stays the current text with its API contract unchanged; CoverLetterVersions
records what it used to be, so an AI rewrite is never destructive. Restore is
additive — the old text comes back as a new version, so what you restored from
still exists. Source and AiAction record whether the user wrote a version or
approved it from a suggestion, and an AI generation only becomes a version once
the user saves it.
Documents are untouched: the existing Attachment system already covers CV, cover
letter, certificates and portfolio files with a Purpose field, so the workspace
mounts that component rather than adding a second upload path.
CoverLetterVersions is the only new table — reconciler-owned, no-op migration,
guarded on JobApplications, and verified on a fresh MariaDB 11: int
AUTO_INCREMENT primary key, varchar(255) owner, datetime(6), composite index
inside the key limit.
360 backend tests, 115 frontend tests, type check, Release build and the
production build all pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A completely empty MariaDB database could not start: the reconciler assumed
migration-owned tables already existed, and migrations assumed reconciler-owned
tables already existed. Neither could go first. Existing databases worked, so
only fresh installs were affected.
Startup is now an explicit sequence: connect, reconcile, migrate, reconcile,
start. The reconciler runs twice because neither position alone works — pass 1
repairs legacy schemas and creates the reconciler-owned tables that migrations
reference, pass 2 picks up everything that could not exist yet on a fresh
database. Every statement is existence-guarded, so the second pass is a no-op
scan on a correct database.
Untangled the overlapping ownership:
- RuleSettings is migration-owned. The reconciler also created it, which made a
clean install fail with "Table 'RuleSettings' already exists". It now only
seeds the default row, and only once the table exists.
- The six CareerProfile child tables are reconciler-owned. Their migration was
scaffolded against SQLite and indexed an unbounded longtext OwnerUserId, which
exceeds MariaDB's 3072-byte key limit; it is now a no-op and the reconciler
carries correct per-provider DDL. OwnerUserId and ItemKey are bounded to
varchar(255) in the model so the index fits.
- Reconciler tables that reference another table are guarded on their parent, so
pass 1 skips them on an empty database instead of failing on the foreign key.
- All index creation goes through one EnsureMySqlIndex helper, guarded on table
existence as well as index existence. This removes ten copies of the raw block
that crashed on a missing table.
- The DbContext-owned connection is no longer disposed by the reconciler, and
Open() is guarded on connection state, so the second pass can reuse it.
Verified against MariaDB 11 and SQLite: empty MariaDB (40 tables, starts),
restart on the populated database (idempotent, rows preserved), empty MariaDB
via the Docker image, fresh SQLite (42 tables), and an existing partially
migrated SQLite dev database (34 tables upgraded to 44 with all 13 applications
and 8 companies intact). 329 backend tests pass in Release.
Ownership rules, startup order, fresh install and production upgrade are
documented in docs/infrastructure/database-ownership.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Evolve the existing readiness workflow into one persisted, user-controlled
checklist rather than adding a second tracker.
ApplicationChecklistItem records only completion state and user intent. Each
default system item carries a stable SystemKey and an AutoSignal — the same
signal /readiness already computed — and re-syncs on every read: a satisfied
signal auto-completes the item, a reverted signal reopens it, and a manual tick
always wins. Users can add, reorder, dismiss and delete.
Readiness is refactored into a projection of the checklist (score = completion
percentage, completed/missing = live items by status). Its DTO shape and the
workflowSignal/reminders health view are unchanged, so no API contract breaks.
The workspace's next recommended action now comes from the first pending
checklist item in category priority order (preparation, submission, follow-up,
interview, custom), replacing the parallel ruleset — so the overview can never
recommend something already ticked off, and a user's own task can be next.
The table follows the established MariaDB-safe path: the scaffolded migration is
a no-op and the idempotent reconciler owns the DDL for both providers. Verified
on MariaDB 11 — auto_increment PK, varchar/datetime(6)/tinyint(1) columns, both
indexes inside the key limit, cascade delete, unique system key per application,
and NULL system keys not colliding for custom items.
329 backend tests, 94 frontend tests, type check, production build and both
Docker builds pass locally.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Deploy failed on prod (MariaDB) while the test job was green: backend startup
threw during Database.Migrate(), so deploy.sh's post-deploy health check exited.
Root cause: AddCvVariants/AddAiInteractions were scaffolded against SQLite, so
they bake SQLite type names into their DDL — DateTimeOffset emits `TEXT`, bool/int
emit `INTEGER`, and the PK gets no AUTO_INCREMENT. Run against MariaDB that yields
a structurally wrong table, and the composite index over a TEXT column then trips
"ERROR 1071: Specified key was too long; max key length is 3072 bytes". SQLite
accepts all of it, which is why local/container verification passed.
Fix, following the pattern already used for CareerProfiles/AiWorkspaceNotes:
- both migrations become no-ops; the three tables are reconciler-owned
- reconciler provisions them idempotently per dialect (MySQL: varchar/int
AUTO_INCREMENT/datetime(6); SQLite: CREATE TABLE IF NOT EXISTS)
- DropMalformedMySqlTable rebuilds a half-built table left by the failed
migration, guarded on row count so a table with ANY rows is never dropped
- bound the indexed string columns with HasMaxLength so the model matches
Verified against a real MariaDB 11 container: reproduced error 1071, then
confirmed the corrected DDL yields auto_increment PKs, varchar/datetime columns
and all previously-failing indexes. 306 backend tests green; SQLite container
starts clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 5 backend. A unified AI Workspace for each application, orchestrating the
five suggestion modules through the existing ISummarizerService provider
abstraction and storing every generation as append-only history (AiInteraction)
so outputs can be reused, compared, and deleted — distinct from the existing
AiWorkspaceNote cache (one row, overwritten).
Modules (all suggestion-only, "never invent facts" guardrail, never mutate the
profile/variant/application): job-analysis, career-match, cover-letter (6 modes),
interview, application-review. Each builds a prompt from the job + master profile
text and returns markdown.
- Models/AiInteraction.cs + migration AddAiInteractions (verified on container)
- Services/AiWorkspaceService.cs (prompts, history, delete)
- Controllers/AiWorkspaceController.cs (/api/jobapplications/{id}/ai:
generate, history, delete, modules+provider)
- 7 tests (store, history filter/order, delete, mode normalization, unknown
module, empty output, tenant scoping); 306 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 4 foundation. A CvVariant is a lens over the master CareerProfile
(section order/visibility, per-item overrides keyed by ItemKey, theme +
builder settings) — it references career data, never duplicates it. One
renderer (ThemedCvRenderer) draws every theme; a theme is pure data
(CvThemeCatalog, 8 professional themes), so adding a theme needs no renderer
change. Autosave version history + non-destructive restore, public CV via
/api/public-cv/{slug} (anonymous, noindex, filter-bypassing owner load), and
an AI-assist endpoint reusing the existing provider abstraction (suggestions
only, never auto-applied).
- Models: CvVariant/CvVariantVersion, CvVariantSettings, CvTheme + catalog
- Services: CvVariantResolver (profile+lens -> render model), ThemedCvRenderer,
CvVariantService, CareerProfileService.LoadStructuredForOwnerAsync (public)
- API: CvVariantController (/api/cv), PublicCvController (/api/public-cv)
- Migration AddCvVariants (2 self-contained tables; verified applied on the
running container), 16 tests (resolver/renderer/service), 296 backend green
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, schema layer. Relational children of CareerProfile — the editable master
career profile. See docs/architecture/career-profile-model.md.
- New entities (Models/CareerEntities.cs): CareerExperience, CareerEducation,
CareerSkill, CareerProject, CareerCertification, CareerLanguage. Each carries
OwnerUserId (tenant filter), a stable ItemKey (carried from the blob so future
CV variants can reference items), and SortOrder. List fields persist as JSON
string columns via [NotMapped] accessors — plain TEXT, reconciler-friendly.
- CareerProfile gains typed child collections + a LongTailJson column (contact,
summary, interests, achievements, orgs, pubs, courses, custom sections,
metadata). ProfileJson becomes a derived projection for legacy read paths.
- DbContext: DbSets + tenant query filters + ordered indexes; FK/cascade by
convention via the typed collections.
- Migration hand-edited to add only the 6 new tables + LongTailJson; the
scaffolder re-emitted four reconciler-owned tables (AiWorkspaceNotes,
CareerProfiles, InterviewPrepNotes, CareerProfileVersions) which were stripped.
The regenerated snapshot now includes them, closing the drift. Verified against
a copy of the real dev DB: applies cleanly, no data loss.
Long tail (achievements/orgs/pubs/courses) starts as JSON; promotable to
relational later without a source-of-truth change. Source-of-truth flip stays
deferred; the blob is kept as a derived projection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Recover the F1 Career Profile foundation + AI-workspace persistence from the
unmerged feature/career-workspace branch, so Phase 2 builds on the documented,
tested target state instead of re-deriving it. Foundation only — CV Builder
commits (variants, ATS badge, rewrite diff) stay deferred per "do not build CV
Builder yet". See docs/career-workspace-branch-assessment.md.
Squashed from 3 branch commits (235e291, 5916f09, 00a035e), resolved against
main + Phase 0:
- CareerProfile + CareerProfileVersion (append-only history), dual-written from
every profile save path via CareerProfileService. ApplicationUser.
ProfileCvStructureJson stays authoritative; the tables mirror it. Stable item
IDs assigned to jobs/education/certifications/projects (the prerequisite for
future variant lineage). CvDateNormalizer for free-text -> YYYY-MM.
- InterviewPrepNote + AiWorkspaceNote: cache AI interview prep / candidate fit /
focus plan keyed by an attachment-context signature, so they stop regenerating
(and re-spending the provider) on every open.
Conflict resolutions (union, favouring current code + Phase 0):
- JobTrackerContext / StartupInitializationExtensions: kept Phase 0's tables and
reconciler blocks, added the career/interview/ai-note tables (both SQLite and
MySQL dialects).
- ProfileCvController: dropped the branch's in-file DTO records (main defines them
in ProfileCvDtos.cs) and the LayoutFamily/AtsRating template fields (deferred
ATS-badge work), keeping main's 7-arg CvTemplateDescriptor.
- JobApplicationsController: kept the branch's cache-check, restored main's
AsNoTracking on the read-only user load.
Tables ship empty (verified dev); nothing to migrate.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Unblocks the documented core workflow and closes the AI-service exposure,
without changing existing behaviour.
Job/JobApplication split (additive; see ADR-002):
- New Job entity (the opportunity) with owner-scoped query filter; nullable
JobApplication.JobId FK. Nothing reads Job yet.
- Migration AddJobEntityAndProspectStages, hand-edited to drop reconciler-owned
tables the scaffolder re-emitted; verified against the real dev DB.
Pipeline: 10 internal stages across three concerns kept separate —
PipelineStage (workflow) / PipelineGroup (UI: NotApplied/Active/Closed) /
PipelineCategory (analytics). Adds Saved/Interested/Preparing/Withdrawn;
keeps Waiting and Ghosted. Kanban shows 3 grouped columns; cards keep a stage
chip and full transitions; drag applies only safe transitions (never infers
Ghosted/Withdrawn).
DateApplied nullable + SavedAt. Cleared when leaving Applied so analytics stay
accurate; the discarded date is preserved as an AppliedDateCleared JobEvent.
AI service lockdown: no host port; private ai_internal network (backend is the
only other member); X-Ai-Service-Token required on all non-/health endpoints;
AI_SERVICE_TOKEN mandatory via compose. Verified backend-only against the live
stack.
Also carries two pre-existing working-tree files (views/ProfilePage.tsx,
views/CareerWorkspacePage.tsx) so the tree is clean for the branch integration.
Tests: +40 backend (247 total), +5 sidecar (16), +15 frontend.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
JWTs were previously fully stateless -- the token alone was the credential
until its own expiry, with no way to list or kill a session server-side. Add
a UserSession table alongside every JWT issued (AppSessionIssuer), embed its
id as a "sid" claim, and check that claim against the DB on every "local"
scheme request (Program.cs OnTokenValidated) so a session can actually be
revoked before its JWT naturally expires. New /api/auth/sessions endpoints
(list, revoke one, revoke-others) plus a Sessions card on the profile page.
Fails closed on a missing "sid" claim: every JWT issued going forward has
one, so a token without it is either pre-deploy (forces one re-login for
already-signed-in users at deploy time, same additive-forward cost the
2FA/trusted-device work on this branch already paid) or forged.
Adds a "trust this device" option to the 2FA challenge: on success, mints a
random token (only its SHA-256 hash is stored), sets it as a new httpOnly,
Secure, SameSite=Strict cookie, and records a TrustedDevice row. AuthController
checks that cookie for the exact signing-in user before gating on 2FA -- a
mismatched user, expired, or revoked device falls through to the normal 2FA
prompt, never errors. TwoFactorController also exposes list/revoke/revoke-all
endpoints for managing trusted devices, scoped to the owning user.
Schema added via the existing raw-SQL reconciler (SQLite + MySQL dialects),
not EF migrations, matching this repo's established pattern.
Adds three layers of account-security hardening, all gated behind the
existing SignInWithAppSessionAsync-equivalent (now AppSessionIssuer) so
every sign-in path -- local, Google, Microsoft -- goes through the same
lockout/2FA checks:
- Per-account lockout: Identity's built-in lockout store (columns already
provisioned, previously unused) is now wired up in AuthController.Login
via IsLockedOutAsync/AccessFailedAsync/ResetAccessFailedCountAsync, 5
failed attempts / 15 min, same generic 401 as wrong-password to avoid
enumeration.
- RFC 6238 TOTP 2FA (Otp.NET) with QR-code setup (QRCoder, fully local/
offline) on a new TwoFactorController: setup requires password
re-confirmation and returns a pending (unconfirmed) secret + QR; the
secret is only persisted as active once verify-setup checks a real
code. Secrets are encrypted at rest via the same IDataProtector pattern
already used for Gmail/Microsoft OAuth refresh tokens.
- Login/OAuth exchange now checks TwoFactorEnabled before issuing a real
session. If enabled, it hands back an opaque, server-side (IMemoryCache)
pending token via a new ITwoFactorPendingTokenService -- deliberately
NOT a JWT, so it can never be presented as a bearer token to bypass the
2FA check on any other endpoint. Only POST /api/auth/2fa/challenge can
redeem it, rate-limited at 5/5min (tighter than password login, since a
6-digit space is far more brute-forceable).
- One-time recovery codes (10 per enable/regenerate, SHA-256-hashed at
rest, shown once in plaintext) accepted in the same challenge endpoint
as an alternative to a TOTP code.
Schema: AspNetUsers gains TotpSecretEncrypted / TotpPendingSecretEncrypted
/ TotpEnabledAtUtc, plus a new TwoFactorRecoveryCodes table, added to both
the SQLite and MySQL dialect blocks in the startup schema reconciler.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).
Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
UIDVALIDITY.
Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.
Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.
168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape
end-to-end so the two stay structurally interchangeable:
- MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL,
same shape as GmailConnection: encrypted refresh/access token, sync state).
- MicrosoftGraphOAuthService: auth-code + offline-access flow against
login.microsoftonline.com, encrypted token storage via IDataProtector,
message search/thread/detail fetch against Microsoft Graph (conversationId
stands in for Gmail's threadId), attachment listing.
- MicrosoftGraphProvider implements IEmailProvider — no contract changes;
the existing seam was already provider-neutral.
- MicrosoftGraphController: connect-url/oauth/callback/status/disconnect,
mirrors GmailController's OAuth surface exactly (including the popup
postMessage handshake). Job-matching/review endpoints stay Gmail-only for
now, per the roadmap — generalising those needs the frontend provider
picker work, not this slice.
- Registered in DI + IEmailProviderRegistry (multi-registration of
IEmailProvider, resolved by ProviderKey).
- Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through
docker-compose.yml + .env.example alongside the existing Google:Gmail* keys.
- Tests: MicrosoftGraphControllerTests (OAuth lifecycle) +
MicrosoftGraphProviderTests (DTO mapping onto the neutral contract).
147/147 green (135 existing + 12 new).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add composite indexes (OwnerUserId, IsDeleted) — for the tenant-scoped
list/board/stats/analytics queries that all filter !IsDeleted — and
(OwnerUserId, FollowUpAt) for the reminders surface. Every JobApplication
query is scoped by the OwnerUserId global filter first, so owner-prefixed
composites are the useful shape; the pre-existing single OwnerUserId index
is now a redundant prefix but kept to avoid churn.
Status is intentionally excluded: Pomelo maps the unbounded string column to
MariaDB longtext, which cannot be indexed without a prefix length.
Applied via the startup schema reconciler (StartupInitializationExtensions),
which is how this repo actually provisions schema/indexes on both providers
(SQLite: CREATE INDEX IF NOT EXISTS; MariaDB: MySqlIndexExists-guarded CREATE
INDEX) — NOT via EF migrations, whose committed ModelSnapshot is stale.
OnModelCreating also declares the indexes for model consistency.
Backend suite: 92/92 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>