Update department etc

This commit is contained in:
Aditya Siregar
2025-09-09 14:41:00 +07:00
parent d869d83d4b
commit 0399c87736
22 changed files with 1193 additions and 502 deletions
+116 -10
View File
@@ -2,7 +2,6 @@ package service
import (
"context"
"eslogad-be/internal/logger"
"fmt"
"time"
@@ -44,7 +43,7 @@ type LetterProcessor interface {
GetEnhancedDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListEnhancedDispositionsResponse, error)
CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error)
UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error)
UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, string, error)
GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error)
UpdateDispositionStatus(ctx context.Context, req *contract.UpdateDispositionStatusRequest) (*contract.DepartmentDispositionStatusResponse, error)
@@ -60,6 +59,7 @@ type LetterServiceImpl struct {
activityLogger ActivityLogger
letterDispositionProcessor LetterDispositionProcessor
notificationProcessor processor.NotificationProcessor
activityProcessor ActivityLogger
}
type NumberGenerator interface {
@@ -68,6 +68,7 @@ type NumberGenerator interface {
type RecipientProcessor interface {
CreateDefaultRecipients(ctx context.Context, letterID uuid.UUID) ([]entities.LetterIncomingRecipient, error)
CreateRecipients(ctx context.Context, letterID uuid.UUID, departmentIDs []uuid.UUID) ([]entities.LetterIncomingRecipient, error)
CreateSingleRecipient(ctx context.Context, recipient *entities.LetterIncomingRecipient) error
}
@@ -89,6 +90,7 @@ func NewLetterService(
activityLogger ActivityLogger,
letterDispositionProcessor LetterDispositionProcessor,
notificationProcessor processor.NotificationProcessor,
activityProc ActivityLogger,
) *LetterServiceImpl {
return &LetterServiceImpl{
processor: processor,
@@ -98,6 +100,7 @@ func NewLetterService(
activityLogger: activityLogger,
letterDispositionProcessor: letterDispositionProcessor,
notificationProcessor: notificationProcessor,
activityProcessor: activityProc,
}
}
@@ -202,7 +205,7 @@ func (s *LetterServiceImpl) logLetterCreation(ctx context.Context, letterID uuid
userID := appcontext.FromGinContext(ctx).UserID
err := s.activityLogger.LogLetterCreated(ctx, letterID, userID, letterNumber)
if err != nil {
logger.FromContext(ctx).Error("error when insert into log", err)
// Log error but don't fail the operation
}
}
@@ -229,8 +232,7 @@ func (s *LetterServiceImpl) addCreatorAsRecipient(ctx context.Context, letterID
// Save the recipient
if err := s.recipientProcessor.CreateSingleRecipient(ctx, &recipient); err != nil {
// Log error but don't fail the whole operation
logger.FromContext(ctx).Error("failed to add creator as recipient", err)
// Failed to add creator as recipient
return nil, err
}
@@ -248,7 +250,34 @@ func (s *LetterServiceImpl) sendLetterNotifications(ctx context.Context, letter
fmt.Sprintf("%s: %s", letter.SenderInstitution.Name, letter.Subject))
if err != nil {
logger.FromContext(ctx).Error("failed to send notification", err)
// Failed to send notification, continue anyway
}
}
}
}
func (s *LetterServiceImpl) sendDispositionNotifications(ctx context.Context, letterID uuid.UUID, recipients []entities.LetterIncomingRecipient) {
// Get letter details for notification
appContext := appcontext.FromGinContext(ctx)
letter, err := s.processor.GetIncomingLetterByID(ctx, letterID)
if err != nil {
return
}
for _, recipient := range recipients {
if recipient.RecipientUserID != nil && recipient.Status != entities.RecipientStatusCompleted {
subject := "Surat Masuk"
message := fmt.Sprintf("Disposisi surat dari %s: %s", appContext.UserName, letter.Subject)
err := s.notificationProcessor.SendIncomingLetterNotification(
ctx,
letterID,
*recipient.RecipientUserID,
subject,
message)
if err != nil {
// Failed to send notification, continue anyway
}
}
}
@@ -356,7 +385,7 @@ func (s *LetterServiceImpl) ListIncomingLetters(ctx context.Context, req *contra
for i := 0; i < 4; i++ {
if err := <-errChan; err != nil {
logger.FromContext(ctx).Error("batch load error", err)
// Batch load error, continue anyway
}
}
@@ -427,16 +456,40 @@ func (s *LetterServiceImpl) CreateDispositions(ctx context.Context, req *contrac
}
var result *contract.ListDispositionsResponse
var recipients []entities.LetterIncomingRecipient
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
var err error
result, err = s.processor.CreateDispositions(txCtx, req)
return err
if err != nil {
return err
}
if len(req.ToDepartmentIDs) > 0 && s.recipientProcessor != nil {
recipients, err = s.recipientProcessor.CreateRecipients(txCtx, req.LetterID, req.ToDepartmentIDs)
if err != nil {
return err
}
}
if s.activityLogger != nil && result != nil && len(result.Dispositions) > 0 {
if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, req.LetterID, userID, "disposition_created"); err != nil {
}
}
return nil
})
if err != nil {
return nil, err
}
// Send notifications to newly created recipients asynchronously
if s.notificationProcessor != nil && len(recipients) > 0 {
go s.sendDispositionNotifications(context.Background(), req.LetterID, recipients)
}
return result, nil
}
@@ -445,11 +498,64 @@ func (s *LetterServiceImpl) GetEnhancedDispositionsByLetter(ctx context.Context,
}
func (s *LetterServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {
return s.processor.CreateDiscussion(ctx, letterID, req)
userID := appcontext.FromGinContext(ctx).UserID
var result *contract.LetterDiscussionResponse
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
var err error
result, err = s.processor.CreateDiscussion(txCtx, letterID, req)
if err != nil {
return err
}
// Log activity for discussion creation
if s.activityLogger != nil && result != nil {
// Create a simple activity log
if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, letterID, userID, "discussion_created"); err != nil {
// Don't fail the transaction for logging errors
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *LetterServiceImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) {
return s.processor.UpdateDiscussion(ctx, letterID, discussionID, req)
userID := appcontext.FromGinContext(ctx).UserID
var result *contract.LetterDiscussionResponse
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
var err error
var oldMessage string
result, oldMessage, err = s.processor.UpdateDiscussion(txCtx, letterID, discussionID, req)
if err != nil {
return err
}
// Log activity for discussion update (could use oldMessage for more detailed logging)
if s.activityLogger != nil && result != nil {
// Create a simple activity log - oldMessage could be included in a more detailed log
_ = oldMessage // Mark as intentionally unused for now
if err := s.activityLogger.LogLetterDispositionStatusUpdate(txCtx, letterID, userID, "discussion_updated"); err != nil {
// Don't fail the transaction for logging errors
}
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *LetterServiceImpl) GetDepartmentDispositionStatus(ctx context.Context, req *contract.GetDepartmentDispositionStatusRequest) (*contract.ListDepartmentDispositionStatusResponse, error) {
+390 -3
View File
@@ -2,7 +2,10 @@ package service
import (
"context"
"sort"
"strings"
"eslogad-be/config"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
@@ -17,10 +20,11 @@ type MasterServiceImpl struct {
institutionRepo *repository.InstitutionRepository
dispRepo *repository.DispositionActionRepository
departmentRepo *repository.DepartmentRepository
config *config.Config
}
func NewMasterService(label *repository.LabelRepository, priority *repository.PriorityRepository, institution *repository.InstitutionRepository, disp *repository.DispositionActionRepository, department *repository.DepartmentRepository) *MasterServiceImpl {
return &MasterServiceImpl{labelRepo: label, priorityRepo: priority, institutionRepo: institution, dispRepo: disp, departmentRepo: department}
func NewMasterService(label *repository.LabelRepository, priority *repository.PriorityRepository, institution *repository.InstitutionRepository, disp *repository.DispositionActionRepository, department *repository.DepartmentRepository, cfg *config.Config) *MasterServiceImpl {
return &MasterServiceImpl{labelRepo: label, priorityRepo: priority, institutionRepo: institution, dispRepo: disp, departmentRepo: department, config: cfg}
}
// Labels
@@ -215,6 +219,385 @@ func (s *MasterServiceImpl) ListDispositionActions(ctx context.Context) (*contra
}
// Departments
func (s *MasterServiceImpl) CreateDepartment(ctx context.Context, req *contract.CreateDepartmentRequest) (*contract.GetDepartmentResponse, error) {
// Build the path based on parent
var path string
if req.ParentID != nil {
// Get parent department to build the path
parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, err
}
// Build path as parent.path + code
path = parent.Path + "." + req.Code
} else {
// Root level department, just use the code as path
path = req.Code
}
entity := &entities.Department{
Name: req.Name,
Code: req.Code,
Path: path,
}
if err := s.departmentRepo.Create(ctx, entity); err != nil {
return nil, err
}
// Get parent name if parent exists
var parentName *string
if req.ParentID != nil {
if parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID); err == nil {
parentName = &parent.Name
}
}
return &contract.GetDepartmentResponse{
ID: entity.ID,
Name: entity.Name,
Code: entity.Code,
Path: entity.Path,
ParentID: req.ParentID,
ParentName: parentName,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}, nil
}
func (s *MasterServiceImpl) GetDepartment(ctx context.Context, id uuid.UUID) (*contract.GetDepartmentResponse, error) {
entity, err := s.departmentRepo.Get(ctx, id)
if err != nil {
return nil, err
}
// Derive parent_id and parent_name from path
var parentID *uuid.UUID
var parentName *string
parts := strings.Split(entity.Path, ".")
if len(parts) > 1 {
// Has parent, try to find it
parentPath := strings.Join(parts[:len(parts)-1], ".")
if parent, err := s.departmentRepo.GetByPath(ctx, parentPath); err == nil {
parentID = &parent.ID
parentName = &parent.Name
}
}
return &contract.GetDepartmentResponse{
ID: entity.ID,
Name: entity.Name,
Code: entity.Code,
Path: entity.Path,
ParentID: parentID,
ParentName: parentName,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}, nil
}
func (s *MasterServiceImpl) UpdateDepartment(ctx context.Context, id uuid.UUID, req *contract.UpdateDepartmentRequest) (*contract.GetDepartmentResponse, error) {
entity, err := s.departmentRepo.Get(ctx, id)
if err != nil {
return nil, err
}
// Store the old path before changes
oldPath := entity.Path
if req.Name != nil {
entity.Name = *req.Name
}
if req.Code != nil {
entity.Code = *req.Code
}
// Rebuild path if parent is being changed or code is being changed
if req.ParentID != nil || req.Code != nil {
// Determine the code to use (new code if provided, otherwise existing)
code := entity.Code
if req.Code != nil {
code = *req.Code
}
// Build the new path based on parent
var path string
if req.ParentID != nil {
if *req.ParentID == uuid.Nil {
// Moving to root level
path = code
} else {
// Get parent department to build the path
parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID)
if err != nil {
return nil, err
}
// Build path as parent.path + code
path = parent.Path + "." + code
}
} else if req.Code != nil {
// Code changed but parent not specified, rebuild path with current parent
// Extract parent path from current path
parts := strings.Split(entity.Path, ".")
if len(parts) > 1 {
// Has parent, rebuild with new code
parentPath := strings.Join(parts[:len(parts)-1], ".")
path = parentPath + "." + code
} else {
// Root level, just use new code
path = code
}
}
if path != "" {
entity.Path = path
}
}
// Update the department
if err := s.departmentRepo.Update(ctx, entity); err != nil {
return nil, err
}
// If the path changed, update all children paths
if oldPath != entity.Path {
if err := s.departmentRepo.UpdateChildrenPaths(ctx, oldPath, entity.Path); err != nil {
// Log the error but don't fail the operation
// You might want to handle this differently based on your requirements
// For now, we'll continue since the parent update succeeded
}
}
// Derive parent_id and parent_name from path for response
var parentID *uuid.UUID
var parentName *string
if req.ParentID != nil {
parentID = req.ParentID
// Get parent name
if parent, err := s.departmentRepo.GetByID(ctx, *req.ParentID); err == nil {
parentName = &parent.Name
}
} else {
// Derive from path if not provided in request
parts := strings.Split(entity.Path, ".")
if len(parts) > 1 {
parentPath := strings.Join(parts[:len(parts)-1], ".")
if parent, err := s.departmentRepo.GetByPath(ctx, parentPath); err == nil {
parentID = &parent.ID
parentName = &parent.Name
}
}
}
return &contract.GetDepartmentResponse{
ID: entity.ID,
Name: entity.Name,
Code: entity.Code,
Path: entity.Path,
ParentID: parentID,
ParentName: parentName,
CreatedAt: entity.CreatedAt,
UpdatedAt: entity.UpdatedAt,
}, nil
}
func (s *MasterServiceImpl) DeleteDepartment(ctx context.Context, id uuid.UUID) error {
return s.departmentRepo.Delete(ctx, id)
}
func (s *MasterServiceImpl) GetOrganizationalChartByID(ctx context.Context, departmentID uuid.UUID) (*contract.OrganizationalChartResponse, error) {
// First get the department to find its path
department, err := s.departmentRepo.Get(ctx, departmentID)
if err != nil {
return nil, err
}
// Now get the organizational chart starting from this department's path
return s.GetOrganizationalChart(ctx, department.Path)
}
func (s *MasterServiceImpl) GetOrganizationalChart(ctx context.Context, rootPath string) (*contract.OrganizationalChartResponse, error) {
var departments []entities.Department
var err error
// Get config values
parentPath := s.config.Department.ParentPath
excludedPaths := s.config.Department.ExcludedPaths
if rootPath == "" {
// Get all departments with parent filter
departments, err = s.departmentRepo.GetAllWithParentFilter(ctx, parentPath, excludedPaths)
} else {
// Get departments under specific path
departments, err = s.departmentRepo.GetByPathPrefix(ctx, rootPath)
// Filter out excluded paths manually for specific path queries
filteredDepts := make([]entities.Department, 0)
for _, dept := range departments {
excluded := false
for _, excludedPath := range excludedPaths {
if strings.Contains(dept.Path, excludedPath) {
excluded = true
break
}
}
if !excluded {
filteredDepts = append(filteredDepts, dept)
}
}
departments = filteredDepts
}
if err != nil {
return nil, err
}
// Build the tree structure
nodeMap := make(map[string]*contract.DepartmentNode)
roots := make([]*contract.DepartmentNode, 0)
// Calculate base level offset based on parent path
baseLevelOffset := 0
if parentPath != "" {
baseLevelOffset = len(strings.Split(parentPath, ".")) - 1
}
// First pass: create all nodes including missing parents
for _, dept := range departments {
pathParts := strings.Split(dept.Path, ".")
// Create any missing parent nodes
for i := 1; i <= len(pathParts); i++ {
currentPath := strings.Join(pathParts[:i], ".")
if _, exists := nodeMap[currentPath]; !exists {
// Calculate level for this path
adjustedLevel := i - baseLevelOffset
if adjustedLevel < 1 {
adjustedLevel = 1
}
// Create node (placeholder for missing parents, real data for existing)
var node *contract.DepartmentNode
if currentPath == dept.Path {
// This is the actual department
node = &contract.DepartmentNode{
ID: dept.ID,
Name: dept.Name,
Code: dept.Code,
Path: dept.Path,
Level: adjustedLevel,
Children: make([]*contract.DepartmentNode, 0),
}
} else {
// This is a missing parent - create placeholder
// Extract the last segment as the name
lastSegment := pathParts[i-1]
node = &contract.DepartmentNode{
ID: uuid.Nil, // Use nil UUID for placeholder
Name: strings.ToUpper(strings.ReplaceAll(lastSegment, "_", " ")),
Code: lastSegment,
Path: currentPath,
Level: adjustedLevel,
Children: make([]*contract.DepartmentNode, 0),
}
}
nodeMap[currentPath] = node
}
}
}
// Second pass: build the tree relationships
// Only process nodes that actually exist in the database (not placeholders)
processedPaths := make(map[string]bool)
for _, dept := range departments {
if processedPaths[dept.Path] {
continue
}
processedPaths[dept.Path] = true
node := nodeMap[dept.Path]
pathParts := strings.Split(dept.Path, ".")
// Check if this should be a root node
isRoot := false
if rootPath != "" && dept.Path == rootPath {
// Explicitly requested root
isRoot = true
} else if rootPath == "" && parentPath != "" && dept.Path == parentPath {
// The configured parent path is the root when showing all
isRoot = true
} else if len(pathParts) == 1 {
// Single segment path
isRoot = true
} else {
// Find parent path
parentPathStr := strings.Join(pathParts[:len(pathParts)-1], ".")
if parent, exists := nodeMap[parentPathStr]; exists {
// Check if this child is already added
alreadyAdded := false
for _, child := range parent.Children {
if child.Path == node.Path {
alreadyAdded = true
break
}
}
if !alreadyAdded {
parent.Children = append(parent.Children, node)
}
} else {
// Parent doesn't exist - this is an orphaned node
// Only include it as a root if it's a direct child of the parent path
if parentPath != "" {
// Check if this is a direct child of the configured parent
expectedParent := parentPath
actualParent := strings.Join(pathParts[:len(pathParts)-1], ".")
if actualParent != expectedParent {
// This is an orphaned node - skip it
continue
}
}
isRoot = true
}
}
if isRoot {
// Check for duplicates in roots
alreadyInRoots := false
for _, r := range roots {
if r.Path == node.Path {
alreadyInRoots = true
break
}
}
if !alreadyInRoots {
roots = append(roots, node)
}
}
}
// Sort children at each level
var sortChildren func([]*contract.DepartmentNode)
sortChildren = func(nodes []*contract.DepartmentNode) {
for _, node := range nodes {
if len(node.Children) > 0 {
// Sort children by name
sort.Slice(node.Children, func(i, j int) bool {
return node.Children[i].Name < node.Children[j].Name
})
sortChildren(node.Children)
}
}
}
// Sort root nodes
sort.Slice(roots, func(i, j int) bool {
return roots[i].Name < roots[j].Name
})
sortChildren(roots)
return &contract.OrganizationalChartResponse{
Chart: roots,
TotalNodes: len(departments),
}, nil
}
func (s *MasterServiceImpl) ListDepartments(ctx context.Context, req *contract.ListDepartmentsRequest) (*contract.ListDepartmentsResponse, error) {
// Set default values if not provided
page := req.Page
@@ -232,7 +615,11 @@ func (s *MasterServiceImpl) ListDepartments(ctx context.Context, req *contract.L
offset := (page - 1) * limit
list, total, err := s.departmentRepo.List(ctx, req.Search, limit, offset)
// Use filtered list with parent path from config
parentPath := s.config.Department.ParentPath
excludedPaths := s.config.Department.ExcludedPaths
list, total, err := s.departmentRepo.ListWithParentFilter(ctx, req.Search, limit, offset, parentPath, excludedPaths)
if err != nil {
return nil, err
}
+1 -1
View File
@@ -26,7 +26,7 @@ type UserProcessor interface {
UpdateUserProfile(ctx context.Context, userID uuid.UUID, req *contract.UpdateUserProfileRequest) (*contract.UserProfileResponse, error)
// New optimized listing
ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error)
ListUsersWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]contract.UserResponse, int, error)
// Get active users for mention purposes
GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error)
+17 -1
View File
@@ -47,16 +47,24 @@ func (s *UserServiceImpl) GetUserByEmail(ctx context.Context, email string) (*co
}
func (s *UserServiceImpl) ListUsers(ctx context.Context, req *contract.ListUsersRequest) (*contract.ListUsersResponse, error) {
// Handle pagination parameters in service layer
page := req.Page
if page <= 0 {
page = 1
}
limit := req.Limit
if limit <= 0 {
limit = 10
}
if limit > 100 {
limit = 100 // Max limit to prevent performance issues
}
offset := (page - 1) * limit
userResponses, totalCount, err := s.userProcessor.ListUsersWithFilters(ctx, req)
// Pass calculated offset and limit to processor
userResponses, totalCount, err := s.userProcessor.ListUsersWithFilters(ctx, req.Search, req.RoleCode, req.IsActive, limit, offset)
if err != nil {
return nil, err
}
@@ -99,5 +107,13 @@ func (s *UserServiceImpl) ListTitles(ctx context.Context) (*contract.ListTitlesR
// GetActiveUsersForMention retrieves active users for mention purposes
func (s *UserServiceImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) {
// Handle limit in service layer
if limit <= 0 {
limit = 50 // Default limit for mention suggestions
}
if limit > 100 {
limit = 100 // Max limit to prevent performance issues
}
return s.userProcessor.GetActiveUsersForMention(ctx, search, limit)
}