Handover workspace

ERS, Todo, OfferReview, and Docu in one view

Imported from live server docs, code structure, and deployment notes.

Apr 3, 2026, 12:38 PM

OfferReview

W6 Implementation: HR Upload Resume

**Status**: Complete **Date**: January 23, 2026 **Scope**: Single-page resume upload flow → candidate creation + resume document storage

docs/W6-UPLOAD-RESUME-IMPLEMENTATION.md

Updated Feb 19, 2026, 6:59 AM

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

  1. POST /api/uploads/presign – Request S3 presigned URL

    • Input: { filename, mimeType, sizeBytes }
    • Output: { uploadUrl, storageKey, expiresIn }
    • RBAC: HR, Admin
  2. 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

  1. /app/(app)/layout.tsx – Minimal layout wrapper for (app) group

  2. /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
  3. /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:

  • applyingFor required, max 255 chars
  • fullName optional, max 255 chars (null if empty string)
  • notes optional, max 2000 chars
  • resume.storageKey must exist (from presign response)
  • RBAC: Requires HR or ADMIN role

Database Transaction:

  1. Generate unique candidateCode (format: CAND-YYYY-#####)
  2. Create Candidate record:
    • status: NEW
    • applyingFor: required
    • fullName: null if not provided
    • createdByUserId: HR's user ID
  3. 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/presignrequireRole([Role.HR, Role.ADMIN])

    • Returns 403 Forbidden if user is MANAGER or SMO
  • API /api/candidatesrequireRole([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-resume while 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-resume as MANAGER or SMO → Should redirect to dashboard
  • Try POST /api/candidates with MANAGER JWT → 403 Forbidden
  • Try POST /api/uploads/presign with 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

  1. Schema: prisma/schema.prisma – Candidate + CandidateDocument models
  2. Validation: src/lib/validation/schemas.ts – Zod schemas for upload + create
  3. Storage: src/lib/storage/s3.ts – Presign URL generation + key generation
  4. Candidates: src/lib/candidates/code.ts – Candidate code generator
  5. API: src/app/api/uploads/presign/route.ts – Presign endpoint
  6. API: src/app/api/candidates/route.ts – Create candidate endpoint
  7. UI: src/app/(app)/upload-resume/page.tsx – Main form (380 lines)
  8. UI: src/app/(app)/upload-resume/_components/Dropzone.tsx – Drag-drop (140 lines)
  9. 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

  1. Presign (POST presign) → Get uploadUrl + storageKey
  2. Upload (PUT uploadUrl) → File goes directly to S3
  3. 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