_private/qwestly-docs/Features/provisioned-accounts/provisioned-accounts.md

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:

  • email is unique: true — no duplicate provisioned accounts per email
  • claimed_auth0_id is index: true — for lookups post-claim
  • Until claimed, the document's _id.toString() serves as the provisional user_id across 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 one interview_notes KB 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 KBgetUserBackgroundKB() now pulls interview_notes content alongside resume and user_input for 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 mode prop: 'onboarding' (default) or 'provision'
  • New userId prop: when set + mode='provision', calls admin API endpoints for fetch/save
  • New initialPreferences prop: 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 PreferencesAdvisory component
    • relocation_willing — Yes/No radio buttons
    • target_comp_ranges — free text input (maps to target_comp_ranges_jsonb)
    • industries_avoid — multi-value input (using MultiLocationInput)
  • 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:

  1. Call Auth0's /oauth/token endpoint with grant_type=password (Resource Owner Password Grant), passing the email + password the candidate just chose.
  2. Auth0 returns an access token + ID token.
  3. Use the @auth0/nextjs-auth0 SDK's session management to store the tokens — either via updateSession() on the response, or by constructing the session cookie manually in the same format the SDK expects.
  4. 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-auth0 SDK 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

  1. Saves anonymized_for_search via PATCH /api/preferences
  2. Sets profile_status: 'active' via PATCH /api/profile-status
  3. 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:

  1. Candidate visits /claim?token={jwt}
  2. Server validates JWT → sets provision_record_id cookie → redirects to /claim
  3. Candidate fills in password → POST /api/claim/create-password-account
  4. Server: creates Auth0 user → runs migration → marks claimed
  5. Client redirects to /auth/login → candidate logs in with new password
  6. beforeSessionSaved fires → checkAuthorization finds candidate under Auth0 ID → session set

Social path:

  1. Candidate visits /claim?token={jwt} → same JWT validation + cookie
  2. Candidate clicks "Continue with Google" → redirects to /auth/login?connection=google-oauth2
  3. Auth0 authenticates → beforeSessionSaved fires
  4. Hook detects provision_record_id cookie → POST /api/claim/confirm → migration runs → { authorized, has_completed_signup } returned
  5. Session set with correct auth status

Migration design

The migration function (migrateClaimedAccount in src/services/db/claim-migration.ts) is:

  • Idempotent — each updateMany filters on { user_id: provisionalId }, so retries skip already-migrated docs
  • Sequential — runs 24 updateMany calls one collection at a time; safe to re-run on failure
  • Dual-field aware — 21 collections use user_id, 3 use candidate_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 for preferred_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
LinkedIn 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:

  1. Runs knowledgeChunkService.searchChunks(query, limit=200, userId/type=none) → top 200 vector matches across all knowledge types
  2. Runs the normal MongoDB $text query on CandidateSearchIndex (fetching all matches up to 10,000)
  3. Fetches any vector-matched candidates not already in the $text result set (with same non-text filters applied)
  4. 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 $text entirely)
  • Running vector search first and using the user_id list as a query-time pre-filter alongside $text (requires a different MongoDB query structure since $text can't share $or with 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