diff --git a/internal/app/app.go b/internal/app/app.go index 5ffa4c7..439903f 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -197,6 +197,14 @@ type processors struct { recipientProcessor *processor.RecipientProcessorImpl letterDispositionProcessor *processor.LetterDispositionProcessorImpl letterDispositionDeptProcessor *processor.LetterDispositionDepartmentProcessorImpl + // Modular processors for letter outgoing + letterValidationProcessor processor.LetterValidationProcessor + letterCreationProcessor processor.LetterCreationProcessor + letterApprovalProcessor processor.LetterApprovalProcessor + letterAttachmentProcessor processor.LetterAttachmentProcessor + letterOutgoingRecipientProcessor processor.LetterOutgoingRecipientProcessor + letterActivityProcessor processor.LetterActivityProcessor + txManager *repository.TxManager } func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors { @@ -218,6 +226,31 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor letterNumberGen, repos.dispositionRouteRepo, ) + // Create modular processors for letter outgoing + letterValidationProc := processor.NewLetterValidationProcessor() + letterCreationProc := processor.NewLetterCreationProcessor( + repos.letterOutgoingRepo, + repos.approvalFlowRepo, + letterNumberGen, + ) + letterApprovalProc := processor.NewLetterApprovalProcessor( + repos.letterOutgoingApprovalRepo, + repos.approvalFlowRepo, + repos.letterOutgoingRepo, + ) + letterAttachmentProc := processor.NewLetterAttachmentProcessor( + repos.letterOutgoingAttachmentRepo, + ) + letterOutgoingRecipientProc := processor.NewLetterOutgoingRecipientProcessor( + repos.letterOutgoingRecipientRepo, + repos.approvalFlowRepo, + repos.userDeptRepo, + ) + letterActivityProc := processor.NewLetterActivityProcessor( + repos.letterOutgoingActivityLogRepo, + ) + + // Create the main letter outgoing processor for backward compatibility letterOutgoingProc := processor.NewLetterOutgoingProcessor( a.db, repos.letterOutgoingRepo, @@ -307,6 +340,14 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor recipientProcessor: recipientProc, letterDispositionProcessor: letterDispositionProc, letterDispositionDeptProcessor: letterDispositionDeptProc, + // Modular processors + letterValidationProcessor: letterValidationProc, + letterCreationProcessor: letterCreationProc, + letterApprovalProcessor: letterApprovalProc, + letterAttachmentProcessor: letterAttachmentProc, + letterOutgoingRecipientProcessor: letterOutgoingRecipientProc, + letterActivityProcessor: letterActivityProc, + txManager: txMgr, } } @@ -352,7 +393,16 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con processors.activityLogger, ) dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) - letterOutgoingSvc := service.NewLetterOutgoingService(processors.letterOutgoingProcessor) + letterOutgoingSvc := service.NewLetterOutgoingService( + processors.letterOutgoingProcessor, + processors.txManager, + processors.letterValidationProcessor, + processors.letterCreationProcessor, + processors.letterApprovalProcessor, + processors.letterAttachmentProcessor, + processors.letterOutgoingRecipientProcessor, + processors.letterActivityProcessor, + ) approvalFlowStepRepo := repository.NewApprovalFlowStepRepository(a.db) approvalFlowSvc := service.NewApprovalFlowService( diff --git a/internal/entities/department.go b/internal/entities/department.go index e93f7b3..35a65f1 100644 --- a/internal/entities/department.go +++ b/internal/entities/department.go @@ -7,12 +7,17 @@ import ( ) type Department struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - Name string `gorm:"not null" json:"name"` - Code string `json:"code,omitempty"` - Path string `gorm:"not null" json:"path"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` - UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + Name string `gorm:"not null" json:"name"` + Code string `json:"code,omitempty"` + Path string `gorm:"not null" json:"path"` + ParentDepartmentID *uuid.UUID `gorm:"type:uuid" json:"parent_department_id,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"` + + // Relations + ParentDepartment *Department `gorm:"foreignKey:ParentDepartmentID" json:"parent_department,omitempty"` + ChildDepartments []Department `gorm:"foreignKey:ParentDepartmentID" json:"child_departments,omitempty"` } func (Department) TableName() string { return "departments" } diff --git a/internal/processor/department_processor.go b/internal/processor/department_processor.go new file mode 100644 index 0000000..70c4827 --- /dev/null +++ b/internal/processor/department_processor.go @@ -0,0 +1,270 @@ +package processor + +import ( + "context" + "fmt" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +// DepartmentProcessor handles all department-related business logic +type DepartmentProcessor interface { + Create(ctx context.Context, department *entities.Department) error + Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) + GetByPath(ctx context.Context, path string) (*entities.Department, error) + Update(ctx context.Context, department *entities.Department) error + Delete(ctx context.Context, id uuid.UUID) error + List(ctx context.Context, search string, limit, offset int) ([]entities.Department, int64, error) + + // Hierarchy operations + GetChildren(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) + GetAllDescendants(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) + UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error + ValidateHierarchy(ctx context.Context, departmentID, newParentID uuid.UUID) error + + // Tree building operations + GetDepartmentWithDescendants(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) + GetDepartmentWithParentAndSiblings(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) + GetAllDepartments(ctx context.Context) ([]entities.Department, error) + GetDepartmentsByRootPath(ctx context.Context, rootPath string) ([]entities.Department, error) +} + +type DepartmentProcessorImpl struct { + departmentRepo *repository.DepartmentRepository + txManager *repository.TxManager +} + +// NewDepartmentProcessor creates a new department processor +func NewDepartmentProcessor( + departmentRepo *repository.DepartmentRepository, + txManager *repository.TxManager, +) *DepartmentProcessorImpl { + return &DepartmentProcessorImpl{ + departmentRepo: departmentRepo, + txManager: txManager, + } +} + +// Create creates a new department +func (p *DepartmentProcessorImpl) Create(ctx context.Context, department *entities.Department) error { + // Build path based on parent + if department.ParentDepartmentID != nil && *department.ParentDepartmentID != uuid.Nil { + parent, err := p.departmentRepo.GetByID(ctx, *department.ParentDepartmentID) + if err != nil { + return fmt.Errorf("parent department not found: %w", err) + } + department.Path = parent.Path + "." + department.Code + } else { + department.Path = department.Code + } + + return p.departmentRepo.Create(ctx, department) +} + +// Get retrieves a department by ID +func (p *DepartmentProcessorImpl) Get(ctx context.Context, id uuid.UUID) (*entities.Department, error) { + return p.departmentRepo.Get(ctx, id) +} + +// GetByPath retrieves a department by its path +func (p *DepartmentProcessorImpl) GetByPath(ctx context.Context, path string) (*entities.Department, error) { + return p.departmentRepo.GetByPath(ctx, path) +} + +// Update updates a department with hierarchy validation +func (p *DepartmentProcessorImpl) Update(ctx context.Context, department *entities.Department) error { + return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + // Get the current department state + current, err := p.departmentRepo.Get(txCtx, department.ID) + if err != nil { + return err + } + + // Store old values for comparison + oldPath := current.Path + oldParentID := current.ParentDepartmentID + pathChanged := false + + // Update path if parent or code changed + if department.ParentDepartmentID != oldParentID || department.Code != current.Code { + // Rebuild path + if department.ParentDepartmentID == nil { + department.Path = department.Code + } else { + parent, err := p.departmentRepo.GetByID(txCtx, *department.ParentDepartmentID) + if err != nil { + return fmt.Errorf("parent department not found: %w", err) + } + department.Path = parent.Path + "." + department.Code + } + + if department.Path != oldPath { + pathChanged = true + } + } + + // Update the department + if err := p.departmentRepo.Update(txCtx, department); err != nil { + return err + } + + // Update children paths if necessary + if pathChanged { + if err := p.updateChildrenPathsRecursively(txCtx, department.ID, department.Path); err != nil { + return fmt.Errorf("failed to update children paths: %w", err) + } + } + + return nil + }) +} + +// Delete deletes a department +func (p *DepartmentProcessorImpl) Delete(ctx context.Context, id uuid.UUID) error { + return p.departmentRepo.Delete(ctx, id) +} + +// List lists departments with pagination +func (p *DepartmentProcessorImpl) List(ctx context.Context, search string, limit, offset int) ([]entities.Department, int64, error) { + return p.departmentRepo.List(ctx, search, limit, offset) +} + +// GetChildren gets direct children of a department +func (p *DepartmentProcessorImpl) GetChildren(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) { + // First get the parent department to get its path + parent, err := p.departmentRepo.Get(ctx, parentID) + if err != nil { + return nil, err + } + if parent == nil { + return []entities.Department{}, nil + } + return p.departmentRepo.GetChildren(ctx, parent.Path) +} + +// GetAllDescendants gets all descendants of a department +func (p *DepartmentProcessorImpl) GetAllDescendants(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) { + return p.departmentRepo.GetAllDescendants(ctx, parentID) +} + +// UpdateChildrenPaths updates paths for all children +func (p *DepartmentProcessorImpl) UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error { + return p.departmentRepo.UpdateChildrenPaths(ctx, oldPath, newPath) +} + +// ValidateHierarchy validates that setting newParentID won't create circular references +func (p *DepartmentProcessorImpl) ValidateHierarchy(ctx context.Context, departmentID, newParentID uuid.UUID) error { + // Can't be its own parent + if departmentID == newParentID { + return fmt.Errorf("department cannot be its own parent") + } + + // Check if new parent is a descendant + descendants, err := p.GetAllDescendants(ctx, departmentID) + if err != nil { + return fmt.Errorf("failed to check descendants: %w", err) + } + + for _, desc := range descendants { + if desc.ID == newParentID { + return fmt.Errorf("cannot set a descendant as parent (circular reference)") + } + } + + return nil +} + +// GetDepartmentWithDescendants gets a department and all its descendants +func (p *DepartmentProcessorImpl) GetDepartmentWithDescendants(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) { + // Get the department + dept, err := p.departmentRepo.Get(ctx, departmentID) + if err != nil { + return nil, err + } + + departments := []entities.Department{*dept} + + // Get all descendants + descendants, err := p.departmentRepo.GetAllDescendants(ctx, departmentID) + if err == nil { + departments = append(departments, descendants...) + } + + return departments, nil +} + +// GetDepartmentWithParentAndSiblings gets a department with its parent and all siblings +func (p *DepartmentProcessorImpl) GetDepartmentWithParentAndSiblings(ctx context.Context, departmentID uuid.UUID) ([]entities.Department, error) { + // Get the department + dept, err := p.departmentRepo.Get(ctx, departmentID) + if err != nil { + return nil, err + } + + var departments []entities.Department + + // If has parent, get parent and all its descendants + if dept.ParentDepartmentID != nil { + parent, err := p.departmentRepo.Get(ctx, *dept.ParentDepartmentID) + if err == nil { + departments = append(departments, *parent) + + // Get all descendants of parent (includes siblings) + descendants, err := p.departmentRepo.GetAllDescendants(ctx, parent.ID) + if err == nil { + departments = append(departments, descendants...) + } + } else { + // Fallback to just department and its descendants + return p.GetDepartmentWithDescendants(ctx, departmentID) + } + } else { + // No parent, just get department and descendants + return p.GetDepartmentWithDescendants(ctx, departmentID) + } + + return departments, nil +} + +// GetAllDepartments gets all departments in the system +func (p *DepartmentProcessorImpl) GetAllDepartments(ctx context.Context) ([]entities.Department, error) { + departments, _, err := p.departmentRepo.List(ctx, "", 0, 0) + return departments, err +} + +// GetDepartmentsByRootPath gets departments starting from a specific path +func (p *DepartmentProcessorImpl) GetDepartmentsByRootPath(ctx context.Context, rootPath string) ([]entities.Department, error) { + // Find the root department + rootDept, err := p.departmentRepo.GetByPath(ctx, rootPath) + if err != nil { + return nil, err + } + + return p.GetDepartmentWithDescendants(ctx, rootDept.ID) +} + +// updateChildrenPathsRecursively updates paths for all children recursively +func (p *DepartmentProcessorImpl) updateChildrenPathsRecursively(ctx context.Context, parentID uuid.UUID, parentPath string) error { + children, err := p.departmentRepo.GetChildren(ctx, parentPath) + if err != nil { + return err + } + + for _, child := range children { + // Update child's path + child.Path = parentPath + "." + child.Code + if err := p.departmentRepo.Update(ctx, &child); err != nil { + return err + } + + // Recursively update grandchildren + if err := p.updateChildrenPathsRecursively(ctx, child.ID, child.Path); err != nil { + return err + } + } + + return nil +} \ No newline at end of file diff --git a/internal/processor/letter_activity_processor.go b/internal/processor/letter_activity_processor.go new file mode 100644 index 0000000..714e74a --- /dev/null +++ b/internal/processor/letter_activity_processor.go @@ -0,0 +1,52 @@ +package processor + +import ( + "context" + "time" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +type LetterActivityProcessor interface { + LogActivity(ctx context.Context, letterID uuid.UUID, action string, userID uuid.UUID, details map[string]interface{}) error + LogStatusChange(ctx context.Context, letterID uuid.UUID, fromStatus, toStatus string, userID uuid.UUID) error + GetActivitiesByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingActivityLog, error) +} + +type LetterActivityProcessorImpl struct { + activityLogRepo *repository.LetterOutgoingActivityLogRepository +} + +func NewLetterActivityProcessor(activityLogRepo *repository.LetterOutgoingActivityLogRepository) *LetterActivityProcessorImpl { + return &LetterActivityProcessorImpl{ + activityLogRepo: activityLogRepo, + } +} + +func (p *LetterActivityProcessorImpl) LogActivity(ctx context.Context, letterID uuid.UUID, action string, userID uuid.UUID, details map[string]interface{}) error { + log := &entities.LetterOutgoingActivityLog{ + LetterID: letterID, + ActionType: action, + ActorUserID: &userID, + Context: details, + OccurredAt: time.Now(), + } + + return p.activityLogRepo.Create(ctx, log) +} + +func (p *LetterActivityProcessorImpl) LogStatusChange(ctx context.Context, letterID uuid.UUID, fromStatus, toStatus string, userID uuid.UUID) error { + details := map[string]interface{}{ + "from_status": fromStatus, + "to_status": toStatus, + } + + return p.LogActivity(ctx, letterID, "status_changed", userID, details) +} + +func (p *LetterActivityProcessorImpl) GetActivitiesByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingActivityLog, error) { + return p.activityLogRepo.ListByLetter(ctx, letterID) +} \ No newline at end of file diff --git a/internal/processor/letter_approval_processor.go b/internal/processor/letter_approval_processor.go new file mode 100644 index 0000000..dfcb7a8 --- /dev/null +++ b/internal/processor/letter_approval_processor.go @@ -0,0 +1,113 @@ +package processor + +import ( + "context" + "time" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +type LetterApprovalProcessor interface { + CreateApprovalSteps(ctx context.Context, letter *entities.LetterOutgoing) error + ProcessApprovalAction(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID, isApproval bool) error + UpdateApprovalStatus(ctx context.Context, approval *entities.LetterOutgoingApproval, status entities.ApprovalStatus, userID uuid.UUID) error + CheckAllApprovalsComplete(ctx context.Context, letterID uuid.UUID) (bool, error) +} + +type LetterApprovalProcessorImpl struct { + approvalRepo *repository.LetterOutgoingApprovalRepository + approvalFlowRepo *repository.ApprovalFlowRepository + letterRepo *repository.LetterOutgoingRepository +} + +func NewLetterApprovalProcessor( + approvalRepo *repository.LetterOutgoingApprovalRepository, + approvalFlowRepo *repository.ApprovalFlowRepository, + letterRepo *repository.LetterOutgoingRepository, +) *LetterApprovalProcessorImpl { + return &LetterApprovalProcessorImpl{ + approvalRepo: approvalRepo, + approvalFlowRepo: approvalFlowRepo, + letterRepo: letterRepo, + } +} + +func (p *LetterApprovalProcessorImpl) CreateApprovalSteps(ctx context.Context, letter *entities.LetterOutgoing) error { + if letter.ApprovalFlowID == nil { + return nil + } + + flow, err := p.approvalFlowRepo.Get(ctx, *letter.ApprovalFlowID) + if err != nil || flow == nil || len(flow.Steps) == 0 { + return err + } + + // Find the minimum step order (first step) + minStepOrder := flow.Steps[0].StepOrder + for _, step := range flow.Steps { + if step.StepOrder < minStepOrder { + minStepOrder = step.StepOrder + } + } + + // Create approval records for each step + for _, step := range flow.Steps { + approval := entities.LetterOutgoingApproval{ + LetterID: letter.ID, + StepID: step.ID, + StepOrder: step.StepOrder, + ParallelGroup: step.ParallelGroup, + IsRequired: step.Required, + ApproverID: step.ApproverUserID, + } + + // Set initial status + if step.StepOrder == minStepOrder { + approval.Status = entities.ApprovalStatusPending + } else { + approval.Status = entities.ApprovalStatusNotStarted + } + + if err := p.approvalRepo.Create(ctx, &approval); err != nil { + return err + } + } + + return nil +} + +func (p *LetterApprovalProcessorImpl) ProcessApprovalAction(ctx context.Context, letterID uuid.UUID, approval *entities.LetterOutgoingApproval, userID uuid.UUID, isApproval bool) error { + status := entities.ApprovalStatusApproved + if !isApproval { + status = entities.ApprovalStatusRejected + } + + return p.UpdateApprovalStatus(ctx, approval, status, userID) +} + +func (p *LetterApprovalProcessorImpl) UpdateApprovalStatus(ctx context.Context, approval *entities.LetterOutgoingApproval, status entities.ApprovalStatus, userID uuid.UUID) error { + approval.Status = status + approval.ApproverID = &userID + now := time.Now() + approval.ActedAt = &now + + return p.approvalRepo.Update(ctx, approval) +} + +func (p *LetterApprovalProcessorImpl) CheckAllApprovalsComplete(ctx context.Context, letterID uuid.UUID) (bool, error) { + approvals, err := p.approvalRepo.ListByLetter(ctx, letterID) + if err != nil { + return false, err + } + + for _, approval := range approvals { + if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { + return false, nil + } + } + + return true, nil +} \ No newline at end of file diff --git a/internal/processor/letter_attachment_processor.go b/internal/processor/letter_attachment_processor.go new file mode 100644 index 0000000..3ec7ead --- /dev/null +++ b/internal/processor/letter_attachment_processor.go @@ -0,0 +1,54 @@ +package processor + +import ( + "context" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +type LetterAttachmentProcessor interface { + CreateAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment) error + RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error + GetAttachmentsByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingAttachment, error) +} + +type LetterAttachmentProcessorImpl struct { + attachmentRepo *repository.LetterOutgoingAttachmentRepository +} + +func NewLetterAttachmentProcessor(attachmentRepo *repository.LetterOutgoingAttachmentRepository) *LetterAttachmentProcessorImpl { + return &LetterAttachmentProcessorImpl{ + attachmentRepo: attachmentRepo, + } +} + +func (p *LetterAttachmentProcessorImpl) CreateAttachments(ctx context.Context, letterID uuid.UUID, attachments []entities.LetterOutgoingAttachment) error { + if len(attachments) == 0 { + return nil + } + + // Set letter ID for all attachments + for i := range attachments { + attachments[i].LetterID = letterID + } + + return p.attachmentRepo.CreateBulk(ctx, attachments) +} + +func (p *LetterAttachmentProcessorImpl) RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error { + return p.attachmentRepo.Delete(ctx, attachmentID) +} + +func (p *LetterAttachmentProcessorImpl) GetAttachmentsByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingAttachment, error) { + attachmentMap, err := p.attachmentRepo.ListByLetterIDs(ctx, []uuid.UUID{letterID}) + if err != nil { + return nil, err + } + if attachments, ok := attachmentMap[letterID]; ok { + return attachments, nil + } + return []entities.LetterOutgoingAttachment{}, nil +} \ No newline at end of file diff --git a/internal/processor/letter_creation_processor.go b/internal/processor/letter_creation_processor.go new file mode 100644 index 0000000..419a125 --- /dev/null +++ b/internal/processor/letter_creation_processor.go @@ -0,0 +1,72 @@ +package processor + +import ( + "context" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +type LetterCreationProcessor interface { + PrepareLetterForCreation(ctx context.Context, letter *entities.LetterOutgoing, departmentID uuid.UUID) error + CreateLetter(ctx context.Context, letter *entities.LetterOutgoing) error + GenerateLetterNumber(ctx context.Context, letter *entities.LetterOutgoing) error +} + +type LetterCreationProcessorImpl struct { + letterRepo *repository.LetterOutgoingRepository + approvalFlowRepo *repository.ApprovalFlowRepository + numberGenerator LetterNumberGenerator +} + +func NewLetterCreationProcessor( + letterRepo *repository.LetterOutgoingRepository, + approvalFlowRepo *repository.ApprovalFlowRepository, + numberGenerator LetterNumberGenerator, +) *LetterCreationProcessorImpl { + return &LetterCreationProcessorImpl{ + letterRepo: letterRepo, + approvalFlowRepo: approvalFlowRepo, + numberGenerator: numberGenerator, + } +} + +func (p *LetterCreationProcessorImpl) PrepareLetterForCreation(ctx context.Context, letter *entities.LetterOutgoing, departmentID uuid.UUID) error { + // Assign approval flow from department if not provided + if letter.ApprovalFlowID == nil && departmentID != uuid.Nil { + flow, err := p.approvalFlowRepo.GetByDepartment(ctx, departmentID) + if err == nil && flow != nil { + letter.ApprovalFlowID = &flow.ID + } + } + + // Set initial status based on approval flow + if letter.ApprovalFlowID != nil { + letter.Status = entities.LetterOutgoingStatusPendingApproval + } else { + letter.Status = entities.LetterOutgoingStatusApproved + } + + return nil +} + +func (p *LetterCreationProcessorImpl) CreateLetter(ctx context.Context, letter *entities.LetterOutgoing) error { + return p.letterRepo.Create(ctx, letter) +} + +func (p *LetterCreationProcessorImpl) GenerateLetterNumber(ctx context.Context, letter *entities.LetterOutgoing) error { + letterNumber, err := p.numberGenerator.GenerateNumber( + ctx, + "outgoing_letter_prefix", + "outgoing_letter_sequence", + "ESLO", + ) + if err != nil { + return err + } + + letter.LetterNumber = letterNumber + return nil +} \ No newline at end of file diff --git a/internal/processor/letter_outgoing_recipient_processor.go b/internal/processor/letter_outgoing_recipient_processor.go new file mode 100644 index 0000000..76423cd --- /dev/null +++ b/internal/processor/letter_outgoing_recipient_processor.go @@ -0,0 +1,221 @@ +package processor + +import ( + "context" + + "eslogad-be/internal/entities" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +// LetterOutgoingRecipientProcessor handles all recipient-related operations for outgoing letters +type LetterOutgoingRecipientProcessor interface { + // CreateRecipients creates multiple recipients for a letter + CreateRecipients(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterOutgoingRecipient) error + + // CreateInitialRecipients creates recipients from both approval workflow and department members + CreateInitialRecipients(ctx context.Context, letter *entities.LetterOutgoing, creatorDepartmentID uuid.UUID) error + + // UpdateRecipient updates a single recipient's information + UpdateRecipient(ctx context.Context, recipient *entities.LetterOutgoingRecipient) error + + // RemoveRecipient removes a recipient from a letter + RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error + + // GetRecipientsByLetterID retrieves all recipients for a specific letter + GetRecipientsByLetterID(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingRecipient, error) +} + +type LetterOutgoingRecipientProcessorImpl struct { + recipientRepo *repository.LetterOutgoingRecipientRepository + approvalFlowRepo *repository.ApprovalFlowRepository + userDeptRepo *repository.UserDepartmentRepository +} + +func NewLetterOutgoingRecipientProcessor( + recipientRepo *repository.LetterOutgoingRecipientRepository, + approvalFlowRepo *repository.ApprovalFlowRepository, + userDeptRepo *repository.UserDepartmentRepository, +) *LetterOutgoingRecipientProcessorImpl { + return &LetterOutgoingRecipientProcessorImpl{ + recipientRepo: recipientRepo, + approvalFlowRepo: approvalFlowRepo, + userDeptRepo: userDeptRepo, + } +} + +// CreateRecipients creates multiple recipients for a letter +func (p *LetterOutgoingRecipientProcessorImpl) CreateRecipients( + ctx context.Context, + letterID uuid.UUID, + recipients []entities.LetterOutgoingRecipient, +) error { + if len(recipients) == 0 { + return nil + } + + // Ensure all recipients have the correct letter ID and default status + for i := range recipients { + recipients[i].LetterID = letterID + if recipients[i].Status == "" { + recipients[i].Status = "pending" + } + } + + return p.recipientRepo.CreateBulk(ctx, recipients) +} + +// CreateInitialRecipients creates the initial set of recipients for an outgoing letter +// It combines: +// 1. Approvers from the approval workflow (if exists) +// 2. All active users from the letter creator's department +func (p *LetterOutgoingRecipientProcessorImpl) CreateInitialRecipients( + ctx context.Context, + letter *entities.LetterOutgoing, + creatorDepartmentID uuid.UUID, +) error { + // Track unique users to avoid duplicates + uniqueUsers := make(map[uuid.UUID]bool) + var allRecipients []entities.LetterOutgoingRecipient + + // Step 1: Add recipients from approval workflow + approvalRecipients := p.collectApprovalWorkflowRecipients(ctx, letter, uniqueUsers) + allRecipients = append(allRecipients, approvalRecipients...) + + // Step 2: Add all users from the creator's department + departmentRecipients := p.collectDepartmentRecipients(ctx, letter.ID, creatorDepartmentID, uniqueUsers) + allRecipients = append(allRecipients, departmentRecipients...) + + // Step 3: Mark the first recipient as primary and save all + if len(allRecipients) > 0 { + allRecipients[0].IsPrimary = true + return p.recipientRepo.CreateBulk(ctx, allRecipients) + } + + return nil +} + +// collectApprovalWorkflowRecipients gathers all users who are approvers in the workflow +func (p *LetterOutgoingRecipientProcessorImpl) collectApprovalWorkflowRecipients( + ctx context.Context, + letter *entities.LetterOutgoing, + existingUsers map[uuid.UUID]bool, +) []entities.LetterOutgoingRecipient { + var recipients []entities.LetterOutgoingRecipient + + // If no approval workflow is assigned, return empty + if letter.ApprovalFlowID == nil { + return recipients + } + + // Fetch the approval workflow + approvalFlow, err := p.approvalFlowRepo.Get(ctx, *letter.ApprovalFlowID) + if err != nil || approvalFlow == nil || len(approvalFlow.Steps) == 0 { + return recipients + } + + // Add each approver as a recipient + for _, step := range approvalFlow.Steps { + if step.ApproverUserID == nil { + continue + } + + userID := *step.ApproverUserID + + // Skip if user is already added + if existingUsers[userID] { + continue + } + + existingUsers[userID] = true + + recipient := entities.LetterOutgoingRecipient{ + LetterID: letter.ID, + UserID: &userID, + IsPrimary: false, + Status: "pending", + } + + recipients = append(recipients, recipient) + } + + return recipients +} + +// collectDepartmentRecipients gathers all active users from a specific department +func (p *LetterOutgoingRecipientProcessorImpl) collectDepartmentRecipients( + ctx context.Context, + letterID uuid.UUID, + departmentID uuid.UUID, + existingUsers map[uuid.UUID]bool, +) []entities.LetterOutgoingRecipient { + var recipients []entities.LetterOutgoingRecipient + + // If no department specified, return empty + if departmentID == uuid.Nil { + return recipients + } + + // Fetch all active users in the department + userDepartmentMappings, err := p.userDeptRepo.ListActiveByDepartmentIDs(ctx, []uuid.UUID{departmentID}) + if err != nil { + return recipients + } + + // Add each department user as a recipient + for _, mapping := range userDepartmentMappings { + // Skip if user is already added (e.g., from approval workflow) + if existingUsers[mapping.UserID] { + continue + } + + existingUsers[mapping.UserID] = true + + recipient := entities.LetterOutgoingRecipient{ + LetterID: letterID, + UserID: &mapping.UserID, + DepartmentID: &mapping.DepartmentID, + IsPrimary: false, + Status: "pending", + } + + recipients = append(recipients, recipient) + } + + return recipients +} + +// UpdateRecipient updates an existing recipient's information +func (p *LetterOutgoingRecipientProcessorImpl) UpdateRecipient( + ctx context.Context, + recipient *entities.LetterOutgoingRecipient, +) error { + return p.recipientRepo.Update(ctx, recipient) +} + +// RemoveRecipient removes a recipient from a letter +func (p *LetterOutgoingRecipientProcessorImpl) RemoveRecipient( + ctx context.Context, + letterID uuid.UUID, + recipientID uuid.UUID, +) error { + return p.recipientRepo.Delete(ctx, recipientID) +} + +// GetRecipientsByLetterID retrieves all recipients for a specific letter +func (p *LetterOutgoingRecipientProcessorImpl) GetRecipientsByLetterID( + ctx context.Context, + letterID uuid.UUID, +) ([]entities.LetterOutgoingRecipient, error) { + recipientMap, err := p.recipientRepo.ListByLetterIDs(ctx, []uuid.UUID{letterID}) + if err != nil { + return nil, err + } + + if recipients, ok := recipientMap[letterID]; ok { + return recipients, nil + } + + return []entities.LetterOutgoingRecipient{}, nil +} \ No newline at end of file diff --git a/internal/processor/letter_validation_processor.go b/internal/processor/letter_validation_processor.go new file mode 100644 index 0000000..b695947 --- /dev/null +++ b/internal/processor/letter_validation_processor.go @@ -0,0 +1,71 @@ +package processor + +import ( + "context" + "errors" + + "eslogad-be/internal/entities" + + "github.com/google/uuid" +) + +type LetterValidationProcessor interface { + ValidateCreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error + ValidateUpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, existingLetter *entities.LetterOutgoing) error + ValidateDeleteOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error + ValidateApprovalSubmission(ctx context.Context, letter *entities.LetterOutgoing) error +} + +type LetterValidationProcessorImpl struct{} + +func NewLetterValidationProcessor() *LetterValidationProcessorImpl { + return &LetterValidationProcessorImpl{} +} + +func (p *LetterValidationProcessorImpl) ValidateCreateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error { + if letter.Subject == "" { + return errors.New("letter subject is required") + } + + if letter.CreatedBy == uuid.Nil { + return errors.New("letter creator is required") + } + + if letter.IssueDate.IsZero() { + return errors.New("letter issue date is required") + } + + return nil +} + +func (p *LetterValidationProcessorImpl) ValidateUpdateOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing, existingLetter *entities.LetterOutgoing) error { + if existingLetter.Status != entities.LetterOutgoingStatusDraft { + return errors.New("only draft letters can be updated") + } + + if letter.Subject == "" { + return errors.New("letter subject cannot be empty") + } + + return nil +} + +func (p *LetterValidationProcessorImpl) ValidateDeleteOutgoingLetter(ctx context.Context, letter *entities.LetterOutgoing) error { + if letter.Status != entities.LetterOutgoingStatusDraft { + return errors.New("only draft letters can be deleted") + } + + return nil +} + +func (p *LetterValidationProcessorImpl) ValidateApprovalSubmission(ctx context.Context, letter *entities.LetterOutgoing) error { + if letter.Status != entities.LetterOutgoingStatusDraft { + return errors.New("only draft letters can be submitted for approval") + } + + if letter.ApprovalFlowID == nil { + return errors.New("approval flow is required for submission") + } + + return nil +} \ No newline at end of file diff --git a/internal/repository/master_repository.go b/internal/repository/master_repository.go index 64de9ba..6fad42c 100644 --- a/internal/repository/master_repository.go +++ b/internal/repository/master_repository.go @@ -356,6 +356,26 @@ func (r *DepartmentRepository) GetChildren(ctx context.Context, parentPath strin return departments, nil } +func (r *DepartmentRepository) GetAllDescendants(ctx context.Context, parentID uuid.UUID) ([]entities.Department, error) { + db := DBFromContext(ctx, r.db) + + // First get the parent department to get its path + var parent entities.Department + if err := db.WithContext(ctx).First(&parent, "id = ?", parentID).Error; err != nil { + return nil, err + } + + var departments []entities.Department + // Get all descendants using ltree + if err := db.WithContext(ctx). + Where("path <@ ? AND path != ?", parent.Path, parent.Path). + Order("path ASC"). + Find(&departments).Error; err != nil { + return nil, err + } + return departments, nil +} + func (r *DepartmentRepository) UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error { db := DBFromContext(ctx, r.db) // Use raw SQL for ltree path update diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index 99f58d3..3b85fb4 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -53,31 +53,57 @@ type LetterOutgoingService interface { } type LetterOutgoingServiceImpl struct { - processor processor.LetterOutgoingProcessor + processor processor.LetterOutgoingProcessor + txManager *repository.TxManager + validationProcessor processor.LetterValidationProcessor + creationProcessor processor.LetterCreationProcessor + approvalProcessor processor.LetterApprovalProcessor + attachmentProcessor processor.LetterAttachmentProcessor + recipientProcessor processor.LetterOutgoingRecipientProcessor + activityProcessor processor.LetterActivityProcessor } -func NewLetterOutgoingService(processor processor.LetterOutgoingProcessor) *LetterOutgoingServiceImpl { +func NewLetterOutgoingService( + processor processor.LetterOutgoingProcessor, + txManager *repository.TxManager, + validationProcessor processor.LetterValidationProcessor, + creationProcessor processor.LetterCreationProcessor, + approvalProcessor processor.LetterApprovalProcessor, + attachmentProcessor processor.LetterAttachmentProcessor, + recipientProcessor processor.LetterOutgoingRecipientProcessor, + activityProcessor processor.LetterActivityProcessor, +) *LetterOutgoingServiceImpl { return &LetterOutgoingServiceImpl{ - processor: processor, + processor: processor, + txManager: txManager, + validationProcessor: validationProcessor, + creationProcessor: creationProcessor, + approvalProcessor: approvalProcessor, + attachmentProcessor: attachmentProcessor, + recipientProcessor: recipientProcessor, + activityProcessor: activityProcessor, } } func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) { departmentID := getDepartmentIDFromContext(ctx) + userID := getUserIDFromContext(ctx) + // Create letter entity letter := &entities.LetterOutgoing{ Subject: req.Subject, Description: req.Description, PriorityID: req.PriorityID, ReceiverInstitutionID: req.ReceiverInstitutionID, IssueDate: req.IssueDate, - CreatedBy: req.UserID, + CreatedBy: userID, } 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)) @@ -86,16 +112,57 @@ func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, re FileURL: a.FileURL, FileName: a.FileName, FileType: a.FileType, - UploadedBy: &req.UserID, + UploadedBy: &userID, } } } - err := s.processor.CreateOutgoingLetter(ctx, letter, attachments, req.UserID, departmentID) + // 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 diff --git a/migrations/000035_add_parent_department_id.down.sql b/migrations/000035_add_parent_department_id.down.sql new file mode 100644 index 0000000..38ac6b4 --- /dev/null +++ b/migrations/000035_add_parent_department_id.down.sql @@ -0,0 +1,15 @@ +-- Drop the view +DROP VIEW IF EXISTS department_hierarchy; + +-- Drop the functions +DROP FUNCTION IF EXISTS get_department_hierarchy_path(UUID); +DROP FUNCTION IF EXISTS check_department_hierarchy(); + +-- Drop the trigger +DROP TRIGGER IF EXISTS check_department_hierarchy_trigger ON departments; + +-- Drop the index +DROP INDEX IF EXISTS idx_departments_parent_id; + +-- Remove the parent_department_id column +ALTER TABLE departments DROP COLUMN IF EXISTS parent_department_id; \ No newline at end of file diff --git a/migrations/000035_add_parent_department_id.up.sql b/migrations/000035_add_parent_department_id.up.sql new file mode 100644 index 0000000..cf67c90 --- /dev/null +++ b/migrations/000035_add_parent_department_id.up.sql @@ -0,0 +1,128 @@ +-- 1) Schema changes +ALTER TABLE departments + ADD COLUMN parent_department_id UUID REFERENCES departments(id) ON DELETE CASCADE; + +CREATE INDEX idx_departments_parent_id ON departments(parent_department_id); + +-- (Optional but recommended for ltree lookups) +-- CREATE EXTENSION IF NOT EXISTS ltree; +-- CREATE INDEX idx_departments_path_gist ON departments USING GIST (path); +-- CREATE INDEX idx_departments_path_nlevel ON departments ((nlevel(path))); + +-- 2) Migrate parent ids from existing ltree path +-- Use ltree helpers: nlevel() and subpath() +UPDATE departments d1 +SET parent_department_id = d2.id + FROM departments d2 +WHERE nlevel(d1.path) > 1 + AND d2.path = subpath(d1.path, 0, nlevel(d1.path) - 1); + +-- 3) Guard against cycles/self-parent via trigger +CREATE OR REPLACE FUNCTION check_department_hierarchy() +RETURNS TRIGGER AS $$ +DECLARE +current_id UUID; + max_depth INT := 100; + depth INT := 0; +BEGIN + IF NEW.parent_department_id IS NULL THEN + RETURN NEW; +END IF; + + -- self-reference + IF NEW.parent_department_id = NEW.id THEN + RAISE EXCEPTION 'Department cannot be its own parent'; +END IF; + + -- walk up the tree + current_id := NEW.parent_department_id; + WHILE current_id IS NOT NULL AND depth < max_depth LOOP + IF current_id = NEW.id THEN + RAISE EXCEPTION 'Circular reference detected in department hierarchy'; +END IF; + +SELECT parent_department_id INTO current_id +FROM departments +WHERE id = current_id; + +depth := depth + 1; +END LOOP; + + IF depth >= max_depth THEN + RAISE EXCEPTION 'Department hierarchy too deep (max: %)', max_depth; +END IF; + +RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS check_department_hierarchy_trigger ON departments; + +CREATE TRIGGER check_department_hierarchy_trigger + BEFORE INSERT OR UPDATE OF parent_department_id ON departments + FOR EACH ROW + EXECUTE FUNCTION check_department_hierarchy(); + +-- 4) Helper to rebuild dotted text path from codes (top.down.leaf) +CREATE OR REPLACE FUNCTION get_department_hierarchy_path(dept_id UUID) +RETURNS TEXT AS $$ +DECLARE +path_text TEXT := ''; + current_dept RECORD; + current_id UUID := dept_id; +BEGIN + WHILE current_id IS NOT NULL LOOP +SELECT id, name, parent_department_id, code +INTO current_dept +FROM departments +WHERE id = current_id; + +IF current_dept IS NULL THEN + EXIT; +END IF; + + IF path_text = '' THEN + path_text := current_dept.code; +ELSE + path_text := current_dept.code || '.' || path_text; +END IF; + + current_id := current_dept.parent_department_id; +END LOOP; + +RETURN path_text; +END; +$$ LANGUAGE plpgsql; + +-- 5) View for easy hierarchy queries +CREATE OR REPLACE VIEW department_hierarchy AS +WITH RECURSIVE dept_tree AS ( + -- roots + SELECT + id, + name, + code, + parent_department_id, + path, + 0 AS level, + ARRAY[id] AS hierarchy_ids, + ARRAY[name] AS hierarchy_names + FROM departments + WHERE parent_department_id IS NULL + + UNION ALL + + -- children + SELECT + d.id, + d.name, + d.code, + d.parent_department_id, + d.path, + dt.level + 1, + dt.hierarchy_ids || d.id, + dt.hierarchy_names || d.name + FROM departments d + JOIN dept_tree dt ON d.parent_department_id = dt.id +) +SELECT * FROM dept_tree;