Skip to content

Review & Approval Workflow

Status: Final Version: 2.0 Last Updated: 2026-01-11


Table of Contents

  1. Purpose
  2. Workflow Overview
  3. State Machine
  4. Segregation of Duties (SoD)
  5. Task Assignment Algorithms
  6. Review Process
  7. Approval Process
  8. SLA Tracking
  9. Notification Triggers
  10. Workload Balancing
  11. Priority Management
  12. Implementation Guide
  13. Performance Considerations
  14. Security Requirements
  15. Testing Strategy
  16. Acceptance Criteria

Purpose

The Review & Approval Workflow ensures data quality, compliance, and accountability for all ESG metric submissions before they are used in regulatory reporting. This workflow implements:

  1. Segregation of Duties (SoD) - No one can approve their own submission
  2. Two-Stage Review - Reviewer validation followed by Approver sign-off
  3. SLA Tracking - Time-bound review with escalation for overdue tasks
  4. Audit Trail - Complete chain-of-custody for regulatory compliance
  5. Workload Balancing - Fair distribution of review tasks across reviewers

Why This Matters: - ESG data is used for regulatory compliance (GRI, SASB, TCFD) - Investors rely on approved data for ESG ratings and investment decisions - Audit firms require demonstrable review and approval processes - Segregation of Duties prevents fraud and ensures data integrity


Workflow Overview

The Review & Approval Workflow consists of two mandatory stages:

High-Level Flow

┌─────────────┐
│  VALIDATED  │  ← Submission passed all 6 validation types
└──────┬──────┘
┌─────────────────────┐
│ Auto-Assign         │  ← Algorithm selects reviewer based on:
│ Reviewer            │     - Site access
└──────┬──────────────┘     - Workload balance
       │                    - NOT the submitter (SoD)
┌─────────────┐
│ UNDER_REVIEW│  ← Reviewer has 3 business days (SLA)
└──────┬──────┘
       ├────────────┬──────────────┐
       ▼            ▼              ▼
 ┌──────────┐  ┌─────────┐  ┌──────────┐
 │ REVIEWED │  │REJECTED │  │ESCALATED │
 └────┬─────┘  └────┬────┘  └────┬─────┘
      │             │              │
      │             ▼              │
      │    ┌────────────────┐     │
      │    │ Collector Fixes│     │
      │    │ & Resubmits    │     │
      │    └────────────────┘     │
      │                            │
      ▼                            ▼
┌─────────────────────┐    ┌─────────────┐
│ Auto-Assign         │    │Manager      │
│ Approver            │    │Intervention │
└──────┬──────────────┘    └─────────────┘
┌─────────────┐
│ PENDING_    │  ← Approver has 2 business days (SLA)
│ APPROVAL    │
└──────┬──────┘
       ├─────────┬─────────┐
       ▼         ▼         ▼
 ┌─────────┐ ┌─────────┐ ┌──────────┐
 │APPROVED │ │REJECTED │ │ESCALATED │
 └─────────┘ └─────────┘ └──────────┘
┌─────────────┐
│  Data Used  │  ← Available for reporting period closure
│for Reporting│     and report generation
└─────────────┘

Workflow Stages

Stage State Actor SLA Next State
1. Validation Complete VALIDATED System Immediate UNDER_REVIEW
2. Reviewer Assignment UNDER_REVIEW Auto-assigned 3 business days REVIEWED or REJECTED
3. Review Decision REVIEWED Reviewer N/A PENDING_APPROVAL
4. Approver Assignment PENDING_APPROVAL Auto-assigned 2 business days APPROVED or REJECTED
5. Approval Decision APPROVED Approver N/A Final state

State Machine

Submission States in Review Workflow

State Transitions:

VALIDATED
    ├─→ UNDER_REVIEW (auto-assign reviewer)
UNDER_REVIEW
    ├─→ REVIEWED (reviewer approves)
    ├─→ REJECTED (reviewer rejects)
    └─→ ESCALATED (SLA breach: 3+ days overdue)

REVIEWED
    └─→ PENDING_APPROVAL (auto-assign approver)

PENDING_APPROVAL
    ├─→ APPROVED (approver approves)
    ├─→ REJECTED (approver rejects)
    └─→ ESCALATED (SLA breach: 2+ days overdue)

REJECTED
    └─→ DRAFT (collector edits)
         └─→ QUEUED (collector resubmits)
              └─→ VALIDATED (if passes validation)

ESCALATED
    └─→ Manager manually reassigns or resolves

Allowed State Transitions

From State To State Actor Conditions
VALIDATED UNDER_REVIEW System Automatic on validation success
UNDER_REVIEW REVIEWED Reviewer Reviewer ≠ Submitter
UNDER_REVIEW REJECTED Reviewer Must provide rejection reason
UNDER_REVIEW ESCALATED System Due date + 3 days passed
REVIEWED PENDING_APPROVAL System Automatic after reviewer approval
PENDING_APPROVAL APPROVED Approver Approver ≠ Submitter, Approver may = Reviewer with warning
PENDING_APPROVAL REJECTED Approver Must provide rejection reason
PENDING_APPROVAL ESCALATED System Due date + 2 days passed
REJECTED DRAFT Submitter Only original submitter can edit
ESCALATED UNDER_REVIEW Manager Manual reassignment

Segregation of Duties (SoD)

Segregation of Duties prevents fraud by ensuring no single person can submit and approve their own data.

SoD Rules

Rule Severity Enforcement
Submitter ≠ Reviewer HARD Database constraint, API validation
Submitter ≠ Approver HARD Database constraint, API validation
Reviewer = Approver SOFT Allowed with audit log warning flag

Implementation

Database Constraints:

-- Prevent submitter from being assigned as reviewer
ALTER TABLE review_tasks ADD CONSTRAINT chk_reviewer_not_submitter
CHECK (
  assigned_to_user_id != (
    SELECT submitted_by_user_id
    FROM metric_submissions
    WHERE id = submission_id
  )
);

-- Prevent submitter from being assigned as approver
ALTER TABLE approval_tasks ADD CONSTRAINT chk_approver_not_submitter
CHECK (
  assigned_to_user_id != (
    SELECT submitted_by_user_id
    FROM metric_submissions
    WHERE id = submission_id
  )
);

Kotlin Service Layer Validation:

