Interview prep re-ran its AI call every time the tab opened -- flagged
in the product teardown as work evaporating on every re-open (cost,
latency, and non-determinism for no reason). GetInterviewPrep now
persists one note per job application and reuses it on subsequent
reads, only regenerating when the selected attachment context changes
or a refresh is explicitly requested.
- InterviewPrepNote: one row per (owner, job), keyed additionally by
an attachment-selection fingerprint so picking different attachments
correctly triggers a fresh brief without needing an explicit flag.
- GetInterviewPrep gained a `refresh` query param; the frontend adds a
small "Regenerate" button as the explicit escape hatch for when the
underlying job/notes have changed since the note was written.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects.
- 3 new tests: reuse across calls, refresh regenerates, attachment
context change regenerates. Verified against the real dev DB.
CvTemplateRenderer (six hardcoded HTML templates, ~450 lines) had zero
test coverage. Lock in current behavior before any future theme
extraction touches it: renders without throwing and contains the
content it was given, deterministic for identical input, falls back
to ats-minimal for an unknown template id, and HTML-encodes
user-supplied content (regression guard against CV text containing
markup).
Introduces the Career Workspace's bounded data foundation, additive
and backwards-compatible: ApplicationUser.ProfileCvStructureJson
stays the authoritative column every existing read path uses; the new
CareerProfiles/CareerProfileVersions tables mirror it via
ICareerProfileService so future Career Workspace features (variants,
history UI) have real tables to build on rather than starting a
second migration later.
- CareerProfile: one snapshot row per user (Version, ProfileJson).
- CareerProfileVersion: append-only history, one row per save
(upload/rebuild/improve/reprocess/parse), so a profile edit is never
silently lost the way ProfileCvStructureJson overwrites are today.
- Stable item IDs assigned to jobs/education/certifications/projects
on first save and preserved across later saves -- the prerequisite
for CV variants to reference "this job" by identity instead of
array position.
- CvDateNormalizer: best-effort free-string -> "YYYY-MM" parsing for
job/education/certification/project date ranges, kept alongside
(never replacing) the original free-string fields.
- Both SQLite (dev) and MySQL/MariaDB (prod) reconciler dialects,
matching this repo's schema-via-raw-SQL-reconciler convention
rather than EF migrations.
Job tracking is untouched -- this is entirely within the profile/CV
domain per the Career Workspace product boundary.
Wave 7. Mirrors the existing Google ID-token-exchange pattern (Program.cs
smart-scheme dispatch, JWT bearer scheme, AuthController exchange/link/
unlink endpoints, ApplicationUser fields, reconciler columns) for
Microsoft Entra ID + personal accounts via the multi-tenant "common"
endpoint.
Google/Microsoft sign-in previously only worked for accounts already
linked to an existing local user -- there was no way to actually sign
up via OAuth. Both exchange endpoints now create a new user when no
match is found and Auth:AllowRegistration is true, same gate as
email/password registration.
Frontend: new MicrosoftAuthCard (MSAL popup flow -- Microsoft has no
vanilla-JS equivalent to Google's Identity Services script) wired into
the login page's provider tabs and the profile page's account-linking
section. REACT_APP_MICROSOFT_CLIENT_ID env var, Auth:MicrosoftClientId
config gate on the backend.
Wave 4 hardening. Wrap untrusted CV/job-description/instruction text
in tools/summarizer prompts with explicit delimiters and an
ignore-embedded-instructions rule, since JD text, recruiter emails,
and free-text candidate background all flow into rewrite/normalize
prompts unescaped today.
Match score previously normalized synonyms (JS/Kubernetes/K8s/etc)
only when scanning the job posting, not when checking the CV corpus,
so a CV using an abbreviation the job spelled out never matched.
SkillTagger.MatchesTag reuses the same synonym regex for both sides.
Backlog item 4 (Wave 3, first sub-item). HasResume/HasCoverLetter/HasPortfolio/
HasOtherAttachment were manually-editable checkboxes in EditJobDialog,
completely independent of whether a file was actually attached -- classic
drift: mark 'resume ready' by hand, later delete the resume attachment, flag
stays stuck true forever. User confirmed (asked directly, since removing the
manual-override capability is a product decision, not purely technical):
make them fully computed from Attachments, no manual override.
- AttachmentsController.RecomputeAttachmentFlagsAsync: the single place these
four fields get written now, called after every attachment mutation
(upload, delete, Purpose change) that could affect them. Deliberately kept
as persisted columns (not [NotMapped] computed properties reading the
Attachments navigation collection) -- ~15 query sites build JobApplication
DTOs without .Include(Attachments), so a live-computed property would
silently return false everywhere instead of throwing, the worst kind of
bug. Recomputing at the one write funnel avoids touching any read path.
- Removed HasResume/etc from CreateJobApplicationRequest/
UpdateJobApplicationRequest -- no longer client-settable.
- EditJobDialog: removed the manual checkboxes, kept the (now genuinely
accurate) read-only status chips.
- AddJobModal: stopped sending has*-flags at job-creation time; the
follow-up attachment upload call now sets them correctly via the same
recompute path.
Caught a real bug while testing this: the Purpose-change path recomputed
before saving the Purpose change, so a fresh query missed the pending edit
and the flags never updated. Fixed by committing the mutation before
recomputing.
3 new backend tests (purpose-change sets flag, delete clears flag,
non-primary purpose counts as "other"). 172/172 backend, 25/25 frontend
suites (57 tests) green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b4 of the multi-provider email roadmap. The manual/free-text correspondence
entry path already existed (CorrespondenceController.Create) -- this slice
was narrower than the roadmap wording suggests: tag every Correspondence row
with which provider it came from (gmail | manual today; microsoft | imap
once those providers grow an import-into-Correspondence path of their own),
not build a new endpoint.
- Correspondence.Provider (nullable string), reconciled via the existing
EnsureColumn pattern (SQLite + MySQL).
- Idempotent backfill: rows with an ExternalThreadId (historically only
ever written by Gmail import) get 'gmail'; everything else gets 'manual'.
- GmailController.ImportSingleMessageAsync now tags Provider = "gmail".
- CorrespondenceController.Create now tags Provider = "manual".
- Both write sites use a fixed literal, not request input -- no injection
surface introduced. Backfill SQL is static, no interpolation.
148/148 green (147 existing + 1 new CorrespondenceControllerTests; the
GmailController import test gained a Provider assertion in place).
Co-Authored-By: Claude Opus 4.8 <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>
Cross-user access to the new endpoints returns NotFound (carried by the
JobTrackerContext global query filters). Regression guard for the class
of tenant-leak bugs found in the M013-M015 assessments.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New EmailStatusClassifier scans a message subject/body for outcome
signals (interview invite, offer, rejection) and suggests a canonical
pipeline status. Priority-ordered so a rejection that mentions the prior
interview still classifies as Rejected. Deterministic - no AI - so it is
instant, reproducible, and safe.
- GET /api/jobapplications/{id}/status-suggestion reads the job's latest
inbound correspondence (incl. Gmail imports) and suggests a forward
status move, suppressed when already in/past that stage
- always human-confirmed via the existing PATCH .../status
- 7 classifier unit tests + 2 endpoint integration tests; backend green (133)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- New pure StageAnalytics.TimeInStage: median days jobs have spent in
each active pipeline stage (entry time from the last StatusChanged
event into that stage, else applied date). Closed/success stages
excluded since 'how long stuck' only applies to actionable stages.
- analytics-overview now derives the funnel from JobPipeline (includes
the previously-omitted Waiting stage, normalizes legacy spellings) and
returns TimeInStage.
- 4 unit tests; full backend suite green (124).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New JobPipeline: ordered canonical stages (Applied, Waiting, Interview,
Offer, Rejected, Ghosted) with category grouping and a Normalize() that
canonicalizes casing and known synonyms (Interviewing->Interview,
declined->Rejected, ...) while preserving unknown custom statuses.
- normalize status on every write path (Create/Update/PATCH status) so
the stored value stays canonical without destroying custom values
- GET /api/jobapplications/pipeline exposes the ordered stages so the UI
renders from one source instead of duplicated hardcoded lists
- 14 unit tests; full backend suite green (120)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds a MatchScoreCard at the top of the Candidate Fit tab that loads the
deterministic /match-score endpoint independently of the slow AI
narrative, so users see a reproducible score, matched/missing keyword
chips, and per-section coverage immediately.
- MatchScore types + cached, attachment-independent load effect
- graceful 'not enough signal' state
- EN/NB translations
- frontend panel test (matched/missing/section + degraded state)
- backend integration tests for GetMatchScore (happy path + missing CV)
- README endpoint reference
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New JobCvMatchService: a pure, AI-free keyword-coverage scorer that
returns a stable, reproducible 0-100 match score plus matched/missing
keyword lists and per-CV-section coverage. Unlike candidate-fit (AI
narrative), it makes no model calls, so results are instant and
identical for identical inputs - the Jobscan-style differentiator.
- GET /api/jobapplications/{id}/match-score
- keywords = curated SkillTagger tags (high weight) + salient posting
terms (title terms boosted); word-boundary matching avoids false hits
- section coverage shows where CV evidence is concentrated
- fix(SkillTagger): punctuation-tolerant C#/.NET patterns; the old \b
boundaries silently missed 'C#,' and '.NET,' everywhere they are used
- 7 unit tests on the pure scorer; full backend suite green (104)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Adds SalaryMin/SalaryMax/SalaryCurrency/SalaryPeriod alongside the
existing free-text Salary field (kept for back-compat and display).
- JobApplication model + idempotent column bridging for SQLite and MySQL
- Create/Update DTOs with NormalizeSalary (clamps negatives, swaps
inverted min/max, uppercases currency, whitelists period)
- JobApplicationDto exposes the fields; CSV export gains 4 columns
- UI: add/edit dialogs get min/max/currency/period inputs; job table
renders a formatted range via shared salary.ts formatter (falls back
to free-text when structured values are absent)
- EN/NB translations; backend + full frontend suites green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New DatabaseBackupHostedService + SqliteDatabaseBackupRunner:
- daily VACUUM INTO snapshot to <Data:Root>/backups (safe with WAL)
- catch-up backup at startup when none exists from the last 24h
- retention pruning (Backups:RetainCount, default 14)
- warns and stays idle on MySQL/MariaDB where external backups apply
Production previously had no automated database backup on Linux
(the /api/backup endpoint is Windows-DPAPI-only).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>