package service import ( "context" "fmt" "log" "sort" "time" "eslogad-be/internal/appcontext" "eslogad-be/internal/contract" "eslogad-be/internal/entities" "eslogad-be/internal/processor" "eslogad-be/internal/repository" "github.com/google/uuid" "gorm.io/gorm" ) type LetterOutgoingService interface { CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error) UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID) error 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 SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error AddFinalAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error) UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) // GetLetterApprovals returns all approvals and their status for a letter GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) // GetApprovalDiscussions returns both approvals and discussions for an outgoing letter GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) // GetApprovalTimeline returns a chronological timeline of all events for a letter GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) } type LetterOutgoingServiceImpl struct { processor processor.LetterOutgoingProcessor txManager *repository.TxManager validationProcessor processor.LetterValidationProcessor creationProcessor processor.LetterCreationProcessor approvalProcessor processor.LetterApprovalProcessor attachmentProcessor processor.LetterAttachmentProcessor recipientProcessor processor.LetterOutgoingRecipientProcessor notificationProcessor processor.NotificationProcessor activityProcessor processor.LetterActivityProcessor } func NewLetterOutgoingService( processor processor.LetterOutgoingProcessor, txManager *repository.TxManager, validationProcessor processor.LetterValidationProcessor, creationProcessor processor.LetterCreationProcessor, approvalProcessor processor.LetterApprovalProcessor, attachmentProcessor processor.LetterAttachmentProcessor, recipientProcessor processor.LetterOutgoingRecipientProcessor, notificationProcessor processor.NotificationProcessor, activityProcessor processor.LetterActivityProcessor, ) *LetterOutgoingServiceImpl { return &LetterOutgoingServiceImpl{ processor: processor, txManager: txManager, validationProcessor: validationProcessor, creationProcessor: creationProcessor, approvalProcessor: approvalProcessor, attachmentProcessor: attachmentProcessor, recipientProcessor: recipientProcessor, notificationProcessor: notificationProcessor, activityProcessor: activityProcessor, } } func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) { departmentID := getDepartmentIDFromContext(ctx) userID := getUserIDFromContext(ctx) existingOutgoing, err := s.processor.GetOutgoingLetterByReferenceNumber(ctx, req.ReferenceNumber) if err == nil && existingOutgoing != nil { return nil, fmt.Errorf("surat dengan nomor %s sudah ada", *req.ReferenceNumber) } // Create letter entity letter := &entities.LetterOutgoing{ Subject: req.Subject, Description: req.Description, PriorityID: req.PriorityID, ReceiverInstitutionID: req.ReceiverInstitutionID, ReceiverName: req.ReceiverName, IssueDate: req.IssueDate, CreatedBy: userID, ApprovalFlowID: req.ApprovalFlowID, } if req.ReferenceNumber != nil { letter.ReferenceNumber = req.ReferenceNumber } // Prepare attachments var attachments []entities.LetterOutgoingAttachment if len(req.Attachments) > 0 { attachments = make([]entities.LetterOutgoingAttachment, len(req.Attachments)) for i, a := range req.Attachments { attachments[i] = entities.LetterOutgoingAttachment{ FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID, } } } // Execute creation with transaction in service layer err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error { // Step 1: Validate letter if err := s.validationProcessor.ValidateCreateOutgoingLetter(txCtx, letter); err != nil { return err } // Step 2: Prepare letter for creation (assign approval flow, set initial status) if err := s.creationProcessor.PrepareLetterForCreation(txCtx, letter, departmentID); err != nil { return err } // Step 3: Generate letter number if err := s.creationProcessor.GenerateLetterNumber(txCtx, letter); err != nil { return err } // Step 4: Create the letter if err := s.creationProcessor.CreateLetter(txCtx, letter); err != nil { return err } // Step 5: Create approval steps if needed if err := s.approvalProcessor.CreateApprovalSteps(txCtx, letter); err != nil { return err } // Step 6: Create initial recipients (approval workflow users + department members) if err := s.recipientProcessor.CreateInitialRecipients(txCtx, letter, departmentID); err != nil { return err } // Step 7: Create attachments if err := s.attachmentProcessor.CreateAttachments(txCtx, letter.ID, attachments); err != nil { return err } // Step 8: Log the activity return s.activityProcessor.LogActivity(txCtx, letter.ID, entities.LetterOutgoingActionCreated, userID, nil) }) if err != nil { return nil, err } // Get the created letter with all relationships result, err := s.processor.GetOutgoingLetterByID(ctx, letter.ID) if err != nil { return nil, err } // Send notifications if letter needs approval log.Printf("[DEBUG] createOutgoingLetter Finsig") log.Printf("[DEBUG] NotificationProcessor is nil: %v", s.notificationProcessor == nil) if s.notificationProcessor != nil && len(result.Approvals) > 0 { log.Printf("[DEBUG] sendFirstStepApprovalNotifications start") go s.sendStepApprovalNotifications(context.Background(), result.ID, result.Subject, 1) } return transformLetterToResponse(result), nil } func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error) { letter, err := s.processor.GetOutgoingLetterByID(ctx, id) if err != nil { return nil, err } return transformLetterToResponse(letter), nil } func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) { // Extract user context from gin context appCtx := appcontext.FromGinContext(ctx) userID := appCtx.UserID departmentID := appCtx.DepartmentID offset := (req.Page - 1) * req.Limit if offset < 0 { offset = 0 } filter := repository.ListOutgoingLettersFilter{ CreatedBy: req.CreatedBy, DepartmentID: req.DepartmentID, ReceiverInstitutionID: req.ReceiverInstitutionID, PriorityID: req.PriorityID, PriorityIDs: req.PriorityIDs, UserID: &userID, IsRead: req.IsRead, } if departmentID != uuid.Nil { filter.DepartmentID = &departmentID } if len(req.PriorityIDs) > 0 { filter.PriorityIDs = req.PriorityIDs } if req.Status != "" { filter.Status = &req.Status } if req.Query != "" { filter.Query = &req.Query } if req.SortBy != "" { filter.SortBy = &req.SortBy } if req.SortOrder != "" { filter.SortOrder = &req.SortOrder } if req.FromDate != "" { if date, err := time.Parse("2006-01-02", req.FromDate); err == nil { filter.FromDate = &date } } if req.ToDate != "" { if date, err := time.Parse("2006-01-02", req.ToDate); err == nil { endOfDay := date.Add(23*time.Hour + 59*time.Minute + 59*time.Second) filter.ToDate = &endOfDay } } archived := true filter.IsArchived = &archived if filter.IsArchived != nil { filter.IsArchived = req.IsArchived } if filter.IsRead != nil { filter.IsRead = req.IsRead } fmt.Printf("[DEBUG] filter: %v\n", filter) // Get raw letters data letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, offset) if err != nil { return nil, err } // Collect IDs for batch loading letterIDs := make([]uuid.UUID, len(letters)) priorityIDs := make(map[uuid.UUID]bool) institutionIDs := make(map[uuid.UUID]bool) for i, letter := range letters { letterIDs[i] = letter.ID if letter.PriorityID != nil { priorityIDs[*letter.PriorityID] = true } if letter.ReceiverInstitutionID != nil { institutionIDs[*letter.ReceiverInstitutionID] = true } } // Convert maps to slices priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDs)) for id := range priorityIDs { priorityIDSlice = append(priorityIDSlice, id) } institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDs)) for id := range institutionIDs { institutionIDSlice = append(institutionIDSlice, id) } // Parallel batch loading type batchResult struct { attachments map[uuid.UUID][]entities.LetterOutgoingAttachment recipients map[uuid.UUID][]entities.LetterOutgoingRecipient priorities map[uuid.UUID]*entities.Priority institutions map[uuid.UUID]*entities.Institution err error } result := batchResult{} errChan := make(chan error, 4) // Load attachments go func() { result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs) errChan <- err }() // Load recipients go func() { result.recipients, err = s.processor.GetBatchRecipients(ctx, letterIDs) errChan <- err }() // Load priorities go func() { result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDSlice) errChan <- err }() // Load institutions go func() { result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDSlice) errChan <- err }() // Wait for all goroutines and check for errors for i := 0; i < 4; i++ { if err := <-errChan; err != nil { return nil, err } } // Transform letters with batch loaded data items := make([]*contract.OutgoingLetterResponse, len(letters)) for i, letter := range letters { // Attach batch loaded data to letter if attachments, ok := result.attachments[letter.ID]; ok { letter.Attachments = attachments } if recipients, ok := result.recipients[letter.ID]; ok { letter.Recipients = recipients } if letter.PriorityID != nil { if priority, ok := result.priorities[*letter.PriorityID]; ok { letter.Priority = priority } } if letter.ReceiverInstitutionID != nil { if institution, ok := result.institutions[*letter.ReceiverInstitutionID]; ok { letter.ReceiverInstitution = institution } } isRead := false recipientByUser := make(map[uuid.UUID]*entities.LetterOutgoingRecipient) recipientByUser, err = s.processor.GetBatchOutgoingRecipientsByUser(ctx, letterIDs, userID) if err != nil { // Handle error return nil, err } // Ambil isRead dari recipientByUser berdasarkan letter.ID if recipient, exists := recipientByUser[letter.ID]; exists && recipient != nil { isRead = recipient.ReadAt != nil } response := transformLetterToResponse(&letter) response.IsRead = isRead items[i] = response } return &contract.ListOutgoingLettersResponse{ Items: items, Total: total, }, nil } func (s *LetterOutgoingServiceImpl) SearchOutgoingLetters(ctx context.Context, req *contract.SearchOutgoingLettersRequest) (*contract.SearchOutgoingLettersResponse, error) { userID := getUserIDFromContext(ctx) departmentID := getDepartmentIDFromContext(ctx) // Build search filters filters := buildOutgoingSearchFilters(req, userID, departmentID) // Execute search with pagination letters, total, err := s.processor.SearchOutgoingLetters(ctx, filters, req.Page, req.Limit, req.SortBy, req.SortOrder) if err != nil { return nil, err } // Collect IDs for batch loading letterIDs := make([]uuid.UUID, len(letters)) priorityIDMap := make(map[uuid.UUID]bool) institutionIDMap := make(map[uuid.UUID]bool) for i, letter := range letters { letterIDs[i] = letter.ID if letter.PriorityID != nil { priorityIDMap[*letter.PriorityID] = true } if letter.ReceiverInstitutionID != nil { institutionIDMap[*letter.ReceiverInstitutionID] = true } } // Convert maps to slices priorityIDSlice := make([]uuid.UUID, 0, len(priorityIDMap)) for id := range priorityIDMap { priorityIDSlice = append(priorityIDSlice, id) } institutionIDSlice := make([]uuid.UUID, 0, len(institutionIDMap)) for id := range institutionIDMap { institutionIDSlice = append(institutionIDSlice, id) } // Parallel batch loading type batchLoadResult struct { attachments map[uuid.UUID][]entities.LetterOutgoingAttachment recipients map[uuid.UUID][]entities.LetterOutgoingRecipient priorities map[uuid.UUID]*entities.Priority institutions map[uuid.UUID]*entities.Institution } var result batchLoadResult errChan := make(chan error, 4) // Load attachments go func() { result.attachments, err = s.processor.GetBatchAttachments(ctx, letterIDs) errChan <- err }() // Load recipients go func() { result.recipients, err = s.processor.GetBatchRecipients(ctx, letterIDs) errChan <- err }() // Load priorities go func() { result.priorities, err = s.processor.GetBatchPriorities(ctx, priorityIDSlice) errChan <- err }() // Load institutions go func() { result.institutions, err = s.processor.GetBatchInstitutions(ctx, institutionIDSlice) errChan <- err }() // Wait for all goroutines and check for errors for i := 0; i < 4; i++ { if err := <-errChan; err != nil { return nil, err } } // Transform letters with batch loaded data items := make([]contract.OutgoingLetterResponse, len(letters)) for i, letter := range letters { // Attach batch loaded data to letter if attachments, ok := result.attachments[letter.ID]; ok { letter.Attachments = attachments } if recipients, ok := result.recipients[letter.ID]; ok { letter.Recipients = recipients } if letter.PriorityID != nil { if priority, ok := result.priorities[*letter.PriorityID]; ok { letter.Priority = priority } } if letter.ReceiverInstitutionID != nil { if institution, ok := result.institutions[*letter.ReceiverInstitutionID]; ok { letter.ReceiverInstitution = institution } } items[i] = *transformLetterToResponse(&letter) } return &contract.SearchOutgoingLettersResponse{ Letters: items, TotalCount: total, Page: req.Page, Limit: req.Limit, }, nil } func buildOutgoingSearchFilters(req *contract.SearchOutgoingLettersRequest, userID, departmentID uuid.UUID) map[string]interface{} { filters := make(map[string]interface{}) if req.Query != "" { filters["query"] = req.Query } if req.LetterNumber != "" { filters["letter_number"] = req.LetterNumber } if req.Subject != "" { filters["subject"] = req.Subject } if req.Status != "" { filters["status"] = req.Status } if req.PriorityID != nil { filters["priority_id"] = *req.PriorityID } if req.InstitutionID != nil { filters["receiver_institution_id"] = *req.InstitutionID } if req.CreatedBy != nil { filters["created_by"] = *req.CreatedBy } if req.DateFrom != nil { filters["date_from"] = *req.DateFrom } if req.DateTo != nil { filters["date_to"] = *req.DateTo } // Add user/department context filters filters["user_context"] = map[string]interface{}{ "user_id": userID, "department_id": departmentID, } return filters } func (s *LetterOutgoingServiceImpl) UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, id) if err != nil { return nil, err } if letter.Status != entities.LetterOutgoingStatusDraft { return nil, gorm.ErrInvalidData } if req.Subject != nil { letter.Subject = *req.Subject } if req.Description != nil { letter.Description = req.Description } if req.PriorityID != nil { letter.PriorityID = req.PriorityID } if req.ReceiverInstitutionID != nil { letter.ReceiverInstitutionID = req.ReceiverInstitutionID } if req.IssueDate != nil { letter.IssueDate = *req.IssueDate } if req.ReferenceNumber != nil { letter.ReferenceNumber = req.ReferenceNumber } if req.ReceiverName != nil { letter.ReceiverName = req.ReceiverName } err = s.processor.UpdateOutgoingLetter(ctx, letter, userID) if err != nil { return nil, err } result, err := s.processor.GetOutgoingLetterByID(ctx, id) if err != nil { return nil, err } return transformLetterToResponse(result), nil } func (s *LetterOutgoingServiceImpl) DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error { userID := getUserIDFromContext(ctx) //letter, err := s.processor.GetOutgoingLetterByID(ctx, id) //if err != nil { // return err //} //if letter.Status != entities.LetterOutgoingStatusDraft { // return gorm.ErrInvalidData //} return s.processor.DeleteOutgoingLetter(ctx, id, userID) } func (s *LetterOutgoingServiceImpl) BulkDeleteOutgoingLetters(ctx context.Context, ids []uuid.UUID) error { if len(ids) == 0 { return nil } userID := getUserIDFromContext(ctx) // Validasi semua letters sebelum delete //for _, id := range ids { // letter, err := s.processor.GetOutgoingLetterByID(ctx, id) // if err != nil { // return err // } // // if letter.Status != entities.LetterOutgoingStatusDraft { // return gorm.ErrInvalidData // } //} return s.processor.BulkDeleteOutgoingLetters(ctx, ids, userID) } func (s *LetterOutgoingServiceImpl) SubmitForApproval(ctx context.Context, letterID uuid.UUID) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusDraft { return gorm.ErrInvalidData } if letter.ApprovalFlowID == nil { return gorm.ErrInvalidData } return s.processor.ProcessApprovalSubmission(ctx, letterID, *letter.ApprovalFlowID, userID) } func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusPendingApproval { return gorm.ErrInvalidData } // Get approvals for the current revision only approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return err } // Find user's pending approval var currentApproval *entities.LetterOutgoingApproval for i := range approvals { if approvals[i].Status == entities.ApprovalStatusPending && approvals[i].ApproverID != nil && *approvals[i].ApproverID == userID { currentApproval = &approvals[i] break } } if currentApproval == nil { return gorm.ErrInvalidData } currentApproval.Remarks = req.Remarks err = s.processor.ProcessApproval(ctx, letterID, currentApproval, userID) if err != nil { return err } // Send notifications after successful approval if s.notificationProcessor != nil { // 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 } func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusPendingApproval { return gorm.ErrInvalidData } // Get approvals for the current revision only approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return err } // Find user's pending approval var currentApproval *entities.LetterOutgoingApproval for i := range approvals { if approvals[i].Status == entities.ApprovalStatusPending && approvals[i].ApproverID != nil && *approvals[i].ApproverID == userID { currentApproval = &approvals[i] break } } if currentApproval == nil { return gorm.ErrInvalidData } currentApproval.Remarks = &req.Reason err = s.processor.ProcessRejection(ctx, letterID, currentApproval, userID) if err != nil { return err } // Send notification to letter creator (rejection always notifies creator) if s.notificationProcessor != nil { 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) } 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) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusApproved { return gorm.ErrInvalidData } fromStatus := string(entities.LetterOutgoingStatusApproved) toStatus := string(entities.LetterOutgoingStatusSent) return s.processor.UpdateLetterStatus(ctx, letterID, entities.LetterOutgoingStatusSent, userID, &fromStatus, &toStatus) } func (s *LetterOutgoingServiceImpl) ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } // Can only archive sent letters if letter.Status != entities.LetterOutgoingStatusSent { return gorm.ErrInvalidData } // Use the new archive method instead of changing status return s.processor.ArchiveOutgoingLetter(ctx, letterID, userID) } func (s *LetterOutgoingServiceImpl) AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusDraft { return gorm.ErrInvalidData } recipients := make([]entities.LetterOutgoingRecipient, len(req.Recipients)) for i, r := range req.Recipients { recipients[i] = entities.LetterOutgoingRecipient{ LetterID: letterID, UserID: r.UserID, DepartmentID: r.DepartmentID, IsPrimary: r.IsPrimary, Status: r.Status, Flag: r.Flag, IsArchived: r.IsArchived, } } return s.processor.AddRecipients(ctx, letterID, recipients, userID) } func (s *LetterOutgoingServiceImpl) UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error { letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusDraft { return gorm.ErrInvalidData } recipient := &entities.LetterOutgoingRecipient{ ID: recipientID, IsPrimary: req.IsPrimary, } if req.UserID != nil { recipient.UserID = req.UserID } if req.DepartmentID != nil { recipient.DepartmentID = req.DepartmentID } if req.Status != nil { recipient.Status = *req.Status } if req.Flag != nil { recipient.Flag = req.Flag } if req.IsArchived != nil { recipient.IsArchived = *req.IsArchived } return s.processor.UpdateRecipient(ctx, recipient) } func (s *LetterOutgoingServiceImpl) RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusDraft { return gorm.ErrInvalidData } return s.processor.RemoveRecipient(ctx, letterID, recipientID, userID) } func (s *LetterOutgoingServiceImpl) AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error { userID := getUserIDFromContext(ctx) _, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } //if letter.Status != entities.LetterOutgoingStatusDraft { // return gorm.ErrInvalidData //} attachments := make([]entities.LetterOutgoingAttachment, len(req.Attachments)) for i, a := range req.Attachments { attachments[i] = entities.LetterOutgoingAttachment{ LetterID: letterID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, } } return s.processor.AddAttachments(ctx, letterID, attachments, userID) } func (s *LetterOutgoingServiceImpl) RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusDraft { return gorm.ErrInvalidData } return s.processor.RemoveAttachment(ctx, letterID, attachmentID, userID) } func (s *LetterOutgoingServiceImpl) AddFinalAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error { userID := getUserIDFromContext(ctx) _, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } //if letter.Status != entities.LetterOutgoingStatusDraft { // return gorm.ErrInvalidData //} attachments := make([]entities.LetterOutgoingFinalAttachment, len(req.Attachments)) for i, a := range req.Attachments { attachments[i] = entities.LetterOutgoingFinalAttachment{ LetterID: letterID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, } } return s.processor.AddFinalAttachments(ctx, letterID, attachments, userID) } func (s *LetterOutgoingServiceImpl) RemoveFinalAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error { userID := getUserIDFromContext(ctx) letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return err } if letter.Status != entities.LetterOutgoingStatusDraft { return gorm.ErrInvalidData } return s.processor.RemoveFinalAttachment(ctx, letterID, attachmentID, userID) } func (s *LetterOutgoingServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error) { userID := getUserIDFromContext(ctx) discussion := &entities.LetterOutgoingDiscussion{ LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, } if req.Mentions != nil { discussion.Mentions = req.Mentions } var attachments []entities.LetterOutgoingDiscussionAttachment if len(req.Attachments) > 0 { attachments = make([]entities.LetterOutgoingDiscussionAttachment, len(req.Attachments)) for i, a := range req.Attachments { attachments[i] = entities.LetterOutgoingDiscussionAttachment{ DiscussionID: discussion.ID, FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, UploadedBy: &userID, } } } err := s.processor.CreateDiscussion(ctx, discussion, attachments, userID) if err != nil { return nil, err } result, err := s.processor.GetDiscussionByID(ctx, discussion.ID) if err != nil { return nil, err } if s.notificationProcessor != nil && req.Mentions != nil { go s.sendOutgoingDiscussionMentionNotifications(context.Background(), letterID, userID, req.Mentions, req.Message) } return transformDiscussionToResponse(result), nil } func (s *LetterOutgoingServiceImpl) UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error { discussion, err := s.processor.GetDiscussionByID(ctx, discussionID) if err != nil { return err } userID := getUserIDFromContext(ctx) if discussion.UserID != userID { return gorm.ErrInvalidData } discussion.Message = req.Message if req.Mentions != nil { discussion.Mentions = req.Mentions } return s.processor.UpdateDiscussion(ctx, discussion) } func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error { discussion, err := s.processor.GetDiscussionByID(ctx, discussionID) if err != nil { return err } userID := getUserIDFromContext(ctx) if discussion.UserID != userID { return gorm.ErrInvalidData } return s.processor.DeleteDiscussion(ctx, discussionID) } func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) { userID := getUserIDFromContext(ctx) // Verify letter exists letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return nil, err } // Get all approvals for this letter's current revision approvals, err := s.processor.GetApprovalsByLetterAndRevision(ctx, letterID, letter.RevisionNumber) if err != nil { return nil, err } // Simple check: can user approve if they have a pending approval var canApprove bool var userApproval *entities.LetterOutgoingApproval 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 } } // Build actions based on eligibility var actions []contract.ApprovalAction if canApprove && userApproval != nil { actions = []contract.ApprovalAction{ { Type: "APPROVE", Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/approve", letterID), Method: "POST", }, { Type: "REJECT", Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/reject", letterID), Method: "POST", }, } } // 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" // Check if all required approvals are completed allCompleted := true hasRejection := false for _, approval := range approvals { // Check required approvals only if approval.IsRequired { if approval.Status == entities.ApprovalStatusPending || approval.Status == entities.ApprovalStatusNotStarted { allCompleted = false } if approval.Status == entities.ApprovalStatusRejected { hasRejection = true } } } if hasRejection { decisionStatus = "REJECTED" } else if allCompleted { decisionStatus = "COMPLETED" } else if letter.Status == entities.LetterOutgoingStatusPendingApproval { decisionStatus = "PENDING" } // Determine notes visibility notesVisibility := "READONLY" if canApprove { notesVisibility = "FULL" } info := &contract.LetterApprovalInfoResponse{ IsApproverOnActiveStep: canApprove, // User can approve means they're on the active step DecisionStatus: decisionStatus, CanApprove: canApprove, Actions: actions, NotesVisibility: notesVisibility, } return info, nil } func (s *LetterOutgoingServiceImpl) GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) { // Get letter details letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return nil, err } // Get all approvals for this letter (all revisions) approvals, err := s.processor.GetAllApprovalsByLetter(ctx, letterID) if err != nil { return nil, err } // Group approvals by revision number from approval itself revisionMap := make(map[int][]entities.LetterOutgoingApproval) for _, approval := range approvals { revisionMap[approval.RevisionNumber] = append(revisionMap[approval.RevisionNumber], approval) } // Get sorted revision numbers revisionNumbers := make([]int, 0, len(revisionMap)) for revNum := range revisionMap { revisionNumbers = append(revisionNumbers, revNum) } sort.Sort(sort.Reverse(sort.IntSlice(revisionNumbers))) // Process each revision revisionResponses := make([]contract.OutgoingLetterApprovalRevisionNumberResponse, 0, len(revisionNumbers)) totalSteps := 0 currentStep := 0 for _, revNum := range revisionNumbers { revisionApprovals := revisionMap[revNum] // Sort approvals within this revision by step order and parallel group sort.Slice(revisionApprovals, func(i, j int) bool { if revisionApprovals[i].StepOrder != revisionApprovals[j].StepOrder { return revisionApprovals[i].StepOrder < revisionApprovals[j].StepOrder } return revisionApprovals[i].ParallelGroup < revisionApprovals[j].ParallelGroup }) // Transform to response format approvalResponses := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(revisionApprovals)) // Only calculate totalSteps and currentStep for the current letter's revision if revNum == letter.RevisionNumber { stepOrdersSeen := make(map[int]bool) for _, approval := range revisionApprovals { // Count unique step orders for total steps if !stepOrdersSeen[approval.StepOrder] { stepOrdersSeen[approval.StepOrder] = true totalSteps++ } // Determine current step (lowest step with pending status) if approval.Status == entities.ApprovalStatusPending && (currentStep == 0 || approval.StepOrder < currentStep) { currentStep = approval.StepOrder } } // If no current step found but there are approvals, check if all are completed if currentStep == 0 && len(revisionApprovals) > 0 { allCompleted := true for _, approval := range revisionApprovals { if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { allCompleted = false break } } if allCompleted { currentStep = totalSteps // All steps completed } } } for _, approval := range revisionApprovals { approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ ID: approval.ID, LetterID: approval.LetterID, StepID: approval.StepID, StepOrder: approval.StepOrder, ParallelGroup: approval.ParallelGroup, IsRequired: approval.IsRequired, ApproverID: approval.ApproverID, RevisionNumber: approval.RevisionNumber, Status: string(approval.Status), Remarks: approval.Remarks, ActedAt: approval.ActedAt, CreatedAt: approval.CreatedAt, } // Add step details if available if approval.Step != nil { approvalResp.Step = &contract.ApprovalFlowStepResponse{ ID: approval.Step.ID, StepOrder: approval.Step.StepOrder, ParallelGroup: approval.Step.ParallelGroup, Required: approval.Step.Required, CreatedAt: approval.Step.CreatedAt, UpdatedAt: approval.Step.UpdatedAt, } // Add approver role if available if approval.Step.ApproverRole != nil { approvalResp.Step.ApproverRole = &contract.RoleResponse{ ID: approval.Step.ApproverRole.ID, Name: approval.Step.ApproverRole.Name, Code: approval.Step.ApproverRole.Code, } } // Add approver user if available if approval.Step.ApproverUser != nil { approvalResp.Step.ApproverUser = &contract.UserResponse{ ID: approval.Step.ApproverUser.ID, Name: approval.Step.ApproverUser.Name, Email: approval.Step.ApproverUser.Email, } } } // Add approver details if available if approval.Approver != nil { approvalResp.Approver = &contract.UserResponse{ ID: approval.Approver.ID, Name: approval.Approver.Name, Email: approval.Approver.Email, } } approvalResponses = append(approvalResponses, approvalResp) } // Add revision response revisionResponses = append(revisionResponses, contract.OutgoingLetterApprovalRevisionNumberResponse{ RevisionNumber: revNum, Approvals: approvalResponses, }) } response := &contract.GetLetterApprovalsResponse{ LetterID: letter.ID, LetterNumber: letter.LetterNumber, LetterStatus: string(letter.Status), TotalSteps: totalSteps, CurrentStep: currentStep, CurrentRevisionNumber: letter.RevisionNumber, Approvals: revisionResponses, } return response, nil } func getUserIDFromContext(ctx context.Context) uuid.UUID { appCtx := appcontext.FromGinContext(ctx) if appCtx != nil { return appCtx.UserID } return uuid.New() } func getDepartmentIDFromContext(ctx context.Context) uuid.UUID { appCtx := appcontext.FromGinContext(ctx) if appCtx != nil { return appCtx.DepartmentID } return uuid.Nil } func userHasRole(ctx context.Context, roleID uuid.UUID) bool { return false } func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) { // Get the letter with all related data letter, err := s.processor.GetOutgoingLetterWithDetails(ctx, letterID) if err != nil { return nil, err } // Transform approvals approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals)) for _, approval := range letter.Approvals { approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ ID: approval.ID, LetterID: approval.LetterID, StepID: approval.StepID, StepOrder: approval.StepOrder, ParallelGroup: approval.ParallelGroup, IsRequired: approval.IsRequired, ApproverID: approval.ApproverID, RevisionNumber: approval.RevisionNumber, Status: string(approval.Status), Remarks: approval.Remarks, ActedAt: approval.ActedAt, CreatedAt: approval.CreatedAt, } // Add step details if available if approval.Step != nil { approvalResp.Step = &contract.ApprovalFlowStepResponse{ ID: approval.Step.ID, StepOrder: approval.Step.StepOrder, ParallelGroup: approval.Step.ParallelGroup, Required: approval.Step.Required, CreatedAt: approval.Step.CreatedAt, UpdatedAt: approval.Step.UpdatedAt, } if approval.Step.ApproverRoleID != nil { approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID } if approval.Step.ApproverUserID != nil { approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID } // Add role information if available if approval.Step.ApproverRole != nil { approvalResp.Step.ApproverRole = &contract.RoleResponse{ ID: approval.Step.ApproverRole.ID, Name: approval.Step.ApproverRole.Name, Code: approval.Step.ApproverRole.Code, } } // Add user information if available if approval.Step.ApproverUser != nil { approvalResp.Step.ApproverUser = &contract.UserResponse{ ID: approval.Step.ApproverUser.ID, Name: approval.Step.ApproverUser.Name, Email: approval.Step.ApproverUser.Email, } } } // Add approver details if available if approval.Approver != nil { approvalResp.Approver = &contract.UserResponse{ ID: approval.Approver.ID, Name: approval.Approver.Name, Email: approval.Approver.Email, } // Add profile if available if approval.Approver.Profile != nil { approvalResp.Approver.Profile = &contract.UserProfileResponse{ UserID: approval.Approver.Profile.UserID, FullName: approval.Approver.Profile.FullName, DisplayName: approval.Approver.Profile.DisplayName, Phone: approval.Approver.Profile.Phone, AvatarURL: approval.Approver.Profile.AvatarURL, JobTitle: approval.Approver.Profile.JobTitle, EmployeeNo: approval.Approver.Profile.EmployeeNo, Bio: approval.Approver.Profile.Bio, Timezone: approval.Approver.Profile.Timezone, Locale: approval.Approver.Profile.Locale, } } } approvals = append(approvals, approvalResp) } // Transform discussions discussions := make([]contract.OutgoingLetterDiscussionResponse, 0, len(letter.Discussions)) for _, discussion := range letter.Discussions { // Extract mentioned user IDs from mentions mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions) discussionResp := contract.OutgoingLetterDiscussionResponse{ ID: discussion.ID, LetterID: discussion.LetterID, ParentID: discussion.ParentID, UserID: discussion.UserID, Message: discussion.Message, Mentions: discussion.Mentions, CreatedAt: discussion.CreatedAt, UpdatedAt: discussion.UpdatedAt, EditedAt: discussion.EditedAt, } // Add user details if available if discussion.User != nil { discussionResp.User = &contract.UserResponse{ ID: discussion.User.ID, Name: discussion.User.Name, Email: discussion.User.Email, IsActive: discussion.User.IsActive, CreatedAt: discussion.User.CreatedAt, UpdatedAt: discussion.User.UpdatedAt, } // Add profile if available if discussion.User.Profile != nil { discussionResp.User.Profile = &contract.UserProfileResponse{ UserID: discussion.User.Profile.UserID, FullName: discussion.User.Profile.FullName, DisplayName: discussion.User.Profile.DisplayName, Phone: discussion.User.Profile.Phone, AvatarURL: discussion.User.Profile.AvatarURL, JobTitle: discussion.User.Profile.JobTitle, EmployeeNo: discussion.User.Profile.EmployeeNo, Bio: discussion.User.Profile.Bio, Timezone: discussion.User.Profile.Timezone, Locale: discussion.User.Profile.Locale, } } } // Get mentioned users details if len(mentionedUserIDs) > 0 { mentionedUsers, _ := s.processor.GetUsersByIDs(ctx, mentionedUserIDs) for _, user := range mentionedUsers { mentionedUserResp := contract.UserResponse{ ID: user.ID, Name: user.Name, Email: user.Email, IsActive: user.IsActive, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, } if user.Profile != nil { mentionedUserResp.Profile = &contract.UserProfileResponse{ UserID: user.Profile.UserID, FullName: user.Profile.FullName, DisplayName: user.Profile.DisplayName, Timezone: user.Profile.Timezone, Locale: user.Profile.Locale, } } discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp) } } // Add attachments if available for _, attachment := range discussion.Attachments { attachmentResp := contract.OutgoingLetterDiscussionAttachmentResponse{ ID: attachment.ID, DiscussionID: attachment.DiscussionID, FileURL: attachment.FileURL, FileName: attachment.FileName, FileType: attachment.FileType, UploadedBy: attachment.UploadedBy, UploadedAt: attachment.UploadedAt, } discussionResp.Attachments = append(discussionResp.Attachments, attachmentResp) } discussions = append(discussions, discussionResp) } return &contract.OutgoingLetterApprovalDiscussionsResponse{ Approvals: approvals, Discussions: discussions, }, nil } // Helper function to extract user IDs from mentions func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID { var userIDs []uuid.UUID if mentions == nil { return userIDs } if userIDsInterface, ok := mentions["user_ids"]; ok { if userIDsList, ok := userIDsInterface.([]interface{}); ok { for _, id := range userIDsList { if idStr, ok := id.(string); ok { if userID, err := uuid.Parse(idStr); err == nil { userIDs = append(userIDs, userID) } } } } } return userIDs } func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.OutgoingLetterResponse { resp := &contract.OutgoingLetterResponse{ ID: letter.ID, LetterNumber: letter.LetterNumber, ReferenceNumber: letter.ReferenceNumber, Subject: letter.Subject, Description: letter.Description, PriorityID: letter.PriorityID, ReceiverInstitutionID: letter.ReceiverInstitutionID, ReceiverName: letter.ReceiverName, IssueDate: letter.IssueDate, Status: string(letter.Status), ApprovalFlowID: letter.ApprovalFlowID, RevisionNumber: letter.RevisionNumber, CreatedBy: letter.CreatedBy, CreatedAt: letter.CreatedAt, UpdatedAt: letter.UpdatedAt, } if letter.Priority != nil { resp.Priority = &contract.PriorityResponse{ ID: letter.Priority.ID.String(), Name: letter.Priority.Name, Level: letter.Priority.Level, CreatedAt: letter.Priority.CreatedAt, UpdatedAt: letter.Priority.UpdatedAt, } } if letter.ReceiverInstitution != nil { resp.ReceiverInstitution = &contract.InstitutionResponse{ ID: letter.ReceiverInstitution.ID.String(), Name: letter.ReceiverInstitution.Name, Type: string(letter.ReceiverInstitution.Type), Address: letter.ReceiverInstitution.Address, ContactPerson: letter.ReceiverInstitution.ContactPerson, Phone: letter.ReceiverInstitution.Phone, Email: letter.ReceiverInstitution.Email, CreatedAt: letter.ReceiverInstitution.CreatedAt, UpdatedAt: letter.ReceiverInstitution.UpdatedAt, } } if len(letter.Recipients) > 0 { resp.Recipients = make([]contract.OutgoingLetterRecipientResponse, len(letter.Recipients)) for i, recipient := range letter.Recipients { recipResp := contract.OutgoingLetterRecipientResponse{ ID: recipient.ID, LetterID: recipient.LetterID, UserID: recipient.UserID, DepartmentID: recipient.DepartmentID, IsPrimary: recipient.IsPrimary, Status: recipient.Status, ReadAt: recipient.ReadAt, Flag: recipient.Flag, IsArchived: recipient.IsArchived, CreatedAt: recipient.CreatedAt, } if recipient.User != nil { recipResp.User = &contract.UserResponse{ ID: recipient.User.ID, Name: recipient.User.Name, Email: recipient.User.Email, } } if recipient.Department != nil { recipResp.Department = &contract.DepartmentResponse{ ID: recipient.Department.ID, Name: recipient.Department.Name, Code: recipient.Department.Code, } } resp.Recipients[i] = recipResp } } if len(letter.Attachments) > 0 { resp.Attachments = make([]contract.OutgoingLetterAttachmentResponse, len(letter.Attachments)) for i, attachment := range letter.Attachments { resp.Attachments[i] = contract.OutgoingLetterAttachmentResponse{ ID: attachment.ID, FileURL: attachment.FileURL, FileName: attachment.FileName, FileType: attachment.FileType, UploadedAt: attachment.UploadedAt, } } } if len(letter.Approvals) > 0 { resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals)) for i, approval := range letter.Approvals { approvalResp := contract.OutgoingLetterApprovalResponse{ ID: approval.ID, StepOrder: approval.StepOrder, ParallelGroup: approval.ParallelGroup, IsRequired: approval.IsRequired, ApproverID: approval.ApproverID, Status: string(approval.Status), Remarks: approval.Remarks, ActedAt: approval.ActedAt, CreatedAt: approval.CreatedAt, } resp.Approvals[i] = approvalResp } } return resp } func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion) *contract.DiscussionResponse { return &contract.DiscussionResponse{ ID: discussion.ID, UserID: discussion.UserID, Message: discussion.Message, CreatedAt: discussion.CreatedAt, UpdatedAt: discussion.UpdatedAt, } } // GetApprovalTimeline generates a chronological timeline of all events for a letter func (s *LetterOutgoingServiceImpl) GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) { // Get letter details letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return nil, err } // Get approvals and discussions approvalDiscussions, err := s.GetApprovalDiscussions(ctx, letterID) if err != nil { return nil, err } // Create timeline events timeline := make([]contract.TimelineEvent, 0) // Add letter creation event timeline = append(timeline, contract.TimelineEvent{ ID: letter.ID.String(), Type: "submission", Timestamp: letter.CreatedAt, Actor: nil, // Could add creator info here if needed Action: "created", Description: "Letter was created", Status: "created", }) // Add approval events for _, approval := range approvalDiscussions.Approvals { if approval.ActedAt != nil { eventType := "approval" action := "approved" status := "approved" if approval.Status == "rejected" { eventType = "rejection" action = "rejected" status = "rejected" } else if approval.Status == "pending" { continue // Skip pending approvals as they haven't happened yet } description := fmt.Sprintf("Step %d: %s by %s", approval.StepOrder, action, getApproverName(approval.Approver)) timeline = append(timeline, contract.TimelineEvent{ ID: approval.ID.String(), Type: eventType, Timestamp: *approval.ActedAt, Actor: approval.Approver, Action: action, Description: description, Status: status, StepOrder: approval.StepOrder, Message: getLetterStringValue(approval.Remarks), Data: approval, }) } } // Add discussion events for _, discussion := range approvalDiscussions.Discussions { timeline = append(timeline, contract.TimelineEvent{ ID: discussion.ID.String(), Type: "discussion", Timestamp: discussion.CreatedAt, Actor: discussion.User, Action: "commented", Description: fmt.Sprintf("%s added a comment", getUserName(discussion.User)), Message: discussion.Message, Data: discussion, }) } // Sort timeline by timestamp sort.Slice(timeline, func(i, j int) bool { return timeline[i].Timestamp.Before(timeline[j].Timestamp) }) // Calculate summary statistics summary := s.calculateTimelineSummary(letter, approvalDiscussions.Approvals, timeline) return &contract.ApprovalTimelineResponse{ LetterID: letter.ID, LetterNumber: letter.LetterNumber, Subject: letter.Subject, Status: string(letter.Status), CreatedAt: letter.CreatedAt, Timeline: timeline, Summary: summary, }, nil } func (s *LetterOutgoingServiceImpl) calculateTimelineSummary( letter *entities.LetterOutgoing, approvals []contract.EnhancedOutgoingLetterApprovalResponse, timeline []contract.TimelineEvent, ) contract.TimelineSummary { totalSteps := 0 completedSteps := 0 pendingSteps := 0 currentStep := 0 // Count unique step orders stepMap := make(map[int]string) for _, approval := range approvals { if _, exists := stepMap[approval.StepOrder]; !exists { stepMap[approval.StepOrder] = approval.Status totalSteps++ } switch approval.Status { case "approved": if stepMap[approval.StepOrder] == "approved" { completedSteps++ currentStep = approval.StepOrder + 1 } case "pending": pendingSteps++ if currentStep == 0 { currentStep = approval.StepOrder } } } // Calculate duration totalDuration := "" averageStepTime := "" if len(timeline) > 0 { lastEvent := timeline[len(timeline)-1] duration := lastEvent.Timestamp.Sub(letter.CreatedAt) totalDuration = formatDuration(duration) if completedSteps > 0 { avgDuration := duration / time.Duration(completedSteps) averageStepTime = formatDuration(avgDuration) } } status := "in_progress" if letter.Status == entities.LetterOutgoingStatusApproved { status = "completed" } else if letter.Status == "rejected" { status = "rejected" } return contract.TimelineSummary{ TotalSteps: totalSteps, CompletedSteps: completedSteps, PendingSteps: pendingSteps, CurrentStep: currentStep, TotalDuration: totalDuration, AverageStepTime: averageStepTime, Status: status, } } func getApproverName(user *contract.UserResponse) string { if user == nil { return "Unknown" } if user.Name != "" { return user.Name } return user.Email } func getUserName(user *contract.UserResponse) string { if user == nil { return "Unknown" } if user.Name != "" { return user.Name } return user.Email } func getLetterStringValue(s *string) string { if s == nil { return "" } return *s } func formatDuration(d time.Duration) string { days := int(d.Hours() / 24) hours := int(d.Hours()) % 24 minutes := int(d.Minutes()) % 60 if days > 0 { return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) } else if hours > 0 { return fmt.Sprintf("%dh %dm", hours, minutes) } return fmt.Sprintf("%dm", minutes) } func ApplyLetterFilterOverrides(ctx context.Context, filter *repository.ListOutgoingLettersFilter) { appCtx := appcontext.FromGinContext(ctx) if appCtx == nil { return } isSuperAdmin := false if appCtx.UserRole == "superadmin" || appCtx.UserRole == "admin" { isSuperAdmin = true } if !isSuperAdmin && appCtx.UserID != uuid.Nil { filter.UserID = &appCtx.UserID } } func (s *LetterOutgoingServiceImpl) BulkArchiveOutgoingLetters(ctx context.Context, letterIDs []uuid.UUID) (*contract.BulkArchiveLettersResponse, error) { userID := appcontext.FromGinContext(ctx).UserID archivedCount, err := s.processor.BulkArchiveIncomingLettersForUser(ctx, letterIDs, userID) if err != nil { return nil, err } return &contract.BulkArchiveLettersResponse{ Success: true, Message: "Letters archived successfully", ArchivedCount: int(archivedCount), }, nil } 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) // 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 step for _, approval := range approvals { log.Printf("[DEBUG] Checking approval: Step=%d, Status=%s, ApproverID=%v", approval.StepOrder, approval.Status, approval.ApproverID) if approval.StepOrder == stepOrder { log.Printf("[DEBUG] Sending notification to approver %s for step %d", approval.ApproverID.String(), stepOrder) err := s.notificationProcessor.SendOutgoingLetterNotification( ctx, letterID, *approval.ApproverID, "Surat Keluar Perlu Persetujuan", fmt.Sprintf("Surat keluar '%s' memerlukan persetujuan Anda pada tahap %d", subject, stepOrder)) 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()) } } } } // Kirim notifikasi ke creator func (s *LetterOutgoingServiceImpl) sendApprovalNotificationToCreator(ctx context.Context, letterID uuid.UUID, creatorID uuid.UUID, title string, message string) { log.Printf("[DEBUG] sendApprovalNotificationToCreator START - LetterID: %s, CreatorID: %s", letterID.String(), creatorID.String()) err := s.notificationProcessor.SendOutgoingLetterNotification( ctx, letterID, creatorID, title, message) if err != nil { log.Printf("[ERROR] Failed to send notification to creator %s: %v", creatorID.String(), err) } else { log.Printf("[DEBUG] Successfully sent notification to creator %s", creatorID.String()) } } // 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()) // Extract user_ids dari mentions userIDs := s.extractUserIDsFromMentions(mentions) if len(userIDs) == 0 { log.Printf("[DEBUG] No user IDs found in mentions") return } log.Printf("[DEBUG] Found %d mentioned users", len(userIDs)) // Get letter details untuk notification letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { log.Printf("[ERROR] Failed to get letter details: %v", err) return } // Get sender user name dari context (bisa juga dari user service) appContext := appcontext.FromGinContext(ctx) senderName := appContext.UserName if senderName == "" { senderName = "Seseorang" // fallback jika nama tidak tersedia } // Kirim notification ke setiap mentioned user for _, mentionedUserID := range userIDs { // Jangan kirim notification ke sender sendiri if mentionedUserID == senderUserID { continue } subject := "Anda Disebutkan dalam Diskusi Surat Keluar" notificationMessage := fmt.Sprintf("%s menyebutkan Anda dalam diskusi surat keluar: %s", senderName, letter.Subject) err := s.notificationProcessor.SendOutgoingLetterNotification( ctx, letterID, mentionedUserID, subject, notificationMessage) if err != nil { log.Printf("[ERROR] Failed to send mention notification to user %s: %v", mentionedUserID.String(), err) } else { log.Printf("[DEBUG] Successfully sent mention notification to user %s", mentionedUserID.String()) } } } // Helper function untuk extract user IDs dari mentions map func (s *LetterOutgoingServiceImpl) extractUserIDsFromMentions(mentions map[string]interface{}) []uuid.UUID { userIDs := make([]uuid.UUID, 0) if mentions == nil { return userIDs } if userIDsInterface, exists := mentions["user_ids"]; exists { switch userIDsValue := userIDsInterface.(type) { case []interface{}: for _, userIDInterface := range userIDsValue { if userIDStr, ok := userIDInterface.(string); ok { if userID, err := uuid.Parse(userIDStr); err == nil { userIDs = append(userIDs, userID) } } } case []string: for _, userIDStr := range userIDsValue { if userID, err := uuid.Parse(userIDStr); err == nil { userIDs = append(userIDs, userID) } } case []uuid.UUID: userIDs = userIDsValue } } return userIDs }