From e082d4fce5bfaa666574748afa62d1f9a42a57dd Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Mon, 20 Oct 2025 01:10:48 +0700 Subject: [PATCH] Update group approvals --- .../processor/letter_approval_processor.go | 12 +- .../processor/letter_outgoing_processor.go | 106 +++++++++- internal/service/letter_outgoing_service.go | 196 ++++++++++-------- 3 files changed, 218 insertions(+), 96 deletions(-) diff --git a/internal/processor/letter_approval_processor.go b/internal/processor/letter_approval_processor.go index dfcb7a8..241a1a3 100644 --- a/internal/processor/letter_approval_processor.go +++ b/internal/processor/letter_approval_processor.go @@ -45,11 +45,11 @@ func (p *LetterApprovalProcessorImpl) CreateApprovalSteps(ctx context.Context, l return err } - // Find the minimum step order (first step) - minStepOrder := flow.Steps[0].StepOrder + // Find the minimum parallel group + minParallelGroup := flow.Steps[0].ParallelGroup for _, step := range flow.Steps { - if step.StepOrder < minStepOrder { - minStepOrder = step.StepOrder + if step.ParallelGroup < minParallelGroup { + minParallelGroup = step.ParallelGroup } } @@ -64,8 +64,8 @@ func (p *LetterApprovalProcessorImpl) CreateApprovalSteps(ctx context.Context, l ApproverID: step.ApproverUserID, } - // Set initial status - if step.StepOrder == minStepOrder { + // Set initial status - all approvals in first parallel group are pending + if step.ParallelGroup == minParallelGroup { approval.Status = entities.ApprovalStatusPending } else { approval.Status = entities.ApprovalStatusNotStarted diff --git a/internal/processor/letter_outgoing_processor.go b/internal/processor/letter_outgoing_processor.go index 20bc03a..bf66325 100644 --- a/internal/processor/letter_outgoing_processor.go +++ b/internal/processor/letter_outgoing_processor.go @@ -3,6 +3,7 @@ package processor import ( "context" "fmt" + "sort" "time" "eslogad-be/internal/contract" @@ -502,22 +503,22 @@ func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, lette return err } - // Step 2: Get all approvals FOR THE SAME REVISION and organize by step - approvalsByStep, err := p.getApprovalsByStepForRevision(txCtx, letterID, approval.RevisionNumber) + // Step 2: Get all approvals FOR THE SAME REVISION and organize by parallel group + approvalsByGroup, err := p.getApprovalsByParallelGroupForRevision(txCtx, letterID, approval.RevisionNumber) if err != nil { return err } - // Step 3: Check if current step is completed - if p.isStepCompleted(approvalsByStep[approval.StepOrder]) { - // Step 4: Activate next step if exists - if err := p.activateNextStepForRevision(txCtx, letterID, approval.StepOrder, approval.RevisionNumber, approvalsByStep); err != nil { + // Step 3: Check if current parallel group is completed + if p.isParallelGroupCompleted(approvalsByGroup[approval.ParallelGroup]) { + // Step 4: Activate next parallel group if exists + if err := p.activateNextParallelGroupForRevision(txCtx, letterID, approval.ParallelGroup, approval.RevisionNumber, approvalsByGroup); err != nil { return err } } // Step 5: Check if all required approvals are completed FOR THIS REVISION - if p.areAllRequiredApprovalsCompleted(approvalsByStep) { + if p.areAllRequiredApprovalsCompletedByGroup(approvalsByGroup) { // Step 6: Update letter status to approved if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil { return err @@ -572,6 +573,24 @@ func (p *LetterOutgoingProcessorImpl) getApprovalsByStepForRevision(ctx context. return approvalsByStep, nil } +// getApprovalsByParallelGroupForRevision fetches approvals for a specific revision and organizes them by parallel group +func (p *LetterOutgoingProcessorImpl) getApprovalsByParallelGroupForRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) (map[int][]entities.LetterOutgoingApproval, error) { + allApprovals, err := p.approvalRepo.ListByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + approvalsByGroup := make(map[int][]entities.LetterOutgoingApproval) + for _, approval := range allApprovals { + // Only include approvals from the same revision + if approval.RevisionNumber == revisionNumber { + approvalsByGroup[approval.ParallelGroup] = append(approvalsByGroup[approval.ParallelGroup], approval) + } + } + + return approvalsByGroup, nil +} + // isStepCompleted checks if all required approvals in a step are approved // For parallel groups, at least one approval per group must be completed func (p *LetterOutgoingProcessorImpl) isStepCompleted(stepApprovals []entities.LetterOutgoingApproval) bool { @@ -741,6 +760,79 @@ func (p *LetterOutgoingProcessorImpl) areAllRequiredApprovalsCompleted(approvals return true } +// isParallelGroupCompleted checks if all required approvals in a parallel group are approved +func (p *LetterOutgoingProcessorImpl) isParallelGroupCompleted(groupApprovals []entities.LetterOutgoingApproval) bool { + for _, approval := range groupApprovals { + if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { + return false + } + } + return true +} + +// activateNextParallelGroupForRevision activates the next parallel group for a specific revision +func (p *LetterOutgoingProcessorImpl) activateNextParallelGroupForRevision(ctx context.Context, letterID uuid.UUID, currentGroup int, revisionNumber int, approvalsByGroup map[int][]entities.LetterOutgoingApproval) error { + // Find the next parallel group (handles non-sequential group numbers) + var groups []int + for group := range approvalsByGroup { + groups = append(groups, group) + } + sort.Ints(groups) + + var nextGroup int = -1 + for _, group := range groups { + if group > currentGroup { + nextGroup = group + break + } + } + + if nextGroup == -1 { + return nil // No next group + } + + nextGroupApprovals, exists := approvalsByGroup[nextGroup] + if !exists { + return nil + } + + // Get existing recipients to avoid duplicates + existingUserIDs, err := p.getExistingRecipientUserIDs(ctx, letterID) + if err != nil { + return err + } + + // Process each approval in the next group + for _, nextApproval := range nextGroupApprovals { + // Only process if it's the same revision + if nextApproval.RevisionNumber == revisionNumber { + // Activate approval if not started + if err := p.activateApprovalIfNotStarted(ctx, &nextApproval); err != nil { + return err + } + + // Add approver as recipient if not already exists + if err := p.addApproverAsRecipientIfNeeded(ctx, letterID, nextApproval.ApproverID, existingUserIDs); err != nil { + return err + } + } + } + + return nil +} + +// areAllRequiredApprovalsCompletedByGroup checks if all required approvals are completed (organized by parallel group) +func (p *LetterOutgoingProcessorImpl) areAllRequiredApprovalsCompletedByGroup(approvalsByGroup map[int][]entities.LetterOutgoingApproval) bool { + for _, groupApprovals := range approvalsByGroup { + for _, approval := range groupApprovals { + if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { + return false + } + } + } + return true +} + // logApprovalActivity creates an activity log for the approval action func (p *LetterOutgoingProcessorImpl) logApprovalActivity(ctx context.Context, letterID, approvalID uuid.UUID, userID uuid.UUID) error { activityLog := &entities.LetterOutgoingActivityLog{ diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index 8cefb1f..e4a2afe 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -634,15 +634,14 @@ func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, l return err } + // Find user's pending approval var currentApproval *entities.LetterOutgoingApproval for i := range approvals { - if approvals[i].Status == entities.ApprovalStatusPending { - step := approvals[i].Step - if (step.ApproverUserID != nil && *step.ApproverUserID == userID) || - (step.ApproverRoleID != nil && userHasRole(ctx, *step.ApproverRoleID)) { - currentApproval = &approvals[i] - break - } + if approvals[i].Status == entities.ApprovalStatusPending && + approvals[i].ApproverID != nil && + *approvals[i].ApproverID == userID { + currentApproval = &approvals[i] + break } } @@ -659,14 +658,21 @@ func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, l // Send notifications after successful approval if s.notificationProcessor != nil { - // Step approved but not final - notify creator about step completion AND next approvers - creatorMessage := fmt.Sprintf("Surat keluar '%s' telah disetujui pada tahap %d, menunggu persetujuan tahap berikutnya", letter.Subject, currentApproval.StepOrder) - go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Disetujui Tahap "+fmt.Sprintf("%d", currentApproval.StepOrder), creatorMessage) - - // Notify next step approvers - nextStepOrder := currentApproval.StepOrder + 1 - go s.sendStepApprovalNotifications(context.Background(), letterID, letter.Subject, nextStepOrder) + // Get next parallel group to determine notification message + nextParallelGroup := s.getNextParallelGroup(approvals, currentApproval.ParallelGroup) + + if nextParallelGroup > 0 { + // Notify creator about group completion AND next group approvers + creatorMessage := fmt.Sprintf("Surat keluar '%s' telah disetujui pada grup %d, menunggu persetujuan grup berikutnya", letter.Subject, currentApproval.ParallelGroup) + go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Disetujui Grup "+fmt.Sprintf("%d", currentApproval.ParallelGroup), creatorMessage) + // Notify next parallel group approvers + go s.sendParallelGroupApprovalNotifications(context.Background(), letterID, letter.Subject, nextParallelGroup) + } else { + // All groups completed + creatorMessage := fmt.Sprintf("Surat keluar '%s' telah selesai disetujui", letter.Subject) + go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Disetujui", creatorMessage) + } } return nil @@ -690,15 +696,14 @@ func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, le return err } + // Find user's pending approval var currentApproval *entities.LetterOutgoingApproval for i := range approvals { - if approvals[i].Status == entities.ApprovalStatusPending { - step := approvals[i].Step - if (step.ApproverUserID != nil && *step.ApproverUserID == userID) || - (step.ApproverRoleID != nil && userHasRole(ctx, *step.ApproverRoleID)) { - currentApproval = &approvals[i] - break - } + if approvals[i].Status == entities.ApprovalStatusPending && + approvals[i].ApproverID != nil && + *approvals[i].ApproverID == userID { + currentApproval = &approvals[i] + break } } @@ -715,7 +720,7 @@ func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, le // Send notification to letter creator (rejection always notifies creator) if s.notificationProcessor != nil { - message := fmt.Sprintf("Surat keluar '%s' ditolak pada tahap %d dengan alasan: %s", letter.Subject, currentApproval.StepOrder, req.Reason) + message := fmt.Sprintf("Surat keluar '%s' ditolak pada grup %d dengan alasan: %s", letter.Subject, currentApproval.ParallelGroup, req.Reason) go s.sendApprovalNotificationToCreator(context.Background(), letterID, letter.CreatedBy, "Surat Keluar Ditolak", message) } @@ -997,69 +1002,20 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l return nil, err } - // Group approvals by step order and parallel group to understand the workflow - approvalsByStepAndGroup := make(map[int]map[int][]entities.LetterOutgoingApproval) - for _, approval := range approvals { - if approvalsByStepAndGroup[approval.StepOrder] == nil { - approvalsByStepAndGroup[approval.StepOrder] = make(map[int][]entities.LetterOutgoingApproval) - } - approvalsByStepAndGroup[approval.StepOrder][approval.ParallelGroup] = append( - approvalsByStepAndGroup[approval.StepOrder][approval.ParallelGroup], - approval, - ) - } - - // Find the current active step (lowest step order with pending approvals) - var currentStepOrder int = -1 - var userApproval *entities.LetterOutgoingApproval + // Simple check: can user approve if they have a pending approval var canApprove bool + var userApproval *entities.LetterOutgoingApproval - // Find the minimum step order that has pending approvals - for stepOrder, groupApprovals := range approvalsByStepAndGroup { - stepHasPending := false - - // Check each parallel group in this step - for _, groupMembers := range groupApprovals { - groupHasPending := false - var userApprovalInGroup *entities.LetterOutgoingApproval - - // Check if this group has pending approvals and if user is in this group - for i := range groupMembers { - approval := groupMembers[i] - if approval.Status == entities.ApprovalStatusPending { - groupHasPending = true - stepHasPending = true - - // Check if this user is an approver in this group - if approval.ApproverID != nil && *approval.ApproverID == userID { - userApprovalInGroup = &approval - } - } - } - - // If this is the earliest step with pending approvals and user is in a pending group - if groupHasPending && userApprovalInGroup != nil { - if currentStepOrder == -1 || stepOrder < currentStepOrder { - currentStepOrder = stepOrder - userApproval = userApprovalInGroup - // User can approve if they're in a parallel group with pending approvals at the current active step - canApprove = true - } else if stepOrder == currentStepOrder { - // Same step order - user can still approve if in parallel group - userApproval = userApprovalInGroup - canApprove = true - } - } - } - - // Track the lowest step with pending approvals - if stepHasPending && (currentStepOrder == -1 || stepOrder < currentStepOrder) { - currentStepOrder = stepOrder - // Reset canApprove if we found a lower step and user is not in it - if userApproval == nil || userApproval.StepOrder != stepOrder { - canApprove = false - userApproval = nil - } + for i := range approvals { + approval := approvals[i] + + // Check if this approval is pending and belongs to the current user + if approval.Status == entities.ApprovalStatusPending && + approval.ApproverID != nil && + *approval.ApproverID == userID { + canApprove = true + userApproval = &approval + break } } @@ -1933,6 +1889,80 @@ func (s *LetterOutgoingServiceImpl) sendApprovalNotificationToCreator(ctx contex } } +// Helper function to get the next parallel group number (handles non-sequential groups) +func (s *LetterOutgoingServiceImpl) getNextParallelGroup(approvals []entities.LetterOutgoingApproval, currentGroup int) int { + // Collect all unique parallel groups + groupsMap := make(map[int]bool) + for _, approval := range approvals { + groupsMap[approval.ParallelGroup] = true + } + + // Convert to sorted slice + var groups []int + for group := range groupsMap { + groups = append(groups, group) + } + sort.Ints(groups) + + // Find the next group after current + for _, group := range groups { + if group > currentGroup { + // Check if this group has any pending approvals + for _, approval := range approvals { + if approval.ParallelGroup == group && approval.Status == entities.ApprovalStatusNotStarted { + return group + } + } + } + } + + return 0 // No next group +} + +// Send notifications to approvers in a specific parallel group +func (s *LetterOutgoingServiceImpl) sendParallelGroupApprovalNotifications(ctx context.Context, letterID uuid.UUID, subject string, parallelGroup int) { + log.Printf("[DEBUG] sendParallelGroupApprovalNotifications START - LetterID: %s, ParallelGroup: %d", letterID.String(), parallelGroup) + + // Get the letter to know the current revision + letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) + if err != nil { + log.Printf("[ERROR] Failed to get letter: %v", err) + return + } + + // Get approvals for the current revision only + approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) + if err != nil { + log.Printf("[ERROR] Failed to get approvals: %v", err) + return + } + + log.Printf("[DEBUG] Found %d approvals", len(approvals)) + + // Find approvers for the specified parallel group + for _, approval := range approvals { + log.Printf("[DEBUG] Checking approval: ParallelGroup=%d, Status=%s, ApproverID=%v", + approval.ParallelGroup, approval.Status, approval.ApproverID) + + if approval.ParallelGroup == parallelGroup && approval.ApproverID != nil { + log.Printf("[DEBUG] Sending notification to approver %s for parallel group %d", approval.ApproverID.String(), parallelGroup) + + err := s.notificationProcessor.SendOutgoingLetterNotification( + ctx, + letterID, + *approval.ApproverID, + "Surat Keluar Perlu Persetujuan", + fmt.Sprintf("Surat keluar '%s' memerlukan persetujuan Anda pada grup %d", subject, parallelGroup)) + + if err != nil { + log.Printf("[ERROR] Failed to send notification to approver %s: %v", approval.ApproverID.String(), err) + } else { + log.Printf("[DEBUG] Successfully sent notification to approver %s", approval.ApproverID.String()) + } + } + } +} + func (s *LetterOutgoingServiceImpl) sendOutgoingDiscussionMentionNotifications(ctx context.Context, letterID uuid.UUID, senderUserID uuid.UUID, mentions map[string]interface{}, message string) { log.Printf("[DEBUG] sendOutgoingDiscussionMentionNotifications START - LetterID: %s", letterID.String())