_private/qwestly-docs/Features/provisioned-accounts/provisioned-accounts.md
Table of Contents
Provisioned Account — Feature Implementation
Status
| Task | Status |
|---|---|
| T1 — Model, Type, Service | ✅ Done |
| T2 — interview_notes KB type | ✅ Done |
| T3 — Management page (recruiter-side) | ✅ Done |
| T4 — SendGrid email template + wiring | 🔲 Pending |
| T5 — Admin candidate search (vector + cross-search KB) | ✅ Done |
| T6 — Admin search (new structured filters) | ✅ Done |
| T7 — Candidate claim flow (landing page + migration) | ✅ Done |
| T8 — Confirmation page + anonymity toggle | ✅ Done |
Data Model
Collection: provisioned_accounts
interface IProvisionedAccount {
_id: ObjectId; // ← becomes user_id in related collections
name: string; // from recruiter input
email: string; // from recruiter input
claim_token: string | null; // JWT encoding provision record ID + expiration
claim_token_expires_at: Date | null; // null until claim email is generated
claim_invite_sent: boolean; // default false
claimed: boolean; // default false
claimed_auth0_id: string | null; // null until claimed
created_at: Date;
updated_at: Date;
}
Key invariants:
emailisunique: true— no duplicate provisioned accounts per emailclaimed_auth0_idisindex: true— for lookups post-claim- Until claimed, the document's
_id.toString()serves as the provisionaluser_idacross every related collection (candidates, preferences, knowledge_base, knowledge_chunk, uploads, etc.)
File Layout
| File | Role |
|---|---|
src/types/provisioned-account.d.ts |
TypeScript interface extending Document |
src/models/ProvisionedAccount.ts |
Mongoose schema + model with Next.js hot-reload guard |
src/services/db/provisioned-account.ts |
DB service class + singleton export |
src/app/admin/provision/[id]/provision-contact-card.tsx |
Contact info card (name, email, LinkedIn) |
src/app/admin/provision/[id]/provision-background-card.tsx |
Background card (resume upload, user_input) |
src/app/admin/provision/[id]/provision-interview-notes-card.tsx |
Interview notes card |
src/app/admin/provision/[id]/provision-actions-card.tsx |
Actions card (provision, claim, delete) |
src/app/admin/provision/[id]/provision-qwestly-card.tsx |
Qwestly card generation card |
Service API
CRUD
| Method | Signature | Notes |
|---|---|---|
create |
({ name, email }) → IProvisionedAccount |
Returns the doc; _id is the provisional user_id |
getById |
(id) → IProvisionedAccount | null |
Single lookup |
getAll |
() → IProvisionedAccount[] |
Newest-first |
getByEmail |
(email) → IProvisionedAccount | null |
Unique email lookup |
update |
(id, { name?, email? }) → IProvisionedAccount | null |
$set on editable fields |
delete |
(id) → boolean |
Only if not claimed (enforced at API layer). Cascades — removes all related records (Candidate, Interview, KnowledgeBase, Upload, etc.) keyed by the provisioned _id as user_id via deleteMany. |
Claim Token
| Method | Signature | Notes |
|---|---|---|
generateClaimJwt |
(id) → string |
Signs HS256 JWT via existing encodeToken with { provisioned_id, exp (7d) }, stores token + expiration on doc |
validateClaimJwt |
(token) → IProvisionedAccount | null |
Verifies via decodeToken (handles signature + expiry), checks claimed === false and token match |
Status
| Method | Signature | Notes |
|---|---|---|
markInviteSent |
(id) → IProvisionedAccount | null |
Sets claim_invite_sent = true |
claim |
(id, auth0UserId) → IProvisionedAccount | null |
Sets claimed = true, stores claimed_auth0_id, clears claim_token + claim_token_expires_at. The full cross-collection user_id migration is handled separately (T7). |
Design Decisions
JWT reuse
Claim tokens use the existing src/services/auth/token.ts helpers (encodeToken/decodeToken) which sign HS256 with JWT_SITE_TOKEN_SECRET. No separate signing key needed — the provisioned_id inside the token is scoped enough that sharing the site-wide secret is acceptable. The JWT's built-in exp claim handles automatic expiry verification via jose.jwtVerify.
Service pattern
Follows the established BaseDBService subclass pattern: all operations wrapped in this.withDb(async () => {...}), top-level try/catch with console.error + rethrow, Mongoose calls terminated with .exec().
claim() scope
The claim() method on the service only updates the provisioned_account document. The actual user_id migration across 25+ collections (listed in the plan doc) will be orchestrated by the claim endpoint handler (T7), which calls this method as part of a larger transaction or migration loop.
Collection name
Mapped to provisioned_accounts (snake_case, plural), consistent with existing collection naming conventions.
T2 — interview_notes Knowledge Base Type
What changed
Added 'interview_notes' as a first-class KnowledgeBaseType, with the same single-entry-upsert semantics as 'user_input'.
| File | Change |
|---|---|
src/types/knowledge-base.d.ts |
KnowledgeBaseType = UploadType | 'user_input' | 'interview_notes' |
src/models/KnowledgeBase.ts |
Added to type field enum |
src/models/KnowledgeChunk.ts |
Added to type field enum |
src/services/db/knowledge-base.ts |
saveUserInputKB() now accepts optional type param (default 'user_input'); getUserBackgroundKB() and hasBackgroundKB() include 'interview_notes' in their $in filters |
src/app/api/kb/[[...slug]]/route.ts |
POST: interview_notes follows same upsert + chunk-cleanup path as user_input; structural data processing gated to user_input only. GET: type filter extended. |
__tests__/unit/knowledge-base-has-background.test.ts |
Updated $in array expectation to include 'interview_notes'; length 2→3 |
Semantics
POST /api/kb { text_content, type: 'interview_notes' }
→ saveUserInputKB(user_id, text_content, 'interview_notes') // upsert by (user, type)
→ deleteChunksByUserAndType(user_id, 'interview_notes') // wipe old chunks
→ generateChunksFromPages(...) // re-chunk + re-embed
- Single entry per user — same as
user_input, only oneinterview_notesKB doc exists per user - Re-save replaces old chunks are deleted, new chunks + embeddings generated
- Empty string supported — allows clearing notes
- No structural data parsing — interview notes are freeform recruiter notes, not a resume/profile
- Included in background KB —
getUserBackgroundKB()now pullsinterview_notescontent alongsideresumeanduser_inputfor interview/summary prompts
Key design decision
Generalized saveUserInputKB() with an optional type param rather than creating a separate saveInterviewNotesKB() wrapper. The upsert logic is identical; only the type field value differs. Defaulting to 'user_input' preserves backward compatibility for all existing callers.
T3 — Admin Management Page + API
What changed
Full CRUD management page for recruiters at /admin/provision, plus admin API endpoints under /api/admin/provision.
API Endpoints
| Route | Method | Purpose |
|---|---|---|
/api/admin/provision |
GET |
List all provisioned accounts |
/api/admin/provision |
POST |
Create provision — creates account + candidate (profile_status: 'draft') + prefs + interview notes + LinkedIn enrichment (async) in one call |
/api/admin/provision/{id} |
GET |
Get single account |
/api/admin/provision/{id} |
PUT |
Update name/email/linkedin_username. LinkedIn saved to candidate record. |
/api/admin/provision/{id} |
DELETE |
Remove + cascade delete all user_id-keyed records (only if !claimed) |
/api/admin/provision/{id}/send-claim |
POST |
Generate JWT, store on doc, return claim URL (SendGrid wiring deferred to T4) |
/api/admin/provision/{id}/preferences |
GET |
Fetch preferences for the provisional user_id |
/api/admin/provision/{id}/preferences |
PUT, PATCH |
Upsert preferences for the provisional user_id (PATCH for debounced form saves, PUT for bulk) |
All endpoints call requireAdmin() for defense-in-depth (in addition to proxy.ts middleware gating).
Frontend Pages
| Route | File(s) | Purpose |
|---|---|---|
/admin/provision |
page.tsx + client.tsx |
Index — stats cards (Draft/Invited/Claimed) + table with name/email/status |
/admin/provision/new |
new/page.tsx |
Create mode — renders detail client with _account=null |
/admin/provision/[id] |
[id]/page.tsx + client.tsx + 5 card sub-components |
Detail — Contact Info (name, email, LinkedIn URL, LinkedIn profile fetch dialog) + Background (resume upload, user_input textarea) + Interview Notes (textarea → type: 'interview_notes' KB) + Preferences (shared component, provision mode, with server-side preloaded preferences) + Qwestly Card generation + Action buttons (provision, claim, delete). The actions card is hidden when the account is claimed; a green "Claimed" badge appears in the page heading instead. The server page fetches preferences (Preference.findOne) and LinkedIn username (Candidate.findOne) and passes them as _preferences and _linkedinUsername to the client for pre-population. All cards constrained to max-w-3xl and centered. |
PreferencesForm Refactor
Extracted from src/app/onboarding/(steps)/preferences/ → src/components/preferences/preferences-form.tsx:
- New
modeprop:'onboarding'(default) or'provision' - New
userIdprop: when set +mode='provision', calls admin API endpoints for fetch/save - New
initialPreferencesprop: when provided (server-side loaded preferences), skips the client-side fetch and uses the passed data directly - In
'provision'mode: renders an Additional Preferences section (below the core fields, separated by a dashed divider) with the following fields:preferred_functions— checkbox grid (Engineering, Product, Design, Data, Marketing, Sales, Finance, Operations, People/HR, Legal)preferred_levels— checkboxes (Senior, Staff, Principal, Director, VP, C-Suite)search_status— radio buttons (Active, Passive, Not Searching)- Advisory / Fractional Interest — reused
PreferencesAdvisorycomponent relocation_willing— Yes/No radio buttonstarget_comp_ranges— free text input (maps totarget_comp_ranges_jsonb)industries_avoid— multi-value input (usingMultiLocationInput)
- In
'onboarding'mode: identical to existing behavior, no new fields shown - Onboarding path: thin re-export at old location (
src/app/onboarding/(steps)/preferences/preferences-form.tsx→ just re-exports from@/components/preferences/preferences-form)
New Preference Model Fields
| Field | Type | Notes |
|---|---|---|
preferred_functions |
string[] |
Functional areas (Engineering, Product, etc.) — indexed |
preferred_levels |
('senior' | 'staff' | 'principal' | 'director' | 'vp' | 'c_suite')[] |
Seniority levels — indexed |
search_status |
'active' | 'passive' | 'not_searching' |
Candidate availability — indexed |
relocation_willing |
boolean |
Willing to relocate (present in model, now rendered in Additional Preferences) |
target_comp_ranges_jsonb |
object |
Compensation expectations as JSON (present in model, now rendered as free-text input mapped via target_comp_ranges in the form) |
industries_avoid |
string[] |
Industries to exclude (present in model, now rendered in Additional Preferences) |
advisory_fractional_interest |
boolean | null |
Interest in advisory/fractional roles (present in model, now rendered via PreferencesAdvisory component in Additional Preferences) |
These are provision-only — not shown in the candidate onboarding flow.
ApiRouter Nested Path Test
Added 2 tests to __tests__/unit/api-router-routing.test.ts confirming /{id}/preferences nested sub-resource paths match correctly (validated that addRoute's leading-slash stripping makes ['{id}', 'preferences'] match ['someId', 'preferences']).
New Files Created
| File | Purpose |
|---|---|
src/app/api/admin/provision/[[...slug]]/route.ts |
8 API endpoints |
src/app/admin/provision/page.tsx |
Index server page |
src/app/admin/provision/client.tsx |
Index client — table + stats |
src/app/admin/provision/new/page.tsx |
Create mode |
src/app/admin/provision/[id]/page.tsx |
Detail server page |
src/app/admin/provision/[id]/client.tsx |
Detail form with panels |
src/components/preferences/preferences-form.tsx |
Shared PreferencesForm |
Files Modified
| File | Change |
|---|---|
src/types/preference.d.ts |
+3 new fields |
src/models/Preference.ts |
+3 new schema fields with indexes |
src/app/onboarding/(steps)/preferences/preferences-form.tsx |
Thin re-export to shared |
__tests__/unit/api-router-routing.test.ts |
+2 nested path tests |
docs/plan/recruiting-pilot.md |
Fleshed-out implementation plan |
src/app/admin/provision/[id]/page.tsx |
Added server-side fetching of preferences (Preference.findOne) and LinkedIn username (Candidate.findOne) — passes _preferences and _linkedinUsername to the client |
src/components/preferences/preferences-form.tsx |
Added initialPreferences prop for server-side preloading; expanded Additional Preferences section with relocation_willing, target_comp_ranges/target_comp_ranges_jsonb, industries_avoid, and advisory/fractional interest fields |
Post-implementation refinements
| Refinement | Detail |
|---|---|
| LinkedIn persistence | PUT /{id} accepts linkedin_username, saves to Candidate.linkedin_user_name. Server page fetches candidate to pre-populate the input on load. |
| Background card | Resume upload button + user_input KB textarea. Candidates see this content after claiming; used for Qwestly card generation and structural profile parsing. |
| Preferences PATCH | Added PATCH /{id}/preferences (same handler as PUT) — the PreferencesForm debounced saves use PATCH, not PUT. |
| "Next" button hidden | PreferencesForm "Next" button only renders in mode === 'onboarding'. Provision mode auto-saves via debounced patches. |
| Card descriptions | Each card has a CardDescription explaining purpose: Contact Info (claim link + LinkedIn), Background (candidate-visible, used for card/parser), Interview Notes (NOT candidate-visible). |
| Width alignment | Header row and all cards share max-w-3xl mx-auto for consistent centering matching the PreferencesForm. |
| 1Password ignore | Name, Email, and LinkedIn inputs have data-1p-ignore="true" to prevent autofill interference. |
| Server-side preference preloading | [id]/page.tsx fetches preferences and LinkedIn username server-side via Preference.findOne and Candidate.findOne, passing them to the client as _preferences and _linkedinUsername. The client passes _preferences as initialPreferences to PreferencesForm, avoiding a client-side round trip on page load. |
| Additional Preferences section | The provision-mode PreferencesForm now shows a full Additional Preferences block (below a dashed divider) including relocation_willing, target_comp_ranges (→ target_comp_ranges_jsonb), industries_avoid, and advisory/fractional interest — all previously present in the model but not surfaced in the UI. |
| Component extraction | Detail page refactored from a single client.tsx into 5 focused sub-components: provision-contact-card, provision-background-card, provision-interview-notes-card, provision-actions-card, and provision-qwestly-card. |
| LinkedIn profile fetch | Contact card has a "Fetch LinkedIn" button that retrieves profile data via a scraper endpoint and displays it in a JSON viewer dialog. |
| Qwestly card generation | New provision-qwestly-card component allows generating a Qwestly card for a provisioned account with a "Generate Card" button (shown only in edit mode for unclaimed accounts). |
| Cascade delete | DELETE /api/admin/provision/{id} now cascades to all collections using user_id (19 models: Candidate, Interview, KnowledgeBase, Upload, etc.). The service's delete() method runs deleteMany in parallel across all related models before removing the provisioned account. |
| Claimed badge | When an account is claimed, a green "Claimed" badge appears next to the page heading, and the entire ProvisionActionsCard (provision/claim/delete buttons) is hidden. |
| Delete confirmation dialog | The native confirm() call was replaced with an AlertDialog that lists all record types that will be deleted before the user confirms. |
T7 — Claim Flow Decisions (2026-06-13)
Resolved open questions before implementing T7:
| # | Question | Decision |
|---|---|---|
| 1 | Password flow login after account creation | Accept the existing signup pattern: create Auth0 user → run migration → redirect to /auth/login. Candidate logs in with the password they just created, same as normal password signup. See follow-up below for a smoother alternative. |
| 2 | Migration atomicity | MongoDB Atlas uses replica sets by default → transactions are available. Use a MongoDB multi-document transaction wrapping all updateMany calls. Fallback: design the migration to be idempotent (run-able multiple times safely; second run finds user_id already matches Auth0 ID and skips). |
| 3 | Collection names | Use actual collection names (e.g. candidates_enhanced, preferences_enhanced, …), not the simplified names from the plan doc. Three collections use candidate_id instead of user_id (v_candidate_profile_cache_enhanced, qwestly_card_sections, qwestly_card_anonymity_mapping) — migrate those too. CandidateSearchIndex.user_mongo_id is the MongoDB _id and does NOT change. |
| 4 | JWT in URL | Validate JWT on first load, then store the provision record ID in a short-lived cookie and redirect to a clean /claim URL (no token in query string). |
| 5 | Already-authenticated user visits claim link | Detect session mismatch on the claim page. If the user is logged in with a different account, show: "This page is for new accounts only." + a "Log out" button + a fallback "I think this is a mistake" link → mailto:support@qwestly.com?subject=Already have account, need to claim provisioned account&body=.... |
| 6 | Email mismatch (social claim with different email than provisioned) | Allowed — no enforcement. If the candidate's social login email differs from the provisioned email, the claim still proceeds. |
| 7 | T4 dependency | Not a blocker. The send-claim endpoint already returns a claim URL; the recruiter can share it manually. T4 (SendGrid email) is a separate task. |
| 8 | Claim URL base | Use the existing appBaseUrl from @/config/server (reads APP_BASE_URL or VERCEL_BRANCH_URL). The current send-claim endpoint should be updated from its raw process.env.APP_BASE_URL fallback to use this helper. |
Follow-up: Programmatic login after password claim (Option B)
Currently after the candidate creates a password on the claim form, we redirect them to Auth0's hosted login page where they enter the same password again. A smoother flow would authenticate them programmatically without the second password entry.
Approach: After creating the Auth0 user via Management API and running the migration:
- Call Auth0's
/oauth/tokenendpoint withgrant_type=password(Resource Owner Password Grant), passing the email + password the candidate just chose. - Auth0 returns an access token + ID token.
- Use the
@auth0/nextjs-auth0SDK's session management to store the tokens — either viaupdateSession()on the response, or by constructing the session cookie manually in the same format the SDK expects. - Redirect the candidate directly to the confirmation page (T8) — no second login step.
Prerequisites:
- The Password grant type must be enabled in the Auth0 tenant (it's disabled by default for security reasons).
- The
@auth0/nextjs-auth0SDK must expose a way to set a session programmatically, or we need to replicate its cookie format (encrypted JWT containing the tokens).
Risk: Password grant is considered a legacy flow by OAuth standards. It's fine for a trusted first-party app (our backend calling our own Auth0 tenant), but it does mean the candidate's password touches our server code briefly (we forward it to Auth0's token endpoint). We already handle the password on the createUser Management API call, so this isn't a new exposure.
Status: Deferred. Implement the existing redirect-to-login pattern for MVP; revisit if candidate feedback indicates the double password entry is a significant drop-off point.
T8 — Claim Confirmation Page + Anonymity Toggle
What was built
A one-shot confirmation page at /claim/confirmation that replaces standard onboarding for provisioned accounts. After claiming, candidates land here instead of the onboarding flow. Four sections let them review preferences, edit their Qwestly card, manage their resume, set an anonymity preference, and publish their profile.
Files created
| File | Purpose |
|---|---|
src/app/claim/confirmation/page.tsx |
Server component — verifies user is provisioned + profile_status === 'draft', fetches preferences/card, passes to client |
src/app/claim/confirmation/client.tsx |
Client component — 4-section layout with publish button |
Files modified
| File | Change |
|---|---|
src/models/Preference.ts |
Added anonymized_for_search boolean field (default false) |
src/types/preference.d.ts |
Added type for anonymized_for_search |
src/services/db/provisioned-account.ts |
Added getByClaimedAuth0Id(auth0Id) lookup method |
src/app/dashboard/page.tsx |
Added provisioned user detection — redirects draft provisioned users to /claim/confirmation instead of onboarding |
src/app/claim/client.tsx |
Updated returnTo URLs to /claim/confirmation for both password and social paths |
Page sections
| # | Section | Behavior |
|---|---|---|
| 1 | Preferences | Read-only summary. "Edit" button opens PreferencesForm in mode='provision' for inline edits (auto-saves via debounced PATCH). |
| 2 | Qwestly Card | View/edit toggle reusing QwestlyCardView + QwestlyCardEditor from the onboarding review step. Card is pre-generated from T3 LinkedIn + resume processing. |
| 3 | Resume | Shows current resume link if uploaded during T3. Upload button (marked TBD — resume upload component deferred). |
| 4 | Anonymity toggle | Switch: De-anonymized (direct referral — full profile visible to specific HMs) vs Anonymized (general matching — anonymous until mutual interest). Saves to Preference.anonymized_for_search. Placeholder explanatory copy (Adam will update). |
Publish flow
- Saves
anonymized_for_searchviaPATCH /api/preferences - Sets
profile_status: 'active'viaPATCH /api/profile-status - Redirects to
/(dashboard — no longer redirected since profile is active)
TBD
- Publish notifications — email/Slack webhook on publish. Requirements TBD.
- Resume upload — the upload button is disabled pending integration of the upload component into the confirmation page layout.
T7 — Candidate Claim Flow
What was built
The full end-to-end claim experience: a public landing page where candidates create an account (password or social), a cross-collection user_id migration that swaps the provisional ID for the real Auth0 ID, and a beforeSessionSaved hook extension for the social login path.
Files created
| File | Purpose |
|---|---|
src/app/claim/page.tsx |
Server component — JWT-in-URL → cookie → redirect pattern; reads cookie to fetch account; detects session mismatch |
src/app/claim/client.tsx |
Client component — 5 UI states (loading, invalid/expired, already-claimed, session-mismatch, claim form); reuses SignupFormCard from signup flow |
src/app/api/claim/[[...slug]]/route.ts |
3 public endpoints: GET ?token= (JWT validation), POST create-password-account, POST confirm (social flow trigger) |
src/services/db/claim-migration.ts |
Idempotent cross-collection migration — swaps user_id/candidate_id from provisional → Auth0 ID across 24 collections |
Files modified
| File | Change |
|---|---|
src/services/auth/auth0.ts |
Added confirmClaimAndCheckAuth() helper + provision_record_id cookie detection in beforeSessionSaved |
src/config/server.ts |
Added /claim to UNAUTHENTICATED_PAGES and /api/claim to UNAUTHENTICATED_APIS |
src/app/api/admin/provision/[[...slug]]/route.ts |
Fixed send-claim base URL to also fall back to VERCEL_BRANCH_URL |
Claim flow paths
Password path:
- Candidate visits
/claim?token={jwt} - Server validates JWT → sets
provision_record_idcookie → redirects to/claim - Candidate fills in password →
POST /api/claim/create-password-account - Server: creates Auth0 user → runs migration → marks claimed
- Client redirects to
/auth/login→ candidate logs in with new password beforeSessionSavedfires →checkAuthorizationfinds candidate under Auth0 ID → session set
Social path:
- Candidate visits
/claim?token={jwt}→ same JWT validation + cookie - Candidate clicks "Continue with Google" → redirects to
/auth/login?connection=google-oauth2 - Auth0 authenticates →
beforeSessionSavedfires - Hook detects
provision_record_idcookie →POST /api/claim/confirm→ migration runs →{ authorized, has_completed_signup }returned - Session set with correct auth status
Migration design
The migration function (migrateClaimedAccount in src/services/db/claim-migration.ts) is:
- Idempotent — each
updateManyfilters on{ user_id: provisionalId }, so retries skip already-migrated docs - Sequential — runs 24
updateManycalls one collection at a time; safe to re-run on failure - Dual-field aware — 21 collections use
user_id, 3 usecandidate_id(v_candidate_profile_cache_enhanced,qwestly_card_sections,qwestly_card_anonymity_mapping)
Edge cases handled
| Case | Handling |
|---|---|
| Invalid/expired JWT | Error state with "contact your recruiter" message |
| Already claimed account | Message + link to /auth/login |
| Already logged in (different account) | "This page is for new accounts only" + Logout button + mailto:support@qwestly.com |
| Email mismatch (social login with different email) | Allowed — no enforcement |
| Migration partial failure | Mark account as claimed anyway; candidate sees error with support contact; recruiter can see status |
T6 — Admin Search: New Structured Filters
What changed
Added 3 recruiting-specific preference fields as structured search filters on the admin candidate search page at /admin/search. Each filter flows through the full pipeline: Preference model → profile cache → candidate search index → API query param → frontend filter UI.
New filters
| Filter | Field | Query type | Frontend component |
|---|---|---|---|
| Function | preferred_functions |
$in (match any) |
MultiSelect checkbox group |
| Level | preferred_levels |
$in (match any) |
MultiSelect checkbox group |
| Search status | search_status |
Exact match | Single-select dropdown |
Files changed
| File | Change |
|---|---|
src/types/candidate-search-index.d.ts |
Added preferred_functions, preferred_levels, search_status |
src/types/candidate-profile-cache.d.ts |
Added 4 new fields to profile cache type |
src/models/CandidateSearchIndex.ts |
Added schema fields + indexes |
src/services/db/profile-cache-triggers.ts |
Added 4 new fields to preferences_enhanced trigger mapping |
src/services/db/candidate-profile-assembly.ts |
Populates 4 new fields in profile cache JSON |
src/services/db/candidate-search-index.service.ts |
Populates new fields in full and minimal index builds; includes in preference_text |
src/services/db/candidate-search.service.ts |
Added to CandidateSearchFilters + buildQuery for $in/exact-match |
src/app/api/admin/search/candidates/route.ts |
Parses 3 new query params |
src/app/admin/search/page.tsx |
Parses params server-side for SSR |
src/app/admin/search/client.tsx |
State vars, URL building, filter UI (MultiSelect + Select) |
src/config/preference-constants.ts |
Added PREFERRED_FUNCTIONS, PREFERRED_LEVELS, SEARCH_STATUS_OPTIONS |
src/components/preferences/preferences-form.tsx |
Refactored to use shared constants |
Deferred
Comp expectations (target_comp_ranges_jsonb) was planned but deferred — the field is freeform text/JSON in the provision form (see T3), not structured enough for range filtering. Remains text-searchable via preference_text. Moving this to a structured filter requires first restructuring the data field itself.
Tests added
__tests__/unit/candidate-search-service.test.ts— 5 new test cases verifying query building forpreferred_functions($in),preferred_levels($in),search_status(exact), empty array handling, and whitespace stripping.
Post-deploy
Rebuild all search indices to backfill the new fields for existing candidates.
T5 — Admin Candidate Search: Cross-Search Knowledge Base
What was built
Enhanced the existing GET /api/admin/search/candidates endpoint so that when a search text query is provided, the backend also runs vector search against all knowledge_chunk docs (types: resume, user_input, interview_notes) and merges the results as a union with the MongoDB $text search results. The frontend search results table was extracted into focused sub-components and the table columns were optimized for usability.
No new API endpoint — purely a backend enhancement to the existing route + service.
Implementation
Backend
| File | Change |
|---|---|
src/services/db/candidate-search.service.ts |
Added KnowledgeSnippet interface, extended CandidateSearchResult with knowledgeMatches. New runVectorSearch() helper. Modified searchCandidates to run vector search alongside $text, union results by user_id, attach per-candidate metadata. In-memory merge pagination when search is provided. |
src/services/db/knowledge-chunk.ts |
Extended VectorSearchResult to include user_id and type in both Atlas $project and fallback cosine-similarity paths |
src/app/api/admin/search/candidates/route.ts |
Passes knowledgeMatches through in the API response |
Frontend — component extraction
The monolithic client.tsx (~1922 lines) was broken into focused sub-components:
| File | Role |
|---|---|
src/app/admin/search/search-utils.tsx |
Extracted getUserName() and highlightText() helpers |
src/app/admin/search/knowledge-match-badge.tsx |
Match badge with hover tooltip (anchored bottom-right, min-w-[300px], shows "Matched in {type}" + "Relevance: {score}%" + snippet) |
src/app/admin/search/search-pagination.tsx |
Pagination with Previous/Next buttons + page number buttons |
src/app/admin/search/search-results-table.tsx |
Full results table with sort controls, 13-column header, candidate row rendering |
src/app/admin/search/client.tsx |
Slim orchestrator (~1616 lines) holding state, handlers, effects, and composing the layout |
Search results table optimizations
| Column | Before | After |
|---|---|---|
| Username text link, full width | LinkedIn brand icon + native title tooltip, min-w-[40px] |
|
| Profile Status | "Profile Status" header | "Status" header |
| Role Type | "Preferred role type" header, "Leadership" / "Individual contributor" values | "Role Type" header, "Mgr" / "IC" values |
| YOE | "Years of Experience" header | "YOE" header, min-w-[80px] |
| Availability | Full date (e.g. "Jan 15, 2026") | MM/DD/YY format |
| Name | Full width | max-w-[150px] with truncate + ellipsis + native title tooltip |
| Actions | Normal scroll | Sticky right-0 z-10 with left-edge shadow, always visible on horizontal scroll |
Union strategy
When search is provided, the service:
- Runs
knowledgeChunkService.searchChunks(query, limit=200, userId/type=none)→ top 200 vector matches across all knowledge types - Runs the normal MongoDB
$textquery onCandidateSearchIndex(fetching all matches up to 10,000) - Fetches any vector-matched candidates not already in the
$textresult set (with same non-text filters applied) - Unions both sets by
user_id, sorts, paginates in-memory
Scaling note
The in-memory merge fetches all $text matches (up to 10,000) before paginating. For the current candidate pool (hundreds) and near-term scale (thousands) this is acceptable. If the pool grows significantly beyond this, a follow-up should redesign pagination to avoid fetching all results — options include:
- Making vector search the primary ranking mechanism (replace
$textentirely) - Running vector search first and using the
user_idlist as a query-time pre-filter alongside$text(requires a different MongoDB query structure since$textcan't share$orwith non-text branches) - Two-phase results with separate pagination for text-matched vs. vector-matched candidates
Future Tasks That Touch These Files
- T3 (Management Page) already calls
provisionedAccountService.create,.getAll,.getById,.update,.delete,.generateClaimJwt,.markInviteSent - T4 (SendGrid wiring) will consume the JWT from
generateClaimJwt - T8 (Confirmation page) will be the post-claim landing page for claimed candidates