dukcapil/internal/service/letter_outgoing_service.go
Aditya Siregar aa662a321f Update
2025-09-01 12:06:14 +07:00

1364 lines
40 KiB
Go

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
}
}