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>
Every JobApplication gets a dedicated workspace at /applications/{id} — a
surface, not a new data store. It owns no data and duplicates none: CV comes
from the Phase 4 CvVariant lens, analysis/match/interview from the existing
AiWorkspacePanel, documents from Attachments, communication from
Correspondence, activity from JobEvent, stage semantics from JobPipeline. No
career data is copied and nothing here writes.
- GET /api/jobapplications/{id}/workspace: one aggregate read (role, company,
stage, dates, attached CV variant, cover letter, documents, AI history,
recent activity) replacing the page fanning out across endpoints
- Next recommended action: ordered rules answering "what do I do next?", the
core product principle for this phase
- ApplicationWorkspacePage: left nav + linkable ?section=, reusing the existing
component for each domain; later-milestone sections say so rather than faking
- Entry point from the job dialog via an optional onOpenWorkspace callback —
the dialog must not depend on router context (it is mounted without a
<Router> in several suites), so the caller owns navigation
- 8 backend tests (aggregate, CV variant surfacing, counts, activity ordering,
next-step rules, tenant scoping)
Local: 314 backend, 88/88 frontend (31 suites), tsc clean, production build ok.
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.5 backend enablers.
- Themes (priority 3): AtsFriendly flag on single-column themes (surfaced in
GET /api/cv/themes), print-quality page-break rules (entries never split
across a page; headings stay with content; widow/orphan control), darkened
the creative sidebar for AA contrast.
- Rich text (priority 1): bullets/summary support **bold**, *italic*,
__underline__, [text](url) via a safe inline pass — everything is HTML-escaped
first, so no user tag can survive; only the whitelist emits markup.
- Entry ordering (priority 1): CvSectionSetting.ItemOrder reorders entries
within a section by ItemKey, never touching the master profile.
- Outline API: GET /api/cv/outline returns the master profile as sections+entries
with ItemKeys, so the Content tab can render editable per-item rows.
- 3 new tests (22 total in the builder suite).
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, version history (list + restore). CareerProfileVersions was already
populated on every save; this makes it usable.
- ICareerProfileService.ListVersionsAsync — the append-only history, newest first,
with the current version flagged.
- RestoreVersionAsync — reapplies a past snapshot NON-DESTRUCTIVELY: it is re-saved
as a new version, so the current state stays in history and the restore is itself
reversible. Syncs the relational children + blob projection like any save.
- Endpoints: GET /career/profile/versions, POST /career/profile/versions/{v}/restore.
Tests (+4): versions listed newest-first with current flagged; restore reapplies
an old snapshot as a new version (history preserved, reversible); restore of a
missing version returns null.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, frontend. /career now reads and writes the master profile through the
relational source of truth instead of the legacy blob path.
- CareerProfilePage loads GET /career/profile (structured profile from the
relational children + cvText + completeness) and saves PUT /career/profile
({ profile, cvText }). This keeps the relational store authoritative — the
previous PUT /auth/profile blob write left it stale after first load.
- Added a "Profile completeness" overview (percent bar + missing sections) at the
top of /career, from the server scorecard.
- PUT /career/profile now accepts { profile, cvText } so the single /career save
covers both the structured profile and the raw imported text; GET returns cvText.
Tests: career-save asserts the /career/profile payload; new completeness-overview
test; controller tests updated for the request wrapper. 75/76 frontend pass (the
1 failure is the unrelated pre-existing settings-view suite); prod build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3, API layer. GET/PUT /api/career/profile — the endpoint the /career editor
uses to read and write the master profile.
- GET: returns the structured profile (assembled from the relational children,
backfilled from the blob if needed) plus a completeness scorecard.
- PUT: validates limits, persists via CareerProfileService (relational children +
append-only version), then serializes the result into
ApplicationUser.ProfileCvStructureJson so the legacy read paths stay in sync.
Identity fields are untouched (they belong to /profile).
- GET /completeness: just the scorecard, for the overview.
- CareerCompleteness: weighted percent + missing sections.
- CareerProfileValidator: item-count/length limits (abuse guard, NOT completeness
— a work-in-progress profile always saves).
Tests (+4): put/get round-trip + projection sync, completeness, over-limit
rejection, empty WIP profile accepted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 2 — Career/Profile separation. The master career profile is the source of
truth; identity and career data are now saved independently so neither wipes the
other. CV Builder deliberately not built yet.
Backend — PUT /auth/profile is now a partial update:
- null/omitted field -> unchanged; "" -> cleared; value -> set (trimmed).
- Email/UserName never cleared to empty (login identifiers).
This lets /profile save identity fields and /career save the master-profile
fields through the same endpoint without one nulling the other. 4 new tests
cover the data-integrity guarantees (identity save keeps the CV, career save
keeps identity, empty clears, null leaves).
Frontend:
- ProfilePage save payload is now scoped by careerOnly: /career sends only
{ profileCvText, profileCvStructureJson }, /profile sends only identity.
- CareerWorkspacePage: removed the inert "CV Builder" tab (careerView) — Phase 2
establishes the master profile only; the builder is Phase 4.
- Dropped the dead careerView prop.
- Updated the CV-save test to render career mode and assert identity is excluded.
Source-of-truth flip (CareerProfileService authoritative) stays deferred to F5
per the branch design; CareerProfileService keeps mirroring via its dual-write.
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>
The sessions unit's live smoke test caught the same bug it fixed in
SessionsController also present in TrustedDeviceService and
TwoFactorController's device list: SQLite/Pomelo's EF Core provider
cannot translate DateTimeOffset relational comparisons or ORDER BY to
SQL, so IsDeviceTrustedAsync (the check that skips 2FA for a trusted
browser) and ListTrustedDevices would 500 on real SQLite despite
passing on EF's InMemory test provider. Same fix: equality-only in
the DB query, expiry comparison and sort after materializing.
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.
Auth:RequireEmailVerification (default off) gates whether local
register requires confirming email before login. OAuth new-user paths
are untouched -- Google/Microsoft already assert a verified email.
Adds verify-email and resend-verification-email endpoints, mirroring
the existing reset-password enumeration-avoidance and rate-limiting
patterns, plus a login-embedded resend affordance and a verify-email
landing page on the frontend.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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>
- Move inline DTOs to GmailDtos.cs, pure parse helpers to GmailParsing.cs
- Batch per-message existence checks in CreateSuggestedJob/RefreshLinkedThreads
- Remove redundant second pass in RelinkThread, reuse existing HashSet
- Replace ToListAsync+scan with FirstOrDefaultAsync for GmailReviewDecisions lookups
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Move inline DTOs to JobApplicationDtos.cs, pure static helpers to JobApplicationHelpers.cs
- GetStats aggregates server-side (COUNT/GROUP BY) instead of loading the full table
- Cache RuleSettings via IMemoryCache, keyed per-user (RulesEngine.GetSettings falls back
to per-user UserRuleSettings overrides, so a single global cache key would leak settings
across users)
- Add missing AsNoTracking() to read-only GET endpoints (GetAll, GetById, GetBoard,
GetReminders, GetStatusSuggestion, GetMatchScore, GetCandidateFit, GetFocusPlan,
GetInterviewPrep, GetReadiness)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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.
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>
Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction,
no behaviour change:
- GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so
every existing GmailController.XyzDto reference (tests included) keeps
working unchanged.
- GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary,
LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/
CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach.
GmailController.cs: 1200 -> 1022 lines. 169/169 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Backlog item 2. CreateSuggestedJob, RelinkThread, and UnlinkThread each
upserted exactly one GmailReviewDecision by ThreadId but loaded every review
decision for the owner (GmailReviewDecisions.Where(OwnerUserId == x).ToList())
just to linear-scan for the one match. Replaced with FirstOrDefaultAsync
filtered on both OwnerUserId and ThreadId, and added a single-row
UpsertReviewDecision overload alongside the existing dictionary-based one
(still used by the review-queue endpoints, which genuinely need every
decision at once to render the queue).
169/169 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>
ImportSingleMessageAsync now fetches the message + connection through the
provider-neutral seam (Email.GetMessageAsync/GetConnectionAsync), mapping the
neutral ExternalAttachmentId onto CorrespondenceAttachmentMetadata. The
controller's import path no longer touches Gmail directly.
OAuth lifecycle, the rich connection-status DTO, and Gmail candidate ranking
stay on IGmailOAuthService until a second provider (Microsoft/IMAP) forces the
contract shape. 135/135 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
GmailController now resolves the "gmail" provider from IEmailProviderRegistry and
uses the provider-neutral seam for its read paths — message search (SearchAsync)
and thread listing (ListThreadMessagesAsync) across ImportThread, RelinkThread,
CreateSuggestedJob, RefreshLinkedThreads and the messages endpoint. OAuth
(connect/callback), connection status and Gmail-specific candidate ranking stay
on IGmailOAuthService until they are generalised.
An optional constructor param keeps direct construction (tests) working via a
fallback single-Gmail registry, so the mocked Gmail service is exercised through
GmailProvider. Behaviour is preserved (neutral DTOs mirror the Gmail shapes).
This makes the seam a real consumer and sets up MicrosoftGraphProvider /
ImapProvider / a manual free-text provider to slot in next.
Build clean; backend suite 135/135 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CreateSuggestedJob ran one AnyAsync per message in the thread to decide
imported-vs-skip — an N+1 that scales with thread length. Replace it with a
single query that loads the already-imported ExternalMessageIds for the job,
then check in memory (identical skip/import behaviour), mirroring the batched
pattern RelinkThread already uses.
Build clean; backend suite 135/135 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Resolve conflicts from main's Wave 0 (PR #1) landing after this branch was cut:
- useViewResource.ts: main's e352aae already fixes the render loop the same way
(load in a ref, dropped from deps) — took main's canonical version. My
independent fix is superseded (my branch predated e352aae, which is why the
loop reproduced live).
- JobApplicationsController.cs: keep BOTH main's IJobCvMatchService and my
AnalyticsService (ctor gets both optional params). GetAnalyticsOverview stays
delegated to AnalyticsService.
- Fold main's H3 additions into the extracted AnalyticsService: pipeline-driven
funnel (JobPipeline.Normalize/Stages) + time-in-stage (StageAnalytics) and add
StageDurationDto + TimeInStage to Models/AnalyticsDtos.cs, preserving the API
contract the frontend expects.
Build clean; backend suite 135/135 green.
First Wave 2 (safe refactor) slice. Move the read-only stats/overview aggregation
out of the 3.3k-line JobApplicationsController into a dedicated, injectable
AnalyticsService, and lift its response DTOs (JobStats, FunnelStagePoint,
ResponseRatePoint, CompanyActivityPoint, AnalyticsOverviewDto) into
Models/AnalyticsDtos.cs.
- GetStats: ~44 lines -> 3 (delegates to AnalyticsService.GetStatsAsync).
- GetAnalyticsOverview: ~82 lines -> 3 (delegates to GetAnalyticsOverviewAsync).
- Registered AddScoped<AnalyticsService>(); controller keeps an optional ctor
param with a `?? new AnalyticsService(db)` fallback so the 6 test sites that
construct the controller directly keep compiling.
- Logic is byte-identical (same tenant-scoped context, same projections) so
behaviour is preserved.
Backend suite: 92/92 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Both endpoints materialised full JobApplication rows (GetAnalyticsOverview also
Include-d full Company) purely to aggregate a few fields, dragging the large
Description/TranslatedDescription/TailoredCvText/Notes/CoverLetter blobs over
the wire on every dashboard load. Project to only the columns each aggregation
needs (mirrors the existing GetTagTrends pattern). Behaviour is identical;
aggregation stays in memory over a small per-tenant set.
Backend suite: 92/92 green.
Note: the planned hot-path *index* migration is deferred — the committed EF
ModelSnapshot is stale (21 lines, no entities), so `migrations add` cannot
produce a clean incremental diff. Resyncing the snapshot is a prerequisite and
is tracked as its own task.
Co-Authored-By: Claude Opus 4.8 <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>
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>
- AddOpenApi/MapOpenApi (anonymous, Development environment only).
- security: mark ProfileCvController.ProcessQueuedRunAsync [NonAction] -
the controller-level [Route] exposed this background-service hook as a
routable any-verb endpoint, which also broke OpenAPI generation.
96 endpoint paths documented.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>