_private/qwestly-docs/Features/provisioned-accounts/recruiting-pilot.md
Table of Contents
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):
- Recruiter provisions an account โ gets a provisional
_idused asuser_ideverywhere - Candidate receives a claim email with a JWT link (via SendGrid)
- Candidate claims the account โ a migration swaps the provisional
user_idwith their real Auth0 ID across all collections - 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.tssrc/types/provisioned-account.d.tssrc/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_idis the provisionaluser_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 linkvalidateClaimJwt(token)โ decodes JWT, looks up by provision record ID, returns the doc if valid and not expiredmarkInviteSent(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:
- Create provisioned account via
ProvisionedAccountService.create({ name, email })โ getprovisionalId - Create candidate record with
user_id = provisionalId,profile_status = 'draft' - Save preferences with
user_id = provisionalId - Upload resume โ
POST /api/uploadwithuser_id = provisionalIdโ thenPOST /api/kb { upload_id, type: 'resume' } - Scrape LinkedIn โ fire off LinkedIn profile enrichment (async, can complete after provisioning)
- 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):
ProvisionedAccountService.create({ name, email })โprovisionalIdcandidateService.createCandidate({ user_id: provisionalId, profile_status: 'draft' })โ bare-minimum candidate record- If preferences provided in body โ save with
user_id = provisionalId - If interview notes provided โ
knowledgeBaseService.saveUserInputKB(provisionalId, notes, 'interview_notes') - If LinkedIn username provided โ fire
linkedInProfileService.fetchProfile({ username })+structuralDataProcessingService.processStructuralData(...)โ async, fire-and-forget - 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_IDor 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/sendwith the template ID and dynamic template data - Sets
to:to the provisioned account email,from:to the recruiter's email or a default
- Calls SendGrid's
- Wire into the
POST /api/admin/provision/[id]/send-claimendpoint (the "Email Claim Link" button's backend handler):- Call
ProvisionedAccountService.generateClaimJwt(id)โ returns a JWT encoding the provision record ID + expiration - Construct the claim URL (using an env var for base URL):
{BASE_URL}/claim?token={jwt} - Call
sendClaimInviteEmail(account, claimUrl) - Call
ProvisionedAccountService.markInviteSent(id) - Return success to the frontend
- Call
Files to create/modify:
src/services/email.ts(new) or add to existing email servicesrc/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:
- When
searchtext is provided, runsknowledgeChunkService.searchChunks(query, 200)across ALL knowledge chunk types (resume,user_input,interview_notes) - Extracts unique
user_ids from vector results, builds aMap<userId, KnowledgeSnippet[]>with truncated snippet + score + type - Runs the normal MongoDB
$textquery onCandidateSearchIndexwith a large limit (10K, matching theexclude_test_accountspattern) - Fetches any vector-matched candidates not in the
$textresult set (with all non-text filters applied) - Unions both sets by
user_id, sorts, paginates in-memory - Attaches
knowledgeMatchesmetadata per candidate in the response
Supporting changes:
src/services/db/knowledge-chunk.tsโVectorSearchResultnow includesuser_idandtypein both Atlas and fallback pathssrc/app/api/admin/search/candidates/route.tsโ passesknowledgeMatchesthrough 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_jsonbis freeform text/JSON in the provision form, cannot support structured range filtering without first restructuring the field. Remains text-searchable viapreference_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_idcookie set โ redirect to clean/claim - UI states:
- Loading โ suspense fallback during server fetch
- Invalid/expired token โ error message + "contact your recruiter"
- Already claimed โ message + link to
/auth/login - Session mismatch โ "You're already logged in" + Logout button +
mailto:support@qwestly.comfallback - 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_idcookie set by server component โbeforeSessionSaveddetects it โ callsPOST /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
updateManyfilters on{ user_id: provisionalId }, so retries skip already-migrated docs. Safe to re-run on failure. - Sequential โ runs
updateManyon 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) andcandidate_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
_enhancedsuffixes) - 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:
- Confirm pre-populated preferences (read-only with edit option)
- View Qwestly card (pre-generated from LinkedIn + resume)
- Upload/edit resume if needed
- 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 componentsrc/app/claim/confirmation/client.tsxโ client component with 4 sections
Files modified:
src/app/dashboard/page.tsxโ provisioned user detectionsrc/app/claim/client.tsxโ returnTo URLssrc/models/Preference.tsโanonymized_for_searchfieldsrc/types/preference.d.tsโ type defsrc/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_notesKB 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_tagssearch filter - Add
sub_specialtiesas a new filterable/searchable field if distinct
T11 โ Structured Comp Expectations Filter
target_comp_ranges_jsonbis 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
T13 โ De-Anonymization Consent Flow
- 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) |