diff --git a/config/configs.go b/config/configs.go index d50518d..db040c8 100644 --- a/config/configs.go +++ b/config/configs.go @@ -31,6 +31,7 @@ type Config struct { S3Config S3Config `mapstructure:"s3"` OnlyOffice OnlyOffice `mapstructure:"onlyoffice"` Novu Novu `mapstructure:"novu"` + Department Department `mapstructure:"department"` } var ( @@ -93,3 +94,8 @@ type Novu struct { BaseURL string `mapstructure:"base_url"` IncomingLetterWorkflowID string `mapstructure:"incoming_letter_workflow_id"` } + +type Department struct { + ParentPath string `mapstructure:"parent_path"` + ExcludedPaths []string `mapstructure:"excluded_paths"` +} diff --git a/eslogad-backend b/eslogad-backend index bed338b..789f59a 100755 Binary files a/eslogad-backend and b/eslogad-backend differ diff --git a/infra/development.yaml b/infra/development.yaml index fca273a..64d83a5 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -41,4 +41,10 @@ novu: api_key: 'f7de60a16abf825996191bf69ea8054a' # Add your Novu API key here application_id: 'cDeX8L5VWe-r' # Add your Novu Application ID here base_url: 'https://novu-api.apskel.org' # Optional: defaults to https://api.novu.co - incoming_letter_workflow_id: 'notification-dashbpard' \ No newline at end of file + incoming_letter_workflow_id: 'notification-dashbpard' + +department: + parent_path: 'eslogad.aslog' # Parent path for departments to be included in API + excluded_paths: # Paths to exclude from department APIs + - 'superadmin' + - 'system' \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index 55fc0f7..5ffa4c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -338,7 +338,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con rbacSvc := service.NewRBACService(repos.rbacRepo) - masterSvc := service.NewMasterService(repos.labelRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo, repos.departmentRepo) + masterSvc := service.NewMasterService(repos.labelRepo, repos.priorityRepo, repos.institutionRepo, repos.dispRepo, repos.departmentRepo, cfg) txManager := repository.NewTxManager(a.db) letterSvc := service.NewLetterService( @@ -349,6 +349,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con processors.activityLogger, processors.letterDispositionProcessor, processors.notificationProcessor, + processors.activityLogger, ) dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) letterOutgoingSvc := service.NewLetterOutgoingService(processors.letterOutgoingProcessor) diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go index 6a297e4..8eededa 100644 --- a/internal/contract/analytics_contract.go +++ b/internal/contract/analytics_contract.go @@ -16,18 +16,18 @@ type AnalyticsDashboardRequest struct { // AnalyticsDashboardResponse represents the complete analytics dashboard type AnalyticsDashboardResponse struct { - Summary LetterSummaryStats `json:"summary"` - PriorityDistribution []PriorityDistribution `json:"priority_distribution"` - DepartmentStats []DepartmentStats `json:"department_stats"` - MonthlyTrend []MonthlyTrend `json:"monthly_trend"` - DepartmentsStats []SimpleDepartmentStats `json:"departments_stats,omitempty"` - InstitutionStats []InstitutionStats `json:"institution_stats"` - DailyActivity []DailyActivity `json:"daily_activity"` - StatusDistribution []StatusDistribution `json:"status_distribution,omitempty"` - TopSenders []TopUserStats `json:"top_senders,omitempty"` - TopRecipients []TopUserStats `json:"top_recipients,omitempty"` - ApprovalMetrics *ApprovalMetrics `json:"approval_metrics,omitempty"` - ResponseTimeStats *ResponseTimeStats `json:"response_time_stats,omitempty"` + Summary LetterSummaryStats `json:"summary"` + PriorityDistribution []PriorityDistribution `json:"priority_distribution"` + DepartmentStats []DepartmentStats `json:"department_stats"` + MonthlyTrend []MonthlyTrend `json:"monthly_trend"` + DepartmentsStats []SimpleDepartmentStats `json:"departments_stats,omitempty"` + InstitutionStats []InstitutionStats `json:"institution_stats"` + DailyActivity []DailyActivity `json:"daily_activity"` + StatusDistribution []StatusDistribution `json:"status_distribution,omitempty"` + TopSenders []TopUserStats `json:"top_senders,omitempty"` + TopRecipients []TopUserStats `json:"top_recipients,omitempty"` + ApprovalMetrics *ApprovalMetrics `json:"approval_metrics,omitempty"` + ResponseTimeStats *ResponseTimeStats `json:"response_time_stats,omitempty"` } // LetterSummaryStats represents overall summary statistics @@ -36,6 +36,7 @@ type LetterSummaryStats struct { TotalOutgoing int64 `json:"total_outgoing"` WeekOverWeekGrowth float64 `json:"week_over_week_growth"` MonthOverMonthGrowth float64 `json:"month_over_month_growth"` + TotalThisWeek float64 `json:"total_this_week"` TotalPending int64 `json:"total_pending,omitempty"` TotalApproved int64 `json:"total_approved,omitempty"` TotalRejected int64 `json:"total_rejected,omitempty"` @@ -54,24 +55,24 @@ type StatusDistribution struct { // PriorityDistribution represents letter distribution by priority type PriorityDistribution struct { - PriorityID string `json:"priority_id"` - PriorityName string `json:"priority_name"` - Level int `json:"level"` - Count int64 `json:"count"` - Percentage float64 `json:"percentage"` + PriorityID string `json:"priority_id"` + PriorityName string `json:"priority_name"` + Level int `json:"level"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` AvgResponseTime float64 `json:"avg_response_time_hours"` } // DepartmentStats represents statistics per department type DepartmentStats struct { - DepartmentID uuid.UUID `json:"department_id"` - DepartmentName string `json:"department_name"` - DepartmentCode string `json:"department_code"` - IncomingCount int64 `json:"incoming_count"` - OutgoingCount int64 `json:"outgoing_count"` - PendingCount int64 `json:"pending_count"` - AvgResponseTime float64 `json:"avg_response_time_hours"` - CompletionRate float64 `json:"completion_rate"` + DepartmentID uuid.UUID `json:"department_id"` + DepartmentName string `json:"department_name"` + DepartmentCode string `json:"department_code"` + IncomingCount int64 `json:"incoming_count"` + OutgoingCount int64 `json:"outgoing_count"` + PendingCount int64 `json:"pending_count"` + AvgResponseTime float64 `json:"avg_response_time_hours"` + CompletionRate float64 `json:"completion_rate"` } // MonthlyTrend represents monthly trend data @@ -86,12 +87,12 @@ type MonthlyTrend struct { // TopUserStats represents top users by letter activity type TopUserStats struct { - UserID uuid.UUID `json:"user_id"` - UserName string `json:"user_name"` - UserEmail string `json:"user_email"` - Department string `json:"department"` - LetterCount int64 `json:"letter_count"` - AvgResponseTime float64 `json:"avg_response_time_hours"` + UserID uuid.UUID `json:"user_id"` + UserName string `json:"user_name"` + UserEmail string `json:"user_email"` + Department string `json:"department"` + LetterCount int64 `json:"letter_count"` + AvgResponseTime float64 `json:"avg_response_time_hours"` } // InstitutionStats represents statistics per institution @@ -107,50 +108,50 @@ type InstitutionStats struct { // ApprovalMetrics represents approval-related metrics type ApprovalMetrics struct { - TotalSubmitted int64 `json:"total_submitted"` - TotalApproved int64 `json:"total_approved"` - TotalRejected int64 `json:"total_rejected"` - TotalPending int64 `json:"total_pending"` - ApprovalRate float64 `json:"approval_rate"` - RejectionRate float64 `json:"rejection_rate"` - AvgApprovalTime float64 `json:"avg_approval_time_hours"` - AvgApprovalSteps float64 `json:"avg_approval_steps"` - BottleneckSteps []BottleneckStep `json:"bottleneck_steps"` + TotalSubmitted int64 `json:"total_submitted"` + TotalApproved int64 `json:"total_approved"` + TotalRejected int64 `json:"total_rejected"` + TotalPending int64 `json:"total_pending"` + ApprovalRate float64 `json:"approval_rate"` + RejectionRate float64 `json:"rejection_rate"` + AvgApprovalTime float64 `json:"avg_approval_time_hours"` + AvgApprovalSteps float64 `json:"avg_approval_steps"` + BottleneckSteps []BottleneckStep `json:"bottleneck_steps"` } // BottleneckStep represents approval steps that cause delays type BottleneckStep struct { - StepOrder int `json:"step_order"` - ApproverName string `json:"approver_name"` - AvgProcessTime float64 `json:"avg_process_time_hours"` - PendingCount int64 `json:"pending_count"` + StepOrder int `json:"step_order"` + ApproverName string `json:"approver_name"` + AvgProcessTime float64 `json:"avg_process_time_hours"` + PendingCount int64 `json:"pending_count"` } // ResponseTimeStats represents response time statistics type ResponseTimeStats struct { - MinResponseTime float64 `json:"min_response_time_hours"` - MaxResponseTime float64 `json:"max_response_time_hours"` - AvgResponseTime float64 `json:"avg_response_time_hours"` + MinResponseTime float64 `json:"min_response_time_hours"` + MaxResponseTime float64 `json:"max_response_time_hours"` + AvgResponseTime float64 `json:"avg_response_time_hours"` MedianResponseTime float64 `json:"median_response_time_hours"` - P95ResponseTime float64 `json:"p95_response_time_hours"` - P99ResponseTime float64 `json:"p99_response_time_hours"` + P95ResponseTime float64 `json:"p95_response_time_hours"` + P99ResponseTime float64 `json:"p99_response_time_hours"` } // SimpleDepartmentStats represents simplified department statistics type SimpleDepartmentStats struct { - DepartmentID uuid.UUID `json:"department_id"` - Department string `json:"department"` - LetterCount int64 `json:"letter_count"` + DepartmentID uuid.UUID `json:"department_id"` + Department string `json:"department"` + LetterCount int64 `json:"letter_count"` } // DailyActivity represents daily activity data type DailyActivity struct { - Date string `json:"date"` - DayOfWeek string `json:"day_of_week"` - IncomingCount int64 `json:"incoming_count"` - OutgoingCount int64 `json:"outgoing_count"` - ApprovedCount int64 `json:"approved_count,omitempty"` - RejectedCount int64 `json:"rejected_count,omitempty"` + Date string `json:"date"` + DayOfWeek string `json:"day_of_week"` + IncomingCount int64 `json:"incoming_count"` + OutgoingCount int64 `json:"outgoing_count"` + ApprovedCount int64 `json:"approved_count,omitempty"` + RejectedCount int64 `json:"rejected_count,omitempty"` HourlyDistribution []HourlyActivity `json:"hourly_distribution,omitempty"` } @@ -182,4 +183,4 @@ type OutgoingLetterVolume struct { ThisMonth int64 `json:"this_month"` ThisYear int64 `json:"this_year"` Total int64 `json:"total"` -} \ No newline at end of file +} diff --git a/internal/contract/user_contract.go b/internal/contract/user_contract.go index 86bcc09..813a180 100644 --- a/internal/contract/user_contract.go +++ b/internal/contract/user_contract.go @@ -7,18 +7,20 @@ import ( ) type CreateUserRequest struct { - Name string `json:"name" validate:"required,min=1,max=255"` - Email string `json:"email" validate:"required,email"` - Password string `json:"password" validate:"required,min=6"` - RoleID *uuid.UUID `json:"role_id,omitempty" validate:"required"` + Name string `json:"name" validate:"required,min=1,max=255"` + Email string `json:"email" validate:"required,email"` + Password string `json:"password" validate:"required,min=6"` + RoleID *uuid.UUID `json:"role_id,omitempty" validate:"required"` + DepartmentIDs []uuid.UUID `json:"department_ids,omitempty"` } type UpdateUserRequest struct { - Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` - Email *string `json:"email,omitempty" validate:"omitempty,email"` - Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"` - IsActive *bool `json:"is_active,omitempty"` - Permissions *map[string]interface{} `json:"permissions,omitempty"` + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Email *string `json:"email,omitempty" validate:"omitempty,email"` + Role *string `json:"role,omitempty" validate:"omitempty,oneof=admin manager cashier waiter"` + IsActive *bool `json:"is_active,omitempty"` + Permissions *map[string]interface{} `json:"permissions,omitempty"` + DepartmentIDs *[]uuid.UUID `json:"department_ids,omitempty"` } type ChangePasswordRequest struct { @@ -96,6 +98,43 @@ type ListDepartmentsResponse struct { Limit int `json:"limit"` } +type CreateDepartmentRequest struct { + Name string `json:"name" validate:"required,min=1,max=255"` + Code string `json:"code" validate:"required,min=1,max=50"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` +} + +type UpdateDepartmentRequest struct { + Name *string `json:"name,omitempty" validate:"omitempty,min=1,max=255"` + Code *string `json:"code,omitempty" validate:"omitempty,min=1,max=50"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` +} + +type GetDepartmentResponse struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Path string `json:"path"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + ParentName *string `json:"parent_name,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type DepartmentNode struct { + ID uuid.UUID `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Path string `json:"path"` + Level int `json:"level"` + Children []*DepartmentNode `json:"children,omitempty"` +} + +type OrganizationalChartResponse struct { + Chart []*DepartmentNode `json:"chart"` + TotalNodes int `json:"total_nodes"` +} + type UserProfileResponse struct { UserID uuid.UUID `json:"user_id"` FullName string `json:"full_name"` diff --git a/internal/handler/letter_handler.go b/internal/handler/letter_handler.go index 1dfa287..455c0fa 100644 --- a/internal/handler/letter_handler.go +++ b/internal/handler/letter_handler.go @@ -286,7 +286,6 @@ func (h *LetterHandler) CreateDispositions(c *gin.Context) { return } - // Extract department ID from context appCtx := appcontext.FromGinContext(c.Request.Context()) req.FromDepartment = appCtx.DepartmentID diff --git a/internal/handler/master_handler.go b/internal/handler/master_handler.go index b23a420..1c40c6f 100644 --- a/internal/handler/master_handler.go +++ b/internal/handler/master_handler.go @@ -8,6 +8,7 @@ import ( "github.com/gin-gonic/gin" "github.com/google/uuid" + "gorm.io/gorm" ) type MasterService interface { @@ -31,7 +32,13 @@ type MasterService interface { DeleteDispositionAction(ctx context.Context, id uuid.UUID) error ListDispositionActions(ctx context.Context) (*contract.ListDispositionActionsResponse, error) + CreateDepartment(ctx context.Context, req *contract.CreateDepartmentRequest) (*contract.GetDepartmentResponse, error) + GetDepartment(ctx context.Context, id uuid.UUID) (*contract.GetDepartmentResponse, error) + UpdateDepartment(ctx context.Context, id uuid.UUID, req *contract.UpdateDepartmentRequest) (*contract.GetDepartmentResponse, error) + DeleteDepartment(ctx context.Context, id uuid.UUID) error ListDepartments(ctx context.Context, req *contract.ListDepartmentsRequest) (*contract.ListDepartmentsResponse, error) + GetOrganizationalChart(ctx context.Context, rootPath string) (*contract.OrganizationalChartResponse, error) + GetOrganizationalChartByID(ctx context.Context, departmentID uuid.UUID) (*contract.OrganizationalChartResponse, error) } type MasterHandler struct{ svc MasterService } @@ -260,15 +267,87 @@ func (h *MasterHandler) ListDispositionActions(c *gin.Context) { } // Departments +func (h *MasterHandler) CreateDepartment(c *gin.Context) { + var req contract.CreateDepartmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) + return + } + resp, err := h.svc.CreateDepartment(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp)) +} + +func (h *MasterHandler) GetDepartment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) + return + } + resp, err := h.svc.GetDepartment(c.Request.Context(), id) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) + return + } + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *MasterHandler) UpdateDepartment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) + return + } + var req contract.UpdateDepartmentRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: 400}) + return + } + resp, err := h.svc.UpdateDepartment(c.Request.Context(), id, &req) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) + return + } + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *MasterHandler) DeleteDepartment(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: 400}) + return + } + if err := h.svc.DeleteDepartment(c.Request.Context(), id); err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) + return + } + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "department deleted successfully"}) +} + func (h *MasterHandler) ListDepartments(c *gin.Context) { var req contract.ListDepartmentsRequest - + // Parse query parameters if err := c.ShouldBindQuery(&req); err != nil { c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: 400}) return } - + resp, err := h.svc.ListDepartments(c.Request.Context(), &req) if err != nil { c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) @@ -276,3 +355,34 @@ func (h *MasterHandler) ListDepartments(c *gin.Context) { } c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) } + +func (h *MasterHandler) GetOrganizationalChart(c *gin.Context) { + // Get optional root path from query parameter + rootPath := c.Query("root_path") + + resp, err := h.svc.GetOrganizationalChart(c.Request.Context(), rootPath) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + +func (h *MasterHandler) GetOrganizationalChartByID(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid department id", Code: 400}) + return + } + + resp, err := h.svc.GetOrganizationalChartByID(c.Request.Context(), id) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "department not found", Code: 404}) + return + } + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: 500}) + return + } + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index 03a8f26..75de6ea 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -353,7 +353,8 @@ func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) { } logger.FromContext(c).Infof("UserHandler::GetActiveUsersForMention -> Successfully retrieved %d active users", len(users)) - c.JSON(http.StatusOK, response) + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(response)) } func (h *UserHandler) sendErrorResponse(c *gin.Context, message string, statusCode int) { diff --git a/internal/processor/letter_processor.go b/internal/processor/letter_processor.go index ba90e64..bffdcc2 100644 --- a/internal/processor/letter_processor.go +++ b/internal/processor/letter_processor.go @@ -2,6 +2,7 @@ package processor import ( "context" + "fmt" "time" "eslogad-be/internal/appcontext" @@ -309,77 +310,129 @@ func (p *LetterProcessorImpl) SoftDeleteIncomingLetter(ctx context.Context, id u }) } +// CreateDispositions creates a new disposition with modular helper functions func (p *LetterProcessorImpl) CreateDispositions(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*contract.ListDispositionsResponse, error) { - var out *contract.ListDispositionsResponse - err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - userID := appcontext.FromGinContext(txCtx).UserID + // Transaction should be handled at service layer + // The context passed here should already contain the transaction if needed - existingDispDepts, err := p.dispositionDeptRepo.GetByLetterAndDepartment(txCtx, req.LetterID, req.FromDepartment) - if err == nil && len(existingDispDepts) > 0 { - for _, existingDispDept := range existingDispDepts { - if existingDispDept.Status == entities.DispositionDepartmentStatusPending { - existingDispDept.Status = entities.DispositionDepartmentStatusDispositioned - if err := p.dispositionDeptRepo.Update(txCtx, &existingDispDept); err != nil { - return err - } - } - } - } + // Step 1: Update existing disposition departments + if err := p.updateExistingDispositionDepartments(ctx, req.LetterID, req.FromDepartment); err != nil { + return nil, err + } - disp := entities.LetterIncomingDisposition{ - LetterID: req.LetterID, - DepartmentID: &req.FromDepartment, - Notes: req.Notes, - CreatedBy: userID, - } - if err := p.dispositionRepo.Create(txCtx, &disp); err != nil { - return err - } - - var dispDepartments []entities.LetterIncomingDispositionDepartment - for _, toDept := range req.ToDepartmentIDs { - dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ - LetterIncomingDispositionID: disp.ID, - LetterIncomingID: req.LetterID, - DepartmentID: toDept, - Status: entities.DispositionDepartmentStatusPending, - }) - } - - if err := p.dispositionDeptRepo.CreateBulk(txCtx, dispDepartments); err != nil { - return err - } - - if len(req.SelectedActions) > 0 { - selections := make([]entities.LetterDispositionActionSelection, 0, len(req.SelectedActions)) - for _, sel := range req.SelectedActions { - selections = append(selections, entities.LetterDispositionActionSelection{ - DispositionID: disp.ID, - ActionID: sel.ActionID, - Note: sel.Note, - CreatedBy: userID, - }) - } - if err := p.dispositionActionSelRepo.CreateBulk(txCtx, selections); err != nil { - return err - } - } - - if p.activity != nil { - action := "disposition.created" - ctxMap := map[string]interface{}{"to_department_id": dispDepartments} - if err := p.activity.Log(txCtx, req.LetterID, action, &userID, nil, nil, &disp.ID, nil, nil, ctxMap); err != nil { - return err - } - } - - out = &contract.ListDispositionsResponse{Dispositions: []contract.DispositionResponse{transformer.DispoToContract(disp)}} - return nil - }) + // Step 2: Create the main disposition + disp, err := p.createMainDisposition(ctx, req) if err != nil { return nil, err } - return out, nil + + // Step 3: Create disposition departments for target departments + dispDepartments, err := p.createDispositionDepartments(ctx, disp.ID, req.LetterID, req.ToDepartmentIDs) + if err != nil { + return nil, err + } + + // Step 4: Create action selections if provided + if err := p.createActionSelections(ctx, disp.ID, req.SelectedActions, req.CreatedBy); err != nil { + return nil, err + } + + // Step 5: Build and return the response + return p.buildDispositionResponse(disp, dispDepartments, req.ToDepartmentIDs), nil +} + +// updateExistingDispositionDepartments updates the status of existing disposition departments +func (p *LetterProcessorImpl) updateExistingDispositionDepartments(ctx context.Context, letterID uuid.UUID, fromDepartment uuid.UUID) error { + existingDispDepts, err := p.dispositionDeptRepo.GetByLetterAndDepartment(ctx, letterID, fromDepartment) + if err != nil { + // If no existing departments found, that's ok + return nil + } + + for _, existingDispDept := range existingDispDepts { + if existingDispDept.Status == entities.DispositionDepartmentStatusPending { + existingDispDept.Status = entities.DispositionDepartmentStatusDispositioned + if err := p.dispositionDeptRepo.Update(ctx, &existingDispDept); err != nil { + return fmt.Errorf("failed to update existing disposition department: %w", err) + } + } + } + + return nil +} + +// createMainDisposition creates the primary disposition record +func (p *LetterProcessorImpl) createMainDisposition(ctx context.Context, req *contract.CreateLetterDispositionRequest) (*entities.LetterIncomingDisposition, error) { + disp := &entities.LetterIncomingDisposition{ + LetterID: req.LetterID, + DepartmentID: &req.FromDepartment, + Notes: req.Notes, + CreatedBy: req.CreatedBy, // Should be set by service layer + } + + if err := p.dispositionRepo.Create(ctx, disp); err != nil { + return nil, fmt.Errorf("failed to create disposition: %w", err) + } + + return disp, nil +} + +// createDispositionDepartments creates disposition department records for target departments +func (p *LetterProcessorImpl) createDispositionDepartments(ctx context.Context, dispositionID, letterID uuid.UUID, toDepartmentIDs []uuid.UUID) ([]entities.LetterIncomingDispositionDepartment, error) { + if len(toDepartmentIDs) == 0 { + return nil, nil + } + + dispDepartments := make([]entities.LetterIncomingDispositionDepartment, 0, len(toDepartmentIDs)) + for _, toDept := range toDepartmentIDs { + dispDepartments = append(dispDepartments, entities.LetterIncomingDispositionDepartment{ + LetterIncomingDispositionID: dispositionID, + LetterIncomingID: letterID, + DepartmentID: toDept, + Status: entities.DispositionDepartmentStatusPending, + }) + } + + if err := p.dispositionDeptRepo.CreateBulk(ctx, dispDepartments); err != nil { + return nil, fmt.Errorf("failed to create disposition departments: %w", err) + } + + return dispDepartments, nil +} + +// createActionSelections creates action selection records for the disposition +func (p *LetterProcessorImpl) createActionSelections(ctx context.Context, dispositionID uuid.UUID, selectedActions []contract.CreateDispositionActionSelection, createdBy uuid.UUID) error { + if len(selectedActions) == 0 { + return nil + } + + selections := make([]entities.LetterDispositionActionSelection, 0, len(selectedActions)) + for _, sel := range selectedActions { + selections = append(selections, entities.LetterDispositionActionSelection{ + DispositionID: dispositionID, + ActionID: sel.ActionID, + Note: sel.Note, + CreatedBy: createdBy, + }) + } + + if err := p.dispositionActionSelRepo.CreateBulk(ctx, selections); err != nil { + return fmt.Errorf("failed to create action selections: %w", err) + } + + return nil +} + +// buildDispositionResponse builds the response for the created disposition +func (p *LetterProcessorImpl) buildDispositionResponse(disp *entities.LetterIncomingDisposition, dispDepartments []entities.LetterIncomingDispositionDepartment, toDepartmentIDs []uuid.UUID) *contract.ListDispositionsResponse { + response := &contract.ListDispositionsResponse{ + Dispositions: []contract.DispositionResponse{transformer.DispoToContract(*disp)}, + } + + // The toDepartmentIDs are available in the dispDepartments for service layer logging + // No need to store them in the response as DispositionResponse doesn't have this field + + return response } func (p *LetterProcessorImpl) ListDispositionsByLetter(ctx context.Context, letterID uuid.UUID) (*contract.ListDispositionsResponse, error) { @@ -449,67 +502,54 @@ func (p *LetterProcessorImpl) GetEnhancedDispositionsByLetter(ctx context.Contex } func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { - var out *contract.LetterDiscussionResponse - err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - userID := appcontext.FromGinContext(txCtx).UserID - mentions := entities.JSONB(nil) - if req.Mentions != nil { - mentions = entities.JSONB(req.Mentions) - } - disc := &entities.LetterDiscussion{ID: uuid.New(), LetterID: letterID, ParentID: req.ParentID, UserID: userID, Message: req.Message, Mentions: mentions} - if err := p.discussionRepo.Create(txCtx, disc); err != nil { - return err - } - if p.activity != nil { - action := "discussion.created" - tgt := "discussion" - ctxMap := map[string]interface{}{"message": req.Message, "parent_id": req.ParentID} - if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil { - return err - } - } - out = transformer.DiscussionEntityToContract(disc) - return nil - }) - if err != nil { - return nil, err + userID := appcontext.FromGinContext(ctx).UserID + + mentions := entities.JSONB(nil) + if req.Mentions != nil { + mentions = entities.JSONB(req.Mentions) } - return out, nil + + disc := &entities.LetterDiscussion{ + ID: uuid.New(), + LetterID: letterID, + ParentID: req.ParentID, + UserID: userID, + Message: req.Message, + Mentions: mentions, + } + + if err := p.discussionRepo.Create(ctx, disc); err != nil { + return nil, fmt.Errorf("failed to create discussion: %w", err) + } + + // Activity logging should be handled at service layer + return transformer.DiscussionEntityToContract(disc), nil } -func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, error) { - var out *contract.LetterDiscussionResponse - err := p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { - disc, err := p.discussionRepo.Get(txCtx, discussionID) - if err != nil { - return err - } - oldMessage := disc.Message - disc.Message = req.Message - if req.Mentions != nil { - disc.Mentions = entities.JSONB(req.Mentions) - } - now := time.Now() - disc.EditedAt = &now - if err := p.discussionRepo.Update(txCtx, disc); err != nil { - return err - } - if p.activity != nil { - userID := appcontext.FromGinContext(txCtx).UserID - action := "discussion.updated" - tgt := "discussion" - ctxMap := map[string]interface{}{"old_message": oldMessage, "new_message": req.Message} - if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil { - return err - } - } - out = transformer.DiscussionEntityToContract(disc) - return nil - }) +func (p *LetterProcessorImpl) UpdateDiscussion(ctx context.Context, letterID uuid.UUID, discussionID uuid.UUID, req *contract.UpdateLetterDiscussionRequest) (*contract.LetterDiscussionResponse, string, error) { + // Transaction should be handled at service layer + disc, err := p.discussionRepo.Get(ctx, discussionID) if err != nil { - return nil, err + return nil, "", fmt.Errorf("failed to get discussion: %w", err) } - return out, nil + + // Store old message for activity logging + oldMessage := disc.Message + + // Update discussion fields + disc.Message = req.Message + if req.Mentions != nil { + disc.Mentions = entities.JSONB(req.Mentions) + } + now := time.Now() + disc.EditedAt = &now + + if err := p.discussionRepo.Update(ctx, disc); err != nil { + return nil, "", fmt.Errorf("failed to update discussion: %w", err) + } + + // Return both the updated discussion and old message for service layer logging + return transformer.DiscussionEntityToContract(disc), oldMessage, nil } func (p *LetterProcessorImpl) createAttachments(ctx context.Context, letterID uuid.UUID, attachments []contract.CreateIncomingLetterAttachment, userID uuid.UUID) error { diff --git a/internal/processor/user_processor.go b/internal/processor/user_processor.go index 68821bc..efe688f 100644 --- a/internal/processor/user_processor.go +++ b/internal/processor/user_processor.go @@ -78,12 +78,29 @@ func (p *UserProcessorImpl) CreateUser(ctx context.Context, req *contract.Create } } + // Assign departments if provided + if len(req.DepartmentIDs) > 0 { + departments := make([]entities.Department, len(req.DepartmentIDs)) + for i, deptID := range req.DepartmentIDs { + departments[i] = entities.Department{ID: deptID} + } + if err := p.userRepo.UpdateDepartments(ctx, userEntity.ID, departments); err != nil { + return nil, fmt.Errorf("failed to assign departments: %w", err) + } + } + if p.novuProcessor != nil { if err := p.novuProcessor.CreateSubscriber(ctx, userEntity); err != nil { _ = err } } + // Fetch the user with departments for response + userWithDepts, _ := p.userRepo.GetByIDWithDepartments(ctx, userEntity.ID) + if userWithDepts != nil { + userEntity = userWithDepts + } + return transformer.EntityToContract(userEntity), nil } @@ -107,6 +124,17 @@ func (p *UserProcessorImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *c return nil, fmt.Errorf("failed to update user: %w", err) } + // Update departments if provided + if req.DepartmentIDs != nil { + departments := make([]entities.Department, len(*req.DepartmentIDs)) + for i, deptID := range *req.DepartmentIDs { + departments[i] = entities.Department{ID: deptID} + } + if err := p.userRepo.UpdateDepartments(ctx, updated.ID, departments); err != nil { + return nil, fmt.Errorf("failed to update departments: %w", err) + } + } + // Update Novu subscriber if p.novuProcessor != nil { if err := p.novuProcessor.UpdateSubscriber(ctx, updated); err != nil { @@ -114,6 +142,12 @@ func (p *UserProcessorImpl) UpdateUser(ctx context.Context, id uuid.UUID, req *c } } + // Fetch the user with departments for response + userWithDepts, _ := p.userRepo.GetByIDWithDepartments(ctx, updated.ID) + if userWithDepts != nil { + updated = userWithDepts + } + return transformer.EntityToContract(updated), nil } @@ -184,18 +218,8 @@ func (p *UserProcessorImpl) GetUserByEmail(ctx context.Context, email string) (* return transformer.EntityToContract(user), nil } -func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, req *contract.ListUsersRequest) ([]contract.UserResponse, int, error) { - page := req.Page - if page <= 0 { - page = 1 - } - limit := req.Limit - if limit <= 0 { - limit = 10 - } - offset := (page - 1) * limit - - users, totalCount, err := p.userRepo.ListWithFilters(ctx, req.Search, req.RoleCode, req.IsActive, limit, offset) +func (p *UserProcessorImpl) ListUsersWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]contract.UserResponse, int, error) { + users, totalCount, err := p.userRepo.ListWithFilters(ctx, search, roleCode, isActive, limit, offset) if err != nil { return nil, 0, fmt.Errorf("failed to get users: %w", err) } @@ -333,12 +357,7 @@ func (p *UserProcessorImpl) UpdateUserProfile(ctx context.Context, userID uuid.U // GetActiveUsersForMention retrieves active users for mention purposes with optional username search func (p *UserProcessorImpl) GetActiveUsersForMention(ctx context.Context, search *string, limit int) ([]contract.UserResponse, error) { - if limit <= 0 { - limit = 50 // Default limit for mention suggestions - } - if limit > 100 { - limit = 100 // Max limit for mention suggestions - } + // Limit validation is handled in the service layer // Set isActive to true to only get active users isActive := true diff --git a/internal/processor/user_processor_test.go b/internal/processor/user_processor_test.go deleted file mode 100644 index 674844e..0000000 --- a/internal/processor/user_processor_test.go +++ /dev/null @@ -1,249 +0,0 @@ -package processor - -import ( - "context" - "testing" - - "eslogad-be/internal/entities" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/mock" -) - -// MockUserRepository is a mock implementation of UserRepository -type MockUserRepository struct { - mock.Mock -} - -func (m *MockUserRepository) Create(ctx context.Context, user *entities.User) error { - args := m.Called(ctx, user) - return args.Error(0) -} - -func (m *MockUserRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.User, error) { - args := m.Called(ctx, id) - return args.Get(0).(*entities.User), args.Error(1) -} - -func (m *MockUserRepository) GetByEmail(ctx context.Context, email string) (*entities.User, error) { - args := m.Called(ctx, email) - return args.Get(0).(*entities.User), args.Error(1) -} - -func (m *MockUserRepository) GetByRole(ctx context.Context, role entities.UserRole) ([]*entities.User, error) { - args := m.Called(ctx, role) - return args.Get(0).([]*entities.User), args.Error(1) -} - -func (m *MockUserRepository) GetActiveUsers(ctx context.Context, organizationID uuid.UUID) ([]*entities.User, error) { - args := m.Called(ctx, organizationID) - return args.Get(0).([]*entities.User), args.Error(1) -} - -func (m *MockUserRepository) Update(ctx context.Context, user *entities.User) error { - args := m.Called(ctx, user) - return args.Error(0) -} - -func (m *MockUserRepository) Delete(ctx context.Context, id uuid.UUID) error { - args := m.Called(ctx, id) - return args.Error(0) -} - -func (m *MockUserRepository) UpdatePassword(ctx context.Context, id uuid.UUID, passwordHash string) error { - args := m.Called(ctx, id, passwordHash) - return args.Error(0) -} - -func (m *MockUserRepository) UpdateActiveStatus(ctx context.Context, id uuid.UUID, isActive bool) error { - args := m.Called(ctx, id, isActive) - return args.Error(0) -} - -func (m *MockUserRepository) List(ctx context.Context, filters map[string]interface{}, limit, offset int) ([]*entities.User, int64, error) { - args := m.Called(ctx, filters, limit, offset) - return args.Get(0).([]*entities.User), args.Get(1).(int64), args.Error(2) -} - -func (m *MockUserRepository) Count(ctx context.Context, filters map[string]interface{}) (int64, error) { - args := m.Called(ctx, filters) - return args.Get(0).(int64), args.Error(1) -} - -func (m *MockUserRepository) GetRolesByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Role, error) { - args := m.Called(ctx, userID) - return args.Get(0).([]entities.Role), args.Error(1) -} - -func (m *MockUserRepository) GetPermissionsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Permission, error) { - args := m.Called(ctx, userID) - return args.Get(0).([]entities.Permission), args.Error(1) -} - -func (m *MockUserRepository) GetDepartmentsByUserID(ctx context.Context, userID uuid.UUID) ([]entities.Department, error) { - args := m.Called(ctx, userID) - return args.Get(0).([]entities.Department), args.Error(1) -} - -func (m *MockUserRepository) GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) { - args := m.Called(ctx, userIDs) - return args.Get(0).(map[uuid.UUID][]entities.Role), args.Error(1) -} - -func (m *MockUserRepository) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) { - args := m.Called(ctx, search, roleCode, isActive, limit, offset) - return args.Get(0).([]*entities.User), args.Get(1).(int64), args.Error(2) -} - -// MockUserProfileRepository is a mock implementation of UserProfileRepository -type MockUserProfileRepository struct { - mock.Mock -} - -func (m *MockUserProfileRepository) GetByUserID(ctx context.Context, userID uuid.UUID) (*entities.UserProfile, error) { - args := m.Called(ctx, userID) - return args.Get(0).(*entities.UserProfile), args.Error(1) -} - -func (m *MockUserProfileRepository) Create(ctx context.Context, profile *entities.UserProfile) error { - args := m.Called(ctx, profile) - return args.Error(0) -} - -func (m *MockUserProfileRepository) Upsert(ctx context.Context, profile *entities.UserProfile) error { - args := m.Called(ctx, profile) - return args.Error(0) -} - -func (m *MockUserProfileRepository) Update(ctx context.Context, profile *entities.UserProfile) error { - args := m.Called(ctx, profile) - return args.Error(0) -} - -func TestGetActiveUsersForMention(t *testing.T) { - tests := []struct { - name string - search *string - limit int - mockUsers []*entities.User - mockRoles map[uuid.UUID][]entities.Role - expectedCount int - expectedError bool - setupMocks func(*MockUserRepository, *MockUserProfileRepository) - }{ - { - name: "success with search", - search: stringPtr("john"), - limit: 10, - mockUsers: []*entities.User{ - { - ID: uuid.New(), - Name: "John Doe", - Email: "john@example.com", - IsActive: true, - }, - }, - expectedCount: 1, - expectedError: false, - setupMocks: func(mockRepo *MockUserRepository, mockProfileRepo *MockUserProfileRepository) { - mockRepo.On("ListWithFilters", mock.Anything, stringPtr("john"), (*string)(nil), boolPtr(true), 10, 0). - Return([]*entities.User{ - { - ID: uuid.New(), - Name: "John Doe", - Email: "john@example.com", - IsActive: true, - }, - }, int64(1), nil) - - mockRepo.On("GetRolesByUserIDs", mock.Anything, mock.AnythingOfType("[]uuid.UUID")). - Return(map[uuid.UUID][]entities.Role{}, nil) - }, - }, - { - name: "success without search", - search: nil, - limit: 50, - mockUsers: []*entities.User{ - { - ID: uuid.New(), - Name: "Jane Doe", - Email: "jane@example.com", - IsActive: true, - }, - }, - expectedCount: 1, - expectedError: false, - setupMocks: func(mockRepo *MockUserRepository, mockProfileRepo *MockUserProfileRepository) { - mockRepo.On("ListWithFilters", mock.Anything, (*string)(nil), (*string)(nil), boolPtr(true), 50, 0). - Return([]*entities.User{ - { - ID: uuid.New(), - Name: "Jane Doe", - Email: "jane@example.com", - IsActive: true, - }, - }, int64(1), nil) - - mockRepo.On("GetRolesByUserIDs", mock.Anything, mock.AnythingOfType("[]uuid.UUID")). - Return(map[uuid.UUID][]entities.Role{}, nil) - }, - }, - { - name: "limit validation - too high", - search: nil, - limit: 150, - mockUsers: []*entities.User{}, - expectedCount: 0, - expectedError: false, - setupMocks: func(mockRepo *MockUserRepository, mockProfileRepo *MockUserProfileRepository) { - mockRepo.On("ListWithFilters", mock.Anything, (*string)(nil), (*string)(nil), boolPtr(true), 100, 0). - Return([]*entities.User{}, int64(0), nil) - - mockRepo.On("GetRolesByUserIDs", mock.Anything, mock.AnythingOfType("[]uuid.UUID")). - Return(map[uuid.UUID][]entities.Role{}, nil) - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // Create mocks - mockRepo := &MockUserRepository{} - mockProfileRepo := &MockUserProfileRepository{} - - // Setup mocks - if tt.setupMocks != nil { - tt.setupMocks(mockRepo, mockProfileRepo) - } - - // Create processor - processor := NewUserProcessor(mockRepo, mockProfileRepo) - - // Call method - result, err := processor.GetActiveUsersForMention(context.Background(), tt.search, tt.limit) - - // Assertions - if tt.expectedError { - assert.Error(t, err) - } else { - assert.NoError(t, err) - assert.Len(t, result, tt.expectedCount) - } - - // Verify mocks - mockRepo.AssertExpectations(t) - mockProfileRepo.AssertExpectations(t) - }) - } -} - -// Helper functions -func stringPtr(s string) *string { - return &s -} - -func boolPtr(b bool) *bool { - return &b -} diff --git a/internal/processor/user_repository.go b/internal/processor/user_repository.go index 105f05f..f286bed 100644 --- a/internal/processor/user_repository.go +++ b/internal/processor/user_repository.go @@ -28,4 +28,7 @@ type UserRepository interface { // New optimized helpers GetRolesByUserIDs(ctx context.Context, userIDs []uuid.UUID) (map[uuid.UUID][]entities.Role, error) ListWithFilters(ctx context.Context, search *string, roleCode *string, isActive *bool, limit, offset int) ([]*entities.User, int64, error) + + GetByIDWithDepartments(ctx context.Context, id uuid.UUID) (*entities.User, error) + UpdateDepartments(ctx context.Context, userID uuid.UUID, departments []entities.Department) error } diff --git a/internal/repository/disposition_route_repository.go b/internal/repository/disposition_route_repository.go index e328355..c196e2c 100644 --- a/internal/repository/disposition_route_repository.go +++ b/internal/repository/disposition_route_repository.go @@ -23,20 +23,20 @@ func (r *DispositionRouteRepository) Create(ctx context.Context, e *entities.Dis // Upsert creates or updates a disposition route based on from_department_id and to_department_id func (r *DispositionRouteRepository) Upsert(ctx context.Context, e *entities.DispositionRoute) error { db := DBFromContext(ctx, r.db) - + // Check if route exists var existing entities.DispositionRoute err := db.WithContext(ctx). Where("from_department_id = ? AND to_department_id = ?", e.FromDepartmentID, e.ToDepartmentID). First(&existing).Error - + if err == gorm.ErrRecordNotFound { // Create new route return db.WithContext(ctx).Create(e).Error } else if err != nil { return err } - + // Update existing route e.ID = existing.ID return db.WithContext(ctx).Model(&entities.DispositionRoute{}). @@ -47,7 +47,7 @@ func (r *DispositionRouteRepository) Upsert(ctx context.Context, e *entities.Dis // BulkUpsert performs bulk create or update for multiple routes func (r *DispositionRouteRepository) BulkUpsert(ctx context.Context, fromDeptID uuid.UUID, toDeptIDs []uuid.UUID, isActive bool, allowedActions entities.JSONB) (created int, updated int, err error) { db := DBFromContext(ctx, r.db) - + // Start transaction tx := db.WithContext(ctx).Begin() defer func() { @@ -55,19 +55,19 @@ func (r *DispositionRouteRepository) BulkUpsert(ctx context.Context, fromDeptID tx.Rollback() } }() - + // Get existing routes for this from_department_id var existingRoutes []entities.DispositionRoute if err = tx.Where("from_department_id = ?", fromDeptID).Find(&existingRoutes).Error; err != nil { return 0, 0, err } - + // Create map of existing routes existingMap := make(map[uuid.UUID]entities.DispositionRoute) for _, route := range existingRoutes { existingMap[route.ToDepartmentID] = route } - + // Process each to_department_id for _, toDeptID := range toDeptIDs { route := entities.DispositionRoute{ @@ -76,7 +76,7 @@ func (r *DispositionRouteRepository) BulkUpsert(ctx context.Context, fromDeptID IsActive: isActive, AllowedActions: allowedActions, } - + if existing, exists := existingMap[toDeptID]; exists { // Update existing route route.ID = existing.ID @@ -96,7 +96,7 @@ func (r *DispositionRouteRepository) BulkUpsert(ctx context.Context, fromDeptID created++ } } - + // Optionally deactivate routes that are no longer in the list // (routes that exist in DB but not in the new list) for _, oldRoute := range existingMap { @@ -106,12 +106,12 @@ func (r *DispositionRouteRepository) BulkUpsert(ctx context.Context, fromDeptID return created, updated, err } } - + // Commit transaction if err = tx.Commit().Error; err != nil { return 0, 0, err } - + return created, updated, nil } func (r *DispositionRouteRepository) Update(ctx context.Context, e *entities.DispositionRoute) error { @@ -133,7 +133,7 @@ func (r *DispositionRouteRepository) Get(ctx context.Context, id uuid.UUID) (*en func (r *DispositionRouteRepository) ListByFromDept(ctx context.Context, fromDept uuid.UUID) ([]entities.DispositionRoute, error) { db := DBFromContext(ctx, r.db) var list []entities.DispositionRoute - if err := db.WithContext(ctx).Where("from_department_id = ?", fromDept). + if err := db.WithContext(ctx).Where("from_department_id = ? and is_active=true", fromDept). Preload("FromDepartment"). Preload("ToDepartment"). Order("to_department_id").Find(&list).Error; err != nil { @@ -160,20 +160,20 @@ func (r *DispositionRouteRepository) SetActive(ctx context.Context, id uuid.UUID func (r *DispositionRouteRepository) ListAllGrouped(ctx context.Context) (map[uuid.UUID][]uuid.UUID, error) { db := DBFromContext(ctx, r.db) var routes []entities.DispositionRoute - + if err := db.WithContext(ctx). Where("is_active = ?", true). Order("from_department_id, to_department_id"). Find(&routes).Error; err != nil { return nil, err } - + // Group by from_department_id grouped := make(map[uuid.UUID][]uuid.UUID) for _, route := range routes { grouped[route.FromDepartmentID] = append(grouped[route.FromDepartmentID], route.ToDepartmentID) } - + return grouped, nil } @@ -181,7 +181,7 @@ func (r *DispositionRouteRepository) ListAllGrouped(ctx context.Context) (map[uu func (r *DispositionRouteRepository) ListAllGroupedWithDepartments(ctx context.Context) ([]entities.DispositionRoute, error) { db := DBFromContext(ctx, r.db) var routes []entities.DispositionRoute - + if err := db.WithContext(ctx). Preload("FromDepartment"). Preload("ToDepartment"). @@ -190,7 +190,7 @@ func (r *DispositionRouteRepository) ListAllGroupedWithDepartments(ctx context.C Find(&routes).Error; err != nil { return nil, err } - + return routes, nil } @@ -198,7 +198,7 @@ func (r *DispositionRouteRepository) ListAllGroupedWithDepartments(ctx context.C func (r *DispositionRouteRepository) ListAll(ctx context.Context) ([]entities.DispositionRoute, error) { db := DBFromContext(ctx, r.db) var routes []entities.DispositionRoute - + if err := db.WithContext(ctx). Preload("FromDepartment"). Preload("ToDepartment"). @@ -207,6 +207,6 @@ func (r *DispositionRouteRepository) ListAll(ctx context.Context) ([]entities.Di Find(&routes).Error; err != nil { return nil, err } - + return routes, nil } diff --git a/internal/repository/master_repository.go b/internal/repository/master_repository.go index f7050f6..64de9ba 100644 --- a/internal/repository/master_repository.go +++ b/internal/repository/master_repository.go @@ -225,6 +225,45 @@ func (r *DepartmentRepository) List(ctx context.Context, search string, limit, o return list, total, nil } +func (r *DepartmentRepository) ListWithParentFilter(ctx context.Context, search string, limit, offset int, parentPath string, excludedPaths []string) ([]entities.Department, int64, error) { + db := DBFromContext(ctx, r.db) + + query := db.WithContext(ctx).Model(&entities.Department{}) + + // Filter by parent path if provided - include the parent itself and all descendants + if parentPath != "" { + query = query.Where("path = ? OR path <@ ?", parentPath, parentPath) + } + + // Exclude specific paths + for _, excludedPath := range excludedPaths { + query = query.Where("NOT (path ~ ?)", excludedPath) + } + + // Add search filter if provided + if search != "" { + query = query.Where("name ILIKE ? OR code ILIKE ?", "%"+search+"%", "%"+search+"%") + } + + // Get total count + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + // Get paginated results + var list []entities.Department + if err := query. + Order("name ASC"). + Limit(limit). + Offset(offset). + Find(&list).Error; err != nil { + return nil, 0, err + } + + return list, total, nil +} + func (r *DepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.Department, error) { db := DBFromContext(ctx, r.db) var e entities.Department @@ -233,3 +272,98 @@ func (r *DepartmentRepository) GetByID(ctx context.Context, id uuid.UUID) (*enti } return &e, nil } + +func (r *DepartmentRepository) Create(ctx context.Context, department *entities.Department) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Create(department).Error +} + +func (r *DepartmentRepository) Update(ctx context.Context, department *entities.Department) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Save(department).Error +} + +func (r *DepartmentRepository) Delete(ctx context.Context, id uuid.UUID) error { + db := DBFromContext(ctx, r.db) + return db.WithContext(ctx).Delete(&entities.Department{}, "id = ?", id).Error +} + +func (r *DepartmentRepository) GetByPath(ctx context.Context, path string) (*entities.Department, error) { + db := DBFromContext(ctx, r.db) + var department entities.Department + if err := db.WithContext(ctx).Where("path = ?", path).First(&department).Error; err != nil { + return nil, err + } + return &department, nil +} + +func (r *DepartmentRepository) GetAll(ctx context.Context) ([]entities.Department, error) { + db := DBFromContext(ctx, r.db) + var departments []entities.Department + if err := db.WithContext(ctx).Order("path ASC").Find(&departments).Error; err != nil { + return nil, err + } + return departments, nil +} + +func (r *DepartmentRepository) GetAllWithParentFilter(ctx context.Context, parentPath string, excludedPaths []string) ([]entities.Department, error) { + db := DBFromContext(ctx, r.db) + var departments []entities.Department + + query := db.WithContext(ctx) + + // Filter by parent path if provided - include the parent itself and all descendants + if parentPath != "" { + query = query.Where("path = ? OR path <@ ?", parentPath, parentPath) + } + + // Exclude specific paths + for _, excludedPath := range excludedPaths { + query = query.Where("NOT (path ~ ?)", excludedPath) + } + + if err := query.Order("path ASC").Find(&departments).Error; err != nil { + return nil, err + } + return departments, nil +} + +func (r *DepartmentRepository) GetByPathPrefix(ctx context.Context, pathPrefix string) ([]entities.Department, error) { + db := DBFromContext(ctx, r.db) + var departments []entities.Department + // Using ltree operators for hierarchical queries + query := db.WithContext(ctx).Order("path ASC") + if pathPrefix != "" { + // Get all descendants of a path + query = query.Where("path <@ ?", pathPrefix) + } + if err := query.Find(&departments).Error; err != nil { + return nil, err + } + return departments, nil +} + +func (r *DepartmentRepository) GetChildren(ctx context.Context, parentPath string) ([]entities.Department, error) { + db := DBFromContext(ctx, r.db) + var departments []entities.Department + // Get direct children and all descendants + if err := db.WithContext(ctx). + Where("path <@ ? AND path != ?", parentPath, parentPath). + 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 + // This will update all children paths by replacing the old prefix with the new one + query := ` + UPDATE departments + SET path = ? || subpath(path, nlevel(?)) + WHERE path <@ ? AND path != ? + ` + return db.WithContext(ctx).Exec(query, newPath, oldPath, oldPath, oldPath).Error +} diff --git a/internal/repository/user_repository.go b/internal/repository/user_repository.go index d107eda..c3ceef8 100644 --- a/internal/repository/user_repository.go +++ b/internal/repository/user_repository.go @@ -194,26 +194,86 @@ func (r *UserRepositoryImpl) ListWithFilters(ctx context.Context, search *string var users []*entities.User var total int64 - q := r.b.WithContext(ctx).Table("users").Model(&entities.User{}) + // Build base query - use Model directly without Table for proper field mapping + baseQuery := r.b.WithContext(ctx).Model(&entities.User{}) + if search != nil && *search != "" { like := "%" + *search + "%" - q = q.Where("users.name ILIKE ?", like) + baseQuery = baseQuery.Where("name ILIKE ? OR email ILIKE ?", like, like) } + if isActive != nil { - q = q.Where("users.is_active = ?", *isActive) + baseQuery = baseQuery.Where("is_active = ?", *isActive) } + + // For counting with role filter, we need to use a subquery or join + countQuery := baseQuery if roleCode != nil && *roleCode != "" { - q = q.Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL"). + countQuery = countQuery. + Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL"). Joins("JOIN roles r ON r.id = ur.role_id"). Where("r.code = ?", *roleCode) } - if err := q.Distinct("users.id").Count(&total).Error; err != nil { + // Get total count + if err := countQuery.Count(&total).Error; err != nil { return nil, 0, err } - if err := q.Select("users.*").Distinct("users.id").Limit(limit).Offset(offset).Preload("Profile").Preload("Departments").Find(&users).Error; err != nil { + // Build query for fetching data + dataQuery := r.b.WithContext(ctx).Model(&entities.User{}) + + if search != nil && *search != "" { + like := "%" + *search + "%" + dataQuery = dataQuery.Where("name ILIKE ? OR email ILIKE ?", like, like) + } + + if isActive != nil { + dataQuery = dataQuery.Where("is_active = ?", *isActive) + } + + if roleCode != nil && *roleCode != "" { + dataQuery = dataQuery. + Joins("JOIN user_role ur ON ur.user_id = users.id AND ur.removed_at IS NULL"). + Joins("JOIN roles r ON r.id = ur.role_id"). + Where("r.code = ?", *roleCode) + } + + // Fetch users with preloads + if err := dataQuery. + Limit(limit). + Offset(offset). + Preload("Profile"). + Preload("Departments"). + Find(&users).Error; err != nil { return nil, 0, err } + return users, total, nil } + +func (r *UserRepositoryImpl) GetByIDWithDepartments(ctx context.Context, id uuid.UUID) (*entities.User, error) { + var user entities.User + err := r.b.WithContext(ctx). + Preload("Profile"). + Preload("Departments"). + First(&user, "id = ?", id).Error + if err != nil { + return nil, err + } + return &user, nil +} + +func (r *UserRepositoryImpl) UpdateDepartments(ctx context.Context, userID uuid.UUID, departments []entities.Department) error { + // First, clear existing associations + if err := r.b.WithContext(ctx).Model(&entities.User{ID: userID}).Association("Departments").Clear(); err != nil { + return err + } + + // Then add new associations + if len(departments) > 0 { + return r.b.WithContext(ctx).Model(&entities.User{ID: userID}).Association("Departments").Append(&departments) + } + + return nil +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 4464c3e..3f4c9e4 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -62,6 +62,12 @@ type MasterHandler interface { ListDispositionActions(c *gin.Context) // departments ListDepartments(c *gin.Context) + CreateDepartment(c *gin.Context) + GetDepartment(c *gin.Context) + UpdateDepartment(c *gin.Context) + DeleteDepartment(c *gin.Context) + GetOrganizationalChart(c *gin.Context) + GetOrganizationalChartByID(c *gin.Context) } type LetterHandler interface { diff --git a/internal/router/router.go b/internal/router/router.go index e61a9bc..97210e5 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -154,7 +154,13 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { master.PUT("/disposition-actions/:id", r.masterHandler.UpdateDispositionAction) master.DELETE("/disposition-actions/:id", r.masterHandler.DeleteDispositionAction) + master.POST("/departments", r.masterHandler.CreateDepartment) master.GET("/departments", r.masterHandler.ListDepartments) + master.GET("/departments/chart", r.masterHandler.GetOrganizationalChart) + master.GET("/departments/:id", r.masterHandler.GetDepartment) + master.GET("/departments/:id/chart", r.masterHandler.GetOrganizationalChartByID) + master.PUT("/departments/:id", r.masterHandler.UpdateDepartment) + master.DELETE("/departments/:id", r.masterHandler.DeleteDepartment) } lettersch := v1.Group("/letters") diff --git a/internal/service/letter_service.go b/internal/service/letter_service.go index d27e030..df936cf 100644 --- a/internal/service/letter_service.go +++ b/internal/service/letter_service.go @@ -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) { diff --git a/internal/service/master_service.go b/internal/service/master_service.go index 68b45a8..31b8ca0 100644 --- a/internal/service/master_service.go +++ b/internal/service/master_service.go @@ -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 } diff --git a/internal/service/user_processor.go b/internal/service/user_processor.go index 0400139..d48f528 100644 --- a/internal/service/user_processor.go +++ b/internal/service/user_processor.go @@ -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) diff --git a/internal/service/user_service.go b/internal/service/user_service.go index 3e39159..a8209a8 100644 --- a/internal/service/user_service.go +++ b/internal/service/user_service.go @@ -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) }