@ApplicationScoped
class ReviewAssignmentService(
    private val userRepository: UserRepository,
    private val reviewTaskRepository: ReviewTaskRepository,
    private val auditLogService: AuditLogService,
    private val notificationService: NotificationService
) {
    @Transactional
    fun assignReviewer(submission: MetricSubmission): ReviewTask {
        val reviewer = selectReviewer(submission)

        // SoD Check: Reviewer cannot be submitter
        if (reviewer.id == submission.submittedByUserId) {
            throw SegregationOfDutiesException(
                "Cannot assign submitter as reviewer",
                "VIOLATION_SUBMITTER_AS_REVIEWER"
            )
        }

        val reviewTask = ReviewTask(
            submissionId = submission.id,
            assignedToUserId = reviewer.id,
            dueDate = LocalDateTime.now().plusBusinessDays(3),
            state = ReviewTaskState.PENDING,
            priority = calculatePriority(submission),
            tenantId = submission.tenantId
        )

        // Persist with Panache
        reviewTaskRepository.persist(reviewTask)

        // Audit log
        auditLogService.log(
            action = "review_task.assigned",
            entityType = "ReviewTask",
            entityId = reviewTask.id,
            actorId = null, // System action
            metadata = mapOf(
                "submissionId" to submission.id,
                "reviewerId" to reviewer.id,
                "dueDate" to reviewTask.dueDate
            )
        )

        // Send notification
        notificationService.send(
            userId = reviewer.id,
            type = NotificationType.REVIEW_TASK_ASSIGNED,
            data = mapOf(
                "submissionId" to submission.id,
                "metricName" to submission.metricTemplate.name,
                "siteName" to submission.site.name,
                "dueDate" to reviewTask.dueDate
            )
        )

        return reviewTask
    }

    @Transactional
    fun assignApprover(submission: MetricSubmission): ApprovalTask {
        val approver = selectApprover(submission)

        // SoD Check: Approver cannot be submitter (HARD)
        if (approver.id == submission.submittedByUserId) {
            throw SegregationOfDutiesException(
                "Cannot assign submitter as approver",
                "VIOLATION_SUBMITTER_AS_APPROVER"
            )
        }

        val approvalTask = ApprovalTask(
            submissionId = submission.id,
            assignedToUserId = approver.id,
            dueDate = LocalDateTime.now().plusBusinessDays(2),
            state = ApprovalTaskState.PENDING,
            priority = calculatePriority(submission),
            tenantId = submission.tenantId
        )

        // SoD Check: Approver = Reviewer allowed but flagged (SOFT)
        if (approver.id == submission.reviewedByUserId) {
            auditLogService.log(
                action = "approval_task.assigned",
                entityType = "ApprovalTask",
                entityId = approvalTask.id,
                actorId = null,
                metadata = mapOf(
                    "submissionId" to submission.id,
                    "approverId" to approver.id,
                    "sodWarning" to "APPROVER_IS_REVIEWER"
                )
            )
        }

        // Persist with Panache
        approvalTaskRepository.persist(approvalTask)
        return approvalTask
    }
}

// Extension function for business day calculation
fun LocalDateTime.plusBusinessDays(days: Int): LocalDateTime {
    var current = this
    var remaining = days

    while (remaining > 0) {
        current = current.plusDays(1)
        // Skip weekends (Saturday = 6, Sunday = 7)
        if (current.dayOfWeek.value !in listOf(6, 7)) {
            remaining--
        }
    }

    return current
}

class SegregationOfDutiesException(
    message: String,
    val code: String
) : RuntimeException(message)

Task Assignment Algorithms

Reviewer Assignment Algorithm

Selection Criteria (in priority order):

  1. Site Access - Reviewer must have access to the submission's site
  2. Segregation of Duties - Reviewer ≠ Submitter
  3. Skill Match - Reviewer has expertise in the metric category (optional)
  4. Workload Balance - Reviewer with lowest current pending review count
  5. Round-Robin Tie-Breaking - If workload equal, use last assigned timestamp

Algorithm Implementation:

@ApplicationScoped
class ReviewerSelectionService(
    private val userRepository: UserRepository,
    private val reviewTaskRepository: ReviewTaskRepository
) {
    fun selectReviewer(submission: MetricSubmission): User {
        // Step 1: Find reviewers with site access and NOT submitter
        val eligibleReviewers = userRepository.findByRoleAndSiteAccess(
            role = "REVIEWER",
            siteId = submission.siteId,
            tenantId = submission.tenantId
        ).filter { it.id != submission.submittedByUserId }

        if (eligibleReviewers.isEmpty()) {
            throw NoEligibleReviewerException(
                "No eligible reviewers for site ${submission.site.name}"
            )
        }

        // Step 2: Calculate workload for each reviewer
        val reviewersWithWorkload = eligibleReviewers.map { reviewer ->
            val pendingCount = reviewTaskRepository.countByAssignedToUserIdAndState(
                assignedToUserId = reviewer.id,
                state = ReviewTaskState.PENDING
            )

            val skillMatch = reviewer.metricExpertise.contains(
                submission.metricTemplate.category
            )

            ReviewerCandidate(
                user = reviewer,
                pendingCount = pendingCount,
                skillMatch = skillMatch,
                lastAssignedAt = reviewTaskRepository.findTopByAssignedToUserIdOrderByCreatedAtDesc(
                    reviewer.id
                )?.createdAt
            )
        }

        // Step 3: Sort by workload (asc), skill match (desc), last assigned (asc)
        val sortedCandidates = reviewersWithWorkload.sortedWith(
            compareBy<ReviewerCandidate> { it.pendingCount }
                .thenByDescending { it.skillMatch }
                .thenBy { it.lastAssignedAt ?: LocalDateTime.MIN }
        )

        return sortedCandidates.first().user
    }
}

data class ReviewerCandidate(
    val user: User,
    val pendingCount: Int,
    val skillMatch: Boolean,
    val lastAssignedAt: LocalDateTime?
)

Approver Assignment Algorithm

Selection Criteria (in priority order):

  1. Organization Level - Approver must have authority over the site's organization
  2. Segregation of Duties - Approver ≠ Submitter (HARD), Approver may = Reviewer (SOFT)
  3. Workload Balance - Approver with lowest current pending approval count
  4. Escalation Path - If submission is high-value or flagged, assign senior approver

Algorithm Implementation:

@ApplicationScoped
class ApproverSelectionService(
    private val userRepository: UserRepository,
    private val approvalTaskRepository: ApprovalTaskRepository
) {
    fun selectApprover(submission: MetricSubmission): User {
        // Step 1: Determine required approval level
        val requiredLevel = when {
            submission.hasAnomalyWarnings -> ApprovalLevel.SENIOR
            submission.metricTemplate.isMandatory -> ApprovalLevel.STANDARD
            else -> ApprovalLevel.STANDARD
        }

        // Step 2: Find approvers with organization access and NOT submitter
        val eligibleApprovers = userRepository.findByRoleAndOrganizationAccess(
            role = if (requiredLevel == ApprovalLevel.SENIOR) "SENIOR_APPROVER" else "APPROVER",
            organizationId = submission.site.organizationId,
            tenantId = submission.tenantId
        ).filter { it.id != submission.submittedByUserId }

        if (eligibleApprovers.isEmpty()) {
            throw NoEligibleApproverException(
                "No eligible approvers for organization ${submission.site.organization.name}"
            )
        }

        // Step 3: Calculate workload
        val approversWithWorkload = eligibleApprovers.map { approver ->
            val pendingCount = approvalTaskRepository.countByAssignedToUserIdAndState(
                assignedToUserId = approver.id,
                state = ApprovalTaskState.PENDING
            )

            ApproverCandidate(
                user = approver,
                pendingCount = pendingCount
            )
        }

        // Step 4: Select approver with lowest workload
        return approversWithWorkload.minByOrNull { it.pendingCount }!!.user
    }
}

data class ApproverCandidate(
    val user: User,
    val pendingCount: Int
)

enum class ApprovalLevel {
    STANDARD,
    SENIOR
}

Review Process

Reviewer Actions

Reviewers can perform three actions:

  1. Approve - Move submission to REVIEWED state
  2. Reject - Move submission to REJECTED state with detailed feedback
  3. Comment - Add internal comments without changing state

