Add Surat Keluar
This commit is contained in:
parent
0399c87736
commit
e4946d6e05
@ -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(
|
||||
|
||||
@ -11,8 +11,13 @@ type Department struct {
|
||||
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" }
|
||||
|
||||
270
internal/processor/department_processor.go
Normal file
270
internal/processor/department_processor.go
Normal 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
|
||||
}
|
||||
52
internal/processor/letter_activity_processor.go
Normal file
52
internal/processor/letter_activity_processor.go
Normal 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)
|
||||
}
|
||||
113
internal/processor/letter_approval_processor.go
Normal file
113
internal/processor/letter_approval_processor.go
Normal 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
|
||||
}
|
||||
54
internal/processor/letter_attachment_processor.go
Normal file
54
internal/processor/letter_attachment_processor.go
Normal 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
|
||||
}
|
||||
72
internal/processor/letter_creation_processor.go
Normal file
72
internal/processor/letter_creation_processor.go
Normal 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
|
||||
}
|
||||
221
internal/processor/letter_outgoing_recipient_processor.go
Normal file
221
internal/processor/letter_outgoing_recipient_processor.go
Normal 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
|
||||
}
|
||||
71
internal/processor/letter_validation_processor.go
Normal file
71
internal/processor/letter_validation_processor.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -54,30 +54,56 @@ type LetterOutgoingService interface {
|
||||
|
||||
type LetterOutgoingServiceImpl struct {
|
||||
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,
|
||||
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
|
||||
|
||||
15
migrations/000035_add_parent_department_id.down.sql
Normal file
15
migrations/000035_add_parent_department_id.down.sql
Normal 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;
|
||||
128
migrations/000035_add_parent_department_id.up.sql
Normal file
128
migrations/000035_add_parent_department_id.up.sql
Normal 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;
|
||||
Loading…
x
Reference in New Issue
Block a user