Codex 5.3 Refactor Note: Canonical refactor plan: docs/CODEX-5.3-REFACTOR-PLAN.md. This document is retained for historical and implementation context during the refactor.
W6 Implementation: HR Upload Resume
Status: Complete
Date: January 23, 2026
Scope: Single-page resume upload flow → candidate creation + resume document storage
A. Summary
W6 implements the HR resume upload flow as the entry point to the hiring process. HR users upload a PDF/DOC/DOCX resume, enter candidate position and optional details, and the system creates:
- Candidate record (status=NEW)
- Document record (category=RESUME, version=1)
- Audit logs for both events
- Auto-redirect to W7 (candidate intake/HR screening)
Key constraint: Candidate creation requires resume upload first (upload-first pattern).
B. Routes
Pages
- GET
/upload-resume– File upload form (HR/Admin only)
API Endpoints
-
POST
/api/uploads/presign– Request S3 presigned URL- Input: { filename, mimeType, sizeBytes }
- Output: { uploadUrl, storageKey, expiresIn }
- RBAC: HR, Admin
-
POST
/api/candidates– Create candidate + resume document- Input: { applyingFor, fullName?, notes?, resume: {filename, mimeType, sizeBytes, storageKey} }
- Output: { candidateId, candidateCode, message }
- RBAC: HR, Admin
C. Data Model Changes
Enums (Added)
enum CandidateStatus {
NEW
HR_SCREENED
MANAGER_EVAL_PENDING
MANAGER_EVAL_COMPLETED
TO_SMO
DECISION_MADE
COMPLETED
}
enum DocumentCategory {
RESUME
SCREENING_NOTES
EVALUATION
DECISION
OFFER
}
enum DocumentStatus {
AVAILABLE
PENDING_SCAN // For future virus scan integration
}
Models (Added)
model Candidate {
id String @id @default(cuid())
candidateCode String @unique // CAND-YYYY-#####
fullName String? // Optional at creation, required before W7 completion
applyingFor String // Required: position name
status CandidateStatus @default(NEW)
createdByUserId String
documents CandidateDocument[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model CandidateDocument {
id String @id @default(cuid())
candidateId String
category DocumentCategory // RESUME for W6
filename String
mimeType String
sizeBytes Int
storageKey String // S3 path
version Int @default(1)
status DocumentStatus @default(AVAILABLE)
uploadedByUserId String
createdAt DateTime @default(now())
}
User Relationship (Updated)
User model now has:
createdCandidates Candidate[] @relation("CandidateCreatedBy")
uploadedDocuments CandidateDocument[] @relation("DocumentUploadedBy")
Migration Name
add_candidates_and_documents
D. UI Components
Files Created/Updated
-
/app/(app)/layout.tsx– Minimal layout wrapper for (app) group -
/app/(app)/upload-resume/page.tsx– Main form component (380 lines)- Dropzone for file selection
- Position dropdown (required)
- Full name input (optional)
- Notes textarea (optional)
- Three-stage submission: presign → upload to S3 → create candidate
- Progress indicator (50% presign, 75% uploaded, 100% created)
- Success/error states with auto-redirect
-
/app/(app)/upload-resume/_components/Dropzone.tsx– Reusable drag-drop component- Drag & drop + click to browse
- File type validation (PDF/DOC/DOCX)
- Size validation (10MB max)
- Visual feedback (drag active state, error messages)
Key UI Features
- Layout: Card-based on white background with gray page background
- Form Fields:
- Applying For (required dropdown with 6 positions)
- Candidate Name (optional text input)
- Resume (required file via dropzone)
- Notes (optional textarea)
- States:
- Idle: Form visible
- Uploading: Progress bar + disabled inputs
- Success: Green success message + auto-redirect after 1.5s
- Error: Red error box with message
- Buttons:
- Primary: "Upload & Create Candidate" (full width)
- Secondary: "Cancel" → /dashboard
E. API Logic
Endpoint 1: POST /api/uploads/presign
Purpose: Get S3 presigned URL without candidate ID (temp upload)
Validation:
- File type: PDF, DOC, DOCX only
- File size: Max 10MB
- RBAC: Requires HR or ADMIN role
Response:
{
"uploadUrl": "https://s3.../presigned?...",
"storageKey": "candidates/temp-abc123/resume/1704974400000.pdf",
"expiresIn": 3600
}
Error Codes:
- 400: Invalid file metadata
- 401: Unauthorized
- 403: Forbidden (not HR/Admin)
- 500: Presign service failure
Endpoint 2: POST /api/candidates
Purpose: Create candidate + resume document after S3 upload completes
Validation:
applyingForrequired, max 255 charsfullNameoptional, max 255 chars (null if empty string)notesoptional, max 2000 charsresume.storageKeymust exist (from presign response)- RBAC: Requires HR or ADMIN role
Database Transaction:
- Generate unique
candidateCode(format: CAND-YYYY-#####) - Create Candidate record:
- status: NEW
- applyingFor: required
- fullName: null if not provided
- createdByUserId: HR's user ID
- Create CandidateDocument record:
- category: RESUME
- version: 1
- status: AVAILABLE
- uploadedByUserId: HR's user ID
Response:
{
"candidateId": "cuid123",
"candidateCode": "CAND-2026-45678",
"message": "Candidate created and resume uploaded successfully"
}
Error Codes:
- 400: Invalid candidate data (validation)
- 401: Unauthorized
- 403: Forbidden (not HR/Admin)
- 500: Database or transaction error
F. RBAC Checks
Access Control:
-
Page:
/upload-resume→ HR, ADMIN only- Enforce via middleware redirect to /login if not authenticated
- Enforce via component-level check (future: MW)
-
API
/api/uploads/presign→requireRole([Role.HR, Role.ADMIN])- Returns 403 Forbidden if user is MANAGER or SMO
-
API
/api/candidates→requireRole([Role.HR, Role.ADMIN])- Returns 403 Forbidden if user is MANAGER or SMO
Implementation:
- Uses
requireRole()from/lib/auth/rbac.ts - Checks JWT payload.role against allowed roles
- Returns 401/403 with error message on failure
G. Audit Events
Event 1: CANDIDATE_CREATED
When: Immediately after candidate insert
Who: HR user ID from JWT
Fields Logged:
{
"entity_type": "Candidate",
"entity_id": "cuid...",
"candidateCode": "CAND-2026-45678",
"status": "NEW",
"applyingFor": "Software Engineer",
"fullName": "John Doe" // null if not provided
}
Event 2: RESUME_UPLOADED
When: Immediately after document insert
Who: HR user ID from JWT
Fields Logged:
{
"entity_type": "CandidateDocument",
"entity_id": "doc-cuid...",
"candidateId": "cuid...",
"candidateCode": "CAND-2026-45678",
"filename": "john_doe_resume.pdf",
"sizeBytes": 524288,
"storageKey": "candidates/temp-abc123/resume/1704974400000.pdf"
}
Function: logAuditEvent() from /lib/audit.ts
Non-blocking: If audit logging fails, the operation completes anyway
H. Test Checklist
Manual Tests (Run in browser after W6 deployment)
Test 1: Form Validation
- Navigate to
/upload-resumewhile logged in as HR - Verify page loads with empty form
- Try submitting without selecting file → Error: "Please select a resume file"
- Try submitting without selecting position → Error: "Please select a position"
- Select PDF file, position, leave name blank → Should work
Test 2: File Upload Validation
- Try drag-drop invalid file type (e.g., .txt) → Error: "Only PDF, DOC, DOCX allowed"
- Try drag-drop file >10MB → Error: "File size must be less than 10MB"
- Try drag-drop valid PDF → File shows in UI as selected
Test 3: Successful Flow
- Select valid PDF, fill position + name, click "Upload & Create Candidate"
- Verify progress bar shows 0% → 50% → 75% → 100%
- Verify green success message appears
- Verify auto-redirect to
/candidates/{id}after 1.5 seconds - Verify candidate record exists in database with NEW status
- Verify resume document exists with category=RESUME, version=1
Test 4: Audit Logging
- Check database audit_logs table
- Verify CANDIDATE_CREATED event with candidateCode
- Verify RESUME_UPLOADED event with filename + storageKey
- Verify userId matches HR user ID
Test 5: RBAC
- Try accessing
/upload-resumeas MANAGER or SMO → Should redirect to dashboard - Try POST
/api/candidateswith MANAGER JWT → 403 Forbidden - Try POST
/api/uploads/presignwith SMO JWT → 403 Forbidden
Test 6: Cancel Button
- Click Cancel → Navigate to
/dashboard
Test 7: S3 Integration
- Verify presign endpoint returns valid S3 URL
- Verify file actually uploads to S3 storage
- Verify storageKey reflects correct path:
candidates/{tempId}/resume/{timestamp}.{ext}
Test 8: Optional Fields
- Submit with name blank → fullName should be null in DB
- Submit with notes blank → notes should not be stored (not in schema) – OK
- Submit with both filled → Both should be stored
I. Implementation Notes
Key Files
- Schema:
prisma/schema.prisma– Candidate + CandidateDocument models - Validation:
src/lib/validation/schemas.ts– Zod schemas for upload + create - Storage:
src/lib/storage/s3.ts– Presign URL generation + key generation - Candidates:
src/lib/candidates/code.ts– Candidate code generator - API:
src/app/api/uploads/presign/route.ts– Presign endpoint - API:
src/app/api/candidates/route.ts– Create candidate endpoint - UI:
src/app/(app)/upload-resume/page.tsx– Main form (380 lines) - UI:
src/app/(app)/upload-resume/_components/Dropzone.tsx– Drag-drop (140 lines) - Layout:
src/app/(app)/layout.tsx– Minimal group layout
Dependencies Assumed
@aws-sdk/client-s3(for S3 presign)@aws-sdk/s3-request-presigner(for getSignedUrl)zod(validation)@prisma/client(ORM)jsonwebtoken(JWT parsing)
Environment Variables Required
DATABASE_URL=postgresql://...
JWT_SECRET=...
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID=...
AWS_SECRET_ACCESS_KEY=...
S3_BUCKET_NAME=offers-review
S3_ENDPOINT=https://s3.amazonaws.com # Optional for MinIO/LocalStack
Candidate Code Generation
Format: CAND-YYYY-#####
- YYYY = current year
-
= 5-digit random number (padded with zeros)
- Example:
CAND-2026-45678
Storage Key Format
Format: candidates/{tempId}/resume/{timestamp}.{ext}
- tempId = random 7-char string (before candidate creation)
- timestamp = Date.now()
- ext = file extension (pdf, doc, docx)
- Example:
candidates/temp-abc123/resume/1704974400000.pdf
Three-Stage Upload
- Presign (POST presign) → Get uploadUrl + storageKey
- Upload (PUT uploadUrl) → File goes directly to S3
- Create (POST candidates) → Create DB records with storageKey from step 1
Success Redirect
- Redirects to
/candidates/{candidateId}(W7 or candidate detail page) - 1.5s delay to show success message
- Uses
router.push()for client-side navigation
J. Future Enhancements (Not W6)
- Virus scanning (status: PENDING_SCAN → AVAILABLE)
- Bulk upload (multiple candidates at once)
- Resume parsing (extract name, email, phone)
- Resume storage cleanup (S3 lifecycle policies)
- Role-based position list (SMO sees only SMO-visible positions)
End of W6 Specification