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

Qwestly Recruiting Pilot โ€” Task Breakdown

Feature Overview

What this does: Recruiters can pre-provision candidate accounts before the candidate ever signs up. From an admin management page, they enter contact info, upload a resume (or pull from LinkedIn), add interview notes from initial conversations, set preferences (function, level, location, remote preference, company stage, comp expectations, availability), and configure everything โ€” then send a templated claim invitation email. The candidate clicks a link, creates an account (password or social), and all the pre-loaded data is immediately available.

Why it matters: Currently candidates go through a full onboarding flow after signing up, but a lot of that data is already captured by the recruiter during initial outreach and interviews. This eliminates redundant data entry and makes the candidate's experience seamless โ€” their preferences, Qwestly card, resume, and everything else is ready from the moment they first log in (skipping the standard onboarding flow entirely in favor of a dedicated confirmation page). It also enables the recruiter to index and search candidates by interview notes content from day one.

Key design decisions (from Weekly Leadership Meeting, 2026-05-12):

  • Single candidate record combining interview notes with Qwestly card data as the searchable source of truth
  • Account claiming creates candidates before they claim โ€” recruiter captures everything upfront
  • Skip standard onboarding for claimed accounts โ€” replace with a confirmation flow (confirm preferences โ†’ view Qwestly card โ†’ upload/edit resume)
  • Anonymity toggle on the confirmation page โ€” show de-anonymized profile first with copy explaining anonymization for broader role sharing
  • Admin search enhanced with vector search across notes + new structured filters (location, remote, company stage, search status)
  • JWT-encoded claim links using provision record ID + expiration instead of separate UUID tokens

How it works (high-level):

  1. Recruiter provisions an account โ†’ gets a provisional _id used as user_id everywhere
  2. Candidate receives a claim email with a JWT link (via SendGrid)
  3. Candidate claims the account โ†’ a migration swaps the provisional user_id with their real Auth0 ID across all collections
  4. Candidate lands on a confirmation page with their preferences, Qwestly card, and anonymity toggle

Architecture: Provisioned Account Collection

Before the tasks, here's the new data model this all hinges on:

provisioned_accounts
โ”œโ”€โ”€ _id: ObjectId            โ† becomes user_id in all related collections
โ”œโ”€โ”€ name: string
โ”œโ”€โ”€ email: string
โ”œโ”€โ”€ claim_token: string | null        โ† JWT, encodes provision record ID + expiration
โ”œโ”€โ”€ claim_token_expires_at: Date | null
โ”œโ”€โ”€ claim_invite_sent: boolean (or Date|null)
โ”œโ”€โ”€ claimed: boolean
โ”œโ”€โ”€ claimed_auth0_id: string | null   โ† populated on claim
โ”œโ”€โ”€ created_at: Date
โ””โ”€โ”€ updated_at: Date

Key design decision: When the recruiter provisions an account, the provisioned account's _id.toString() becomes the user_id in every collection that references a candidate. On claim, a migration swaps the user_id to the Auth0 ID.


๐Ÿš€ MVP: Before First Candidate Call

โœ… T1 โ€” Provisioned Account Model + Collection (DONE)

New files:

  • src/models/ProvisionedAccount.ts
  • src/types/provisioned-account.d.ts
  • src/services/db/provisioned-account.ts

Schema:

{
  name: String,           // from recruiter input
  email: String,          // from recruiter input
  claim_token: String,              // JWT encoding provision record ID + expiration
  claim_token_expires_at: Date,    // null until claim email is generated
  claim_invite_sent: { type: Boolean, default: false },
  claimed: { type: Boolean, default: false },
  claimed_auth0_id: String,  // null until claimed
}
// + timestamps (created_at, updated_at)

Service methods needed:

  • create(data) โ†’ returns the new doc (its _id is the provisional user_id)
  • getById(id)
  • getAll() โ€” returns all provisioned accounts (for the index view)
  • getByEmail(email)
  • update(id, data) โ€” editable fields (name, email)
  • delete(id)
  • generateClaimJwt(id) โ€” generates a JWT encoding the provision record ID with expiration, stores it on the doc, returns the encoded link
  • validateClaimJwt(token) โ€” decodes JWT, looks up by provision record ID, returns the doc if valid and not expired
  • markInviteSent(id)
  • claim(id, auth0UserId) โ†’ this is where the user_id swap happens (see T7)

โœ… T2 โ€” New interview_notes Knowledge Base Type (DONE)

