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

W10 SMO Decision Implementation

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).

W10-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.

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 DecisionUpdated emitted (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

  1. 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
  2. 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:

  • CandidateStatus includes: APPROVED, REJECTED, KIV (already present)
  • DecisionType includes: APPROVED, REJECTED, KIV (already present)
  • AuditEventType includes: DECISION_FINALIZED, CANDIDATE_STATUS_UPDATED (already present)

models:

  • Decision model with fields:
    • id (primary key)
    • candidateId (unique, foreign key)
    • decision (DecisionType)
    • notes (nullable text)
    • decidedByUserId (foreign key to User)
    • decidedAt (datetime)
    • createdAt (datetime)
  • Candidate model 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:

  1. 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
  2. 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)
  3. 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

ActionHRManagerSMOAdminCandidate
View decisionโœ…โœ…โœ…โœ…โŒ
Finalize decisionโŒโŒโœ…โœ…โŒ
Edit draftN/AN/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:

  1. Create candidate with status TO_SMO
  2. 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:

  1. Create candidate with status TO_SMO
  2. As SMO user, visit /candidates/[id]?tab=smo-decision

Actions:

  1. Select "APPROVED" radio button
  2. (Optional) Add notes
  3. Click "Finalize Decision"

Expected:

  • โœ… Form validates
  • โœ… API POST /api/candidates/[id]/decision returns 200
  • โœ… Candidate status updated to APPROVED
  • โœ… decidedAt set 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:

  1. Create candidate with status TO_SMO
  2. As SMO user, visit /candidates/[id]?tab=smo-decision

Actions:

  1. Select "REJECTED" radio button
  2. Type notes: "Does not meet technical requirements"
  3. 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:

  1. Same as Test Case 3
  2. As SMO user

Actions:

  1. Select "REJECTED" radio button
  2. Leave notes empty
  3. 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:

  1. Create candidate with status TO_SMO
  2. As SMO user

Actions:

  1. Select "KIV" radio button
  2. Type notes: "Need more time to assess"
  3. 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:

  1. Run Test Case 2 (decision finalized as APPROVED)
  2. As SMO user, still on /candidates/[id]?tab=smo-decision

Actions:

  1. 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:

  1. Create candidate with status NEW (not TO_SMO)
  2. As SMO user, visit /candidates/[id]?tab=smo-decision

Actions:

  1. 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:

  1. Create candidate with status TO_SMO
  2. As HR user, visit /candidates/[id]?tab=smo-decision

Actions:

  1. 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:

  1. Create candidate with status TO_SMO
  2. As Admin user, visit /candidates/[id]?tab=smo-decision

Actions:

  1. Select "APPROVED" radio button
  2. 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:

  1. No valid JWT cookie

Actions:

  1. POST /api/candidates/[id]/decision (no auth)

Expected:

  • โœ… API returns 401 Unauthorized

Test Case 11: API - Missing Candidate

Setup:

  1. Valid JWT, SMO role
  2. Non-existent candidateId

Actions:

  1. POST /api/candidates/invalid-id/decision

Expected:

  • โœ… API returns 404 Not Found
  • โœ… Error: "Candidate not found"

Test Case 12: Audit Log Verification

Setup:

  1. Run Test Case 2 (finalize as APPROVED)

Actions:

  1. 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:

  1. Run Test Case 2

Actions:

  1. 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