From e58472c963f551b7a635b000d3aed5b3929ef212 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Mon, 13 Oct 2025 08:46:26 +0700 Subject: [PATCH] add revision number --- internal/contract/letter_outgoing_contract.go | 7 + internal/entities/approval_flow.go | 23 +- internal/entities/letter_outgoing.go | 16 +- .../entities/letter_outgoing_activity_log.go | 1 + internal/handler/letter_outgoing_handler.go | 22 ++ .../processor/letter_outgoing_processor.go | 223 ++++++++++++++++-- internal/router/health_handler.go | 1 + internal/router/router.go | 1 + internal/service/letter_outgoing_service.go | 73 ++++-- ...evision_number_to_letter_outgoing.down.sql | 8 + ..._revision_number_to_letter_outgoing.up.sql | 8 + ...mber_to_attachments_and_approvals.down.sql | 14 ++ ...number_to_attachments_and_approvals.up.sql | 15 ++ 13 files changed, 364 insertions(+), 48 deletions(-) create mode 100644 migrations/000042_add_revision_number_to_letter_outgoing.down.sql create mode 100644 migrations/000042_add_revision_number_to_letter_outgoing.up.sql create mode 100644 migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql create mode 100644 migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql diff --git a/internal/contract/letter_outgoing_contract.go b/internal/contract/letter_outgoing_contract.go index e842828..702a993 100644 --- a/internal/contract/letter_outgoing_contract.go +++ b/internal/contract/letter_outgoing_contract.go @@ -107,6 +107,7 @@ type OutgoingLetterResponse struct { IssueDate time.Time `json:"issue_date"` Status string `json:"status"` ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"` + RevisionNumber int `json:"revision_number"` CreatedBy uuid.UUID `json:"created_by"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` @@ -157,6 +158,12 @@ type RejectLetterRequest struct { Reason string `json:"reason" validate:"required"` } +type ReviseLetterRequest struct { + FileURL string `json:"file_url" validate:"required"` + FileName string `json:"file_name" validate:"required"` + FileType string `json:"file_type" validate:"required"` +} + type AddRecipientsRequest struct { Recipients []CreateOutgoingLetterRecipient `json:"recipients" validate:"required,dive"` } diff --git a/internal/entities/approval_flow.go b/internal/entities/approval_flow.go index 7b9e877..d1fd7d5 100644 --- a/internal/entities/approval_flow.go +++ b/internal/entities/approval_flow.go @@ -49,17 +49,18 @@ const ( ) type LetterOutgoingApproval struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"` - StepOrder int `gorm:"not null" json:"step_order"` - ParallelGroup int `gorm:"default:1" json:"parallel_group"` - IsRequired bool `gorm:"default:true" json:"is_required"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` + StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"` + RevisionNumber int `gorm:"not null;default:0" json:"revision_number"` + StepOrder int `gorm:"not null" json:"step_order"` + ParallelGroup int `gorm:"default:1" json:"parallel_group"` + IsRequired bool `gorm:"default:true" json:"is_required"` + ApproverID *uuid.UUID `json:"approver_id,omitempty"` + Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"` + Remarks *string `json:"remarks,omitempty"` + ActedAt *time.Time `json:"acted_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` // Relations Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"` diff --git a/internal/entities/letter_outgoing.go b/internal/entities/letter_outgoing.go index 1bbbcee..35e6fcf 100644 --- a/internal/entities/letter_outgoing.go +++ b/internal/entities/letter_outgoing.go @@ -28,6 +28,7 @@ type LetterOutgoing struct { IssueDate time.Time `json:"issue_date"` Status LetterOutgoingStatus `gorm:"not null;default:'draft'" json:"status"` ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"` + RevisionNumber int `gorm:"not null;default:0" json:"revision_number"` CreatedBy uuid.UUID `gorm:"not null" json:"created_by"` IsArchived bool `gorm:"not null;default:false" json:"is_archived"` ArchivedAt *time.Time `json:"archived_at,omitempty"` @@ -67,13 +68,14 @@ type LetterOutgoingRecipient struct { func (LetterOutgoingRecipient) TableName() string { return "letter_outgoing_recipients" } type LetterOutgoingAttachment struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - FileURL string `gorm:"not null" json:"file_url"` - FileName string `gorm:"not null" json:"file_name"` - FileType string `gorm:"not null" json:"file_type"` - UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` - UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` + RevisionNumber int `gorm:"not null;default:0" json:"revision_number"` + FileURL string `gorm:"not null" json:"file_url"` + FileName string `gorm:"not null" json:"file_name"` + FileType string `gorm:"not null" json:"file_type"` + UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` + UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"` } func (LetterOutgoingAttachment) TableName() string { return "letter_outgoing_attachments" } diff --git a/internal/entities/letter_outgoing_activity_log.go b/internal/entities/letter_outgoing_activity_log.go index 7fe4f71..d8ba089 100644 --- a/internal/entities/letter_outgoing_activity_log.go +++ b/internal/entities/letter_outgoing_activity_log.go @@ -36,6 +36,7 @@ const ( LetterOutgoingActionSubmittedApproval = "submitted_for_approval" LetterOutgoingActionApproved = "approved" LetterOutgoingActionRejected = "rejected" + LetterOutgoingActionRevised = "revised" LetterOutgoingActionSent = "sent" LetterOutgoingActionArchived = "archived" LetterOutgoingActionAttachmentAdded = "attachment_added" diff --git a/internal/handler/letter_outgoing_handler.go b/internal/handler/letter_outgoing_handler.go index 3971a30..40a4657 100644 --- a/internal/handler/letter_outgoing_handler.go +++ b/internal/handler/letter_outgoing_handler.go @@ -23,6 +23,7 @@ type LetterOutgoingService interface { SubmitForApproval(ctx context.Context, letterID uuid.UUID) error ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error + ReviseOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ReviseLetterRequest) error SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error @@ -217,6 +218,27 @@ func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) { c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "rejected"})) } +func (h *LetterOutgoingHandler) ReviseOutgoingLetter(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) + return + } + + var req contract.ReviseLetterRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest}) + return + } + + if err := h.svc.ReviseOutgoingLetter(c.Request.Context(), id, &req); err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "revised"})) +} + func (h *LetterOutgoingHandler) SendOutgoingLetter(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) if err != nil { diff --git a/internal/processor/letter_outgoing_processor.go b/internal/processor/letter_outgoing_processor.go index 7128df0..7ef818a 100644 --- a/internal/processor/letter_outgoing_processor.go +++ b/internal/processor/letter_outgoing_processor.go @@ -24,8 +24,9 @@ type LetterOutgoingProcessor interface { ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID, userID uuid.UUID) error ProcessApprovalSubmission(ctx context.Context, letterID uuid.UUID, approvalFlowID uuid.UUID, userID uuid.UUID) error - ProcessApproval(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID, allApproved bool) error + ProcessApproval(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error ProcessRejection(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error + ProcessRevision(ctx context.Context, letterID uuid.UUID, attachment entities.LetterOutgoingAttachment, userID uuid.UUID) error AddRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterOutgoingRecipient, userID uuid.UUID) error UpdateRecipient(ctx context.Context, recipient *entities.LetterOutgoingRecipient) error @@ -40,6 +41,7 @@ type LetterOutgoingProcessor interface { DeleteDiscussion(ctx context.Context, id uuid.UUID) error GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) + GetApprovalsByLetterAndRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) ([]entities.LetterOutgoingApproval, error) GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error) // GetOutgoingLetterWithDetails fetches letter with all related data @@ -397,6 +399,12 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont } return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + // Get the letter to get the current revision number + letter, err := p.letterRepo.Get(txCtx, letterID) + if err != nil { + return err + } + // Find the minimum step order (first step) minStepOrder := flow.Steps[0].StepOrder for _, step := range flow.Steps { @@ -408,13 +416,14 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont approvals := make([]entities.LetterOutgoingApproval, len(flow.Steps)) for i, step := range flow.Steps { approvals[i] = entities.LetterOutgoingApproval{ - LetterID: letterID, - StepID: step.ID, - StepOrder: step.StepOrder, - ParallelGroup: step.ParallelGroup, - IsRequired: step.Required, - ApproverID: step.ApproverUserID, - Status: entities.ApprovalStatusPending, + LetterID: letterID, + StepID: step.ID, + RevisionNumber: letter.RevisionNumber, + StepOrder: step.StepOrder, + ParallelGroup: step.ParallelGroup, + IsRequired: step.Required, + ApproverID: step.ApproverUserID, + Status: entities.ApprovalStatusPending, } // Set status based on step order @@ -483,15 +492,15 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont }) } -func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID, allApproved bool) error { +func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID) error { return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { // Step 1: Update the approval record if err := p.updateApprovalRecord(txCtx, approval, userID); err != nil { return err } - // Step 2: Get all approvals and organize by step - approvalsByStep, err := p.getApprovalsByStep(txCtx, letterID) + // Step 2: Get all approvals FOR THE SAME REVISION and organize by step + approvalsByStep, err := p.getApprovalsByStepForRevision(txCtx, letterID, approval.RevisionNumber) if err != nil { return err } @@ -499,12 +508,12 @@ func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, lette // Step 3: Check if current step is completed if p.isStepCompleted(approvalsByStep[approval.StepOrder]) { // Step 4: Activate next step if exists - if err := p.activateNextStep(txCtx, letterID, approval.StepOrder, approvalsByStep); err != nil { + if err := p.activateNextStepForRevision(txCtx, letterID, approval.StepOrder, approval.RevisionNumber, approvalsByStep); err != nil { return err } } - // Step 5: Check if all required approvals are completed + // Step 5: Check if all required approvals are completed FOR THIS REVISION if p.areAllRequiredApprovalsCompleted(approvalsByStep) { // Step 6: Update letter status to approved if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil { @@ -542,6 +551,24 @@ func (p *LetterOutgoingProcessorImpl) getApprovalsByStep(ctx context.Context, le return approvalsByStep, nil } +// getApprovalsByStepForRevision fetches approvals for a specific revision and organizes them by step order +func (p *LetterOutgoingProcessorImpl) getApprovalsByStepForRevision(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 + } + + approvalsByStep := make(map[int][]entities.LetterOutgoingApproval) + for _, approval := range allApprovals { + // Only include approvals from the same revision + if approval.RevisionNumber == revisionNumber { + approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval) + } + } + + return approvalsByStep, nil +} + // isStepCompleted checks if all required approvals in a step are approved func (p *LetterOutgoingProcessorImpl) isStepCompleted(stepApprovals []entities.LetterOutgoingApproval) bool { for _, approval := range stepApprovals { @@ -582,6 +609,39 @@ func (p *LetterOutgoingProcessorImpl) activateNextStep(ctx context.Context, lett return nil } +// activateNextStepForRevision activates the next approval step for a specific revision +func (p *LetterOutgoingProcessorImpl) activateNextStepForRevision(ctx context.Context, letterID uuid.UUID, currentStepOrder int, revisionNumber int, approvalsByStep map[int][]entities.LetterOutgoingApproval) error { + nextStepOrder := currentStepOrder + 1 + nextStepApprovals, exists := approvalsByStep[nextStepOrder] + if !exists { + return nil // No next step + } + + // Get existing recipients to avoid duplicates + existingUserIDs, err := p.getExistingRecipientUserIDs(ctx, letterID) + if err != nil { + return err + } + + // Process each approval in the next step (already filtered by revision in approvalsByStep) + for _, nextApproval := range nextStepApprovals { + // 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 +} + // getExistingRecipientUserIDs gets a set of existing recipient user IDs func (p *LetterOutgoingProcessorImpl) getExistingRecipientUserIDs(ctx context.Context, letterID uuid.UUID) (map[uuid.UUID]bool, error) { currentRecipients, err := p.recipientRepo.ListByLetter(ctx, letterID) @@ -665,12 +725,39 @@ func (p *LetterOutgoingProcessorImpl) ProcessRejection(ctx context.Context, lett return err } + // Mark all other pending approvals in the same revision as rejected + allApprovals, err := p.approvalRepo.ListByLetter(txCtx, letterID) + if err != nil { + return err + } + + for i := range allApprovals { + // Only update other pending approvals from the same revision + if allApprovals[i].RevisionNumber == approval.RevisionNumber && + allApprovals[i].ID != approval.ID && + allApprovals[i].Status == entities.ApprovalStatusPending { + allApprovals[i].Status = entities.ApprovalStatusRejected + if err := p.approvalRepo.Update(txCtx, &allApprovals[i]); err != nil { + return err + } + } + } + if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusRejected); err != nil { return err } fromStatus := string(entities.LetterOutgoingStatusPendingApproval) toStatus := string(entities.LetterOutgoingStatusRejected) + + // Include rejection remarks in activity log context + var context entities.JSONB + if approval.Remarks != nil && *approval.Remarks != "" { + context = entities.JSONB{"remarks": *approval.Remarks, "revision_number": approval.RevisionNumber} + } else { + context = entities.JSONB{"revision_number": approval.RevisionNumber} + } + activityLog := &entities.LetterOutgoingActivityLog{ LetterID: letterID, ActionType: entities.LetterOutgoingActionRejected, @@ -678,6 +765,79 @@ func (p *LetterOutgoingProcessorImpl) ProcessRejection(ctx context.Context, lett TargetID: &approval.ID, FromStatus: &fromStatus, ToStatus: &toStatus, + Context: context, + } + if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { + return err + } + + return nil + }) +} + +func (p *LetterOutgoingProcessorImpl) ProcessRevision(ctx context.Context, letterID uuid.UUID, attachment entities.LetterOutgoingAttachment, userID uuid.UUID) error { + return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + // Get the current letter + letter, err := p.letterRepo.Get(txCtx, letterID) + if err != nil { + return err + } + + // Increment revision number + letter.RevisionNumber++ + + // Set revision number on the new attachment + attachment.RevisionNumber = letter.RevisionNumber + + // Add the new attachment + if err := p.attachmentRepo.Create(txCtx, &attachment); err != nil { + return err + } + + // Update letter with new revision number + if err := p.letterRepo.Update(txCtx, letter); err != nil { + return err + } + + // Update status to pending approval (ready for re-submission) + if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil { + return err + } + + // Get existing approvals for the current revision + approvals, err := p.approvalRepo.ListByLetter(txCtx, letterID) + if err != nil { + return err + } + + // Create new approval records for the new revision + for _, approval := range approvals { + // Create a new approval for the new revision + newApproval := entities.LetterOutgoingApproval{ + LetterID: approval.LetterID, + StepID: approval.StepID, + RevisionNumber: letter.RevisionNumber, + StepOrder: approval.StepOrder, + ParallelGroup: approval.ParallelGroup, + IsRequired: approval.IsRequired, + Status: entities.ApprovalStatusPending, + } + if err := p.approvalRepo.Create(txCtx, &newApproval); err != nil { + return err + } + } + + // Log activity + fromStatus := string(entities.LetterOutgoingStatusRejected) + toStatus := string(entities.LetterOutgoingStatusPendingApproval) + activityLog := &entities.LetterOutgoingActivityLog{ + LetterID: letterID, + ActionType: entities.LetterOutgoingActionRevised, + ActorUserID: &userID, + TargetID: &attachment.ID, + FromStatus: &fromStatus, + ToStatus: &toStatus, + Context: entities.JSONB{"attachment": attachment.FileName, "revision_number": letter.RevisionNumber}, } if err := p.activityLogRepo.Create(txCtx, activityLog); err != nil { return err @@ -732,6 +892,17 @@ func (p *LetterOutgoingProcessorImpl) RemoveRecipient(ctx context.Context, lette func (p *LetterOutgoingProcessorImpl) AddAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment, userID uuid.UUID) error { return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + // Get the letter to get the current revision number + letter, err := p.letterRepo.Get(txCtx, letterID) + if err != nil { + return err + } + + // Set revision number on all attachments + for i := range attachments { + attachments[i].RevisionNumber = letter.RevisionNumber + } + if err := p.attachmentRepo.CreateBulk(txCtx, attachments); err != nil { return err } @@ -808,7 +979,31 @@ func (p *LetterOutgoingProcessorImpl) DeleteDiscussion(ctx context.Context, id u } func (p *LetterOutgoingProcessorImpl) GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) { - return p.approvalRepo.ListByLetter(ctx, letterID) + // Get the letter first to know the current revision + letter, err := p.letterRepo.Get(ctx, letterID) + if err != nil { + return nil, err + } + + return p.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) +} + +func (p *LetterOutgoingProcessorImpl) GetApprovalsByLetterAndRevision(ctx context.Context, letterID uuid.UUID, revisionNumber int) ([]entities.LetterOutgoingApproval, error) { + // Get all approvals for this letter + approvals, err := p.approvalRepo.ListByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + // Filter to only return approvals for the specified revision + var currentRevisionApprovals []entities.LetterOutgoingApproval + for _, approval := range approvals { + if approval.RevisionNumber == revisionNumber { + currentRevisionApprovals = append(currentRevisionApprovals, approval) + } + } + + return currentRevisionApprovals, nil } func (p *LetterOutgoingProcessorImpl) GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error) { diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index eca4276..0a15b18 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -106,6 +106,7 @@ type LetterOutgoingHandler interface { SubmitForApproval(c *gin.Context) ApproveOutgoingLetter(c *gin.Context) RejectOutgoingLetter(c *gin.Context) + ReviseOutgoingLetter(c *gin.Context) SendOutgoingLetter(c *gin.Context) ArchiveOutgoingLetter(c *gin.Context) GetLetterApprovalInfo(c *gin.Context) diff --git a/internal/router/router.go b/internal/router/router.go index 831abf7..92c3bff 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -193,6 +193,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.POST("/outgoing/:id/submit", r.letterOutgoingHandler.SubmitForApproval) lettersch.POST("/outgoing/:id/approve", r.letterOutgoingHandler.ApproveOutgoingLetter) lettersch.POST("/outgoing/:id/reject", r.letterOutgoingHandler.RejectOutgoingLetter) + lettersch.POST("/outgoing/:id/revise", r.letterOutgoingHandler.ReviseOutgoingLetter) lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter) lettersch.POST("/outgoing/:id/archive", r.letterOutgoingHandler.ArchiveOutgoingLetter) lettersch.POST("/outgoing/archive", r.letterOutgoingHandler.BulkArchiveOutgoingLetters) diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index e1c0db2..4e86b20 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -628,7 +628,8 @@ func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, l return gorm.ErrInvalidData } - approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) + // Get approvals for the current revision only + approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return err } @@ -651,15 +652,7 @@ func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, l currentApproval.Remarks = req.Remarks - allApproved := true - for _, approval := range approvals { - if approval.ID != currentApproval.ID && approval.Status == entities.ApprovalStatusPending { - allApproved = false - break - } - } - - err = s.processor.ProcessApproval(ctx, letterID, currentApproval, userID, allApproved) + err = s.processor.ProcessApproval(ctx, letterID, currentApproval, userID) if err != nil { return err } @@ -691,7 +684,8 @@ func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, le return gorm.ErrInvalidData } - approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) + // Get approvals for the current revision only + approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return err } @@ -728,6 +722,35 @@ func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, le return nil } +func (s *LetterOutgoingServiceImpl) ReviseOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ReviseLetterRequest) error { + userID := getUserIDFromContext(ctx) + + letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) + if err != nil { + return err + } + + // Can only revise rejected letters + if letter.Status != entities.LetterOutgoingStatusRejected { + return gorm.ErrInvalidData + } + + attachment := entities.LetterOutgoingAttachment{ + LetterID: letterID, + FileURL: req.FileURL, + FileName: req.FileName, + FileType: req.FileType, + UploadedBy: &userID, + } + + err = s.processor.ProcessRevision(ctx, letterID, attachment, userID) + if err != nil { + return err + } + + return nil +} + func (s *LetterOutgoingServiceImpl) SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error { userID := getUserIDFromContext(ctx) @@ -968,8 +991,8 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l return nil, err } - // Get all approvals for this letter - approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) + // Get all approvals for this letter's current revision + approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return nil, err } @@ -1030,6 +1053,15 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l } } + // Add REVISE action if letter is rejected and user is the creator + if letter.Status == entities.LetterOutgoingStatusRejected && letter.CreatedBy == userID { + actions = append(actions, contract.ApprovalAction{ + Type: "REVISE", + Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/revise", letterID), + Method: "POST", + }) + } + // Determine overall decision status decisionStatus := "PENDING" @@ -1080,8 +1112,8 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovals(ctx context.Context, lett return nil, err } - // Get all approvals for this letter - approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) + // Get all approvals for this letter's current revision + approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return nil, err } @@ -1435,6 +1467,7 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi IssueDate: letter.IssueDate, Status: string(letter.Status), ApprovalFlowID: letter.ApprovalFlowID, + RevisionNumber: letter.RevisionNumber, CreatedBy: letter.CreatedBy, CreatedAt: letter.CreatedAt, UpdatedAt: letter.UpdatedAt, @@ -1778,7 +1811,15 @@ func (s *LetterOutgoingServiceImpl) BulkArchiveOutgoingLetters(ctx context.Conte func (s *LetterOutgoingServiceImpl) sendStepApprovalNotifications(ctx context.Context, letterID uuid.UUID, subject string, stepOrder int) { log.Printf("[DEBUG] sendStepApprovalNotifications START - LetterID: %s, StepOrder: %d", letterID.String(), stepOrder) - approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) + // 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 diff --git a/migrations/000042_add_revision_number_to_letter_outgoing.down.sql b/migrations/000042_add_revision_number_to_letter_outgoing.down.sql new file mode 100644 index 0000000..68cc7c0 --- /dev/null +++ b/migrations/000042_add_revision_number_to_letter_outgoing.down.sql @@ -0,0 +1,8 @@ +BEGIN; + +DROP INDEX IF EXISTS idx_letters_outgoing_revision_number; + +ALTER TABLE letters_outgoing +DROP COLUMN IF EXISTS revision_number; + +COMMIT; \ No newline at end of file diff --git a/migrations/000042_add_revision_number_to_letter_outgoing.up.sql b/migrations/000042_add_revision_number_to_letter_outgoing.up.sql new file mode 100644 index 0000000..d820989 --- /dev/null +++ b/migrations/000042_add_revision_number_to_letter_outgoing.up.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE letters_outgoing +ADD COLUMN IF NOT EXISTS revision_number INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_letters_outgoing_revision_number ON letters_outgoing(revision_number); + +COMMIT; \ No newline at end of file diff --git a/migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql b/migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql new file mode 100644 index 0000000..3e5bf2a --- /dev/null +++ b/migrations/000043_add_revision_number_to_attachments_and_approvals.down.sql @@ -0,0 +1,14 @@ +BEGIN; + +-- Drop indexes +DROP INDEX IF EXISTS idx_letter_outgoing_attachments_revision; +DROP INDEX IF EXISTS idx_letter_outgoing_approvals_revision; + +-- Remove revision_number from tables +ALTER TABLE letter_outgoing_attachments +DROP COLUMN IF EXISTS revision_number; + +ALTER TABLE letter_outgoing_approvals +DROP COLUMN IF EXISTS revision_number; + +COMMIT; \ No newline at end of file diff --git a/migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql b/migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql new file mode 100644 index 0000000..bef8a32 --- /dev/null +++ b/migrations/000043_add_revision_number_to_attachments_and_approvals.up.sql @@ -0,0 +1,15 @@ +BEGIN; + +-- Add revision_number to letter_outgoing_attachments +ALTER TABLE letter_outgoing_attachments +ADD COLUMN IF NOT EXISTS revision_number INTEGER NOT NULL DEFAULT 0; + +-- Add revision_number to letter_outgoing_approvals +ALTER TABLE letter_outgoing_approvals +ADD COLUMN IF NOT EXISTS revision_number INTEGER NOT NULL DEFAULT 0; + +-- Create indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_revision ON letter_outgoing_attachments(letter_id, revision_number); +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_revision ON letter_outgoing_approvals(letter_id, revision_number); + +COMMIT; \ No newline at end of file