1. Approve Submission

Endpoint: POST /api/v1/admin/submissions/{id}/review/approve

Request:

{
  "comment": "Verified meter readings against utility bills. All evidence files are valid. Calculation methodology follows GHG Protocol Scope 2 guidance.",
  "confidence": "HIGH"
}

Response (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440010",
  "state": "REVIEWED",
  "reviewedBy": {
    "id": "950e8400-e29b-41d4-a716-446655440005",
    "name": "Jane Reviewer"
  },
  "reviewedAt": "2026-01-11T10:30:00Z",
  "reviewerComment": "Verified meter readings against utility bills...",
  "confidence": "HIGH"
}

Implementation:

@ApplicationScoped
class ReviewService(
    private val submissionRepository: MetricSubmissionRepository,
    private val reviewTaskRepository: ReviewTaskRepository,
    private val approvalAssignmentService: ApprovalAssignmentService,
    private val auditLogService: AuditLogService,
    private val notificationService: NotificationService
) {
    @Transactional
    fun approveSubmission(
        submissionId: UUID,
        reviewerId: UUID,
        comment: String,
        confidence: ConfidenceLevel
    ): MetricSubmission {
        val submission = submissionRepository.findByIdAndTenantId(submissionId, tenantId)
            ?: throw SubmissionNotFoundException(submissionId)

        // Validate state
        if (submission.state != SubmissionState.UNDER_REVIEW) {
            throw InvalidStateTransitionException(
                "Cannot approve submission in state ${submission.state}",
                "STATE_INVALID_TRANSITION"
            )
        }

        // SoD Check
        if (reviewerId == submission.submittedByUserId) {
            throw SegregationOfDutiesException(
                "Cannot review own submission",
                "VIOLATION_SUBMITTER_AS_REVIEWER"
            )
        }

        // Update submission
        submission.state = SubmissionState.REVIEWED
        submission.reviewedByUserId = reviewerId
        submission.reviewedAt = LocalDateTime.now()
        submission.reviewerComment = comment
        submission.confidence = confidence

        // Persist with Panache
        submissionRepository.persist(submission)

        // Mark review task as completed
        val reviewTask = reviewTaskRepository.findBySubmissionIdAndState(
            submissionId = submissionId,
            state = ReviewTaskState.PENDING
        )
        reviewTask?.let {
            it.state = ReviewTaskState.COMPLETED
            it.completedAt = LocalDateTime.now()
            reviewTaskRepository.persist(it)
        }

        // Assign approver
        approvalAssignmentService.assignApprover(submission)

        // Audit log
        auditLogService.log(
            action = "submission.reviewed",
            entityType = "MetricSubmission",
            entityId = submission.id,
            actorId = reviewerId,
            metadata = mapOf(
                "previousState" to "UNDER_REVIEW",
                "newState" to "REVIEWED",
                "confidence" to confidence.name,
                "comment" to comment
            )
        )

        // Notify submitter
        notificationService.send(
            userId = submission.submittedByUserId,
            type = NotificationType.SUBMISSION_REVIEWED,
            data = mapOf(
                "submissionId" to submission.id,
                "reviewerName" to reviewerName,
                "state" to "REVIEWED"
            )
        )

        return submission
    }
}

enum class ConfidenceLevel {
    HIGH,    // No concerns, data looks accurate
    MEDIUM,  // Some minor questions but acceptable
    LOW      // Significant concerns, flagged for approver attention
}

2. Reject Submission

Endpoint: POST /api/v1/admin/submissions/{id}/review/reject

Request:

{
  "reason": "Evidence file quality is insufficient",
  "requiredCorrections": [
    {
      "field": "evidence_files",
      "issue": "Uploaded PDF is a scan with low resolution (72 DPI). Cannot verify meter readings clearly.",
      "correction": "Please re-scan utility bill at 300 DPI or provide original digital PDF from utility company."
    },
    {
      "field": "value",
      "issue": "Reported value (15,234 kWh) does not match utility bill total (14,987 kWh).",
      "correction": "Verify calculation and update value to match utility bill."
    }
  ],
  "severity": "MAJOR"
}

Response (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440010",
  "state": "REJECTED",
  "rejectedBy": {
    "id": "950e8400-e29b-41d4-a716-446655440005",
    "name": "Jane Reviewer"
  },
  "rejectedAt": "2026-01-11T10:30:00Z",
  "rejectionReason": "Evidence file quality is insufficient",
  "requiredCorrections": [...],
  "severity": "MAJOR"
}

Implementation:

@ApplicationScoped
class ReviewService {
    @Transactional
    fun rejectSubmission(
        submissionId: UUID,
        reviewerId: UUID,
        reason: String,
        requiredCorrections: List<CorrectionRequired>,
        severity: RejectionSeverity
    ): MetricSubmission {
        val submission = submissionRepository.findByIdAndTenantId(submissionId, tenantId)
            ?: throw SubmissionNotFoundException(submissionId)

        // Validate state
        if (submission.state != SubmissionState.UNDER_REVIEW) {
            throw InvalidStateTransitionException(
                "Cannot reject submission in state ${submission.state}",
                "STATE_INVALID_TRANSITION"
            )
        }

        // Update submission
        submission.state = SubmissionState.REJECTED
        submission.rejectedByUserId = reviewerId
        submission.rejectedAt = LocalDateTime.now()
        submission.rejectionReason = reason
        submission.requiredCorrections = requiredCorrections
        submission.severity = severity

        // Persist with Panache
        submissionRepository.persist(submission)

        // Mark review task as completed
        val reviewTask = reviewTaskRepository.findBySubmissionIdAndState(
            submissionId = submissionId,
            state = ReviewTaskState.PENDING
        )
        reviewTask?.let {
            it.state = ReviewTaskState.COMPLETED
            it.completedAt = LocalDateTime.now()
            it.outcome = ReviewOutcome.REJECTED
            reviewTaskRepository.persist(it)
        }

        // Audit log
        auditLogService.log(
            action = "submission.rejected",
            entityType = "MetricSubmission",
            entityId = submission.id,
            actorId = reviewerId,
            metadata = mapOf(
                "previousState" to "UNDER_REVIEW",
                "newState" to "REJECTED",
                "reason" to reason,
                "severity" to severity.name,
                "correctionsCount" to requiredCorrections.size
            )
        )

        // Notify submitter with detailed feedback
        notificationService.send(
            userId = submission.submittedByUserId,
            type = NotificationType.SUBMISSION_REJECTED,
            data = mapOf(
                "submissionId" to submission.id,
                "reviewerName" to reviewerName,
                "reason" to reason,
                "requiredCorrections" to requiredCorrections,
                "severity" to severity.name
            )
        )

        return submission
    }
}

data class CorrectionRequired(
    val field: String,
    val issue: String,
    val correction: String
)

enum class RejectionSeverity {
    MINOR,   // Small corrections needed, quick fix
    MAJOR,   // Significant issues, requires re-work
    CRITICAL // Fundamental problems, may need resubmission
}

enum class ReviewOutcome {
    APPROVED,
    REJECTED
}

Approval Process

Approver Actions

Approvers can perform two actions:

  1. Approve - Move submission to APPROVED state (final)
  2. Reject - Move submission to REJECTED state with detailed feedback

