Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / frontend/src/lib/workflow.ts |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | frontend/src/lib/workflow.ts |
| Original SHA-256 | 88a3b16859a6488a7353deb1a93b0ebf7de500c2e1de56a28925a1b6d00edb5e |
| Git blob SHA | 58475679ba88f0e511cd85565eb739b4deb2800e |
| Reading-copy SHA-256 | 88a3b16859a6488a7353deb1a93b0ebf7de500c2e1de56a28925a1b6d00edb5e |
Recovered source transcript
Download complete source text ↓372 lines · 13.6 KB
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<T> {
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<string, number>
dropped?: Record<string, number>
retained_on_the_merged_record?: string[]
note?: string
}
export const workflowApi = {
// ---- search ----
search: <T>(q: CandidateQuery) =>
request<CandidateSearchResult<T>>('/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: <T>(id: string, limit = 50, offset = 0) =>
request<CandidateSearchResult<T>>(
`/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<BulkResult>(`/candidate-lists/${encodeURIComponent(listId)}/members`, {
method: 'POST',
body: JSON.stringify({ candidate_ids, note }),
}),
removeFromList: (listId: string, candidate_ids: string[]) =>
request<BulkResult>(`/candidate-lists/${encodeURIComponent(listId)}/members`, {
method: 'DELETE',
body: JSON.stringify({ candidate_ids }),
}),
bulkAssignOwner: (candidate_ids: string[], owner_user_id: string | null) =>
request<BulkResult>('/candidates/bulk/owner', {
method: 'POST',
body: JSON.stringify({ candidate_ids, owner_user_id }),
}),
// ---- who is working what ----
workActivity: (type: string, id: string) =>
request<WorkActivity>(`/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<WorkActivity>(`/work/${type}/${encodeURIComponent(id)}/touch`, { method: 'POST' }),
claim: (type: string, id: string, activity: string, note: string, steal = false) =>
request<WorkActivity>(`/work/${type}/${encodeURIComponent(id)}/claim`, {
method: 'POST',
body: JSON.stringify({ activity, note, steal }),
}),
release: (type: string, id: string) =>
request<WorkActivity>(`/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<MessageTemplate, 'id' | 'status'>) =>
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<Message>('/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<MergeResult>('/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<CandidateDocument> {
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