Review & Approval Workflow
Status: Final Version: 2.0 Last Updated: 2026-01-11
Table of Contents
- Purpose
- Workflow Overview
- State Machine
- Segregation of Duties (SoD)
- Task Assignment Algorithms
- Review Process
- Approval Process
- SLA Tracking
- Notification Triggers
- Workload Balancing
- Priority Management
- Implementation Guide
- Performance Considerations
- Security Requirements
- Testing Strategy
- 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:
- Segregation of Duties (SoD) - No one can approve their own submission
- Two-Stage Review - Reviewer validation followed by Approver sign-off
- SLA Tracking - Time-bound review with escalation for overdue tasks
- Audit Trail - Complete chain-of-custody for regulatory compliance
- 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):
- Site Access - Reviewer must have access to the submission's site
- Segregation of Duties - Reviewer ≠ Submitter
- Skill Match - Reviewer has expertise in the metric category (optional)
- Workload Balance - Reviewer with lowest current pending review count
- 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):
- Organization Level - Approver must have authority over the site's organization
- Segregation of Duties - Approver ≠ Submitter (HARD), Approver may = Reviewer (SOFT)
- Workload Balance - Approver with lowest current pending approval count
- 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:
- Approve - Move submission to
REVIEWEDstate - Reject - Move submission to
REJECTEDstate with detailed feedback - 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:
- Approve - Move submission to
APPROVEDstate (final) - Reject - Move submission to
REJECTEDstate 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_REVIEWERorADMINrole 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:
- Reporting Period Deadline - Submissions near period closure are HIGH priority
- Mandatory Metrics - Mandatory metrics for GRI/SASB are HIGH priority
- Anomaly Flags - Submissions with anomaly warnings are URGENT priority
- 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
- Indexes:
(assigned_to_user_id, state)- Fast workload queries(due_date) WHERE state = 'PENDING'- Partial index for SLA monitoring(tenant_id)- Multi-tenant isolation-
(submission_id)- Fast submission task lookups -
Query Optimization:
- Use database views for workload summary queries
- Cache reviewer/approver eligibility lists (15-minute TTL)
-
Use pagination for task lists (max 100 items per page)
-
Async Processing:
- Task assignment happens asynchronously (don't block submission API)
- Notification sending uses @Async (don't block approval API)
- 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
- Admin API - API endpoints for review and approval
- Collector Workflow - Submission lifecycle before review
- Validation Engine - Validation that precedes review
- Ingestion Pipeline - How submissions enter the system
- Locking & Restatements - Period closure after all approvals
- Data Model - Database schema for tasks
- Security & Compliance - RBAC and audit requirements
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 |