1. Final Approval

Endpoint: POST /api/v1/admin/submissions/{id}/approve

Request:

{
  "justification": "Submission has been reviewed by Jane Reviewer with HIGH confidence. Evidence documentation is complete. Data aligns with historical trends (within 5% of Q4 2025). Approved for inclusion in Q1 2026 sustainability report.",
  "signOff": true
}

Response (200 OK):

{
  "id": "550e8400-e29b-41d4-a716-446655440010",
  "state": "APPROVED",
  "approvedBy": {
    "id": "750e8400-e29b-41d4-a716-446655440007",
    "name": "John Approver"
  },
  "approvedAt": "2026-01-11T14:30:00Z",
  "approvalJustification": "Submission has been reviewed by Jane Reviewer...",
  "signOff": true
}

Implementation:

@ApplicationScoped
class ApprovalService(
    private val submissionRepository: MetricSubmissionRepository,
    private val approvalTaskRepository: ApprovalTaskRepository,
    private val auditLogService: AuditLogService,
    private val notificationService: NotificationService
) {
    @Transactional
    fun approveSubmission(
        submissionId: UUID,
        approverId: UUID,
        justification: String,
        signOff: Boolean
    ): MetricSubmission {
        val submission = submissionRepository.findByIdAndTenantId(submissionId, tenantId)
            ?: throw SubmissionNotFoundException(submissionId)

        // Validate state
        if (submission.state != SubmissionState.PENDING_APPROVAL) {
            throw InvalidStateTransitionException(
                "Cannot approve submission in state ${submission.state}",
                "STATE_INVALID_TRANSITION"
            )
        }

        // SoD Check (HARD): Approver cannot be submitter
        if (approverId == submission.submittedByUserId) {
            throw SegregationOfDutiesException(
                "Cannot approve own submission",
                "VIOLATION_SUBMITTER_AS_APPROVER"
            )
        }

        // SoD Check (SOFT): Approver = Reviewer allowed but flagged
        if (approverId == submission.reviewedByUserId) {
            auditLogService.log(
                action = "submission.approved",
                entityType = "MetricSubmission",
                entityId = submission.id,
                actorId = approverId,
                metadata = mapOf(
                    "sodWarning" to "APPROVER_IS_REVIEWER"
                )
            )
        }

        // Update submission
        submission.state = SubmissionState.APPROVED
        submission.approvedByUserId = approverId
        submission.approvedAt = LocalDateTime.now()
        submission.approvalJustification = justification
        submission.signOff = signOff

        // Persist with Panache
        submissionRepository.persist(submission)

        // Mark approval task as completed
        val approvalTask = approvalTaskRepository.findBySubmissionIdAndState(
            submissionId = submissionId,
            state = ApprovalTaskState.PENDING
        )
        approvalTask?.let {
            it.state = ApprovalTaskState.COMPLETED
            it.completedAt = LocalDateTime.now()
            it.outcome = ApprovalOutcome.APPROVED
            approvalTaskRepository.persist(it)
        }

        // Audit log
        auditLogService.log(
            action = "submission.approved",
            entityType = "MetricSubmission",
            entityId = submission.id,
            actorId = approverId,
            metadata = mapOf(
                "previousState" to "PENDING_APPROVAL",
                "newState" to "APPROVED",
                "justification" to justification,
                "signOff" to signOff
            )
        )

        // Notify submitter and reviewer
        listOf(submission.submittedByUserId, submission.reviewedByUserId).forEach { userId ->
            notificationService.send(
                userId = userId,
                type = NotificationType.SUBMISSION_APPROVED,
                data = mapOf(
                    "submissionId" to submission.id,
                    "approverName" to approverName,
                    "approvedAt" to submission.approvedAt
                )
            )
        }

        return submission
    }
}

enum class ApprovalOutcome {
    APPROVED,
    REJECTED
}

Community Investment Workflow

Overview

Community Investment activities (H.1 Report) follow a simplified Activity-Level Workflow distinct from the standard metric submission workflow.

Key Differences: - Granularity: Approval happens at the individual activity row level. - Workflow: Single-stage review (Reviewer/Admin -> Approve) rather than two-stage (Reviewer -> Approver). - Batching: Activities can be reviewed and approved in bulk.

Activity States

State Description Actor Next State
RECEIVED Activity logged by collector Collector APPROVED or REJECTED
APPROVED Verified and accepted for reporting Reviewer/Admin LOCKED (via Period Lock)
REJECTED Returned for correction Reviewer/Admin RECEIVED (after edit)

Approval Logic

Endpoint: POST /api/v1/admin/community-investment/activities/approve-batch

Implementation:

@Transactional
fun approveActivities(activityIds: List<Long>, reviewerId: UUID) {
    val activities = activityRepository.list("id IN ?1", activityIds)

    activities.forEach { activity ->
        // SoD Check: Reviewer cannot approve their own entry?
        // For log entries, strict SoD is often relaxed, but we enforce it if possible.
        // Assuming we track 'created_by_user_id' (if available in audit logs or inferred).

        activity.state = "APPROVED"
        activity.reviewedByUserId = reviewerId
        activity.reviewedAt = LocalDateTime.now()
    }

    // Bulk persist
    activityRepository.persist(activities)
}

Segregation of Duties

  • Hard Rule: A user cannot approve activities they created if the system tracks creator ID on the activity row.
  • Role Requirement: Only users with COMMUNITY_INVESTMENT_REVIEWER or ADMIN role can approve.

SLA Tracking

SLA Definitions

Task Type SLA Grace Period Escalation Level Notification Trigger
Review 3 business days +1 day (warning) Level 1 (3 days overdue) Email to reviewer
Review 3 business days +3 days (escalate) Level 2 (5 days overdue) Email to reviewer + manager
Review 3 business days +7 days (critical) Level 3 (10 days overdue) Email to reviewer + manager + director
Approval 2 business days +1 day (warning) Level 1 (2 days overdue) Email to approver
Approval 2 business days +2 days (escalate) Level 2 (4 days overdue) Email to approver + senior approver

SLA Tracking Implementation

Database Schema:

@Entity
@Table(name = "review_tasks")
data class ReviewTask(
    @Id
    @GeneratedValue
    val id: UUID = UUID.randomUUID(),

    @Column(name = "submission_id", nullable = false)
    val submissionId: UUID,

    @Column(name = "assigned_to_user_id", nullable = false)
    val assignedToUserId: UUID,

    @Column(name = "due_date", nullable = false)
    var dueDate: LocalDateTime,

    @Enumerated(EnumType.STRING)
    @Column(name = "state", nullable = false)
    var state: ReviewTaskState = ReviewTaskState.PENDING,

    @Enumerated(EnumType.STRING)
    @Column(name = "priority", nullable = false)
    var priority: TaskPriority = TaskPriority.NORMAL,

    @Column(name = "escalation_level", nullable = false)
    var escalationLevel: Int = 0,

    @Column(name = "completed_at")
    var completedAt: LocalDateTime? = null,

    @Enumerated(EnumType.STRING)
    @Column(name = "outcome")
    var outcome: ReviewOutcome? = null,

    @Column(name = "tenant_id", nullable = false)
    val tenantId: UUID,

    @Column(name = "created_at", nullable = false)
    val createdAt: LocalDateTime = LocalDateTime.now()
) {
    fun isOverdue(): Boolean {
        return state == ReviewTaskState.PENDING && dueDate.isBefore(LocalDateTime.now())
    }

    fun getDaysOverdue(): Long {
        if (!isOverdue()) return 0
        return ChronoUnit.DAYS.between(dueDate, LocalDateTime.now())
    }

    fun calculateEscalationLevel(): Int {
        if (!isOverdue()) return 0

        val daysOverdue = getDaysOverdue()

        return when {
            daysOverdue >= 7 -> 3  // Critical: 7+ days overdue
            daysOverdue >= 3 -> 2  // High: 3-6 days overdue
            daysOverdue >= 1 -> 1  // Medium: 1-2 days overdue
            else -> 0
        }
    }
}