What: Add 'interview_notes' as a new KnowledgeBaseType, following the exact pattern of 'user_input'.

Files touched:

File Change
src/types/knowledge-base.d.ts KnowledgeBaseType = UploadType | 'user_input' | 'interview_notes'
src/models/KnowledgeBase.ts Add to enum
src/models/KnowledgeChunk.ts Add to enum
src/app/api/kb/[[...slug]]/route.ts Allow type in POST validation; give it same single-entry upsert + chunk-cleanup semantics as user_input
src/services/db/knowledge-base.ts Generalize saveUserInputKB() to accept a type param (or add saveInterviewNotesKB() wrapper); include 'interview_notes' in getUserBackgroundKB() and hasBackgroundKB() queries
src/services/db/knowledge-chunk.ts No changes needed โ€” generateChunksFromPages() already accepts any KnowledgeBaseType

Semantics: Like user_input, only one interview_notes KB entry per user. Re-saving replaces previous content (delete old chunks โ†’ re-chunk โ†’ re-embed).


โœ… T3 โ€” Provisioned Accounts Management Page (Recruiter-Side) (DONE)

What: A full CRUD management page where the recruiter can see all provisioned accounts, create new ones, view/edit details, and perform actions on each (including sending the claim email).

Page structure โ€” two views:

Index view โ€” table/list of all provisioned accounts:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Provisioned Accounts                   [+ New]     โ”‚
โ”‚                                                     โ”‚
โ”‚  โ”Œโ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”  โ”‚
โ”‚  โ”‚  #  โ”‚ Name       โ”‚ Email      โ”‚ Status โ”‚      โ”‚  โ”‚
โ”‚  โ”œโ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ผโ”€โ”€โ”€โ”€โ”€โ”€โ”ค  โ”‚
โ”‚  โ”‚  1  โ”‚ Jane Doe   โ”‚ jane@...   โ”‚ Invitedโ”‚ [โ†’]  โ”‚  โ”‚
โ”‚  โ”‚  2  โ”‚ John Smith โ”‚ john@...   โ”‚ Draft  โ”‚ [โ†’]  โ”‚  โ”‚
โ”‚  โ”‚  3  โ”‚ ...        โ”‚ ...        โ”‚ Claimedโ”‚ [โ†’]  โ”‚  โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ดโ”€โ”€โ”€โ”€โ”€โ”€โ”˜  โ”‚
โ”‚                                                     โ”‚
โ”‚  Statuses: Draft โ†’ Invited โ†’ Claimed                โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

Clicking a row navigates to the detail view.

Detail view โ€” selected provisioned account:

โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”
โ”‚  Provisioned Account: Jane Doe       [โ† Back] โ”‚
โ”‚                                               โ”‚
โ”‚  โ”Œโ”€ Contact Info โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  Name:  [_______________]  (editable)    โ”‚ โ”‚
โ”‚  โ”‚  Email: [_______________]  (editable)    โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                               โ”‚
โ”‚  โ”Œโ”€ Background โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  LinkedIn URL: [_______________]         โ”‚ โ”‚
โ”‚  โ”‚  Resume:       [Upload / Drag & Drop]    โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                               โ”‚
โ”‚  โ”Œโ”€ Interview Notes โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  [                                   ]   โ”‚ โ”‚
โ”‚  โ”‚  [      large freeform textarea      ]   โ”‚ โ”‚
โ”‚  โ”‚  [                                   ]   โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                               โ”‚
โ”‚  โ”Œโ”€ Preferences โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” โ”‚
โ”‚  โ”‚  Function:        [dropdown/multi]       โ”‚ โ”‚
โ”‚  โ”‚  Level:           [dropdown/multi]       โ”‚ โ”‚
โ”‚  โ”‚  Location:        [_______________]      โ”‚ โ”‚
โ”‚  โ”‚  Remote pref:     [remote/hybrid/on-site]โ”‚ โ”‚
โ”‚  โ”‚  Company stage:   [early/growth/late]    โ”‚ โ”‚
โ”‚  โ”‚  Comp expectations: [_______________]    โ”‚ โ”‚
โ”‚  โ”‚  Availability:    [active/passive]       โ”‚ โ”‚
โ”‚  โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ โ”‚
โ”‚                                               โ”‚
โ”‚  [ Provision Account ]  [ Email Claim Link ]  โ”‚
โ”‚  [ Delete ]                                   โ”‚
โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜

