Bulk CV Import — Frontend Integration
Upload a ZIP file containing multiple CVs (PDF, DOC, DOCX, TXT) to import talents in bulk. The backend processes each CV via AI extraction, creates User/Talent/CV records, and deduplicates by email. Progress is streamed in real-time via GraphQL subscriptions.
Architecture
User clicks "Import" → Import Choice Modal
├── Single Import (existing JSON flow)
└── Bulk CV Import (new ZIP flow)
├── Step 1: Upload ZIP to S3 (presigned URL + XHR)
├── Step 2: createBulkCvImport mutation (AI service)
└── Step 3: bulkCvImport subscription (progress + results)
Service Communication
| Step | Target | Protocol | Client |
|---|---|---|---|
| S3 upload URL | Backend | Apollo Client (GENERATE_UPLOAD_URL) | Apollo |
| ZIP upload | S3/MinIO | XHR PUT to presigned URL | XMLHttpRequest |
| Start import | NestJS AI gateway → BG | aiGatewayRequest('createBulkCvImport', …, 'mutation') | Apollo (via mutateAi) |
| Progress stream | NestJS AI gateway → BG | subscribeAiOp('bulkCvImport', { importId }, …) | Apollo (via subscribeAi) |
Files Created
| File | Purpose |
|---|---|
lib/hooks/useBulkCvImport.ts | Custom hook: upload → mutation → subscription orchestration |
app/company/resume/talents/_components/BulkCvImportModal.tsx | Modal UI: upload, progress, results views |
app/company/resume/talents/_components/ImportChoiceModal.tsx | Chooser modal: Single Import vs Bulk CV Import |
The standalone GraphQL document constants (graphql/operations/bulkCvImport/mutations.ts and the equivalent subscription file) were removed in the Phase 3.7d cutover — both the createBulkCvImport mutation and the bulkCvImport subscription now live in the backend AI operation registries; the FE hook just sends the operation name + payload through the gateway.
Files Modified
| File | Change |
|---|---|
app/company/resume/talents/page.tsx | Import button opens choice modal; hook at page level for background processing; background progress banner; completion toast |
Hook API: useBulkCvImport()
State Machine
IDLE ──[startImport()]──→ UPLOADING ──→ INITIATING ──→ PROCESSING ──→ COMPLETED
└──→ FAILED
Any step can transition to FAILED on error.
Return Type
interface UseBulkCvImportReturn {
// Phase
phase: "idle" | "uploading" | "initiating" | "processing" | "completed" | "failed"
isProcessing: boolean
// Progress
uploadProgress: number // 0-100 during upload
processingProgress: number // 0-100 during processing
statusMessage: string // Human-readable status
// File counts
totalFiles: number
processedFiles: number
successCount: number
failedCount: number
skippedCount: number
// Results (populated on completion)
results: BulkCvFileResult[]
error: string | null
importId: string | null
// Actions
startImport: (file: File) => Promise<void>
reset: () => void
}
interface BulkCvFileResult {
filename: string
status: string // "SUCCESS" | "FAILED" | "SKIPPED"
userId?: string
talentId?: string
cvId?: string
email?: string
errorMessage?: string
}
GraphQL Operations
Mutation (AI Service)
Returns a plain BulkCvImportProcessing object (NOT a union type). Errors are returned as standard GraphQL errors.
mutation CreateBulkCvImport($input: BulkCvImportInput!) {
createBulkCvImport(input: $input) {
id status message progress totalFiles processedFiles
successCount failedCount skippedCount
}
}
Input: { fileUrl: String!, companyId: String!, userId: String! }
fileUrlis the S3 objectPath (e.g.uploads/2026/03/file.zip), not a full URL.
Subscription (AI Service WebSocket)
subscription BulkCvImport($importId: String!) {
bulkCvImport(importId: $importId) {
... on BulkCvImportProcessing {
id status message progress totalFiles processedFiles
successCount failedCount skippedCount
}
... on BulkCvImportSuccess {
id status totalFiles successCount failedCount skippedCount
processingTimeMs
results { filename status userId talentId cvId email errorMessage }
}
... on BulkCvImportFailure {
id status
error { code message recoveryHint }
}
}
}
The importId for the subscription comes from the id field in the mutation response.
UI Flow
- Import Button → Opens
ImportChoiceModalwith two options - "Bulk CV Import" → Opens
BulkCvImportModal - Upload View (idle): Drag & drop Dropzone for ZIP files, shows selected file info
- Processing View (uploading/initiating/processing): Progress bars, live file counts
- Results View (completed): Summary cards (total/imported/failed/skipped) + per-file results table
- Error View (failed): Error message with "Try Again" button
Background Processing
The useBulkCvImport hook is lifted to the page level (not inside the modal), so the WebSocket subscription survives modal close:
- During processing, the modal shows a "Close & Continue in Background" button
- When the modal is closed while processing, a progress banner appears at the top of the talents page showing:
- File counts (
3/5 files processed) - Imported/failed counters
TWProgressbar with live percentage- "View Details" link to re-open the modal
- File counts (
- When processing completes in the background, a toast notification fires with summary counts and a "View Results" button to re-open the modal with the results view
- The talent list is auto-refetched on successful background completion
Error Handling
Client-Side Validation
- Non-ZIP files rejected immediately
- Files > 500 MB rejected
- Missing company context shows error
Server Errors
- Mutation errors (invalid ZIP, no CV files, too many files) displayed in error view
- Subscription errors (network, timeout) show toast + error view
Billing Errors
showBillingToastIfNeeded()checks forINSUFFICIENT_CREDITS,PLAN_LIMIT_EXCEEDED, etc.- Billing-blocked CVs are marked as SKIPPED (not FAILED) by the backend
Environment Variables
The hook needs no AI-specific env vars after the Phase 3.7 cutover. It uses the same NEXT_PUBLIC_API_URL (HTTP) and NEXT_PUBLIC_GRAPHQL_WS_URL (WS) as the rest of the frontend.
Supported File Types (inside ZIP)
| Extension | Format |
|---|---|
.pdf | PDF documents |
.doc | Legacy Word |
.docx | Modern Word |
.txt | Plain text |
Skipped automatically: __MACOSX/ folders, ._ prefixed files, hidden files, unsupported extensions.
Limitations
- No password-protected ZIPs — encrypted ZIP files will fail
- Email required — CVs without an email in the parsed data are marked as FAILED
- No invitation emails — Users created via bulk import are inactive and unverified
- Text files (.txt) may fail if the LLM cannot parse structured data from plain text
- Maximum 500 files per ZIP, maximum 500 MB compressed size
Patterns Reused
| Pattern | Source |
|---|---|
| S3 presigned upload + XHR progress | lib/hooks/useCvUploadAndParse.ts |
| AI streaming subscription | subscribeAiOp in lib/ai/aiGateway.ts (used by every AI hook) |
| AI HTTP mutations | aiGatewayRequest in lib/ai/aiGateway.ts |
| Billing error detection | lib/hooks/useBillingError.ts |
| Modal with results table | ImportTalentModal.tsx |
| File drag & drop | components/reusable/Dropzone.tsx |
Last updated: 2026-03-09