enum class ReviewTaskState {
    PENDING,
    COMPLETED,
    ESCALATED,
    CANCELLED
}

enum class TaskPriority {
    LOW,
    NORMAL,
    HIGH,
    URGENT
}

SLA Monitoring Job

Scheduled Job (runs every hour):

@ApplicationScoped
class SLAMonitoringJob(
    private val reviewTaskRepository: ReviewTaskRepository,
    private val approvalTaskRepository: ApprovalTaskRepository,
    private val notificationService: NotificationService,
    private val auditLogService: AuditLogService
) {
    @Scheduled(cron = "0 0 * * * ?") // Every hour (Quarkus uses '?' for day-of-week)
    @Transactional
    fun checkOverdueReviewTasks() {
        val now = LocalDateTime.now()

        // Find all overdue review tasks
        val overdueTasks = reviewTaskRepository.findByStateAndDueDateBefore(
            state = ReviewTaskState.PENDING,
            dueDate = now
        )

        overdueTasks.forEach { task ->
            val previousLevel = task.escalationLevel
            val newLevel = task.calculateEscalationLevel()

            if (newLevel > previousLevel) {
                task.escalationLevel = newLevel
                reviewTaskRepository.persist(task)

                // Audit log
                auditLogService.log(
                    action = "review_task.escalated",
                    entityType = "ReviewTask",
                    entityId = task.id,
                    actorId = null, // System action
                    metadata = mapOf(
                        "previousLevel" to previousLevel,
                        "newLevel" to newLevel,
                        "daysOverdue" to task.getDaysOverdue()
                    )
                )

                // Send escalation notifications
                sendEscalationNotifications(task, newLevel)
            }
        }
    }

    private fun sendEscalationNotifications(task: ReviewTask, level: Int) {
        val submission = submissionRepository.findById(task.submissionId).get()

        when (level) {
            1 -> {
                // Level 1: Notify reviewer only
                notificationService.send(
                    userId = task.assignedToUserId,
                    type = NotificationType.REVIEW_TASK_OVERDUE,
                    data = mapOf(
                        "submissionId" to submission.id,
                        "metricName" to submission.metricTemplate.name,
                        "daysOverdue" to task.getDaysOverdue(),
                        "level" to level
                    ),
                    priority = NotificationPriority.HIGH
                )
            }
            2 -> {
                // Level 2: Notify reviewer + manager
                val manager = userRepository.findManagerOf(task.assignedToUserId)

                listOf(task.assignedToUserId, manager?.id).filterNotNull().forEach { userId ->
                    notificationService.send(
                        userId = userId,
                        type = NotificationType.REVIEW_TASK_ESCALATED,
                        data = mapOf(
                            "submissionId" to submission.id,
                            "metricName" to submission.metricTemplate.name,
                            "reviewerName" to assignedUser.name,
                            "daysOverdue" to task.getDaysOverdue(),
                            "level" to level
                        ),
                        priority = NotificationPriority.URGENT
                    )
                }
            }
            3 -> {
                // Level 3: Notify reviewer + manager + director
                val manager = userRepository.findManagerOf(task.assignedToUserId)
                val director = manager?.let { userRepository.findManagerOf(it.id) }

                listOf(task.assignedToUserId, manager?.id, director?.id)
                    .filterNotNull()
                    .forEach { userId ->
                        notificationService.send(
                            userId = userId,
                            type = NotificationType.REVIEW_TASK_CRITICAL,
                            data = mapOf(
                                "submissionId" to submission.id,
                                "metricName" to submission.metricTemplate.name,
                                "reviewerName" to assignedUser.name,
                                "daysOverdue" to task.getDaysOverdue(),
                                "level" to level
                            ),
                            priority = NotificationPriority.CRITICAL
                        )
                    }

                // Auto-escalate: Mark task as ESCALATED
                task.state = ReviewTaskState.ESCALATED
                reviewTaskRepository.save(task)
            }
        }
    }

    @Scheduled(cron = "0 0 * * * ?") // Every hour (Quarkus uses '?' for day-of-week)
    @Transactional
    fun checkOverdueApprovalTasks() {
        val now = LocalDateTime.now()

        // Find all overdue approval tasks
        val overdueTasks = approvalTaskRepository.findByStateAndDueDateBefore(
            state = ApprovalTaskState.PENDING,
            dueDate = now
        )

        overdueTasks.forEach { task ->
            val previousLevel = task.escalationLevel
            val newLevel = task.calculateEscalationLevel()

            if (newLevel > previousLevel) {
                task.escalationLevel = newLevel
                approvalTaskRepository.persist(task)

                // Send escalation notifications (similar to review tasks)
                sendApprovalEscalationNotifications(task, newLevel)
            }
        }
    }
}

enum class NotificationPriority {
    LOW,
    NORMAL,
    HIGH,
    URGENT,
    CRITICAL
}

Notification Triggers

Notification Types

Event Trigger Recipients Priority Channels
Review Task Assigned System assigns reviewer Reviewer NORMAL Email, In-App
Approval Task Assigned System assigns approver Approver NORMAL Email, In-App
Submission Reviewed Reviewer approves Submitter NORMAL Email, In-App
Submission Rejected Reviewer/Approver rejects Submitter HIGH Email, In-App, SMS
Submission Approved Approver approves Submitter, Reviewer NORMAL Email, In-App
Review Task Overdue (1 day) Due date + 1 day Reviewer HIGH Email, In-App
Review Task Escalated (3 days) Due date + 3 days Reviewer, Manager URGENT Email, In-App, SMS
Review Task Critical (7 days) Due date + 7 days Reviewer, Manager, Director CRITICAL Email, In-App, SMS, Phone
Approval Task Overdue (1 day) Due date + 1 day Approver HIGH Email, In-App
Approval Task Escalated (2 days) Due date + 2 days Approver, Senior Approver URGENT Email, In-App, SMS

Notification Implementation

