Add Surat Keluar

This commit is contained in:
Aditya Siregar 2025-09-20 16:31:22 +07:00
parent 0399c87736
commit e4946d6e05
13 changed files with 1151 additions and 13 deletions

View File

@ -197,6 +197,14 @@ type processors struct {
recipientProcessor *processor.RecipientProcessorImpl recipientProcessor *processor.RecipientProcessorImpl
letterDispositionProcessor *processor.LetterDispositionProcessorImpl letterDispositionProcessor *processor.LetterDispositionProcessorImpl
letterDispositionDeptProcessor *processor.LetterDispositionDepartmentProcessorImpl 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 { 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, 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( letterOutgoingProc := processor.NewLetterOutgoingProcessor(
a.db, a.db,
repos.letterOutgoingRepo, repos.letterOutgoingRepo,
@ -307,6 +340,14 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
recipientProcessor: recipientProc, recipientProcessor: recipientProc,
letterDispositionProcessor: letterDispositionProc, letterDispositionProcessor: letterDispositionProc,
letterDispositionDeptProcessor: letterDispositionDeptProc, 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, processors.activityLogger,
) )
dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) 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) approvalFlowStepRepo := repository.NewApprovalFlowStepRepository(a.db)
approvalFlowSvc := service.NewApprovalFlowService( approvalFlowSvc := service.NewApprovalFlowService(

View File

@ -11,8 +11,13 @@ type Department struct {
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Code string `json:"code,omitempty"` Code string `json:"code,omitempty"`
Path string `gorm:"not null" json:"path"` 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"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_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" } func (Department) TableName() string { return "departments" }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -356,6 +356,26 @@ func (r *DepartmentRepository) GetChildren(ctx context.Context, parentPath strin
return departments, nil 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 { func (r *DepartmentRepository) UpdateChildrenPaths(ctx context.Context, oldPath, newPath string) error {
db := DBFromContext(ctx, r.db) db := DBFromContext(ctx, r.db)
// Use raw SQL for ltree path update // Use raw SQL for ltree path update

View File

@ -54,30 +54,56 @@ type LetterOutgoingService interface {
type LetterOutgoingServiceImpl struct { 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{ 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) { func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) {
departmentID := getDepartmentIDFromContext(ctx) departmentID := getDepartmentIDFromContext(ctx)
userID := getUserIDFromContext(ctx)
// Create letter entity
letter := &entities.LetterOutgoing{ letter := &entities.LetterOutgoing{
Subject: req.Subject, Subject: req.Subject,
Description: req.Description, Description: req.Description,
PriorityID: req.PriorityID, PriorityID: req.PriorityID,
ReceiverInstitutionID: req.ReceiverInstitutionID, ReceiverInstitutionID: req.ReceiverInstitutionID,
IssueDate: req.IssueDate, IssueDate: req.IssueDate,
CreatedBy: req.UserID, CreatedBy: userID,
} }
if req.ReferenceNumber != nil { if req.ReferenceNumber != nil {
letter.ReferenceNumber = req.ReferenceNumber letter.ReferenceNumber = req.ReferenceNumber
} }
// Prepare attachments
var attachments []entities.LetterOutgoingAttachment var attachments []entities.LetterOutgoingAttachment
if len(req.Attachments) > 0 { if len(req.Attachments) > 0 {
attachments = make([]entities.LetterOutgoingAttachment, len(req.Attachments)) attachments = make([]entities.LetterOutgoingAttachment, len(req.Attachments))
@ -86,16 +112,57 @@ func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, re
FileURL: a.FileURL, FileURL: a.FileURL,
FileName: a.FileName, FileName: a.FileName,
FileType: a.FileType, 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 { if err != nil {
return nil, err return nil, err
} }
// Get the created letter with all relationships
result, err := s.processor.GetOutgoingLetterByID(ctx, letter.ID) result, err := s.processor.GetOutgoingLetterByID(ctx, letter.ID)
if err != nil { if err != nil {
return nil, err return nil, err

View File

@ -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;

View File

@ -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;