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.
W10 SMO Decision Implementation
โ A. Summary
W10 implements SMO Decision (Status + Notes) - the final stage where SMO reviews a candidate escalated to TO_SMO and finalizes an internal decision (APPROVED/REJECTED/KIV). The decision is immutable once created, triggers candidate status updates, audit logging, and a domain event emission for future notification routing (W16).
Key Features:
- SMO/Admin finalize decision with status + optional notes
- Notes required for REJECTED/KIV; optional for APPROVED
- Decision immutable once created (409 Conflict on retry)
- Candidate status transitions: TO_SMO โ APPROVED/REJECTED/KIV
- Audit logging for decision + status change
- Domain event
DecisionUpdatedemitted (stub; W16 will route to Company-All)
๐ B. Routes
Pages
/candidates/[id]?tab=smo-decision- Tab-based UI in existing Candidate Detail page
- Candidate name/code + status badge
- Decision form (editable only for SMO/Admin when
status=TO_SMO) - Decision history (read-only if decision exists)
API Endpoints
-
GET /api/candidates/[id]/decision- Auth required (HR/Manager/SMO/Admin can view)
- Returns:
{ candidateId, decision: { id, decision, notes, decidedBy, decidedAt } } - Returns null if no decision exists yet
-
POST /api/candidates/[id]/decision- SMO/Admin only
- Body:
{ decision: APPROVED|REJECTED|KIV, notes?: string } - Validates: candidate.status == TO_SMO, notes required if REJECTED/KIV
- Returns 409 if decision already exists (immutable)
- Returns:
{ success: true, status, decision, decidedAt }
๐พ C. Data Model Changes
Prisma Schema (No changes needed)
The following already exist in /prisma/schema.prisma:
enums:
CandidateStatusincludes: APPROVED, REJECTED, KIV (already present)DecisionTypeincludes: APPROVED, REJECTED, KIV (already present)AuditEventTypeincludes: DECISION_FINALIZED, CANDIDATE_STATUS_UPDATED (already present)
models:
Decisionmodel with fields:id(primary key)candidateId(unique, foreign key)decision(DecisionType)notes(nullable text)decidedByUserId(foreign key to User)decidedAt(datetime)createdAt(datetime)
Candidatemodel updated with:decidedAt(optional datetime)
No migration required - schema already includes Decision model and required enums.
๐จ D. UI Components
Files Created/Updated
src/app/(app)/candidates/[id]/page.tsx - UPDATED
- Added SMO Decision tab button + tab navigation logic
- Tab switches between:
hr-screeningโ<HrScreeningTab />manager-reviewโ<ManagerReviewTab />smo-decisionโ<SmoDecisionTab />(NEW)
- Status badge styling updated for TO_SMO (purple), APPROVED (green), REJECTED (red), KIV (orange)
src/app/(app)/candidates/[id]/_tabs/SmoDecisionTab.tsx - NEW
UI Sections:
-
Editable Mode (candidate.status == TO_SMO, user is SMO/Admin)
- Warning banner: "โ ๏ธ Internal Decision: Do not contact applicant from this system."
- Decision form:
- Radio buttons: APPROVED, REJECTED, KIV
- Textarea: Notes (required if REJECTED/KIV; optional if APPROVED)
- Buttons: "Finalize Decision" (primary), "Cancel" (secondary)
- Real-time validation display
-
Read-Only Mode (decision already exists)
- Success banner: "โ Decision finalized (read-only)"
- Card showing:
- Decision (color-coded badge)
- Notes (if any)
- Decided By (name + email)
- Decided At (datetime)
-
Not Eligible Mode (candidate.status != TO_SMO)
- Warning banner: "โ Not in SMO review stage. Current status: [status]"
โ๏ธ E. API Logic
File: src/app/api/candidates/[id]/decision/route.ts - NEW
GET /api/candidates/[id]/decision
// Auth required (HR/Manager/SMO/Admin)
1. requireAuth() โ verify user
2. Check allowedRoles = ['HR', 'MANAGER', 'SMO', 'ADMIN'] โ 403 if not
3. Fetch candidate by id โ 404 if not found
4. Query Decision record by candidateId
5. Return { candidateId, decision: {...} or null }
POST /api/candidates/[id]/decision
// Auth + role check
1. requireAuth() โ verify user
2. Check auth.role in ['SMO', 'ADMIN'] โ 403 if not
// Validation
3. Parse body with decisionSchema
4. Fetch candidate by id โ 404 if not found
5. Check candidate.status == TO_SMO โ 400 if not
6. Check if Decision exists for candidateId โ 409 if yes (immutable)
// Transactional update
7. BEGIN TRANSACTION
a. Create Decision record:
- candidateId, decision, notes, decidedByUserId, decidedAt
b. Update Candidate:
- status = APPROVED | REJECTED | KIV
- statusUpdatedAt = now()
- decidedAt = now()
8. COMMIT
// Audit + Events
9. logAuditEvent('DECISION_FINALIZED', userId, {
candidateId, candidateCode, decision, hasNotes
})
10. logAuditEvent('CANDIDATE_STATUS_UPDATED', userId, {
candidateId, candidateCode, fromStatus: TO_SMO, toStatus, reason
})
11. emitDomainEvent('DecisionUpdated', candidateId, {
candidateId, candidateCode, decision, decidedBy, decidedAt, notes
})
12. Return { success: true, status, decision, decidedAt }
๐ F. RBAC Checks
Access Control Matrix
| Action | HR | Manager | SMO | Admin | Candidate |
|---|---|---|---|---|---|
| View decision | โ | โ | โ | โ | โ |
| Finalize decision | โ | โ | โ | โ | โ |
| Edit draft | N/A | N/A | โ | โ | โ |
| View history | โ | โ | โ | โ | โ |
Implementation
Endpoint Checks:
GET /api/candidates/[id]/decision: Require role in [HR, MANAGER, SMO, ADMIN]POST /api/candidates/[id]/decision: Require role in [SMO, ADMIN]
UI Checks:
- Form editable only if:
user.role in [SMO, ADMIN] AND candidate.status == TO_SMO - Otherwise form locked with read-only message
๐ G. Audit Events
Events Logged
DECISION_FINALIZED
eventType: DECISION_FINALIZED
userId: {smะพ/admin user id}
details: {
candidateId: string,
candidateCode: string,
decision: APPROVED | REJECTED | KIV,
hasNotes: boolean
}
CANDIDATE_STATUS_UPDATED
eventType: CANDIDATE_STATUS_UPDATED
userId: {smo/admin user id}
details: {
candidateId: string,
candidateCode: string,
fromStatus: TO_SMO,
toStatus: APPROVED | REJECTED | KIV,
reason: "Decision finalized"
}
Domain Event (Stub)
DecisionUpdated emitted to emitDomainEvent():
eventType: DecisionUpdated
aggregateId: candidateId
payload: {
candidateId: string,
candidateCode: string,
decision: APPROVED | REJECTED | KIV,
decidedBy: {userId},
decidedAt: ISO string,
notes: string | null
}
Note: Domain event currently logs to console. W16 will implement outbox table + notification routing to Company-All channel.
โ H. Test Checklist
Prerequisites
- Database setup (Postgres with Prisma schema)
- Server running:
npm run dev - 3 test users: HR, SMO, Admin with valid JWT cookies
Test Case 1: View Decision (HR can view)
Setup:
- Create candidate with status
TO_SMO - As HR user, visit
/candidates/[id]?tab=smo-decision
Expected:
- โ Form displays "Not in SMO review stage" (read-only)
- โ Cannot finalize decision
Test Case 2: Finalize Decision - Approve (SMO only)
Setup:
- Create candidate with status
TO_SMO - As SMO user, visit
/candidates/[id]?tab=smo-decision
Actions:
- Select "APPROVED" radio button
- (Optional) Add notes
- Click "Finalize Decision"
Expected:
- โ Form validates
- โ API POST /api/candidates/[id]/decision returns 200
- โ Candidate status updated to APPROVED
- โ
decidedAtset to current timestamp - โ Form switches to read-only "Decision finalized"
- โ Decision card shows: APPROVED (green badge), notes (if provided), decided by, decided at
- โ Audit logs written: DECISION_FINALIZED + CANDIDATE_STATUS_UPDATED
- โ
Domain event emitted to console:
[DomainEvent] DecisionUpdated: {...}
Test Case 3: Finalize Decision - Reject with Notes (SMO only)
Setup:
- Create candidate with status
TO_SMO - As SMO user, visit
/candidates/[id]?tab=smo-decision
Actions:
- Select "REJECTED" radio button
- Type notes: "Does not meet technical requirements"
- Click "Finalize Decision"
Expected:
- โ Form validates (notes required for REJECTED)
- โ API POST returns 200
- โ Candidate status updated to REJECTED
- โ Decision card shows: REJECTED (red badge), notes, decided by, decided at
- โ Audit logs written
- โ Domain event emitted
Test Case 4: Finalize Decision - Reject without Notes (validation)
Setup:
- Same as Test Case 3
- As SMO user
Actions:
- Select "REJECTED" radio button
- Leave notes empty
- Click "Finalize Decision"
Expected:
- โ Validation error: "Notes are required for Reject or KIV decisions"
- โ Form does NOT submit
- โ Candidate status NOT updated
Test Case 5: Finalize Decision - KIV with Notes
Setup:
- Create candidate with status
TO_SMO - As SMO user
Actions:
- Select "KIV" radio button
- Type notes: "Need more time to assess"
- Click "Finalize Decision"
Expected:
- โ Form validates
- โ API POST returns 200
- โ Candidate status updated to KIV
- โ Decision card shows: KIV (orange badge), notes, decided by, decided at
- โ Audit logs written
Test Case 6: Immutability - Second Submit (409)
Setup:
- Run Test Case 2 (decision finalized as APPROVED)
- As SMO user, still on
/candidates/[id]?tab=smo-decision
Actions:
- Manually craft POST request:
curl -X POST /api/candidates/{id}/decision \ -H "Content-Type: application/json" \ -d '{"decision": "REJECTED", "notes": "change my mind"}'
Expected:
- โ API returns 409 Conflict
- โ Error message: "Decision already finalized (immutable)"
- โ Candidate status NOT changed
- โ No new audit logs
Test Case 7: Wrong Status - Cannot Finalize
Setup:
- Create candidate with status
NEW(not TO_SMO) - As SMO user, visit
/candidates/[id]?tab=smo-decision
Actions:
- Try to finalize decision
Expected:
- โ Form shows: "Not in SMO review stage. Current status: NEW"
- โ Cannot finalize
- โ Form is read-only
Test Case 8: Non-SMO User Cannot Finalize
Setup:
- Create candidate with status
TO_SMO - As HR user, visit
/candidates/[id]?tab=smo-decision
Actions:
- Try to finalize decision
Expected:
- โ Form shows: "Not in SMO review stage" (read-only message)
- โ No form input controls visible OR disabled
- โ Cannot submit
Test Case 9: Admin Can Finalize (role override)
Setup:
- Create candidate with status
TO_SMO - As Admin user, visit
/candidates/[id]?tab=smo-decision
Actions:
- Select "APPROVED" radio button
- Click "Finalize Decision"
Expected:
- โ Form validates
- โ API POST returns 200
- โ Candidate status updated to APPROVED
- โ All same as SMO user (admin has permission)
Test Case 10: API - Unauthenticated Request
Setup:
- No valid JWT cookie
Actions:
- POST /api/candidates/[id]/decision (no auth)
Expected:
- โ API returns 401 Unauthorized
Test Case 11: API - Missing Candidate
Setup:
- Valid JWT, SMO role
- Non-existent candidateId
Actions:
- POST /api/candidates/invalid-id/decision
Expected:
- โ API returns 404 Not Found
- โ Error: "Candidate not found"
Test Case 12: Audit Log Verification
Setup:
- Run Test Case 2 (finalize as APPROVED)
Actions:
- Query AuditLog table:
SELECT * FROM AuditLog WHERE eventType IN ('DECISION_FINALIZED', 'CANDIDATE_STATUS_UPDATED') ORDER BY createdAt DESC LIMIT 2;
Expected:
- โ
Two rows created in order:
- DECISION_FINALIZED: {candidateId, decision: APPROVED, hasNotes: false}
- CANDIDATE_STATUS_UPDATED: {fromStatus: TO_SMO, toStatus: APPROVED, reason}
- โ Both logged with correct userId
Test Case 13: Domain Event - Console Verification
Setup:
- Run Test Case 2
Actions:
- Check server console output during POST request
Expected:
- โ
Console logs:
[DomainEvent] DecisionUpdated: { aggregateId: '...', payload: { candidateId: '...', decision: 'APPROVED', decidedBy: '...', decidedAt: '2026-01-23T...', notes: null } }
๐ Notes
- No offer letter upload in W10 (out of scope per requirements)
- Decision immutable once created; no edit endpoint
- Domain event stub only - W16 will implement notification routing
- Next step (W11+): Offer flow, notification center, approval workflows