@ApplicationScoped
class NotificationService(
    private val notificationRepository: NotificationRepository,
    private val emailService: EmailService,
    private val smsService: SmsService,
    private val phoneService: PhoneService,
    private val userRepository: UserRepository
) {
    @RunOnVirtualThread
    fun send(
        userId: UUID,
        type: NotificationType,
        data: Map<String, Any>,
        priority: NotificationPriority = NotificationPriority.NORMAL
    ) {
        val user = userRepository.findByIdOptional(userId).orElseThrow()

        // Create in-app notification
        val notification = Notification(
            userId = userId,
            type = type,
            data = data,
            priority = priority,
            read = false,
            createdAt = LocalDateTime.now()
        )
        notificationRepository.persist(notification)

        // Send email
        emailService.send(
            to = user.email,
            subject = getEmailSubject(type, data),
            body = getEmailBody(type, data),
            priority = priority
        )

        // Send SMS for HIGH, URGENT, CRITICAL priorities
        if (priority in listOf(NotificationPriority.HIGH, NotificationPriority.URGENT, NotificationPriority.CRITICAL)) {
            user.phoneNumber?.let { phone ->
                smsService.send(
                    to = phone,
                    message = getSmsMessage(type, data)
                )
            }
        }

        // Phone call for CRITICAL priority
        if (priority == NotificationPriority.CRITICAL) {
            user.phoneNumber?.let { phone ->
                phoneService.call(
                    to = phone,
                    message = getPhoneMessage(type, data)
                )
            }
        }
    }

    private fun getEmailSubject(type: NotificationType, data: Map<String, Any>): String {
        return when (type) {
            NotificationType.REVIEW_TASK_ASSIGNED ->
                "New Review Task: ${data["metricName"]} - ${data["siteName"]}"
            NotificationType.SUBMISSION_REVIEWED ->
                "Your Submission Has Been Reviewed: ${data["metricName"]}"
            NotificationType.SUBMISSION_REJECTED ->
                "Action Required: Submission Rejected - ${data["metricName"]}"
            NotificationType.SUBMISSION_APPROVED ->
                "Submission Approved: ${data["metricName"]}"
            NotificationType.REVIEW_TASK_OVERDUE ->
                "Overdue Review Task: ${data["metricName"]} (${data["daysOverdue"]} days)"
            NotificationType.REVIEW_TASK_ESCALATED ->
                "URGENT: Escalated Review Task - ${data["metricName"]}"
            NotificationType.REVIEW_TASK_CRITICAL ->
                "CRITICAL: Severely Overdue Review Task - ${data["metricName"]}"
            else -> "ESG Platform Notification"
        }
    }

    private fun getEmailBody(type: NotificationType, data: Map<String, Any>): String {
        // Generate HTML email body based on notification type
        // Include links to submission, task details, etc.
        // ...
    }

    private fun getSmsMessage(type: NotificationType, data: Map<String, Any>): String {
        return when (type) {
            NotificationType.SUBMISSION_REJECTED ->
                "ESG: Your ${data["metricName"]} submission was rejected. Reason: ${data["reason"]}. Please log in to view details."
            NotificationType.REVIEW_TASK_ESCALATED ->
                "ESG: Review task for ${data["metricName"]} is ${data["daysOverdue"]} days overdue. Immediate attention required."
            NotificationType.REVIEW_TASK_CRITICAL ->
                "ESG CRITICAL: Review task for ${data["metricName"]} is severely overdue (${data["daysOverdue"]} days). Escalated to management."
            else -> "ESG Platform: ${type.name.replace("_", " ")}"
        }
    }
}

enum class NotificationType {
    REVIEW_TASK_ASSIGNED,
    APPROVAL_TASK_ASSIGNED,
    SUBMISSION_REVIEWED,
    SUBMISSION_REJECTED,
    SUBMISSION_APPROVED,
    REVIEW_TASK_OVERDUE,
    REVIEW_TASK_ESCALATED,
    REVIEW_TASK_CRITICAL,
    APPROVAL_TASK_OVERDUE,
    APPROVAL_TASK_ESCALATED
}

Workload Balancing

Workload Metrics

Track reviewer/approver workload to ensure fair distribution:

@ApplicationScoped
class WorkloadBalancingService(
    private val reviewTaskRepository: ReviewTaskRepository,
    private val approvalTaskRepository: ApprovalTaskRepository
) {
    fun getReviewerWorkload(userId: UUID): WorkloadMetrics {
        val pendingCount = reviewTaskRepository.countByAssignedToUserIdAndState(
            assignedToUserId = userId,
            state = ReviewTaskState.PENDING
        )

        val overdueCount = reviewTaskRepository.countByAssignedToUserIdAndStateAndDueDateBefore(
            assignedToUserId = userId,
            state = ReviewTaskState.PENDING,
            dueDate = LocalDateTime.now()
        )

        val completedThisWeek = reviewTaskRepository.countByAssignedToUserIdAndStateAndCompletedAtAfter(
            assignedToUserId = userId,
            state = ReviewTaskState.COMPLETED,
            completedAt = LocalDateTime.now().minusWeeks(1)
        )

        val avgCompletionTime = reviewTaskRepository.findAvgCompletionTimeByAssignedToUserId(userId)

        return WorkloadMetrics(
            userId = userId,
            pendingCount = pendingCount,
            overdueCount = overdueCount,
            completedThisWeek = completedThisWeek,
            avgCompletionTimeHours = avgCompletionTime
        )
    }

    fun getTeamWorkloadSummary(tenantId: UUID): List<WorkloadMetrics> {
        val reviewers = userRepository.findByRoleAndTenantId("REVIEWER", tenantId)

        return reviewers.map { reviewer ->
            getReviewerWorkload(reviewer.id)
        }.sortedByDescending { it.pendingCount }
    }
}

data class WorkloadMetrics(
    val userId: UUID,
    val pendingCount: Int,
    val overdueCount: Int,
    val completedThisWeek: Int,
    val avgCompletionTimeHours: Double
)

Workload Dashboard API

Endpoint: GET /api/v1/admin/workload/summary

Response:

{
  "reviewers": [
    {
      "userId": "950e8400-e29b-41d4-a716-446655440005",
      "name": "Jane Reviewer",
      "pendingCount": 12,
      "overdueCount": 2,
      "completedThisWeek": 8,
      "avgCompletionTimeHours": 18.5,
      "workloadStatus": "HIGH"
    },
    {
      "userId": "950e8400-e29b-41d4-a716-446655440006",
      "name": "Bob Reviewer",
      "pendingCount": 5,
      "overdueCount": 0,
      "completedThisWeek": 12,
      "avgCompletionTimeHours": 12.3,
      "workloadStatus": "NORMAL"
    }
  ],
  "approvers": [...]
}


Priority Management

Priority Levels

Submissions are assigned priority based on:

  1. Reporting Period Deadline - Submissions near period closure are HIGH priority
  2. Mandatory Metrics - Mandatory metrics for GRI/SASB are HIGH priority
  3. Anomaly Flags - Submissions with anomaly warnings are URGENT priority
  4. Resubmissions - Previously rejected submissions are NORMAL priority

Priority Calculation

@ApplicationScoped
class PriorityCalculationService(
    private val reportingPeriodRepository: ReportingPeriodRepository
) {
    fun calculatePriority(submission: MetricSubmission): TaskPriority {
        val period = reportingPeriodRepository.findById(submission.reportingPeriodId).get()
        val daysUntilPeriodEnd = ChronoUnit.DAYS.between(LocalDateTime.now(), period.endDate)

        return when {
            // URGENT: Anomaly flags or < 7 days until period end
            submission.hasAnomalyWarnings -> TaskPriority.URGENT
            daysUntilPeriodEnd < 7 -> TaskPriority.URGENT

            // HIGH: Mandatory metrics or < 14 days until period end
            submission.metricTemplate.isMandatory -> TaskPriority.HIGH
            daysUntilPeriodEnd < 14 -> TaskPriority.HIGH

            // NORMAL: Everything else
            else -> TaskPriority.NORMAL
        }
    }
}

