import { csrfForUpload, request } from '../../lib/api' // The Phase 2 review path: documents in, scope reviewed, drafts built, one named human promotes. Shapes are // snake_case to match the Go DTOs exactly, so a field rename on either side fails loudly rather than silently // rendering undefined. export interface OpportunityDocument { id: string document_type: string file_name: string media_type: string size_bytes: number sha256: string extraction_status: string extraction_error_code: string is_current: boolean provenance: string created_at: string } export interface ScopeItem { id: string opportunity_id: string scope_type: string title: string description: string source_document_id: string source_page_number: number source_section_label: string source_quote_hash: string extraction_method: string extraction_confidence: number review_status: string display_order: number created_at: string } export interface RequirementDraft { id: string labor_category: string quantity: number clearance_level: string polygraph: string minimum_years_experience: number work_location: string remote_allowed: boolean required_skills: string[] | null review_status: string approved_requirement_id: string derivation_method: string } const NS = '/opportunities/tracked' const body = (v: unknown) => ({ body: JSON.stringify(v) }) // UPLOAD USES XMLHttpRequest, NOT fetch, for exactly one reason: Section 6 screen 3 requires upload PROGRESS // and fetch cannot report it. A solicitation package is tens of megabytes, so a progressless upload looks // indistinguishable from a hung one, which is how a user learns to distrust a screen. export function uploadDocument( opportunityId: string, file: File, documentType: string, onProgress: (percent: number) => void, ): Promise { return csrfForUpload().then( (token) => new Promise((resolve, reject) => { const form = new FormData() form.append('file', file) form.append('document_type', documentType) const xhr = new XMLHttpRequest() xhr.open('POST', `/api/opportunities/${encodeURIComponent(opportunityId)}/documents`) xhr.withCredentials = true xhr.setRequestHeader('X-CSRF-Token', token) // The boundary is generated by the browser, so Content-Type is deliberately NOT set here. xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) } xhr.onload = () => { if (xhr.status >= 200 && xhr.status < 300) { try { resolve(JSON.parse(xhr.responseText) as OpportunityDocument) } catch { reject(new Error('the server accepted the upload but returned an unreadable body')) } return } // A 413 is the one failure a user can act on, and it is worth naming rather than showing a number. if (xhr.status === 413) { reject(new Error('this file is larger than the server accepts')) return } let detail = `upload failed with status ${xhr.status}` try { const parsed = JSON.parse(xhr.responseText) as { error?: string } if (parsed.error) detail = parsed.error } catch { /* a non JSON error body keeps the status line above */ } reject(new Error(detail)) } xhr.onerror = () => reject(new Error('the upload could not reach the server')) xhr.onabort = () => reject(new Error('the upload was cancelled')) xhr.send(form) }), ) } export const scopeApi = { documents: (oppId: string) => request<{ documents: OpportunityDocument[] }>(`/opportunities/${encodeURIComponent(oppId)}/documents`), scope: (oppId: string, status?: string) => { const qs = status ? `?status=${encodeURIComponent(status)}` : '' return request(`${NS}/${encodeURIComponent(oppId)}/scope${qs}`) }, review: (oppId: string, itemId: string, v: Record) => request(`${NS}/${encodeURIComponent(oppId)}/scope/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', ...body(v), }), addManual: (oppId: string, v: { scope_type: string; title: string; description: string }) => request(`${NS}/${encodeURIComponent(oppId)}/scope/items`, { method: 'POST', ...body(v) }), split: (oppId: string, itemId: string, parts: Array<{ scope_type?: string; title: string; description?: string }>) => request<{ children: ScopeItem[] }>( `${NS}/${encodeURIComponent(oppId)}/scope/items/${encodeURIComponent(itemId)}/split`, { method: 'POST', ...body({ parts }) }, ), merge: ( oppId: string, itemId: string, v: { absorb_item_ids: string[]; title: string; description?: string; scope_type?: string }, ) => request(`${NS}/${encodeURIComponent(oppId)}/scope/items/${encodeURIComponent(itemId)}/merge`, { method: 'POST', ...body(v), }), drafts: (oppId: string) => request(`${NS}/${encodeURIComponent(oppId)}/requirement-drafts`), generateDrafts: (oppId: string) => request<{ created: number; skipped_existing: number }>( `${NS}/${encodeURIComponent(oppId)}/requirement-drafts/generate`, { method: 'POST', body: '{}' }, ), patchDraft: (draftId: string, v: Record) => request>(`/requirement-drafts/${encodeURIComponent(draftId)}`, { method: 'PATCH', ...body(v), }), approveDraft: (draftId: string) => request>(`/requirement-drafts/${encodeURIComponent(draftId)}/approve`, { method: 'POST', body: '{}', }), rejectDraft: (draftId: string, reason: string) => request>(`/requirement-drafts/${encodeURIComponent(draftId)}/reject`, { method: 'POST', ...body({ reason }), }), }