CNI Upload and AI Analysis
Summary
After registering, a Pro must upload their French national identity document (CNI) within 72 hours. The file is validated (format, magic bytes, size), stored privately outside the web root, and then analysed asynchronously by Claude Vision. The AI extracts identity data, crosschecks it against the declared name, and posts the result to the pro’s Slack thread. Admins then perform manual verification.
State Diagram
stateDiagram-v2 state "En attente de CNI" as Pending state "Accès bloqué (72h)" as Gated state "Téléversement en cours" as Uploading state "Format refusé" as Rejected_Format state "Taille refusée" as Rejected_Size state "Analyse en cours" as Processing state "CNI valide (IA)" as Valid state "CNI invalide (IA)" as Invalid state "Erreur analyse IA" as AnalysisError state "En attente admin" as AwaitingAdmin state "Identité vérifiée" as Verified [*] --> Pending: Pro registered, cniVerifiedAt = null Pending --> Gated: 72h elapsed, no upload Gated --> Uploading: Pro submits file (gate bypassed for this route) Pending --> Uploading: Pro submits file within 72h Uploading --> Rejected_Format: unsupported MIME or invalid magic bytes Uploading --> Rejected_Size: file exceeds CNI_MAX_SIZE_MB Uploading --> Processing: file stored, cniAnalysisStatus = "processing" Processing --> Valid: Claude crosscheck passes (status = "valid") Processing --> Invalid: Claude crosscheck fails (status = "invalid") Processing --> AnalysisError: Claude API failure Valid --> AwaitingAdmin: admin reviews in admin panel Invalid --> AwaitingAdmin AnalysisError --> AwaitingAdmin AwaitingAdmin --> Verified: admin runs verify-cni → [[cni-verification]]
Steps
1. Check Eligibility (Actor: system)
On every authenticated request the authenticatePro hook enforces the 72h CNI gate. If cniFilePath is null and createdAt is more than 72h ago, all routes except those in CNI_GATE_WHITELIST (/pro/auth/refresh, /pro/auth/logout, /pro/profile/cni-upload) return 403 CNI_REQUIRED.
Triggers: Any authenticated Pro request Outcome: Request blocked or passes through
2. Upload File (Actor: pro)
POST /pro/profile/cni-upload (multipart, rate-limited to 5 requests per hour per proId). Accepted MIME types: image/jpeg, image/png, application/pdf.
If the Pro’s cniVerifiedAt is already set → 409 CNI_ALREADY_VERIFIED (no re-upload allowed without support contact).
Triggers: Pro selects file and submits Outcome: File buffer available for validation
3. Validate File (Actor: system)
Checks in order:
- File presence — 400
NO_FILEif absent - MIME type — 400
INVALID_FORMATif not JPEG/PNG/PDF - Buffer size — 413
FILE_TOO_LARGEif overCNI_MAX_SIZE_MB - Magic bytes — 400
INVALID_IMAGEorINVALID_PDFif header bytes don’t match declared MIME
Triggers: File received Outcome: Buffer accepted or request rejected
4. Store File (Actor: system)
Images (JPEG/PNG) are saved via saveCniImage — stored as {proId}.webp in UPLOAD_PRIVATE_DIR (outside web root). PDFs are rasterized to WebP via saveCniFromPdf (capped at MAX_PDF_PAGES pages). On storage error → 500 IMAGE_PROCESSING_FAILED or PDF_RASTERIZE_FAILED.
Triggers: Validation passes Outcome: File on disk at deterministic path
5. Update DB (Compare-and-Set) (Actor: system)
prisma.pro.updateMany with where: { id, cniVerifiedAt: null } sets cniFilePath, cniAnalysisStatus = "processing", clears cniAnalysisConfidence and cniAnalysisResult. If count === 0, the CNI was verified in a concurrent admin action — the orphan file is cleaned up and 409 CNI_ALREADY_VERIFIED is returned.
Triggers: File stored Outcome: DB row updated atomically
6. Slack Notification (Actor: system)
notifyProCniUploaded posts to #pro-registration in the pro’s existing Slack thread (slackThreadTs) — “CNI prête à vérifier” with a link to the admin fiche. Fire-and-forget.
Triggers: DB updated successfully Outcome: Admin team alerted
7. AI Analysis (Actor: system)
analyzeCni(proId) is called via setImmediate (non-blocking). It reads the stored WebP, base64-encodes it, and calls claude-sonnet-4-6-20250514 with the extract_cni_data tool (forced tool use). The extracted CniExtraction is crosschecked against the declared firstName / lastName:
- Name normalisation: strips accents, lowercases, removes hyphens and spaces
- First name: partial match allowed (CNI may include multiple first names)
- Last name: must match exactly after normalisation
- Expiry: flags expired documents
isIdentityDocument: false→ mismatch- Image quality
"poor"→ mismatch
On failure: cniAnalysisStatus = "error". Results (including mismatches) are stored in cniAnalysisResult (JSON) and cniAnalysisConfidence.
Triggers: setImmediate after 200 response sent
Outcome: cniAnalysisStatus updated to "valid" or "invalid" or "error"; Slack thread reply posted via notifyCniAnalysis
Error States
- No file in request → 400
NO_FILE - Unsupported MIME type → 400
INVALID_FORMAT - File too large → 413
FILE_TOO_LARGE - Invalid magic bytes (JPEG) → 400
INVALID_IMAGE - Invalid magic bytes (PNG) → 400
INVALID_IMAGE - Invalid magic bytes (PDF) → 400
INVALID_PDF - Storage failure → 500
IMAGE_PROCESSING_FAILEDorPDF_RASTERIZE_FAILED - Already verified (pre-check) → 409
CNI_ALREADY_VERIFIED - Already verified (CAS failure) → 409
CNI_ALREADY_VERIFIED - CNI gate active → 403
CNI_REQUIRED(on other routes)
Related Processes
- registration — CNI gate starts 72h after Pro creation
- cni-verification — admin manually verifies after this process completes