Implementation Guide

Database Schema

review_tasks table:

CREATE TABLE review_tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    submission_id UUID NOT NULL REFERENCES metric_submissions(id),
    assigned_to_user_id UUID NOT NULL REFERENCES users(id),
    due_date TIMESTAMP NOT NULL,
    state VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    priority VARCHAR(20) NOT NULL DEFAULT 'NORMAL',
    escalation_level INT NOT NULL DEFAULT 0,
    completed_at TIMESTAMP,
    outcome VARCHAR(20),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),

    -- SoD constraint: reviewer cannot be submitter
    CONSTRAINT chk_reviewer_not_submitter CHECK (
        assigned_to_user_id != (
            SELECT submitted_by_user_id
            FROM metric_submissions
            WHERE id = submission_id
        )
    )
);

CREATE INDEX idx_review_tasks_assigned_user ON review_tasks(assigned_to_user_id, state);
CREATE INDEX idx_review_tasks_due_date ON review_tasks(due_date) WHERE state = 'PENDING';
CREATE INDEX idx_review_tasks_tenant ON review_tasks(tenant_id);

approval_tasks table:

CREATE TABLE approval_tasks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    submission_id UUID NOT NULL REFERENCES metric_submissions(id),
    assigned_to_user_id UUID NOT NULL REFERENCES users(id),
    due_date TIMESTAMP NOT NULL,
    state VARCHAR(20) NOT NULL DEFAULT 'PENDING',
    priority VARCHAR(20) NOT NULL DEFAULT 'NORMAL',
    escalation_level INT NOT NULL DEFAULT 0,
    completed_at TIMESTAMP,
    outcome VARCHAR(20),
    tenant_id UUID NOT NULL REFERENCES tenants(id),
    created_at TIMESTAMP NOT NULL DEFAULT NOW(),

    -- SoD constraint: approver cannot be submitter
    CONSTRAINT chk_approver_not_submitter CHECK (
        assigned_to_user_id != (
            SELECT submitted_by_user_id
            FROM metric_submissions
            WHERE id = submission_id
        )
    )
);

CREATE INDEX idx_approval_tasks_assigned_user ON approval_tasks(assigned_to_user_id, state);
CREATE INDEX idx_approval_tasks_due_date ON approval_tasks(due_date) WHERE state = 'PENDING';
CREATE INDEX idx_approval_tasks_tenant ON approval_tasks(tenant_id);

Quarkus Configuration

application.properties:

# Quarkus Scheduler (uses Quartz extension)
quarkus.scheduler.enabled=true
quarkus.quartz.thread-pool-size=5

# Quarkus Mailer Configuration (quarkus-mailer extension)
quarkus.mailer.host=smtp.example.com
quarkus.mailer.port=587
quarkus.mailer.username=${EMAIL_USERNAME}
quarkus.mailer.password=${EMAIL_PASSWORD}
quarkus.mailer.auth-methods=DIGEST-MD5 CRAM-SHA256 CRAM-SHA1 CRAM-MD5 PLAIN LOGIN
quarkus.mailer.start-tls=REQUIRED

# Custom application properties - SLA Configuration
esg.review.sla.review-days=3
esg.review.sla.approval-days=2
esg.review.sla.escalation-levels[0].days=1
esg.review.sla.escalation-levels[0].severity=MEDIUM
esg.review.sla.escalation-levels[1].days=3
esg.review.sla.escalation-levels[1].severity=HIGH
esg.review.sla.escalation-levels[2].days=7
esg.review.sla.escalation-levels[2].severity=CRITICAL

# Workload Configuration
esg.workload.max-pending-per-reviewer=20
esg.workload.max-pending-per-approver=15

# Profile-specific overrides
%dev.esg.review.sla.review-days=7
%dev.esg.workload.max-pending-per-reviewer=50
%test.quarkus.mailer.mock=true

Email Service with Quarkus Mailer:

@ApplicationScoped
class NotificationService(
    private val mailer: Mailer
) {
    fun sendTaskAssignmentEmail(user: User, task: ReviewTask) {
        mailer.send(
            Mail.withText(
                user.email,
                "New Review Task Assigned",
                """
                Hello ${user.name},

                A new review task has been assigned to you:
                - Submission ID: ${task.submissionId}
                - Due Date: ${task.dueDate}

                Please log in to complete the review.
                """.trimIndent()
            )
        )
    }

    // Async email sending
    fun sendEscalationEmailAsync(task: ReviewTask): Uni<Void> {
        return mailer.send(
            Mail.withText(
                task.assignedToUser.email,
                "URGENT: Overdue Review Task",
                "Your review task ${task.id} is overdue..."
            )
        )
    }
}

Scheduled Task Example:

@ApplicationScoped
class ReviewSlaMonitor(
    private val reviewTaskRepository: ReviewTaskRepository,
    private val notificationService: NotificationService,
    @ConfigProperty(name = "esg.review.sla.review-days")
    private val reviewSladays: Int
) {
    @Scheduled(cron = "0 0 * * * ?") // Every hour
    @Transactional
    fun checkOverdueTasks() {
        val now = Instant.now()
        val overdueTasks = reviewTaskRepository.findOverdueTasks(now)

        overdueTasks.forEach { task ->
            notificationService.sendEscalationEmailAsync(task)
        }
    }
}

Performance Considerations

