Skip to content

Review & Approval Workflow

Status: Final Version: 1.0


Purpose

Define task assignment, review processes, approval sign-off, and segregation of duties for ESG data review.


Workflow Stages

VALIDATED → Assign Reviewer → UNDER_REVIEW → Reviewer Action
                                                    ↓ (approve)        ↓ (reject)
                                               REVIEWED            REJECTED
                                                    ↓                   ↓
                                            Assign Approver      Collector fixes
                                                    ↓                   ↓
                                               APPROVED           Resubmit (new version)

Task Assignment

Automatic Assignment Logic

class ReviewTaskAssignmentService
{
    public function assignReviewer(MetricSubmission $submission)
    {
        // Find reviewer with:
        // 1. Access to submission's site
        // 2. NOT the submitter (SoD)
        // 3. Lowest current workload

        $reviewer = User::role('reviewer')
            ->whereHas('sites', fn($q) => $q->where('site_id', $submission->site_id))
            ->where('id', '!=', $submission->submitted_by_user_id)
            ->withCount(['assignedReviewTasks' => fn($q) => $q->where('state', 'pending')])
            ->orderBy('assigned_review_tasks_count')
            ->first();

        ReviewTask::create([
            'submission_id' => $submission->id,
            'assigned_to_user_id' => $reviewer->id,
            'due_date' => now()->addBusinessDays(3),
            'state' => 'pending',
        ]);

        // Notify reviewer
        $reviewer->notify(new ReviewTaskAssigned($submission));
    }
}

Manual Assignment (Admin Override)

POST /api/v1/admin/submissions/{id}/assign-reviewer
{
  "reviewer_user_id": 88
}

Reviewer Actions

1. Approve (Transition to REVIEWED)

public function approve(MetricSubmission $submission, User $reviewer, string $comment)
{
    if ($submission->submitted_by_user_id === $reviewer->id) {
        throw new SegregationOfDutiesException('Cannot review own submission');
    }

    $submission->update([
        'state' => 'REVIEWED',
        'reviewed_by_user_id' => $reviewer->id,
        'reviewed_at' => now(),
        'reviewer_comment' => $comment,
    ]);

    // Create approval task
    $this->assignApprover($submission);

    AuditLog::log('submission.reviewed', $submission, $reviewer);
}

2. Reject (Transition to REJECTED)

public function reject(MetricSubmission $submission, string $reason, array $corrections)
{
    $submission->update([
        'state' => 'REJECTED',
        'rejection_reason' => $reason,
        'required_corrections' => $corrections,
    ]);

    // Notify collector
    $submission->submittedBy->notify(new SubmissionRejected($submission));

    AuditLog::log('submission.rejected', $submission);
}

Approver Actions

Approve (Transition to APPROVED)

public function approveSubmission(MetricSubmission $submission, User $approver, string $justification)
{
    // Segregation of Duties checks
    if ($submission->submitted_by_user_id === $approver->id) {
        throw new SegregationOfDutiesException('Cannot approve own submission');
    }

    if ($submission->reviewed_by_user_id === $approver->id) {
        Log::warning("Approver is also reviewer", ['submission_id' => $submission->id]);
        // Allow but flag in audit log
    }

    $submission->update([
        'state' => 'APPROVED',
        'approved_by_user_id' => $approver->id,
        'approved_at' => now(),
        'approval_justification' => $justification,
    ]);

    AuditLog::log('submission.approved', $submission, $approver, $justification);

    event(new SubmissionApproved($submission));
}

SLA Tracking

class ReviewTask extends Model
{
    public function isOverdue(): bool
    {
        return $this->state === 'pending' && $this->due_date->isPast();
    }

    public function getEscalationLevel(): int
    {
        if (!$this->isOverdue()) return 0;

        $daysOverdue = now()->diffInDays($this->due_date);

        return match(true) {
            $daysOverdue >= 7 => 3, // Critical
            $daysOverdue >= 3 => 2, // High
            default => 1,           // Medium
        };
    }
}

// Scheduled job: Escalate overdue reviews
class EscalateOverdueReviewsJob
{
    public function handle()
    {
        ReviewTask::where('state', 'pending')
            ->where('due_date', '<', now()->subDays(3))
            ->each(function ($task) {
                $task->update(['escalation_level' => $task->getEscalationLevel()]);

                // Notify manager
                $task->assignedTo->manager->notify(new ReviewTaskOverdue($task));
            });
    }
}

Acceptance Criteria

  • Reviewers cannot review own submissions (enforced by policy)
  • Approvers cannot approve own submissions (hard constraint)
  • Review tasks auto-assigned based on workload
  • SLA tracking with escalation for overdue reviews (3+ days)
  • All approval actions logged to audit trail

Cross-References


Change Log

Version Date Author Changes
1.0 2026-01-03 Senior Product Architect Initial review/approval workflow specification