The detail view uses the same form sections as the onboarding flow (above), pre-populated if data already exists. Sections may render as independent panels โ€” each can submit separately rather than everything in one monolithic call. Existing onboarding components should be reused where possible.

API Endpoints:

Endpoint Method Purpose
/api/admin/provision GET List all provisioned accounts (for index view)
/api/admin/provision POST Create a new provisioned account (minimal: name + email)
/api/admin/provision/[id] GET Get single provisioned account with full details
/api/admin/provision/[id] PUT Update editable fields (name, email, preferences, etc.)
/api/admin/provision/[id] DELETE Remove provisioned account (only if not claimed)
/api/admin/provision/[id]/send-claim POST Generate claim JWT and send the invite email (T4)

"Email Claim Link" action: Sends a templated email via SendGrid to the candidate's email address containing a claim link. The link encodes a JWT containing the provision record ID + expiration. The recruiter can resend at any time while the account is in "Draft" or "Invited" status (generates a fresh JWT each time).

"Provision Account" submit orchestrates several operations using the provisional _id as user_id. Sections that already have data when the page loads (e.g., after partial saves) can be skipped or re-submitted individually:

  1. Create provisioned account via ProvisionedAccountService.create({ name, email }) โ†’ get provisionalId
  2. Create candidate record with user_id = provisionalId, profile_status = 'draft'
  3. Save preferences with user_id = provisionalId
  4. Upload resume โ†’ POST /api/upload with user_id = provisionalId โ†’ then POST /api/kb { upload_id, type: 'resume' }
  5. Scrape LinkedIn โ†’ fire off LinkedIn profile enrichment (async, can complete after provisioning)
  6. Save interview notes โ†’ POST /api/kb { text_content, type: 'interview_notes' } (uses provisionalId as user_id)

Note: The claim link is not generated on provision โ€” it's generated on first "Email Claim Link" click (see T4).

All operations use the provisioned account's _id.toString() as user_id.


๐Ÿ”ง T3 Implementation Plan (fleshed out 2026-06-13)

Phase 1 โ€” New Preference Fields (pre-requisite)

Three fields don't exist in the Preference model yet. These must be added before the forms can render them:

New field Type Purpose
preferred_functions string[] Functional areas (e.g. "Engineering", "Product")
preferred_levels ('senior' | 'staff' | 'principal' | 'director' | 'vp' | 'c_suite')[] Seniority levels
search_status 'active' | 'passive' | 'not_searching' Candidate availability

Files: src/types/preference.d.ts, src/models/Preference.ts.

Phase 2 โ€” Admin API Endpoints

All endpoints in src/app/api/admin/provision/[[...slug]]/route.ts using the standard ApiRouter + [[...slug]] pattern (inherits admin gating from /api/admin prefix).

Route Method Purpose
/ GET List all provisioned accounts
/ POST Create provision โ†’ creates account + candidate (profile_status: 'draft') + optional prefs/KB in one call
/{id} GET Get single account with full details
/{id} PUT Update editable fields (name, email)
/{id} DELETE Remove (only if !claimed)
/{id}/send-claim POST Generate JWT + send invite (full wiring deferred to T4; stub returns JWT for now)
/{id}/preferences GET Fetch preferences for the provisional user_id
/{id}/preferences PUT Save/replace preferences for the provisional user_id

The preferences sub-endpoints are needed because the main /api/preferences derives user_id from session โ€” but for provision, we need to save under the provisional ID. Admin auth gates ensure only recruiters can use this override.

Phase 3 โ€” Frontend Pages

All pages follow the existing admin pattern: server page fetches data โ†’ passes _prefixed props โ†’ client component manages state + API calls.

File Purpose
src/app/admin/provision/page.tsx Server: calls provisionedAccountService.getAll(), passes to client
src/app/admin/provision/client.tsx Client: table with name / email / status / actions. "New" button โ†’ /admin/provision/new
src/app/admin/provision/[id]/page.tsx Server: fetches single account + related data (candidate, prefs, KBs), passes as props
src/app/admin/provision/[id]/client.tsx Client: detail form with independent panels (Contact Info, Background, Interview Notes, Preferences). Each panel has its own save. Bottom bar: "Provision Account" (orchestrates all dirty panels), "Email Claim Link", "Delete"
src/app/admin/provision/new/page.tsx Redirect to [id] with a "new" mode, or render the detail form in create mode

