import { request, csrfForUpload } from './api' // THE RECRUITER'S DAILY SURFACES: search, saved searches, lists, bulk actions, who is working what, // candidate documents, correspondence, and duplicate merge. // // One domain module rather than seven, because a recruiter's screen touches most of them at once and seven // files of six lines each is filing rather than structure. export interface CandidateQuery { text?: string clearance_levels?: string[] clearance_status?: string[] skills?: string[] location?: string available?: boolean remote?: boolean relocatable?: boolean min_years?: number max_years?: number owner_user_id?: string unowned?: boolean list_id?: string exclude_list_id?: string clearance_expiring_days?: number access_postures?: string[] limit?: number offset?: number } // CandidateSearchRow mirrors the server's result row, which is a DELIBERATE SUBSET of the stored candidate // rather than the whole record. The server used to return the table row verbatim, which shipped the resume // embedding and the search index on every line of a two hundred row page, and shipped the email address // regardless of the organization's contact setting. export interface CandidateSearchRow { id: string full_name: string headline?: string clearance_level: string clearance_poly?: string clearance_status: string years_experience: number location?: string remote: boolean relocatable: boolean available: boolean source: string owner_user_id?: string email?: string // contact_redacted distinguishes "hidden from you" from "never captured". Without it a missing email // renders the same either way, and a recruiter goes looking for an address that was there all along. contact_redacted?: boolean // The break in access posture: whether this cleared person can still be reinstated on a new sponsor's // paperwork, or has passed the twenty four month line and now needs an investigation. access?: AccessIntel } // AccessIntel mirrors intelligence.AccessIntel. It is an INDICATOR derived from dates somebody entered, never // an eligibility determination: that belongs to the hiring client's facility security officer through DISS. export interface AccessIntel { posture: 'in_access' | 'reinstatable' | 'closing' | 'investigation_likely' | 'unknown' label: string detail: string months_out_of_access?: number days_left_in_window?: number adjudication_stale?: boolean } // The postures, with what each one means for a desk. unknown is deliberately worded as a gap in the RECORD // rather than a problem with the person, because most benches start there and a product that painted its // whole dataset as risky would be crying wolf. export const ACCESS_POSTURES: { value: string; label: string; hint: string }[] = [ { value: 'in_access', label: 'In access', hint: 'currently sponsored, no clock running' }, { value: 'reinstatable', label: 'Reinstatable', hint: 'inside the 24 month window' }, { value: 'closing', label: 'Window closing', hint: '90 days or less left of the window' }, { value: 'investigation_likely', label: 'Needs an investigation', hint: 'past the window, or a stale adjudication' }, { value: 'unknown', label: 'Not known', hint: 'nobody has recorded their access history' }, ] // The colour each posture wears. Unknown is MUTED, never a warning colour. export function accessColor(p: string): string { if (p === 'in_access') return 'var(--color-ok)' if (p === 'reinstatable') return 'var(--color-blue-soft)' if (p === 'closing') return 'var(--color-warn)' if (p === 'investigation_likely') return 'var(--color-risk)' return 'var(--color-faint)' } export interface CandidateSearchResult { candidates: T[] total: number limit: number offset: number // scope_applied tells the screen that record scope narrowed this answer, so a short list can be EXPLAINED // rather than read as an empty bench. Without it the interface would have to guess, and a recruiter who // sees nothing assumes the system is broken. scope_applied: boolean } export interface SavedSearch { id: string name: string visibility: 'private' | 'shared' pinned: boolean owner_user_id: string owner_name?: string criteria: CandidateQuery last_run_at?: string created_at: string } export interface CandidateList { id: string name: string description?: string purpose: 'shortlist' | 'bench' | 'pipeline' | 'do_not_contact' | 'other' visibility: 'private' | 'shared' status: 'active' | 'archived' owner_user_id?: string owner_name?: string members?: number created_at?: string } export interface BulkResult { requested: number applied: number skipped: number note?: string } export interface WorkClaim { id: string entity_type: string entity_id: string user_id: string user_name: string activity: string note: string claimed_at: string expires_at: string mine: boolean } export interface WorkTouch { user_id: string user_name: string last_seen_at: string mine: boolean } export interface WorkActivity { entity_type: string entity_id: string claim: WorkClaim | null touches: WorkTouch[] desk_owner_user_id?: string desk_owner_name?: string visible_to_you: boolean claim_required: boolean } export interface CandidateDocument { id: string candidate_id: string document_type: string file_name: string media_type: string size_bytes: number sha256: string uploaded_by_name?: string is_current: boolean created_at: string } export interface Message { id: string candidate_id?: string contact_id?: string to_address: string from_address: string subject: string body: string status: 'queued' | 'sent' | 'failed' error_text?: string sent_by_name?: string created_at: string sent_at?: string } export interface MessageTemplate { id: string name: string audience: 'candidate' | 'contact' subject: string body: string status: 'active' | 'archived' } export interface DuplicatePair { a_id: string a_name: string b_id: string b_name: string reason: string strength: 'strong' | 'weak' } export interface MergeResult { kept_id: string merged_id: string moved: Record dropped?: Record retained_on_the_merged_record?: string[] note?: string } export const workflowApi = { // ---- search ---- search: (q: CandidateQuery) => request>('/candidates/search', { method: 'POST', body: JSON.stringify(q), }), savedSearches: () => request<{ saved_searches: SavedSearch[]; count: number }>('/saved-searches'), saveSearch: (name: string, criteria: CandidateQuery, visibility: 'private' | 'shared', pinned: boolean) => request<{ id: string }>('/saved-searches', { method: 'POST', body: JSON.stringify({ name, criteria, visibility, pinned }), }), runSavedSearch: (id: string, limit = 50, offset = 0) => request>( `/saved-searches/${encodeURIComponent(id)}/run?limit=${limit}&offset=${offset}`, { method: 'POST' }, ), deleteSavedSearch: (id: string) => request<{ deleted: boolean }>(`/saved-searches/${encodeURIComponent(id)}`, { method: 'DELETE' }), // ---- lists ---- lists: () => request<{ lists: CandidateList[]; count: number }>('/candidate-lists'), listsForCandidate: (id: string) => request<{ lists: CandidateList[]; count: number }>(`/candidates/${encodeURIComponent(id)}/lists`), createList: (name: string, purpose: string, description = '') => request<{ id: string }>('/candidate-lists', { method: 'POST', body: JSON.stringify({ name, purpose, description }), }), addToList: (listId: string, candidate_ids: string[], note = '') => request(`/candidate-lists/${encodeURIComponent(listId)}/members`, { method: 'POST', body: JSON.stringify({ candidate_ids, note }), }), removeFromList: (listId: string, candidate_ids: string[]) => request(`/candidate-lists/${encodeURIComponent(listId)}/members`, { method: 'DELETE', body: JSON.stringify({ candidate_ids }), }), bulkAssignOwner: (candidate_ids: string[], owner_user_id: string | null) => request('/candidates/bulk/owner', { method: 'POST', body: JSON.stringify({ candidate_ids, owner_user_id }), }), // ---- who is working what ---- workActivity: (type: string, id: string) => request(`/work/${type}/${encodeURIComponent(id)}`), // touch is the call a detail screen makes on open: it records the visit AND returns the activity, so a // page load is one request rather than two. touch: (type: string, id: string) => request(`/work/${type}/${encodeURIComponent(id)}/touch`, { method: 'POST' }), claim: (type: string, id: string, activity: string, note: string, steal = false) => request(`/work/${type}/${encodeURIComponent(id)}/claim`, { method: 'POST', body: JSON.stringify({ activity, note, steal }), }), release: (type: string, id: string) => request(`/work/${type}/${encodeURIComponent(id)}/claim`, { method: 'DELETE' }), myClaims: () => request<{ claims: WorkClaim[]; count: number }>('/work/mine'), // ---- candidate documents ---- documents: (candidateId: string) => request<{ documents: CandidateDocument[]; count: number }>( `/candidates/${encodeURIComponent(candidateId)}/documents`, ), supersedeDocument: (docId: string) => request<{ superseded: boolean }>(`/candidate-documents/${encodeURIComponent(docId)}/supersede`, { method: 'POST', }), // The download is a browser navigation rather than a fetch, so the session cookie rides along and the // Content-Disposition attachment header does its job. Fetching it into memory would defeat both. documentURL: (docId: string) => `/api/candidate-documents/${encodeURIComponent(docId)}/content`, // ---- correspondence ---- templates: () => request<{ templates: MessageTemplate[]; count: number; placeholders: string[] }>('/message-templates'), createTemplate: (t: Omit) => request<{ id: string }>('/message-templates', { method: 'POST', body: JSON.stringify(t) }), previewMessage: (v: { candidate_id?: string; contact_id?: string; template_id?: string; subject?: string; body?: string }) => request<{ to_address: string; subject: string; body: string; sendable: boolean; note: string }>( '/message-templates/preview', { method: 'POST', body: JSON.stringify(v) }, ), sendMessage: (v: { candidate_id?: string; contact_id?: string; template_id?: string; subject?: string; body?: string; requisition_id?: string }) => request('/messages', { method: 'POST', body: JSON.stringify(v) }), candidateMessages: (id: string) => request<{ messages: Message[]; count: number }>(`/candidates/${encodeURIComponent(id)}/messages`), // ---- duplicates ---- duplicates: () => request<{ duplicates: DuplicatePair[]; count: number; note: string }>('/candidates/duplicates'), merge: (keep_id: string, merge_id: string, reason: string) => request('/candidates/merge', { method: 'POST', body: JSON.stringify({ keep_id, merge_id, reason }), }), } // uploadCandidateDocument uses XMLHttpRequest rather than fetch for the same reason the opportunity document // intake does: fetch has no upload progress event, and a recruiter attaching a scanned certification over a // hotel connection needs to see that something is happening. It still sends the double submit token, taken // from the one place that knows how the token is obtained. export async function uploadCandidateDocument( candidateId: string, file: File, documentType: string, onProgress?: (pct: number) => void, ): Promise { const token = await csrfForUpload() const form = new FormData() form.append('document_type', documentType) form.append('file', file) return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest() xhr.open('POST', `/api/candidates/${encodeURIComponent(candidateId)}/documents`) xhr.setRequestHeader('X-CSRF-Token', token) xhr.withCredentials = true xhr.upload.onprogress = (e) => { if (e.lengthComputable && onProgress) onProgress(Math.round((e.loaded / e.total) * 100)) } xhr.onload = () => { let body: unknown try { body = JSON.parse(xhr.responseText) } catch { body = null } if (xhr.status >= 200 && xhr.status < 300) { resolve(body as CandidateDocument) return } // The server's own sentence is surfaced. "That file type is not accepted, send a PDF, Word document, // RTF, text or image" is actionable; "upload failed" is not. const msg = (body as { error?: string } | null)?.error ?? `upload failed (${xhr.status})` reject(new Error(msg)) } xhr.onerror = () => reject(new Error('the upload could not reach the server')) xhr.send(form) }) } export const LIST_PURPOSES: { value: CandidateList['purpose']; label: string; hint: string }[] = [ { value: 'shortlist', label: 'Shortlist', hint: 'people put forward for one thing' }, { value: 'bench', label: 'Bench', hint: 'people available now' }, { value: 'pipeline', label: 'Pipeline', hint: 'people being worked' }, // Named separately because it is a compliance artefact, not a convenience. The interface treats it // differently rather than trusting somebody to read the title. { value: 'do_not_contact', label: 'Do not contact', hint: 'people who asked not to be approached' }, { value: 'other', label: 'Other', hint: '' }, ] export const CLAIM_ACTIVITIES = ['working', 'screening', 'submitting', 'interviewing', 'negotiating'] as const