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>
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>
Backlog item 1. The committed ModelSnapshot was empty/stale (21 lines, no
entities) -- `dotnet ef migrations add` scaffolded the whole database from
scratch against it, including the ASP.NET Identity tables, which have never
been created by a real EF migration in this repo (always provisioned via the
raw-SQL reconciler in StartupInitializationExtensions.cs -- see
EnsureIdentityTables' own comment). Applying that diff for real would throw
"table/column already exists" on every environment.
Fix: added migration 20260711181039_SyncModelSnapshot with an intentionally
empty Up()/Down() (see its doc comment) -- it only records itself in
__EFMigrationsHistory and regenerates the snapshot to match the live model,
so `dotnet ef migrations add` produces a real diff for the next schema
change instead of the whole database again. Verified zero side effects
against a copy of the dev DB (only inserts one history row) and against a
fresh empty DB (full migration + reconciler chain runs clean).
That fresh-DB verification surfaced a real, previously-undiscovered bug:
EnsureColumn/EnsureMySqlColumn calls for JobApplications/Correspondences/
Companies/Attachments ad-hoc columns all no-op on a truly fresh database
(the tables don't exist yet -- Migrate() creates them afterward), so a
brand-new deployment's first boot would be missing dozens of columns
(LastReminderEmailSentAt, RecruiterMessageDraft, salary fields, Correspondence
Provider/Subject/Channel/etc.) until the next restart. Also caught: my own b4
change (Correspondence.Provider backfill, already merged) had the same
unguarded-on-fresh-DB bug in isolation.
Fixed by promoting the schema-reconciliation helpers (Exec/HasTable/
HasColumn/EnsureColumn and their MySQL equivalents) from local functions to
class-level statics, extracting the ad-hoc-column blocks into
ReconcileCoreAppColumns/ReconcileCoreAppColumnsMySql, and calling them a
second time right after Migrate() succeeds (reusing the connection already
opened for the CoreSchemaReady check) -- idempotent, so free on every boot
except the first one, where it's now required. No inline logic changed,
pure extraction + one additional call site.
Also added Microsoft.EntityFrameworkCore.Design to JobTrackerApi.csproj
(dotnet-ef tooling requires it on the startup project since EF Core 6+;
previously only referenced by JobTrackerBackend, where the DbContext lives).
169/169 backend tests green. Verified live: full app boot against both a
fresh empty SQLite DB and a copy of the populated dev DB, both clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
b5 of the multi-provider email roadmap (frontend). Adds EmailProviderConnections
-- one card per provider (Gmail, Outlook/Microsoft 365, generic IMAP) showing
connect status and connect/disconnect actions, mounted in SettingsView's
Account tab alongside the existing app-login GoogleAuthCard (a separate
concern: that card is sign-in identity, this is mailbox linking).
Gmail and Microsoft reuse the OAuth-popup + postMessage handshake already
built server-side (mirrors Correspondence.tsx's existing Gmail-connect flow).
IMAP has no OAuth step, so it's a plain host/port/ssl/username/password form
posting to /api/imap/connect, which verifies the connection server-side
before storing it.
Deliberately NOT touched: the Gmail-specific job-candidate-matching/review UI
in Correspondence.tsx and GmailReviewPage.tsx. That backend pipeline
(ListJobCandidateMessagesAsync, GmailReviewDecisions) is still Gmail-only by
design -- generalising it now would mean building fake UI for capabilities
Microsoft/IMAP don't have yet. This is scoped to the piece that's actually
provider-neutral: connect/disconnect status.
Verified live (backend + frontend dev servers): logged in, confirmed all
three /status calls return 200, Gmail connect-url fetch succeeds, IMAP form
submit hits /api/imap/connect and surfaces the expected 400 on a bad host.
Frontend suite: 25 suites / 57 tests green (2 new).
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>
npm run build (Terser minify + fork-ts-checker workers) has now died three
distinct ways on this runner in this session: a printed Terser minify error,
an explicit SIGSEGV, and a fully silent kill with zero output between
'Creating an optimized production build...' and the failure line (OOM/SIGSEGV
signature — process killed before it could flush an error). All three are the
same resource-starved-runner class as the npm ci and dotnet-install flakes
already retried elsewhere in this workflow. Retry once, matching that pattern.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Shell (the single auth guard wrapping every protected route under /*)
redirected unauthenticated visitors straight to /login instead of the home
page, contrary to the intended behaviour. Root cause was one line in
App.tsx's Shell render gate.
Everything else in the guard was already correct: a single centralized
check (no per-page duplication), a loading gate that blocks render until
/auth/config + /auth/me resolve (no flicker-redirect), and 401-triggered
re-checks via the axios interceptor + auth-changed event for expired
sessions mid-session.
Fix:
- Shell now redirects to "/" (home) instead of "/login", still passing
state={{ from: path }} so the originally-requested page isn't lost.
- LandingPage forwards that location.state through to /login on every
"Sign in" CTA (6 call sites collapsed into one goToLogin() helper), so
the home-page bounce doesn't drop the deep-link intent — sign-in still
returns the user to the page they wanted instead of dropping them on
the default /jobs.
- Added LandingPage.authRedirect.test.tsx covering the from-state handoff
end to end (Landing -> click Sign in -> /login receives from). Full
suite: 25 suites, 56 tests green.
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>
Per project rule that tooling (claude, gsd, etc.) should not live in the
repo. Local files are untouched; .gsd/ is now gitignored.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The single self-hosted act_runner intermittently fails actions/setup-dotnet:
a partial extraction sticks in the shared tool-cache (tar: Cannot open: File
exists) or the SDK tarball download corrupts. Install into a clean private
$HOME/.dotnet via dotnet-install.sh with a rm -rf + retry-once, matching the
npm ci and NuGet publish retries already in this pipeline.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The frontend deps step occasionally crashes with "Segmentation fault (core
dumped)" (exit 139) during `npm ci` — a memory/native flake on the act_runner,
unrelated to the change under test (it failed the deploy-fix PR whose only change
is the Dockerfile). Retry once with a clean node_modules before failing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The prod deploy failed restoring a transitive package
(Microsoft.CodeAnalysis.Workspaces.Common) with NU3008 "package integrity check
failed / has changed since it was signed" — a transient corrupted download on the
build host, not a code change. Wrap the backend `dotnet publish` so that on any
failure it clears all NuGet caches and retries once, re-downloading the package
fresh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
"See it in action" section showing the dashboard, pipeline board and per-job
workspace. Uses the design-mockup SVGs (small + crisp, ~27KB total) served from
public/mockups/, honestly captioned "Interface preview". Verified live: all three
render at "/".
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>
Three honest tiers (Free / Pro £9-mo / Bring-your-own-key £3-mo) billed monthly
or yearly — never by the week (the anti-Teal positioning from
docs/remaster/RESEARCH_COMPETITORS.md), with a "Most popular" highlight and the
assistive-not-autonomous trust note. Prices are indicative placeholders for the
SaaS direction.
Verified live: pricing section + all three tiers render at "/".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First slice toward multi-provider email (Gmail + Microsoft Graph + IMAP +
manual/free-text, per docs/remaster/PRODUCT_DIRECTION.md). Adds a provider-
neutral contract (search / list-thread / get-message / get-connection) with
neutral DTOs, a registry to resolve providers by key, and a GmailProvider that
adapts the existing IGmailOAuthService to it.
No behaviour change: the seam is registered in DI but not yet consumed. Follow-up
slices migrate GmailController's read paths onto IEmailProvider (folding in the
N+1 fixes) and add MicrosoftGraphProvider / ImapProvider / a manual provider.
Build clean; backend suite 135/135 green.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a LandingPage (hero + features + how-it-works + CTAs) served at "/" so
visitors learn about the product before signing in — matching the JobTrack
mockups (indigo/cyan, dark hero + light sections). If the visitor already has a
session, LandingPage redirects into the app (/jobs); otherwise it shows the
marketing page with "Sign in" CTAs. The "/" route is public (outside the
auth-gated Shell), so logged-out users no longer bounce straight to /login.
Verified live: renders at "/" with headline, feature grid, how-it-works steps
and CTAs; no console errors; type-clean.
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.
useViewResource built `reload` with `load` in its useCallback deps, and the
fetch effect depended on `reload`. Callers routinely pass an inline `load`
closure (e.g. JobTable), so `load` — and therefore `reload` and the effect —
changed every render, calling setState and re-rendering: an unbounded
"Maximum update depth exceeded" loop that froze the renderer on /jobs and every
other list view (DashboardView, RemindersView, CompaniesTable).
Fix: hold `load` in a ref (like the existing hasLoadedRef) and drop it from the
dependency arrays. Re-fetching is still driven by `deps`/`enabled`; the ref
always points at the latest closure. No API/behaviour change for callers.
Runtime-verified live: /jobs went from a render storm (frozen renderer, 100s of
console errors) to 0 errors in a 2s window and a clean render. Suites that drive
JobTable→useViewResource pass in isolation; the remaining full-run flakiness is
pre-existing (state-pollution/timing in the heavy RTL suites, unrelated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The axios 401 interceptor calls clearAuthClientState() on every 401, which
dispatched "auth-changed"; the App handler re-fetched /auth/me, which 401'd
again → interceptor → clearAuthClientState() → "auth-changed" → ... an unbounded
request storm (observed live: 100+ GET /auth/me and climbing) that ran whenever
the user was logged out (login page, expired session) — burning CPU, network and
battery and flooding the server.
Fix: make clearAuthClientState idempotent — only emit "auth-changed" when it
actually removes a stored user key (a real signed-in→out transition), so
repeated 401s can no longer re-trigger the fetch.
Runtime-verified in a live stack: /auth/me went from 100+ & growing to 0 &
stable. login-page/settings tests green. Documented in
docs/performance/PERFORMANCE_IMPROVEMENTS.md (Phase 3.5 runtime finding).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Evidence-based investigation across every leak vector (timers, listeners, object
URLs, observers, websockets, static server collections, IMemoryCache, Python
caches). Verdict: no confirmed memory leak — the codebase has disciplined
cleanup. One resource-release correctness bug (over-eager blob-URL revocation in
the CV carousel) was found and fixed (eed9b1f).
Adds docs/performance/: MEMORY_LEAK_REPORT.md, ROOT_CAUSE_ANALYSIS.md,
PERFORMANCE_IMPROVEMENTS.md.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The PDF-carousel cleanup effect had `[pdfCarousel]` deps, so its cleanup ran on
every carousel change and revoked the *previous* array's object URLs — which are
still referenced by unchanged items in the new array. Building a multi-template
deck therefore left every preview except the last with a revoked (broken) blob
URL. Drop paths are already handled explicitly in savePdfToCarousel (replace)
and resetPdfCarousel (clear), so blanket per-change revocation was both harmful
and redundant.
Fix: track the latest carousel in a ref and revoke outstanding URLs only on
unmount (empty-deps effect). No leak either way — unmount still frees them.
Found during the memory-leak/resource audit; this is a resource-release
correctness bug (over-eager revocation), not a leak. profile-page.test: 5/5
green (with an adequate timeout; the suite's 5s-timeout flakiness is pre-existing
and unrelated).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
First UI slice toward the product mockups. Shifts the shared MUI theme to match
the mockup identity without restructuring components:
- Default accent -> indigo #6366f1 (drives primary buttons, active nav, and the
Applied/Interview status colours). Users with a custom accent keep theirs.
- Light app background -> #F4F6FB with white paper, giving the layered dashboard
look (cards/inputs sit above a soft grey canvas).
- Global corner radius 8 -> 10 to match the mockups' rounder cards.
- Accent picker: offer indigo/cyan/violet presets first (kept #15803d).
Behaviour-only theme change. Frontend suite: 38/39 pass; the 1 failure
(end-to-end-trust-loop tailored-CV) is pre-existing on this branch (verified by
running it on the clean tree) and unrelated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
The structured /cv/* calls funnel through a provider router so production can
offload a weak local GPU (GTX 1060) to a cloud provider without any .NET change.
Default stays "ollama" (keyless/local) and /summarize remains local distilbart.
- AI_PROVIDER=ollama|gemini|groq dispatch inside _ollama_generate_json/_text
(entry-point names kept, so no call sites change; Ollama path is byte-identical).
- Gemini (x-goog-api-key header, not URL query) and Groq (OpenAI-compatible
chat/completions) added via stdlib urllib — zero new dependencies.
- /health reports ai_provider + ai_provider_configured.
- Keys read from env only; never logged/committed.
- Compose + .env.example pass AI_PROVIDER/GEMINI_*/GROQ_* through.
Tests: 11 passed (default Ollama unchanged, Gemini/Groq dispatch, missing-key 503,
health reports provider).
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>
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>