Database Optimization

  1. Indexes:
  2. (assigned_to_user_id, state) - Fast workload queries
  3. (due_date) WHERE state = 'PENDING' - Partial index for SLA monitoring
  4. (tenant_id) - Multi-tenant isolation
  5. (submission_id) - Fast submission task lookups

  6. Query Optimization:

  7. Use database views for workload summary queries
  8. Cache reviewer/approver eligibility lists (15-minute TTL)
  9. Use pagination for task lists (max 100 items per page)

  10. Async Processing:

  11. Task assignment happens asynchronously (don't block submission API)
  12. Notification sending uses @Async (don't block approval API)
  13. SLA monitoring runs every hour (not real-time)

Performance Targets

Operation Target Latency (p95) Notes
Assign reviewer < 500ms Async background job
Approve submission < 1 second Includes audit log, notification queue
Reject submission < 1 second Includes audit log, notification queue
Workload query < 200ms With proper indexes
SLA monitoring job < 30 seconds For 10K pending tasks

Security Requirements

Tenant Isolation

CRITICAL: All queries MUST include tenant_id filter to prevent cross-tenant data access.

@Provider
@Priority(Priorities.AUTHENTICATION)
class TenantIsolationFilter : ContainerRequestFilter {
    override fun filter(requestContext: ContainerRequestContext) {
        // Extract tenant ID from JWT SecurityIdentity
        val securityIdentity = requestContext.securityContext?.userPrincipal as? SecurityIdentity
        val tenantId = securityIdentity?.getAttribute<Long>("tenantId")
            ?: throw UnauthorizedException("Tenant ID not found in token")

        // Store tenant ID in request-scoped context
        TenantContext.setTenantId(tenantId)
    }
}

// Hibernate filter for automatic tenant isolation
@FilterDef(
    name = "tenantFilter",
    parameters = [ParamDef(name = "tenantId", type = "uuid")]
)
@Filter(
    name = "tenantFilter",
    condition = "tenant_id = :tenantId"
)
@Entity
class ReviewTask { ... }

Audit Logging

ALL review and approval actions MUST be logged for regulatory compliance:

auditLogService.log(
    action = "submission.approved",
    entityType = "MetricSubmission",
    entityId = submission.id,
    actorId = approverId,
    ipAddress = request.remoteAddr,
    userAgent = request.getHeader("User-Agent"),
    metadata = mapOf(
        "previousState" to "PENDING_APPROVAL",
        "newState" to "APPROVED",
        "justification" to justification,
        "sodWarning" to sodWarning
    )
)

RBAC Enforcement

@RolesAllowed("REVIEWER", "ADMIN")
fun approveSubmission(...) { ... }

@RolesAllowed("APPROVER", "ADMIN")
fun finalApproveSubmission(...) { ... }

Testing Strategy

Unit Tests

@QuarkusTest
class ReviewAssignmentServiceTest {

    @Inject
    lateinit var reviewAssignmentService: ReviewAssignmentService

    @Test
    fun `reviewer assignment should not assign submitter as reviewer`() {
        // Given
        val submission = createSubmission(submittedBy = userId1)

        // Mock user repository to return user1 as only eligible reviewer
        every { userRepository.findByRoleAndSiteAccess(...) } returns listOf(user1)

        // When / Then
        assertThrows<SegregationOfDutiesException> {
            reviewAssignmentService.assignReviewer(submission)
        }
    }
}

@Test
fun `reviewer assignment should balance workload`() {
    // Given
    val submission = createSubmission()
    val reviewer1 = createReviewer(pendingCount = 5)
    val reviewer2 = createReviewer(pendingCount = 2)
    val reviewer3 = createReviewer(pendingCount = 8)

    every { userRepository.findByRoleAndSiteAccess(...) } returns listOf(reviewer1, reviewer2, reviewer3)

    // When
    val task = reviewAssignmentService.assignReviewer(submission)

    // Then
    assertThat(task.assignedToUserId).isEqualTo(reviewer2.id)
}

@Test
fun `SLA escalation should calculate correct level`() {
    // Given
    val task = createReviewTask(dueDate = LocalDateTime.now().minusDays(5))

    // When
    val level = task.calculateEscalationLevel()

    // Then
    assertThat(level).isEqualTo(2) // 5 days overdue = Level 2
}

Integration Tests

@QuarkusTest
@TestTransaction
class ReviewWorkflowIntegrationTest {
    @Test
    fun `full review workflow from validation to approval`() {
        // Given
        val submission = createAndSaveSubmission(state = SubmissionState.VALIDATED)

        // When: Auto-assign reviewer
        val reviewTask = reviewAssignmentService.assignReviewer(submission)

        // Then
        assertThat(reviewTask.state).isEqualTo(ReviewTaskState.PENDING)
        assertThat(submission.state).isEqualTo(SubmissionState.UNDER_REVIEW)

        // When: Reviewer approves
        reviewService.approveSubmission(
            submissionId = submission.id,
            reviewerId = reviewTask.assignedToUserId,
            comment = "Looks good",
            confidence = ConfidenceLevel.HIGH
        )

        // Then
        val updatedSubmission = submissionRepository.findById(submission.id).get()
        assertThat(updatedSubmission.state).isEqualTo(SubmissionState.REVIEWED)

        val approvalTask = approvalTaskRepository.findBySubmissionId(submission.id)
        assertThat(approvalTask).isNotNull
        assertThat(approvalTask.state).isEqualTo(ApprovalTaskState.PENDING)

        // When: Approver approves
        approvalService.approveSubmission(
            submissionId = submission.id,
            approverId = approvalTask.assignedToUserId,
            justification = "Approved for Q1 2026 report",
            signOff = true
        )

        // Then
        val finalSubmission = submissionRepository.findById(submission.id).get()
        assertThat(finalSubmission.state).isEqualTo(SubmissionState.APPROVED)
        assertThat(finalSubmission.approvedAt).isNotNull()
    }
}

Load Tests

@Test
fun `should handle 1000 concurrent reviewer assignments`() {
    val submissions = (1..1000).map { createSubmission() }
    val latch = CountDownLatch(1000)
    val executor = Executors.newFixedThreadPool(50)

    submissions.forEach { submission ->
        executor.submit {
            try {
                reviewAssignmentService.assignReviewer(submission)
            } finally {
                latch.countDown()
            }
        }
    }

    val completed = latch.await(30, TimeUnit.SECONDS)
    assertThat(completed).isTrue()

    // Verify workload balance (no reviewer should have > 2x average)
    val workloads = getReviewerWorkloads()
    val avgWorkload = workloads.map { it.pendingCount }.average()
    val maxWorkload = workloads.maxOf { it.pendingCount }

    assertThat(maxWorkload).isLessThan(avgWorkload * 2)
}

Security Tests

@Test
fun `should prevent cross-tenant task access`() {
    // Given
    val tenant1Submission = createSubmission(tenantId = tenant1)
    val tenant2User = createUser(tenantId = tenant2)

    val reviewTask = reviewAssignmentService.assignReviewer(tenant1Submission)

    // When / Then: User from tenant2 cannot approve tenant1 submission
    TenantContext.setTenantId(tenant2)

    assertThrows<SubmissionNotFoundException> {
        reviewService.approveSubmission(
            submissionId = tenant1Submission.id,
            reviewerId = tenant2User.id,
            comment = "Approved",
            confidence = ConfidenceLevel.HIGH
        )
    }
}

Acceptance Criteria

  • Reviewers cannot review own submissions (enforced by database constraint and API validation)
  • Approvers cannot approve own submissions (enforced by database constraint and API validation)
  • Reviewer = Approver allowed with audit log warning flag
  • Review tasks auto-assigned based on workload balancing algorithm
  • Approval tasks auto-assigned based on workload balancing algorithm
  • SLA tracking with 3 escalation levels for review tasks (1, 3, 7 days overdue)
  • SLA tracking with 2 escalation levels for approval tasks (1, 2 days overdue)
  • All approval actions logged to audit trail with timestamp, actor, IP, user agent
  • Notification triggers for 10 event types (assigned, reviewed, rejected, approved, overdue, escalated, critical)
  • Multi-channel notifications (email, in-app, SMS for urgent, phone call for critical)
  • Workload balancing ensures fair distribution across reviewers
  • Priority management: URGENT for anomalies, HIGH for mandatory metrics, NORMAL for others
  • Tenant isolation enforced at database and application layers
  • Complete Kotlin/Spring Boot implementation examples
  • Performance targets: < 1 second for approve/reject, < 500ms for assign
  • Comprehensive testing strategy (unit, integration, load, security)

Cross-References


Change Log

Version Date Author Changes
1.0 2026-01-03 Senior Product Architect Initial review/approval workflow specification
2.0 2026-01-11 Claude Sonnet 4.5 Comprehensive expansion: task assignment algorithms, SLA tracking with 3 escalation levels, notification triggers (10 types), workload balancing, priority management, Kotlin/Spring Boot implementations, performance/security/testing sections