Phase 4 โ€” Refactor PreferencesForm to Shared Component

The existing preferences-form.tsx (681 lines) in the onboarding path will be extracted into a shared component at src/components/preferences/preferences-form.tsx. Both the onboarding page and the admin provision page will import from there.

Shared component API:

interface PreferencesFormProps {
  className?: string;
  showTitle?: boolean;
  title?: string;
  onNext?: () => void;
  initialPreferences?: Partial<IPreference>;
  userId?: string;       // NEW: when set, calls admin API endpoints for fetch/save
  mode?: 'onboarding' | 'provision';  // NEW: controls which fields render (default 'onboarding')
}
Prop onboarding mode provision mode
mode 'onboarding' (default) 'provision'
userId not set โ†’ uses session auth set to provisionalId โ†’ calls admin endpoints
Fields rendered Existing: work_modes, preferred_stages, location, comp, start_date, role_type All existing + 3 new: preferred_functions, preferred_levels, search_status
Save endpoint PUT /api/preferences PUT /api/admin/provision/{userId}/preferences
Fetch endpoint GET /api/preferences GET /api/admin/provision/{userId}/preferences

Files:

File Change
src/components/preferences/preferences-form.tsx NEW โ€” extracted shared form with mode + userId props
src/app/onboarding/(steps)/preferences/preferences-form.tsx Trims to a thin re-export wrapper: export { PreferencesForm } from '@/components/preferences/preferences-form' (or just imports it directly in step-preferences.tsx)
src/app/onboarding/(steps)/background/linkedin-background-form.tsx Add optional userId prop. When set, LinkedIn scraping and KB saves use the provided user_id instead of session.
src/app/onboarding/(steps)/background/resume-bg-form.tsx Already accepts uploads with arbitrary user_id โ€” no changes needed.

Shared onboarding components that DON'T need modification:

  • MultiLocationInput โ€” pure UI component, works as-is
  • Icons from @/components/icons/preferences.tsx โ€” works as-is
  • Config constants (WORK_MODES, PREFERRED_STAGES, etc.) from @/config/preference-constants โ€” works as-is

Phase 5 โ€” "Provision Account" Orchestration

Initial create (POST /api/admin/provision):

  1. ProvisionedAccountService.create({ name, email }) โ†’ provisionalId
  2. candidateService.createCandidate({ user_id: provisionalId, profile_status: 'draft' }) โ€” bare-minimum candidate record
  3. If preferences provided in body โ†’ save with user_id = provisionalId
  4. If interview notes provided โ†’ knowledgeBaseService.saveUserInputKB(provisionalId, notes, 'interview_notes')
  5. If LinkedIn username provided โ†’ fire linkedInProfileService.fetchProfile({ username }) + structuralDataProcessingService.processStructuralData(...) โ€” async, fire-and-forget
  6. Return { provisionedAccount, candidateId }

Detail page edits โ€” each panel submits independently to its respective endpoint. The provisional _id is always the user_id. No monolithic save-all; the "Provision Account" button on edit mode is really just "Save All" โ€” it calls each dirty panel's save and aggregates results.

LinkedIn enrichment on edit โ€” calling candidate-profile-breakdown on edit is probably unnecessary if the profile hasn't changed. The background panel can just update the LinkedIn username; the full enrichment only fires on initial create.

Background context for profile breakdown โ€” joins: LinkedIn profile JSON + user_input KB text + interview_notes KB text.

Status display logic:

Draft   = !claim_invite_sent && !claimed
Invited =  claim_invite_sent && !claimed
Claimed =  claimed

โœ… Resolved: ApiRouter nested path support โ€” Verified: addRoute strips leading / (line 507), so /{id}/preferences โ†’ ['{id}', 'preferences'] matches slug ['someId', 'preferences'] correctly. Will add a routing test case for nested paths.

โœ… Resolved: PreferencesForm refactor โ€” Extracting to src/components/preferences/preferences-form.tsx with mode prop. New fields render only in 'provision' mode. Onboarding imports from shared component with mode='onboarding' (default).


T4 โ€” SendGrid Email Template & Wiring

What: Create a SendGrid dynamic transactional template for the claim invitation email, and wire it to the "Email Claim Link" button on the T3 detail view.

