package service import ( "context" "fmt" "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) UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) DeleteOutgoingLetter(ctx context.Context, id 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 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 } func NewLetterOutgoingService(processor processor.LetterOutgoingProcessor) *LetterOutgoingServiceImpl { return &LetterOutgoingServiceImpl{ processor: processor, } } func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) { departmentID := getDepartmentIDFromContext(ctx) letter := &entities.LetterOutgoing{ Subject: req.Subject, Description: req.Description, PriorityID: req.PriorityID, ReceiverInstitutionID: req.ReceiverInstitutionID, IssueDate: req.IssueDate, CreatedBy: req.UserID, } if req.ReferenceNumber != nil { letter.ReferenceNumber = req.ReferenceNumber } 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: &req.UserID, } } } err := s.processor.CreateOutgoingLetter(ctx, letter, attachments, req.UserID, departmentID) if err != nil { return nil, err } result, err := s.processor.GetOutgoingLetterByID(ctx, letter.ID) if err != nil { return nil, err } 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) { 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, } 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 } } // Apply access control overrides based on user context ApplyLetterFilterOverrides(ctx, &filter) letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, offset) if err != nil { return nil, err } items := make([]*contract.OutgoingLetterResponse, len(letters)) for i, letter := range letters { items[i] = transformLetterToResponse(&letter) } return &contract.ListOutgoingLettersResponse{ Items: items, Total: total, }, nil } 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 } 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) 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 } approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) if err != nil { return err } 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 currentApproval == nil { return gorm.ErrInvalidData } currentApproval.Remarks = req.Remarks allApproved := true for _, approval := range approvals { if approval.ID != currentApproval.ID && approval.Status == entities.ApprovalStatusPending { allApproved = false break } } return s.processor.ProcessApproval(ctx, letterID, currentApproval, userID, allApproved) } 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 } approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) if err != nil { return err } 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 currentApproval == nil { return gorm.ErrInvalidData } currentApproval.Remarks = &req.Reason return s.processor.ProcessRejection(ctx, letterID, currentApproval, userID) } 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 } if letter.Status != entities.LetterOutgoingStatusSent { return gorm.ErrInvalidData } fromStatus := string(entities.LetterOutgoingStatusSent) toStatus := string(entities.LetterOutgoingStatusArchived) return s.processor.UpdateLetterStatus(ctx, letterID, entities.LetterOutgoingStatusArchived, userID, &fromStatus, &toStatus) } 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) letter, 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, UploadedBy: &userID, } } 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) 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 } 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 approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) if err != nil { return nil, err } // Group approvals by step order to understand the workflow approvalsByStep := make(map[int][]entities.LetterOutgoingApproval) for _, approval := range approvals { approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval) } // Find the current active step (lowest step order with pending approvals) var currentStepOrder int = -1 var userApproval *entities.LetterOutgoingApproval var isApproverOnActiveStep bool var canApprove bool // Find the minimum step order that has pending approvals for stepOrder, stepApprovals := range approvalsByStep { hasPending := false for _, approval := range stepApprovals { if approval.Status == entities.ApprovalStatusPending { hasPending = true // Check if this user is an approver for this pending approval if approval.ApproverID != nil && *approval.ApproverID == userID { if currentStepOrder == -1 || stepOrder < currentStepOrder { currentStepOrder = stepOrder userApproval = &approval isApproverOnActiveStep = true } } } } // Track the lowest pending step if hasPending && (currentStepOrder == -1 || stepOrder < currentStepOrder) { currentStepOrder = stepOrder } } // User can approve if they have a pending approval on the current active step if isApproverOnActiveStep && userApproval != nil && userApproval.Status == entities.ApprovalStatusPending { canApprove = true } // 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", }, } } // 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: isApproverOnActiveStep, 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 approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) if err != nil { return nil, err } // Sort approvals by step order and parallel group sort.Slice(approvals, func(i, j int) bool { if approvals[i].StepOrder != approvals[j].StepOrder { return approvals[i].StepOrder < approvals[j].StepOrder } return approvals[i].ParallelGroup < approvals[j].ParallelGroup }) // Transform to response format approvalResponses := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(approvals)) totalSteps := 0 currentStep := 0 stepOrdersSeen := make(map[int]bool) for _, approval := range approvals { // Count unique step orders for total steps if !stepOrdersSeen[approval.StepOrder] { stepOrdersSeen[approval.StepOrder] = true totalSteps++ } // Determine current step (lowest step with pending/not_started status) if approval.Status == entities.ApprovalStatusPending && (currentStep == 0 || approval.StepOrder < currentStep) { currentStep = approval.StepOrder } approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ ID: approval.ID, LetterID: approval.LetterID, StepID: approval.StepID, 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, } // 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) } // If no current step found but there are approvals, check if all are completed if currentStep == 0 && len(approvals) > 0 { allCompleted := true for _, approval := range approvals { if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { allCompleted = false break } } if allCompleted { currentStep = totalSteps // All steps completed } } response := &contract.GetLetterApprovalsResponse{ LetterID: letter.ID, LetterNumber: letter.LetterNumber, LetterStatus: string(letter.Status), TotalSteps: totalSteps, CurrentStep: currentStep, Approvals: approvalResponses, } 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, 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, IssueDate: letter.IssueDate, Status: string(letter.Status), ApprovalFlowID: letter.ApprovalFlowID, 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 } }