SendGrid template:

  • Template ID stored in an env var (SENDGRID_CLAIM_TEMPLATE_ID or similar)
  • Dynamic variables: {{name}}, {{claim_link}}, {{recruiter_name}}, {{company_name}}
  • Copy should: greet the candidate, explain they've been provisioned, and include a CTA button linking to the claim URL
  • Use SendGrid's drag-and-drop or code-based editor

Integration (new service or existing email service):

  • New function: sendClaimInviteEmail(provisionedAccount, claimLink)
    • Calls SendGrid's /v3/mail/send with the template ID and dynamic template data
    • Sets to: to the provisioned account email, from: to the recruiter's email or a default
  • Wire into the POST /api/admin/provision/[id]/send-claim endpoint (the "Email Claim Link" button's backend handler):
    1. Call ProvisionedAccountService.generateClaimJwt(id) โ†’ returns a JWT encoding the provision record ID + expiration
    2. Construct the claim URL (using an env var for base URL): {BASE_URL}/claim?token={jwt}
    3. Call sendClaimInviteEmail(account, claimUrl)
    4. Call ProvisionedAccountService.markInviteSent(id)
    5. Return success to the frontend

Files to create/modify:

  • src/services/email.ts (new) or add to existing email service
  • src/app/api/admin/provision/[[...slug]]/route.ts (or wherever the "Email Claim Link" endpoint lives) .env.exampleโ€” addSENDGRID_CLAIM_TEMPLATE_IDandCLAIM_BASE_URL`

โœ… T5 โ€” Admin Candidate Search: Cross-Search Knowledge Base (DONE)

What: When the admin searches with the search parameter, also run vector search against knowledge chunks and merge results as a union. The search results table was also refactored and optimized for better usability.

Backend changes in candidate-search.service.ts:

  1. When search text is provided, runs knowledgeChunkService.searchChunks(query, 200) across ALL knowledge chunk types (resume, user_input, interview_notes)
  2. Extracts unique user_ids from vector results, builds a Map<userId, KnowledgeSnippet[]> with truncated snippet + score + type
  3. Runs the normal MongoDB $text query on CandidateSearchIndex with a large limit (10K, matching the exclude_test_accounts pattern)
  4. Fetches any vector-matched candidates not in the $text result set (with all non-text filters applied)
  5. Unions both sets by user_id, sorts, paginates in-memory
  6. Attaches knowledgeMatches metadata per candidate in the response

Supporting changes:

  • src/services/db/knowledge-chunk.ts โ€” VectorSearchResult now includes user_id and type in both Atlas and fallback paths
  • src/app/api/admin/search/candidates/route.ts โ€” passes knowledgeMatches through in the API response

Frontend โ€” component extraction (refactored from ~1922 โ†’ ~1616 lines in client.tsx):

New file Role
src/app/admin/search/search-utils.tsx Extracted getUserName() and highlightText()
src/app/admin/search/knowledge-match-badge.tsx Match badge with hover tooltip (anchored bottom-right, 300px min-width, shows relevance score + snippet)
src/app/admin/search/search-pagination.tsx Pagination controls (replaces 2 inline copies)
src/app/admin/search/search-results-table.tsx Full results table with all 13 columns, sort controls, and row rendering

Search results table optimizations:

Change Detail
LinkedIn column Replaced text link with LinkedIn icon (#0A66C2) + native tooltip on hover; click opens profile
Header labels Shortened: Status, Role Type (Mgr/IC), YOE, Li, Match
Date format Availability dates use MM/DD/YY
Name column max-w-[150px] with text truncation + ellipsis + full-name tooltip
Actions column Sticky right-0 z-10 with left-edge shadow so it's always visible when scrolling horizontally

No new API endpoint โ€” backend enhancement to the existing GET /api/admin/search/candidates.

Scaling note: The in-memory merge fetches all $text matches (up to 10,000) before paginating. OK for current scale (hundreds) and near-term (thousands); revisit pagination strategy if the candidate pool grows beyond that.


โœ… T6 โ€” Admin Search: New Structured Filters (DONE)

What: Added 3 recruiting-specific filters (preferred_functions, preferred_levels, search_status) to the candidate search pipeline โ€” model, index builder, query engine, API, and admin UI.

Filters implemented:

Filter Field Type Query
Function preferred_functions string[] $in โ€” match any selected function
Level preferred_levels string[] $in โ€” match any selected level
Search status search_status string Exact match

Deferred to separate task (was T11):

  • Comp expectations โ€” target_comp_ranges_jsonb is freeform text/JSON in the provision form, cannot support structured range filtering without first restructuring the field. Remains text-searchable via preference_text (already working).
  • Company stage preference โ€” already exists as preferred_stages (filterable since T5). No new work needed.
  • Remote preference โ€” already exists as work_modes (done).

Files changed:

File Change
src/types/candidate-search-index.d.ts Added preferred_functions, preferred_levels, search_status
src/models/CandidateSearchIndex.ts Added schema fields + indexes + text search weights
src/services/db/profile-cache-triggers.ts Added 4 new fields to preferences_enhanced trigger mapping
src/types/candidate-profile-cache.d.ts Added 4 new fields to profile cache type
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 for text search
src/services/db/candidate-search.service.ts Added to CandidateSearchFilters + buildQuery for $in/exact-match filters
src/app/api/admin/search/candidates/route.ts Parses 3 new query params
src/app/admin/search/page.tsx Parses same params server-side for SSR
src/app/admin/search/client.tsx State vars, URL building, fetch params, reset, filter UI (MultiSelect + Select)
src/config/preference-constants.ts Added PREFERRED_FUNCTIONS, PREFERRED_LEVELS, SEARCH_STATUS_OPTIONS constants + values arrays + types
src/components/preferences/preferences-form.tsx Refactored to use shared constants instead of hardcoded arrays

Post-deploy: Rebuild all search indices to backfill the new fields for existing candidates.


โœ… T7 โ€” Candidate Claim Flow (Landing Page + user_id Migration) (DONE)

What: The end-to-end claim experience, from when the candidate clicks the claim link through Auth0 authentication to the user_id migration. Combines the public-facing claim landing page with the backend migration logic.

Full flow:

Candidate clicks link โ†’ /claim?token={jwt}
        โ”‚
        โ–ผ
  Server: validate JWT, set provision_record_id cookie, redirect to /claim
        โ”‚
        โ”œโ”€โ”€ Invalid/expired โ†’ show error page with "Contact your recruiter" msg
        โ”‚
        โ”œโ”€โ”€ Already claimed โ†’ message saying account already claimed, link to log in
        โ”‚
        โ”œโ”€โ”€ Already logged in (different account) โ†’ "This page is for new accounts only"
        โ”‚     + logout button + mailto:support@qwestly.com fallback
        โ”‚
        โ””โ”€โ”€ Valid โ†’ show claim form with candidate name/email
                        โ”‚
                        โ”œโ”€โ”€โ”€ "Create account with password"
                        โ”‚         โ”‚
                        โ”‚         โ–ผ
                        โ”‚    Sign-up form (name/email pre-filled, disabled)
                        โ”‚    On submit: POST /api/claim/create-password-account
                        โ”‚         โ”‚
                        โ”‚         โ–ผ
                        โ”‚    1. Create user in Auth0 (get auth0UserId)
                        โ”‚    2. Run cross-collection migration (provisional โ†’ Auth0 ID)
                        โ”‚    3. Call ProvisionedAccountService.claim(provisionalId, auth0UserId)
                        โ”‚    4. Redirect to /auth/login (user logs in with new password)
                        โ”‚    5. beforeSessionSaved fires โ†’ session set โ†’ redirect to /
                        โ”‚
                        โ””โ”€โ”€โ”€ "Continue with Google / LinkedIn / etc."
                                  โ”‚
                                  โ–ผ
                            Redirect to /auth/login?connection=google-oauth2
                            (provision_record_id cookie already set by server)
                                  โ”‚
                                  โ–ผ
                            User authenticates with provider
                                  โ”‚
                                  โ–ผ
                            beforeSessionSaved callback fires
                                  โ”‚
                                  โ”œโ”€โ”€ Check for provision_record_id cookie
                                  โ”‚   (no cookie โ†’ normal login, skip)
                                  โ”‚
                                  โ””โ”€โ”€ Cookie found โ†’
                                        POST /api/claim/confirm
                                        Body: { provisionalId, auth0Id }
                                        โ†’ runs migration + marks claimed
                                        โ†’ returns { authorized, has_completed_signup }
                                        โ†’ siteToken generated with correct auth status

Three API endpoints (single [[...slug]] route file):

Endpoint Method Purpose
/api/claim?token=... GET Decodes JWT, returns { name, email, provisionalId } if valid; 400/404/410 for invalid/expired/claimed
/api/claim/create-password-account POST Creates Auth0 password account, runs migration, marks claimed. Returns success โ†’ client redirects to /auth/login
/api/claim/confirm POST Called by beforeSessionSaved for social flow. Runs migration, marks claimed, returns { authorized, has_completed_signup }

Claim landing page UI (/claim):

  • Public route โ€” no auth middleware, registered in UNAUTHENTICATED_PAGES
  • Minimal layout โ€” centered card, no header nav / sidebar
  • JWT-in-URL โ†’ validated server-side โ†’ provision_record_id cookie set โ†’ redirect to clean /claim
  • UI states:
    1. Loading โ€” suspense fallback during server fetch
    2. Invalid/expired token โ€” error message + "contact your recruiter"
    3. Already claimed โ€” message + link to /auth/login
    4. Session mismatch โ€” "You're already logged in" + Logout button + mailto:support@qwestly.com fallback
    5. Claim form โ€” password form (name/email pre-filled + disabled, reuses SignupFormCard) + social login buttons

Auth0 integration:

  • Password flow: creates Auth0 user via Management API โ†’ runs migration โ†’ redirects to /auth/login (same pattern as existing password signup โ€” user logs in with the password they just chose)
  • Social flow: provision_record_id cookie set by server component โ†’ beforeSessionSaved detects it โ†’ calls POST /api/claim/confirm โ†’ migration runs โ†’ authorization check returns { authorized, has_completed_signup } โ†’ siteToken generated
  • Cookie: provision_record_id, HTTP-only, sameSite: lax, 1-hour expiry, cleared after successful claim
  • Post-migration login for password accounts follows the existing signup pattern (no programmatic login โ€” see feature doc for deferred Option B)

Actual files created:

File Purpose
src/app/claim/page.tsx Server component โ€” JWT validation, cookie set, redirect to clean URL, session mismatch detection
src/app/claim/client.tsx Client component โ€” 5 UI states, reuses SignupFormCard from signup flow
src/app/api/claim/[[...slug]]/route.ts All 3 claim endpoints in one ApiRouter file
src/services/db/claim-migration.ts Idempotent cross-collection migration โ€” swaps user_id/candidate_id from provisional โ†’ Auth0 ID across 24 collections

Actual 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

Migration design (see src/services/db/claim-migration.ts):

  • Idempotent โ€” each updateMany filters on { user_id: provisionalId }, so retries skip already-migrated docs. Safe to re-run on failure.
  • Sequential โ€” runs updateMany on 24 collections one at a time. A failure at collection N means 0..N-1 are already migrated; re-running picks up from N.
  • No transaction โ€” simpler than multi-document transactions. The window where collections are split between old/new IDs is acceptable because the candidate has no active session during migration.
  • Dual field support โ€” handles both user_id (21 collections) and candidate_id (3 collections: v_candidate_profile_cache_enhanced, qwestly_card_sections, qwestly_card_anonymity_mapping)

Resolved decisions (see docs/features/provisioned-account.md T7 section):

  • Password login: follows existing signup pattern (redirect to /auth/login); Option B (programmatic login via password grant) deferred
  • Migration: idempotent design, no MongoDB transaction needed
  • Collection names: use actual names (with _enhanced suffixes)
  • JWT in URL: stored in cookie after first validation, redirect to clean URL
  • Session mismatch: detected on claim page, shows logout + mailto fallback
  • Email mismatch in social flow: allowed โ€” no enforcement

โœ… T8 โ€” Candidate Claim Confirmation Page + Anonymity Toggle (DONE)

What: The page the candidate sees after claiming their account (first login). Skips the standard onboarding flow since the recruiter already collected preferences.

Flow:

  1. Confirm pre-populated preferences (read-only with edit option)
  2. View Qwestly card (pre-generated from LinkedIn + resume)
  3. Upload/edit resume if needed
  4. Publish vs. stay anonymous toggle/CTA โ€” Decide how the candidate appears in search results:
    • Show de-anonymized profile first with explanatory copy (Adam โ€” D2)
    • Checkbox with context explaining anonymization for broader role sharing
    • Two modes:
      • Direct referral: de-anonymized โ†’ specific HM
      • General matching: anonymized โ†’ shared until interest confirmed

Implementation (2026-06-13):

Page: /claim/confirmation โ€” authenticated, 4-section layout.

Section Component
1. Preferences Read-only summary with "Edit" button โ†’ opens PreferencesForm in mode='provision'
2. Qwestly Card QwestlyCardView / QwestlyCardEditor toggle (reused from onboarding review step)
3. Resume Current resume link + upload button (TBD โ€” resume upload deferred)
4. Anonymity Switch: De-anonymized (direct referral) vs Anonymized (general matching). Saves to Preference.anonymized_for_search. Placeholder copy (Adam will update).

Publish: Saves anonymity preference โ†’ sets profile_status: 'active' โ†’ redirects to dashboard.

Routing: Dashboard guard redirects provisioned draft users to /claim/confirmation instead of onboarding. T7 claim flow returnTo URLs point to /claim/confirmation.

Data: New field anonymized_for_search on Preference (boolean, default false). New service method getByClaimedAuth0Id on ProvisionedAccountService.

Files created:

  • src/app/claim/confirmation/page.tsx โ€” server component
  • src/app/claim/confirmation/client.tsx โ€” client component with 4 sections

Files modified:

  • src/app/dashboard/page.tsx โ€” provisioned user detection
  • src/app/claim/client.tsx โ€” returnTo URLs
  • src/models/Preference.ts โ€” anonymized_for_search field
  • src/types/preference.d.ts โ€” type def
  • src/services/db/provisioned-account.ts โ€” getByClaimedAuth0Id

TBD: Publish notifications (email/Slack webhook) โ€” will add in a fast-follow.


โšก Fast Follow: Within First Two Weeks

T9 โ€” Structured Notes with Type Selector

  • Add required "note type" dropdown (Screening Call, HM Interview, Reference Check, etc.)
  • Store as metadata on the KB entry: parsed_data[0].metadata.note_type
  • Could also be a separate array field on the interview_notes KB for multiple notes with different types

T10 โ€” Skill Tags & Sub-Specialties at Intake

  • Add skill tags + sub-specialty fields to the onboarding page (T3)
  • Wire into existing skill_tags search filter
  • Add sub_specialties as a new filterable/searchable field if distinct

T11 โ€” Structured Comp Expectations Filter

  • target_comp_ranges_jsonb is currently freeform text/JSON in the provision form (placeholder: base: 200k-250k, equity: 0.5-1.0%)
  • Needs a separate effort to restructure the field into structured min/max ranges before it can be used as a search filter
  • Once restructured, add as a numeric range filter (buckets: $0-150k, $150-250k, $250-500k, $500k+)
  • Was scoped as part of T6 but deferred due to the data structure dependency

T12 โ€” Recruiter-Queued Template Questions

  • Recruiter queues standard questions post-call
  • Candidate-side UI to complete them
  • New data model with question text, candidate response, status

๐Ÿ“‹ Backlog: After Initial Feedback Loop

  • Consent scoped to one role + one HM
  • Candidate explicitly consents before de-anonymization

T14 โ€” "Stay Visible" Opt-In at Search Close

T15 โ€” HM Submission View

  • Executive summary + yes/no feedback
  • Anonymized vs. de-anonymized per submission

T16 โ€” Search Dashboard & Pipeline Tracking

  • Pipeline stages, tracking, submission statuses
  • Replace Google Sheet

โ“ Open Decisions

# Decision Owner
D1 Anonymization UX order โœ… Resolved โ€” see T8: show de-anonymized profile first with explanatory copy
D2 Copy for anonymization checkbox and context Adam โ€” needed for T8
D3 Candidate field table: candidate-editable vs. recruiter-only, multi-level selections โœ… Resolved โ€” T6 implemented preferred_functions, preferred_levels, search_status as distinct recruiter-set preference fields

All Collections Using user_id (for T1, T7 & delete-cascade reference)

These all get the provisioned account _id initially and are migrated to Auth0 ID on claim. The same set is used for cascade deletion when an unclaimed provisioned account is removed from the admin panel.

Collection user_id constraint
candidates unique, indexed
preferences unique, indexed
knowledge_base indexed
knowledge_chunk indexed
uploads indexed
candidate_search_index_enhanced unique, indexed
employment_stints indexed
achievements indexed
education indexed
challenges indexed
leadership_quotes indexed
first_interview_context unique
interviews indexed
interview_events indexed
candidate_summaries indexed
candidate_profile_cache candidate_id, unique
qwestly_card_sections candidate_id (dual-key with linkedin_username)
qwestly_card_anonymity_mapping candidate_id (dual-key)
chatbot_sessions indexed
chatbot_messages indexed
feedback unique, indexed
tested_interviews indexed
network_connections indexed
linkedin_profile_suggestions optional (nullable)