diff --git a/eslogad-backend b/eslogad-backend new file mode 100755 index 0000000..bed338b Binary files /dev/null and b/eslogad-backend differ diff --git a/internal/app/app.go b/internal/app/app.go index 0ab0e14..8c77612 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -50,6 +50,7 @@ func (a *App) Initialize(cfg *config.Config) error { adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService) dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService) onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService) + analyticsHandler := handler.NewAnalyticsHandler(services.analyticsService) a.router = router.NewRouter( cfg, @@ -65,6 +66,7 @@ func (a *App) Initialize(cfg *config.Config) error { adminApprovalFlowHandler, dispositionRouteHandler, onlyOfficeHandler, + analyticsHandler, ) return nil @@ -141,6 +143,7 @@ type repositories struct { letterOutgoingActivityLogRepo *repository.LetterOutgoingActivityLogRepository approvalFlowRepo *repository.ApprovalFlowRepository letterOutgoingApprovalRepo *repository.LetterOutgoingApprovalRepository + analyticsRepo *repository.AnalyticsRepository } func (a *App) initRepositories() *repositories { @@ -174,6 +177,7 @@ func (a *App) initRepositories() *repositories { letterOutgoingActivityLogRepo: repository.NewLetterOutgoingActivityLogRepository(a.db), approvalFlowRepo: repository.NewApprovalFlowRepository(a.db), letterOutgoingApprovalRepo: repository.NewLetterOutgoingApprovalRepository(a.db), + analyticsRepo: repository.NewAnalyticsRepository(a.db), } } @@ -255,6 +259,7 @@ type services struct { approvalFlowService *service.ApprovalFlowServiceImpl dispositionRouteService *service.DispositionRouteServiceImpl onlyOfficeService *service.OnlyOfficeServiceImpl + analyticsService *service.AnalyticsServiceImpl } func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services { @@ -289,6 +294,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con // Create OnlyOffice service with file storage onlyOfficeSvc := service.NewOnlyOfficeService(processors.onlyOfficeProcessor, &cfg.OnlyOffice, a.db, s3Client) + // Create Analytics service + analyticsSvc := service.NewAnalyticsService(repos.analyticsRepo) + return &services{ userService: userSvc, authService: authService, @@ -300,6 +308,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con approvalFlowService: approvalFlowSvc, dispositionRouteService: dispRouteSvc, onlyOfficeService: onlyOfficeSvc, + analyticsService: analyticsSvc, } } diff --git a/internal/appcontext/context.go b/internal/appcontext/context.go index d719520..0b912e2 100644 --- a/internal/appcontext/context.go +++ b/internal/appcontext/context.go @@ -21,6 +21,7 @@ const ( DeviceOSKey = key("deviceOS") UserLocaleKey = key("userLocale") UserRoleKey = key("userRole") + UserNameKey = key("UserName") ) func LogFields(ctx interface{}) map[string]interface{} { diff --git a/internal/appcontext/context_info.go b/internal/appcontext/context_info.go index fa29de5..4584320 100644 --- a/internal/appcontext/context_info.go +++ b/internal/appcontext/context_info.go @@ -27,6 +27,7 @@ type ContextInfo struct { DeviceOS string UserLocale string UserRole string + UserName string } type ctxKeyType struct{} @@ -68,6 +69,7 @@ func FromGinContext(ctx context.Context) *ContextInfo { DeviceOS: value(ctx, DeviceOSKey), UserLocale: value(ctx, UserLocaleKey), UserRole: value(ctx, UserRoleKey), + UserName: value(ctx, UserNameKey), } } diff --git a/internal/contract/analytics_contract.go b/internal/contract/analytics_contract.go new file mode 100644 index 0000000..6a297e4 --- /dev/null +++ b/internal/contract/analytics_contract.go @@ -0,0 +1,185 @@ +package contract + +import ( + "time" + + "github.com/google/uuid" +) + +// AnalyticsDashboardRequest represents the request for analytics data +type AnalyticsDashboardRequest struct { + StartDate string `form:"start_date" json:"start_date"` + EndDate string `form:"end_date" json:"end_date"` + DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"` + UserID *uuid.UUID `form:"user_id" json:"user_id,omitempty"` +} + +// 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"` +} + +// LetterSummaryStats represents overall summary statistics +type LetterSummaryStats struct { + TotalIncoming int64 `json:"total_incoming"` + TotalOutgoing int64 `json:"total_outgoing"` + WeekOverWeekGrowth float64 `json:"week_over_week_growth"` + MonthOverMonthGrowth float64 `json:"month_over_month_growth"` + TotalPending int64 `json:"total_pending,omitempty"` + TotalApproved int64 `json:"total_approved,omitempty"` + TotalRejected int64 `json:"total_rejected,omitempty"` + TotalArchived int64 `json:"total_archived,omitempty"` + AvgProcessingTime float64 `json:"avg_processing_time_hours,omitempty"` + CompletionRate float64 `json:"completion_rate,omitempty"` +} + +// StatusDistribution represents letter distribution by status +type StatusDistribution struct { + Status string `json:"status"` + Count int64 `json:"count"` + Percentage float64 `json:"percentage"` + Type string `json:"type"` // incoming or outgoing +} + +// 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"` + 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"` +} + +// MonthlyTrend represents monthly trend data +type MonthlyTrend struct { + Month string `json:"month"` + Year int `json:"year"` + IncomingCount int64 `json:"incoming_count"` + OutgoingCount int64 `json:"outgoing_count"` + TotalCount int64 `json:"total_count"` + GrowthRate float64 `json:"growth_rate"` +} + +// 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"` +} + +// InstitutionStats represents statistics per institution +type InstitutionStats struct { + InstitutionID uuid.UUID `json:"institution_id"` + InstitutionName string `json:"institution_name"` + InstitutionType string `json:"institution_type"` + IncomingCount int64 `json:"incoming_count"` + OutgoingCount int64 `json:"outgoing_count"` + TotalCount int64 `json:"total_count"` + LastActivity time.Time `json:"last_activity"` +} + +// 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"` +} + +// 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"` +} + +// 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"` + MedianResponseTime float64 `json:"median_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"` +} + +// 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"` + HourlyDistribution []HourlyActivity `json:"hourly_distribution,omitempty"` +} + +// HourlyActivity represents hourly activity within a day +type HourlyActivity struct { + Hour int `json:"hour"` + Count int64 `json:"count"` +} + +// LetterVolumeByTypeResponse represents letter volume grouped by type +type LetterVolumeByTypeResponse struct { + Incoming IncomingLetterVolume `json:"incoming"` + Outgoing OutgoingLetterVolume `json:"outgoing"` +} + +// IncomingLetterVolume represents incoming letter statistics +type IncomingLetterVolume struct { + Today int64 `json:"today"` + ThisWeek int64 `json:"this_week"` + ThisMonth int64 `json:"this_month"` + ThisYear int64 `json:"this_year"` + Total int64 `json:"total"` +} + +// OutgoingLetterVolume represents outgoing letter statistics +type OutgoingLetterVolume struct { + Today int64 `json:"today"` + ThisWeek int64 `json:"this_week"` + 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/letter_outgoing_contract.go b/internal/contract/letter_outgoing_contract.go index 242ebbd..5009431 100644 --- a/internal/contract/letter_outgoing_contract.go +++ b/internal/contract/letter_outgoing_contract.go @@ -57,13 +57,15 @@ type OutgoingLetterAttachmentResponse struct { } type OutgoingLetterApprovalResponse struct { - ID uuid.UUID `json:"id"` - StepOrder int `json:"step_order"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - Status string `json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` + ID uuid.UUID `json:"id"` + StepOrder int `json:"step_order"` + ParallelGroup int `json:"parallel_group"` + IsRequired bool `json:"is_required"` + ApproverID *uuid.UUID `json:"approver_id,omitempty"` + Status string `json:"status"` + Remarks *string `json:"remarks,omitempty"` + ActedAt *time.Time `json:"acted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` } type OutgoingLetterResponse struct { @@ -97,14 +99,18 @@ type UpdateOutgoingLetterRequest struct { } type ListOutgoingLettersRequest struct { - Limit int `json:"limit"` - Offset int `json:"offset"` - Status *string `json:"status,omitempty"` - Query *string `json:"query,omitempty"` - CreatedBy *uuid.UUID `json:"created_by,omitempty"` - ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"` - FromDate *time.Time `json:"from_date,omitempty"` - ToDate *time.Time `json:"to_date,omitempty"` + Page int `form:"page" json:"page"` + Limit int `form:"limit" json:"limit"` + Status string `form:"status" json:"status,omitempty"` + Query string `form:"q" json:"query,omitempty"` + CreatedBy *uuid.UUID `form:"created_by" json:"created_by,omitempty"` + DepartmentID *uuid.UUID `form:"department_id" json:"department_id,omitempty"` + ReceiverInstitutionID *uuid.UUID `form:"receiver_institution_id" json:"receiver_institution_id,omitempty"` + FromDate string `form:"from_date" json:"from_date,omitempty"` + ToDate string `form:"to_date" json:"to_date,omitempty"` + PriorityID *uuid.UUID `form:"priority_id" json:"priority_id,omitempty"` + SortBy string `form:"sort_by" json:"sort_by,omitempty"` + SortOrder string `form:"sort_order" json:"sort_order,omitempty"` } type ListOutgoingLettersResponse struct { @@ -242,32 +248,45 @@ type OutgoingLetterApprovalDiscussionsResponse struct { // EnhancedOutgoingLetterApprovalResponse includes approval details with related data type EnhancedOutgoingLetterApprovalResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - StepID uuid.UUID `json:"step_id"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - Status string `json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `json:"created_at"` - Step *ApprovalFlowStepResponse `json:"step,omitempty"` - Approver *UserResponse `json:"approver,omitempty"` + ID uuid.UUID `json:"id"` + LetterID uuid.UUID `json:"letter_id"` + StepID uuid.UUID `json:"step_id"` + StepOrder int `json:"step_order"` + ParallelGroup int `json:"parallel_group"` + IsRequired bool `json:"is_required"` + ApproverID *uuid.UUID `json:"approver_id,omitempty"` + Status string `json:"status"` + Remarks *string `json:"remarks,omitempty"` + ActedAt *time.Time `json:"acted_at,omitempty"` + CreatedAt time.Time `json:"created_at"` + Step *ApprovalFlowStepResponse `json:"step,omitempty"` + Approver *UserResponse `json:"approver,omitempty"` +} + +// GetLetterApprovalsResponse represents the list of approvals for a letter +type GetLetterApprovalsResponse struct { + LetterID uuid.UUID `json:"letter_id"` + LetterNumber string `json:"letter_number"` + LetterStatus string `json:"letter_status"` + TotalSteps int `json:"total_steps"` + CurrentStep int `json:"current_step"` + Approvals []EnhancedOutgoingLetterApprovalResponse `json:"approvals"` } // OutgoingLetterDiscussionResponse represents a discussion on an outgoing letter type OutgoingLetterDiscussionResponse struct { - ID uuid.UUID `json:"id"` - LetterID uuid.UUID `json:"letter_id"` - ParentID *uuid.UUID `json:"parent_id,omitempty"` - UserID uuid.UUID `json:"user_id"` - Message string `json:"message"` - Mentions map[string]interface{} `json:"mentions,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - EditedAt *time.Time `json:"edited_at,omitempty"` - User *UserResponse `json:"user,omitempty"` - MentionedUsers []UserResponse `json:"mentioned_users,omitempty"` - Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"` + ID uuid.UUID `json:"id"` + LetterID uuid.UUID `json:"letter_id"` + ParentID *uuid.UUID `json:"parent_id,omitempty"` + UserID uuid.UUID `json:"user_id"` + Message string `json:"message"` + Mentions map[string]interface{} `json:"mentions,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EditedAt *time.Time `json:"edited_at,omitempty"` + User *UserResponse `json:"user,omitempty"` + MentionedUsers []UserResponse `json:"mentioned_users,omitempty"` + Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"` } // OutgoingLetterDiscussionAttachmentResponse represents an attachment in a discussion @@ -280,3 +299,39 @@ type OutgoingLetterDiscussionAttachmentResponse struct { UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"` UploadedAt time.Time `json:"uploaded_at"` } + +// TimelineEvent represents a single event in the approval timeline +type TimelineEvent struct { + ID string `json:"id"` + Type string `json:"type"` // "approval", "discussion", "submission", "rejection" + Timestamp time.Time `json:"timestamp"` + Actor *UserResponse `json:"actor,omitempty"` + Action string `json:"action"` + Description string `json:"description"` + Status string `json:"status,omitempty"` + StepOrder int `json:"step_order,omitempty"` + Message string `json:"message,omitempty"` + Data interface{} `json:"data,omitempty"` +} + +// ApprovalTimelineResponse represents the complete timeline for a letter +type ApprovalTimelineResponse struct { + LetterID uuid.UUID `json:"letter_id"` + LetterNumber string `json:"letter_number"` + Subject string `json:"subject"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` + Timeline []TimelineEvent `json:"timeline"` + Summary TimelineSummary `json:"summary"` +} + +// TimelineSummary provides overview statistics for the timeline +type TimelineSummary struct { + TotalSteps int `json:"total_steps"` + CompletedSteps int `json:"completed_steps"` + PendingSteps int `json:"pending_steps"` + CurrentStep int `json:"current_step"` + TotalDuration string `json:"total_duration"` + AverageStepTime string `json:"average_step_time"` + Status string `json:"status"` +} diff --git a/internal/entities/approval_flow.go b/internal/entities/approval_flow.go index b51396f..7b9e877 100644 --- a/internal/entities/approval_flow.go +++ b/internal/entities/approval_flow.go @@ -42,20 +42,24 @@ func (ApprovalFlowStep) TableName() string { return "approval_flow_steps" } type ApprovalStatus string const ( - ApprovalStatusPending ApprovalStatus = "pending" - ApprovalStatusApproved ApprovalStatus = "approved" - ApprovalStatusRejected ApprovalStatus = "rejected" + ApprovalStatusNotStarted ApprovalStatus = "not_started" + ApprovalStatusPending ApprovalStatus = "pending" + ApprovalStatusApproved ApprovalStatus = "approved" + ApprovalStatusRejected ApprovalStatus = "rejected" ) type LetterOutgoingApproval struct { - ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` - LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` - StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"` - ApproverID *uuid.UUID `json:"approver_id,omitempty"` - Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"` - Remarks *string `json:"remarks,omitempty"` - ActedAt *time.Time `json:"acted_at,omitempty"` - CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` + ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"` + LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"` + StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"` + StepOrder int `gorm:"not null" json:"step_order"` + ParallelGroup int `gorm:"default:1" json:"parallel_group"` + IsRequired bool `gorm:"default:true" json:"is_required"` + ApproverID *uuid.UUID `json:"approver_id,omitempty"` + Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"` + Remarks *string `json:"remarks,omitempty"` + ActedAt *time.Time `json:"acted_at,omitempty"` + CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` // Relations Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"` diff --git a/internal/handler/analytics_handler.go b/internal/handler/analytics_handler.go new file mode 100644 index 0000000..dda754a --- /dev/null +++ b/internal/handler/analytics_handler.go @@ -0,0 +1,195 @@ +package handler + +import ( + "net/http" + + "eslogad-be/internal/contract" + "eslogad-be/internal/service" + + "github.com/gin-gonic/gin" +) + +type AnalyticsHandler struct { + analyticsService service.AnalyticsService +} + +func NewAnalyticsHandler(analyticsService service.AnalyticsService) *AnalyticsHandler { + return &AnalyticsHandler{ + analyticsService: analyticsService, + } +} + +// GetDashboard handles GET /api/v1/analytics/dashboard +func (h *AnalyticsHandler) GetDashboard(c *gin.Context) { + var req contract.AnalyticsDashboardRequest + + // Bind query parameters + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "Invalid query parameters", + Code: http.StatusBadRequest, + }) + return + } + + // Get analytics dashboard data + response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(response)) +} + +// GetLetterVolume handles GET /api/v1/analytics/volume +func (h *AnalyticsHandler) GetLetterVolume(c *gin.Context) { + response, err := h.analyticsService.GetLetterVolume(c.Request.Context()) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(response)) +} + +// GetStatusDistribution handles GET /api/v1/analytics/status-distribution +func (h *AnalyticsHandler) GetStatusDistribution(c *gin.Context) { + var req contract.AnalyticsDashboardRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "Invalid query parameters", + Code: http.StatusBadRequest, + }) + return + } + + // Get full dashboard and extract status distribution + response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "status_distribution": response.StatusDistribution, + })) +} + +// GetPriorityDistribution handles GET /api/v1/analytics/priority-distribution +func (h *AnalyticsHandler) GetPriorityDistribution(c *gin.Context) { + var req contract.AnalyticsDashboardRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "Invalid query parameters", + Code: http.StatusBadRequest, + }) + return + } + + // Get full dashboard and extract priority distribution + response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "priority_distribution": response.PriorityDistribution, + })) +} + +// GetDepartmentStats handles GET /api/v1/analytics/department-stats +func (h *AnalyticsHandler) GetDepartmentStats(c *gin.Context) { + var req contract.AnalyticsDashboardRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "Invalid query parameters", + Code: http.StatusBadRequest, + }) + return + } + + // Get full dashboard and extract department stats + response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "department_stats": response.DepartmentStats, + })) +} + +// GetMonthlyTrend handles GET /api/v1/analytics/monthly-trend +func (h *AnalyticsHandler) GetMonthlyTrend(c *gin.Context) { + var req contract.AnalyticsDashboardRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "Invalid query parameters", + Code: http.StatusBadRequest, + }) + return + } + + // Get full dashboard and extract monthly trend + response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "monthly_trend": response.MonthlyTrend, + })) +} + +// GetApprovalMetrics handles GET /api/v1/analytics/approval-metrics +func (h *AnalyticsHandler) GetApprovalMetrics(c *gin.Context) { + var req contract.AnalyticsDashboardRequest + + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{ + Error: "Invalid query parameters", + Code: http.StatusBadRequest, + }) + return + } + + // Get full dashboard and extract approval metrics + response, err := h.analyticsService.GetDashboard(c.Request.Context(), &req) + if err != nil { + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{ + Error: err.Error(), + Code: http.StatusInternalServerError, + }) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(map[string]interface{}{ + "approval_metrics": response.ApprovalMetrics, + })) +} \ No newline at end of file diff --git a/internal/handler/letter_outgoing_handler.go b/internal/handler/letter_outgoing_handler.go index dd498b3..56cb3ed 100644 --- a/internal/handler/letter_outgoing_handler.go +++ b/internal/handler/letter_outgoing_handler.go @@ -3,10 +3,8 @@ package handler import ( "context" "eslogad-be/internal/appcontext" - "net/http" - "strconv" - "eslogad-be/internal/contract" + "net/http" "github.com/gin-gonic/gin" "github.com/google/uuid" @@ -38,7 +36,9 @@ type LetterOutgoingService interface { DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) + GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) + GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) } type LetterOutgoingHandler struct { @@ -84,47 +84,22 @@ func (h *LetterOutgoingHandler) GetOutgoingLetter(c *gin.Context) { } func (h *LetterOutgoingHandler) ListOutgoingLetters(c *gin.Context) { - page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) - limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10")) - offset := (page - 1) * limit + var req contract.ListOutgoingLettersRequest - status := c.Query("status") - query := c.Query("q") - createdByStr := c.Query("created_by") - receiverInstitutionStr := c.Query("receiver_institution_id") - - var statusPtr *string - var queryPtr *string - var createdByPtr *uuid.UUID - var receiverInstitutionPtr *uuid.UUID - - if status != "" { - statusPtr = &status - } - if query != "" { - queryPtr = &query - } - if createdByStr != "" { - if createdBy, err := uuid.Parse(createdByStr); err == nil { - createdByPtr = &createdBy - } - } - if receiverInstitutionStr != "" { - if receiverInstitution, err := uuid.Parse(receiverInstitutionStr); err == nil { - receiverInstitutionPtr = &receiverInstitution - } + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid query parameters", Code: http.StatusBadRequest}) + return } - req := &contract.ListOutgoingLettersRequest{ - Limit: limit, - Offset: offset, - Status: statusPtr, - Query: queryPtr, - CreatedBy: createdByPtr, - ReceiverInstitutionID: receiverInstitutionPtr, + if req.Page <= 0 { + req.Page = 1 } - resp, err := h.svc.ListOutgoingLetters(c.Request.Context(), req) + if req.Limit <= 0 { + req.Limit = 10 + } + + resp, err := h.svc.ListOutgoingLetters(c.Request.Context(), &req) if err != nil { c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) return @@ -203,7 +178,7 @@ func (h *LetterOutgoingHandler) ApproveOutgoingLetter(c *gin.Context) { return } - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "approved"}) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "approved"})) } func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) { @@ -224,7 +199,7 @@ func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) { return } - c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "rejected"}) + c.JSON(http.StatusOK, contract.BuildSuccessResponse(&contract.SuccessResponse{Message: "rejected"})) } func (h *LetterOutgoingHandler) SendOutgoingLetter(c *gin.Context) { @@ -442,6 +417,27 @@ func (h *LetterOutgoingHandler) GetLetterApprovalInfo(c *gin.Context) { c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) } +// GetLetterApprovals returns all approvals and their status for a letter +func (h *LetterOutgoingHandler) GetLetterApprovals(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) + return + } + + resp, err := h.svc.GetLetterApprovals(c.Request.Context(), id) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound}) + return + } + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} + // GetApprovalDiscussions returns both approvals and discussions for an outgoing letter func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) { id, err := uuid.Parse(c.Param("id")) @@ -461,4 +457,25 @@ func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) { } c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) -} \ No newline at end of file +} + +// GetApprovalTimeline returns a chronological timeline of approval and discussion events +func (h *LetterOutgoingHandler) GetApprovalTimeline(c *gin.Context) { + id, err := uuid.Parse(c.Param("id")) + if err != nil { + c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest}) + return + } + + resp, err := h.svc.GetApprovalTimeline(c.Request.Context(), id) + if err != nil { + if err == gorm.ErrRecordNotFound { + c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound}) + return + } + c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError}) + return + } + + c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp)) +} diff --git a/internal/middleware/auth_middleware.go b/internal/middleware/auth_middleware.go index 0eb2f34..7d030b3 100644 --- a/internal/middleware/auth_middleware.go +++ b/internal/middleware/auth_middleware.go @@ -41,6 +41,7 @@ func (m *AuthMiddleware) RequireAuth() gin.HandlerFunc { } setKeyInContext(c, appcontext.UserIDKey, userResponse.ID.String()) + setKeyInContext(c, appcontext.UserNameKey, userResponse.Name) if len(userResponse.DepartmentResponse) > 0 { departmentID := userResponse.DepartmentResponse[0].ID.String() setKeyInContext(c, appcontext.DepartmentIDKey, departmentID) diff --git a/internal/processor/letter_outgoing_processor.go b/internal/processor/letter_outgoing_processor.go index 84c290f..bc2ebbe 100644 --- a/internal/processor/letter_outgoing_processor.go +++ b/internal/processor/letter_outgoing_processor.go @@ -39,7 +39,7 @@ type LetterOutgoingProcessor interface { GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error) - + // GetOutgoingLetterWithDetails fetches letter with all related data GetOutgoingLetterWithDetails(ctx context.Context, letterID uuid.UUID) (*entities.LetterOutgoing, error) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) @@ -178,7 +178,38 @@ func (p *LetterOutgoingProcessorImpl) createRecipientsFromApprovalFlow(ctx conte } } - // Collect all recipients from the first step (can be multiple if parallel) + // Create all approval steps in letter_outgoing_approvals + var approvals []entities.LetterOutgoingApproval + for _, step := range flow.Steps { + approval := entities.LetterOutgoingApproval{ + LetterID: letter.ID, + StepID: step.ID, + StepOrder: step.StepOrder, + ParallelGroup: step.ParallelGroup, + IsRequired: step.Required, + ApproverID: step.ApproverUserID, + } + + // Set status based on step order + if step.StepOrder == minStepOrder { + // First step(s) are set to pending + approval.Status = entities.ApprovalStatusPending + } else { + // All other steps are set to not_started + approval.Status = entities.ApprovalStatusNotStarted + } + + approvals = append(approvals, approval) + } + + // Bulk create all approvals + if len(approvals) > 0 { + if err := p.approvalRepo.CreateBulk(ctx, approvals); err != nil { + return err + } + } + + // Also create recipients from the first step (for backward compatibility) var recipients []entities.LetterOutgoingRecipient for i, step := range flow.Steps { // Only process steps with the minimum step order (first step) @@ -198,13 +229,12 @@ func (p *LetterOutgoingProcessorImpl) createRecipientsFromApprovalFlow(ctx conte } } - // If no recipients were created, return without error - if len(recipients) == 0 { - return nil + // Bulk create all recipients if any + if len(recipients) > 0 { + return p.recipientRepo.CreateBulk(ctx, recipients) } - // Bulk create all recipients - return p.recipientRepo.CreateBulk(ctx, recipients) + return nil } // createRecipientFromApprovalStep creates a recipient from an approval flow step @@ -331,12 +361,31 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont } return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error { + // Find the minimum step order (first step) + minStepOrder := flow.Steps[0].StepOrder + for _, step := range flow.Steps { + if step.StepOrder < minStepOrder { + minStepOrder = step.StepOrder + } + } + approvals := make([]entities.LetterOutgoingApproval, len(flow.Steps)) for i, step := range flow.Steps { approvals[i] = entities.LetterOutgoingApproval{ - LetterID: letterID, - StepID: step.ID, - Status: entities.ApprovalStatusPending, + LetterID: letterID, + StepID: step.ID, + StepOrder: step.StepOrder, + ParallelGroup: step.ParallelGroup, + IsRequired: step.Required, + ApproverID: step.ApproverUserID, + Status: entities.ApprovalStatusPending, + } + + // Set status based on step order + if step.StepOrder == minStepOrder { + approvals[i].Status = entities.ApprovalStatusPending + } else { + approvals[i].Status = entities.ApprovalStatusNotStarted } } @@ -344,6 +393,39 @@ func (p *LetterOutgoingProcessorImpl) ProcessApprovalSubmission(ctx context.Cont return err } + // Add first step approvers as recipients + existingRecipients, err := p.recipientRepo.ListByLetter(txCtx, letterID) + if err != nil { + return err + } + + // Create a map of existing user IDs for quick lookup + existingUserIDs := make(map[uuid.UUID]bool) + for _, recipient := range existingRecipients { + if recipient.UserID != nil { + existingUserIDs[*recipient.UserID] = true + } + } + + // Add approvers from the first step as recipients + for _, approval := range approvals { + if approval.StepOrder == minStepOrder && approval.ApproverID != nil { + if !existingUserIDs[*approval.ApproverID] { + newRecipient := entities.LetterOutgoingRecipient{ + LetterID: letterID, + UserID: approval.ApproverID, + IsPrimary: false, + Status: "unread", + IsArchived: false, + } + if err := p.recipientRepo.Create(txCtx, &newRecipient); err != nil { + return err + } + existingUserIDs[*approval.ApproverID] = true + } + } + } + if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil { return err } @@ -376,7 +458,78 @@ func (p *LetterOutgoingProcessorImpl) ProcessApproval(ctx context.Context, lette return err } - if allApproved { + allApprovals, err := p.approvalRepo.ListByLetter(txCtx, letterID) + if err != nil { + return err + } + + approvalsByStep := make(map[int][]entities.LetterOutgoingApproval) + for _, a := range allApprovals { + approvalsByStep[a.StepOrder] = append(approvalsByStep[a.StepOrder], a) + } + + currentStepCompleted := true + for _, a := range approvalsByStep[approval.StepOrder] { + if a.IsRequired && a.Status != entities.ApprovalStatusApproved { + currentStepCompleted = false + break + } + } + + // If current step is completed, activate the next step and add approvers as recipients + if currentStepCompleted { + nextStepOrder := approval.StepOrder + 1 + if nextStepApprovals, exists := approvalsByStep[nextStepOrder]; exists { + currentRecipients, err := p.recipientRepo.ListByLetter(txCtx, letterID) + if err != nil { + return err + } + + existingUserIDs := make(map[uuid.UUID]bool) + for _, recipient := range currentRecipients { + if recipient.UserID != nil { + existingUserIDs[*recipient.UserID] = true + } + } + + for _, nextApproval := range nextStepApprovals { + + if nextApproval.Status == entities.ApprovalStatusNotStarted { + nextApproval.Status = entities.ApprovalStatusPending + if err := p.approvalRepo.Update(txCtx, &nextApproval); err != nil { + return err + } + } + + if nextApproval.ApproverID != nil && !existingUserIDs[*nextApproval.ApproverID] { + newRecipient := entities.LetterOutgoingRecipient{ + LetterID: letterID, + UserID: nextApproval.ApproverID, + IsPrimary: false, + Status: "unread", + IsArchived: false, + } + if err := p.recipientRepo.Create(txCtx, &newRecipient); err != nil { + return err + } + + existingUserIDs[*nextApproval.ApproverID] = true + } + } + } + } + + // Check if all required approvals are completed + allRequiredApproved := true + for _, a := range allApprovals { + if a.IsRequired && a.Status != entities.ApprovalStatusApproved { + allRequiredApproved = false + break + } + } + + // Update letter status if all required approvals are done + if allRequiredApproved { if err := p.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil { return err } @@ -587,11 +740,11 @@ func (p *LetterOutgoingProcessorImpl) GetOutgoingLetterWithDetails(ctx context.C "Discussions.Attachments", "ActivityLogs", }) - + if err != nil { return nil, err } - + return letter, nil } @@ -600,16 +753,16 @@ func (p *LetterOutgoingProcessorImpl) GetUsersByIDs(ctx context.Context, userIDs if len(userIDs) == 0 { return []entities.User{}, nil } - + var users []entities.User err := p.db.WithContext(ctx). Preload("Profile"). Where("id IN ?", userIDs). Find(&users).Error - + if err != nil { return nil, err } - + return users, nil } diff --git a/internal/repository/analytics_repository.go b/internal/repository/analytics_repository.go new file mode 100644 index 0000000..c9d138d --- /dev/null +++ b/internal/repository/analytics_repository.go @@ -0,0 +1,751 @@ +package repository + +import ( + "context" + "fmt" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" +) + +type AnalyticsRepository struct { + db *gorm.DB +} + +func NewAnalyticsRepository(db *gorm.DB) *AnalyticsRepository { + return &AnalyticsRepository{db: db} +} + +// GetLetterSummaryStats gets overall summary statistics using summary tables for better performance +func (r *AnalyticsRepository) GetLetterSummaryStats(ctx context.Context, startDate, endDate time.Time, userID, departmentID *uuid.UUID) (map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + stats := make(map[string]interface{}) + + // Use summary tables for better performance when possible + if userID == nil && departmentID != nil { + // Use department_letter_summary for department-specific stats + query := db.Table("department_letter_summary"). + Where("department_id = ?", *departmentID) + + if !startDate.IsZero() { + query = query.Where("summary_date >= ?", startDate) + } + if !endDate.IsZero() { + query = query.Where("summary_date <= ?", endDate) + } + + var result struct { + TotalIncoming int64 `gorm:"column:total_incoming"` + TotalOutgoing int64 `gorm:"column:total_outgoing"` + PendingOutgoing int64 `gorm:"column:pending_outgoing"` + ApprovedOutgoing int64 `gorm:"column:approved_outgoing"` + RejectedOutgoing int64 `gorm:"column:rejected_outgoing"` + AvgResponseHours float64 `gorm:"column:avg_response_hours"` + CompletionRate float64 `gorm:"column:completion_rate"` + } + + query.Select(` + COALESCE(SUM(incoming_count), 0) as total_incoming, + COALESCE(SUM(outgoing_count), 0) as total_outgoing, + COALESCE(SUM(pending_outgoing), 0) as pending_outgoing, + COALESCE(SUM(approved_outgoing), 0) as approved_outgoing, + COALESCE(SUM(rejected_outgoing), 0) as rejected_outgoing, + COALESCE(AVG(avg_response_hours), 0) as avg_response_hours, + COALESCE(AVG(completion_rate), 0) as completion_rate + `).Scan(&result) + + stats["total_incoming"] = result.TotalIncoming + stats["total_outgoing"] = result.TotalOutgoing + stats["total_pending"] = result.PendingOutgoing + stats["total_approved"] = result.ApprovedOutgoing + stats["total_rejected"] = result.RejectedOutgoing + stats["total_archived"] = int64(0) // Calculate separately if needed + stats["avg_processing_time"] = result.AvgResponseHours + stats["completion_rate"] = result.CompletionRate + } else if userID == nil && departmentID == nil { + // Use letter_summary for overall stats + query := db.Table("letter_summary") + + if !startDate.IsZero() { + query = query.Where("summary_date >= ?", startDate) + } + if !endDate.IsZero() { + query = query.Where("summary_date <= ?", endDate) + } + + var result struct { + TotalIncoming int64 `gorm:"column:total_incoming"` + TotalOutgoing int64 `gorm:"column:total_outgoing"` + TotalPending int64 `gorm:"column:total_pending"` + TotalApproved int64 `gorm:"column:total_approved"` + TotalRejected int64 `gorm:"column:total_rejected"` + TotalArchived int64 `gorm:"column:total_archived"` + TotalSent int64 `gorm:"column:total_sent"` + AvgProcessing float64 `gorm:"column:avg_processing"` + } + + query.Select(` + COALESCE(SUM(CASE WHEN letter_type = 'incoming' THEN total_count ELSE 0 END), 0) as total_incoming, + COALESCE(SUM(CASE WHEN letter_type = 'outgoing' THEN total_count ELSE 0 END), 0) as total_outgoing, + COALESCE(SUM(pending_count), 0) as total_pending, + COALESCE(SUM(approved_count), 0) as total_approved, + COALESCE(SUM(rejected_count), 0) as total_rejected, + COALESCE(SUM(archived_count), 0) as total_archived, + COALESCE(SUM(sent_count), 0) as total_sent, + COALESCE(AVG(avg_processing_hours), 0) as avg_processing + `).Scan(&result) + + stats["total_incoming"] = result.TotalIncoming + stats["total_outgoing"] = result.TotalOutgoing + stats["total_pending"] = result.TotalPending + stats["total_approved"] = result.TotalApproved + stats["total_rejected"] = result.TotalRejected + stats["total_archived"] = result.TotalArchived + stats["avg_processing_time"] = result.AvgProcessing + + // Calculate completion rate + completionRate := float64(0) + if result.TotalOutgoing > 0 { + completedCount := result.TotalSent + result.TotalArchived + completionRate = float64(completedCount) / float64(result.TotalOutgoing) * 100 + } + stats["completion_rate"] = completionRate + } else { + // Fall back to original implementation for user-specific queries + // Base query builders + incomingQuery := db.Table("letters_incoming").Where("letters_incoming.deleted_at IS NULL") + outgoingQuery := db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL") + + // Apply date filters + if !startDate.IsZero() { + incomingQuery = incomingQuery.Where("letters_incoming.created_at >= ?", startDate) + outgoingQuery = outgoingQuery.Where("letters_outgoing.created_at >= ?", startDate) + } + if !endDate.IsZero() { + incomingQuery = incomingQuery.Where("letters_incoming.created_at <= ?", endDate) + outgoingQuery = outgoingQuery.Where("letters_outgoing.created_at <= ?", endDate) + } + + // Apply user/department filters for outgoing letters + if userID != nil { + outgoingQuery = outgoingQuery. + Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *userID) + } + + // Count incoming letters + var totalIncoming int64 + incomingQuery.Count(&totalIncoming) + stats["total_incoming"] = totalIncoming + + // Count outgoing letters + var totalOutgoing int64 + outgoingQuery.Count(&totalOutgoing) + stats["total_outgoing"] = totalOutgoing + + // Count by status - need to clone query for each count + var pendingCount, approvedCount, rejectedCount, archivedCount int64 + + db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). + Where("letters_outgoing.status = ?", "pending_approval"). + Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *userID). + Count(&pendingCount) + + db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). + Where("letters_outgoing.status = ?", "approved"). + Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *userID). + Count(&approvedCount) + + db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). + Where("letters_outgoing.status = ?", "rejected"). + Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *userID). + Count(&rejectedCount) + + db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). + Where("letters_outgoing.status = ?", "archived"). + Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *userID). + Count(&archivedCount) + + stats["total_pending"] = pendingCount + stats["total_approved"] = approvedCount + stats["total_rejected"] = rejectedCount + stats["total_archived"] = archivedCount + + // Calculate average processing time + var avgProcessingTime float64 + db.Table("letters_outgoing"). + Select("AVG(EXTRACT(EPOCH FROM (letters_outgoing.updated_at - letters_outgoing.created_at))/3600) as avg_hours"). + Where("letters_outgoing.status IN ('approved', 'sent', 'archived')"). + Where("letters_outgoing.deleted_at IS NULL"). + Scan(&avgProcessingTime) + + stats["avg_processing_time"] = avgProcessingTime + + // Calculate completion rate + var completedCount int64 + db.Table("letters_outgoing").Where("letters_outgoing.deleted_at IS NULL"). + Where("letters_outgoing.status IN ('sent', 'archived')"). + Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *userID). + Count(&completedCount) + + completionRate := float64(0) + if totalOutgoing > 0 { + completionRate = float64(completedCount) / float64(totalOutgoing) * 100 + } + stats["completion_rate"] = completionRate + } + + return stats, nil +} + +// GetStatusDistribution gets letter distribution by status +func (r *AnalyticsRepository) GetStatusDistribution(ctx context.Context, startDate, endDate time.Time, userID *uuid.UUID) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + query := ` + WITH combined_letters AS ( + SELECT + status, + 'incoming' as type, + COUNT(*) as count + FROM letters_incoming + WHERE deleted_at IS NULL + %s + GROUP BY status + + UNION ALL + + SELECT + lo.status, + 'outgoing' as type, + COUNT(DISTINCT lo.id) as count + FROM letters_outgoing lo + %s + WHERE lo.deleted_at IS NULL + %s + GROUP BY lo.status + ) + SELECT + status, + type, + count, + ROUND(count * 100.0 / SUM(count) OVER (PARTITION BY type), 2) as percentage + FROM combined_letters + ORDER BY type, count DESC + ` + + incomingDateFilter := "" + outgoingDateFilter := "" + if !startDate.IsZero() { + incomingDateFilter += fmt.Sprintf(" AND created_at >= '%s'", startDate.Format("2006-01-02")) + outgoingDateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + incomingDateFilter += fmt.Sprintf(" AND created_at <= '%s'", endDate.Format("2006-01-02")) + outgoingDateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02")) + } + + joinClause := "" + userFilter := "" + if userID != nil { + joinClause = "LEFT JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id" + userFilter = fmt.Sprintf(" AND lor.user_id = '%s'", userID.String()) + } + + query = fmt.Sprintf(query, incomingDateFilter, joinClause, outgoingDateFilter+userFilter) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + return nil, err + } + + return results, nil +} + +// GetPriorityDistribution gets letter distribution by priority +func (r *AnalyticsRepository) GetPriorityDistribution(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + query := ` + SELECT + p.id as priority_id, + p.name as priority_name, + p.level, + COUNT(lo.id) as count, + ROUND(COUNT(lo.id) * 100.0 / SUM(COUNT(lo.id)) OVER (), 2) as percentage, + AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_response_time + FROM priorities p + LEFT JOIN letters_outgoing lo ON lo.priority_id = p.id AND lo.deleted_at IS NULL + WHERE 1=1 + %s + GROUP BY p.id, p.name, p.level + ORDER BY p.level ASC + ` + + dateFilter := "" + if !startDate.IsZero() { + dateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + dateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02")) + } + + query = fmt.Sprintf(query, dateFilter) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + return nil, err + } + + return results, nil +} + +// GetDepartmentStats gets statistics per department using summary tables +func (r *AnalyticsRepository) GetDepartmentStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + // First try using summary table for better performance + query := ` + SELECT + d.id as department_id, + d.name as department_name, + d.code as department_code, + COALESCE(SUM(dls.incoming_count), 0) as incoming_count, + COALESCE(SUM(dls.outgoing_count), 0) as outgoing_count, + COALESCE(SUM(dls.pending_outgoing), 0) as pending_count, + COALESCE(AVG(dls.avg_response_hours), 0) as avg_response_time, + COALESCE(AVG(dls.completion_rate), 0) as completion_rate + FROM departments d + LEFT JOIN department_letter_summary dls ON dls.department_id = d.id + WHERE 1=1 + %s + GROUP BY d.id, d.name, d.code + ORDER BY (COALESCE(SUM(dls.incoming_count), 0) + COALESCE(SUM(dls.outgoing_count), 0)) DESC + ` + + dateFilter := "" + if !startDate.IsZero() { + dateFilter += fmt.Sprintf(" AND dls.summary_date >= '%s'", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + dateFilter += fmt.Sprintf(" AND dls.summary_date <= '%s'", endDate.Format("2006-01-02")) + } + + query = fmt.Sprintf(query, dateFilter) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + return nil, err + } + + // If no results from summary table, fall back to direct query + if len(results) == 0 { + fallbackQuery := ` + SELECT + d.id as department_id, + d.name as department_name, + d.code as department_code, + COUNT(DISTINCT lir.letter_id) as incoming_count, + COUNT(DISTINCT lor.letter_id) as outgoing_count, + COUNT(DISTINCT CASE WHEN lo.status = 'pending_approval' THEN lo.id END) as pending_count, + COALESCE(AVG(CASE + WHEN lo.status IN ('approved', 'sent', 'archived') + THEN EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600 + END), 0) as avg_response_time, + CASE + WHEN COUNT(DISTINCT lo.id) > 0 + THEN ROUND(COUNT(DISTINCT CASE WHEN lo.status IN ('sent', 'archived') THEN lo.id END) * 100.0 / COUNT(DISTINCT lo.id), 2) + ELSE 0 + END as completion_rate + FROM departments d + LEFT JOIN letter_incoming_recipients lir ON lir.recipient_department_id = d.id + LEFT JOIN letter_outgoing_recipients lor ON lor.department_id = d.id + LEFT JOIN letters_outgoing lo ON lo.id = lor.letter_id AND lo.deleted_at IS NULL + WHERE 1=1 + %s + GROUP BY d.id, d.name, d.code + ORDER BY (COUNT(DISTINCT lir.letter_id) + COUNT(DISTINCT lor.letter_id)) DESC + ` + + fallbackDateFilter := "" + if !startDate.IsZero() { + fallbackDateFilter += fmt.Sprintf(" AND (lo.created_at >= '%s' OR lo.created_at IS NULL)", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + fallbackDateFilter += fmt.Sprintf(" AND (lo.created_at <= '%s' OR lo.created_at IS NULL)", endDate.Format("2006-01-02")) + } + + fallbackQuery = fmt.Sprintf(fallbackQuery, fallbackDateFilter) + + if err := db.Raw(fallbackQuery).Scan(&results).Error; err != nil { + return nil, err + } + } + + return results, nil +} + +// GetMonthlyTrend gets monthly trend data using summary tables for better performance +func (r *AnalyticsRepository) GetMonthlyTrend(ctx context.Context, months int) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + // Use summary table for better performance + query := ` + WITH monthly_aggregated AS ( + SELECT + TO_CHAR(summary_date, 'Month') as month, + EXTRACT(YEAR FROM summary_date) as year, + EXTRACT(MONTH FROM summary_date) as month_num, + SUM(CASE WHEN letter_type = 'incoming' THEN total_count ELSE 0 END) as incoming_count, + SUM(CASE WHEN letter_type = 'outgoing' THEN total_count ELSE 0 END) as outgoing_count, + SUM(total_count) as total_count + FROM letter_summary + WHERE summary_date >= NOW() - INTERVAL '%d months' + GROUP BY TO_CHAR(summary_date, 'Month'), + EXTRACT(YEAR FROM summary_date), + EXTRACT(MONTH FROM summary_date) + ) + SELECT + month, + year, + incoming_count, + outgoing_count, + total_count, + LAG(total_count) OVER (ORDER BY year, month_num) as prev_total + FROM monthly_aggregated + ORDER BY year DESC, month_num DESC + LIMIT %d + ` + + query = fmt.Sprintf(query, months, months) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + // If summary table is empty, fall back to direct query + if len(results) == 0 { + fallbackQuery := ` + WITH monthly_data AS ( + SELECT + TO_CHAR(date_trunc('month', created_at), 'Month') as month, + EXTRACT(YEAR FROM created_at) as year, + EXTRACT(MONTH FROM created_at) as month_num, + COUNT(*) as incoming_count, + 0 as outgoing_count + FROM letters_incoming + WHERE deleted_at IS NULL + AND created_at >= NOW() - INTERVAL '%d months' + GROUP BY date_trunc('month', created_at), EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at) + + UNION ALL + + SELECT + TO_CHAR(date_trunc('month', created_at), 'Month') as month, + EXTRACT(YEAR FROM created_at) as year, + EXTRACT(MONTH FROM created_at) as month_num, + 0 as incoming_count, + COUNT(*) as outgoing_count + FROM letters_outgoing + WHERE deleted_at IS NULL + AND created_at >= NOW() - INTERVAL '%d months' + GROUP BY date_trunc('month', created_at), EXTRACT(YEAR FROM created_at), EXTRACT(MONTH FROM created_at) + ) + SELECT + month, + year, + SUM(incoming_count) as incoming_count, + SUM(outgoing_count) as outgoing_count, + SUM(incoming_count + outgoing_count) as total_count, + LAG(SUM(incoming_count + outgoing_count)) OVER (ORDER BY year, month_num) as prev_total + FROM monthly_data + GROUP BY month, year, month_num + ORDER BY year DESC, month_num DESC + LIMIT %d + ` + + fallbackQuery = fmt.Sprintf(fallbackQuery, months, months, months) + + if err := db.Raw(fallbackQuery).Scan(&results).Error; err != nil { + return nil, err + } + } + } + + // Calculate growth rate + for i := range results { + if results[i]["prev_total"] != nil { + prevVal, ok := results[i]["prev_total"].(float64) + if ok && prevVal > 0 { + current := getFloat64FromInterface(results[i]["total_count"]) + results[i]["growth_rate"] = ((current - prevVal) / prevVal) * 100 + } else { + results[i]["growth_rate"] = float64(0) + } + } else { + results[i]["growth_rate"] = float64(0) + } + delete(results[i], "prev_total") + } + + return results, nil +} + +// Helper function to safely convert interface{} to float64 +func getFloat64FromInterface(v interface{}) float64 { + if v == nil { + return 0 + } + switch val := v.(type) { + case float64: + return val + case int64: + return float64(val) + case int: + return float64(val) + default: + return 0 + } +} + +// GetTopSenders gets top letter senders +func (r *AnalyticsRepository) GetTopSenders(ctx context.Context, limit int, startDate, endDate time.Time) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + query := ` + SELECT + u.id as user_id, + u.name as user_name, + u.email as user_email, + COALESCE(d.name, 'No Department') as department, + COUNT(lo.id) as letter_count, + AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_response_time + FROM users u + LEFT JOIN letters_outgoing lo ON lo.created_by = u.id + LEFT JOIN user_department ud ON ud.user_id = u.id + LEFT JOIN departments d ON d.id = ud.department_id + WHERE lo.deleted_at IS NULL + %s + GROUP BY u.id, u.name, u.email, d.name + ORDER BY letter_count DESC + LIMIT %d + ` + + dateFilter := "" + if !startDate.IsZero() { + dateFilter += fmt.Sprintf(" AND lo.created_at >= '%s'", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + dateFilter += fmt.Sprintf(" AND lo.created_at <= '%s'", endDate.Format("2006-01-02")) + } + + query = fmt.Sprintf(query, dateFilter, limit) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + return nil, err + } + + return results, nil +} + +// GetInstitutionStats gets statistics per institution using summary tables +func (r *AnalyticsRepository) GetInstitutionStats(ctx context.Context, startDate, endDate time.Time) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + // Use summary table for better performance + query := ` + SELECT + i.id as institution_id, + i.name as institution_name, + i.type as institution_type, + COALESCE(SUM(ils.incoming_sent), 0) as incoming_count, + COALESCE(SUM(ils.outgoing_received), 0) as outgoing_count, + COALESCE(SUM(ils.total_correspondence), 0) as total_count, + MAX(ils.last_activity_at) as last_activity + FROM institutions i + LEFT JOIN institution_letter_summary ils ON ils.institution_id = i.id + WHERE 1=1 + %s + GROUP BY i.id, i.name, i.type + HAVING COALESCE(SUM(ils.total_correspondence), 0) > 0 + ORDER BY total_count DESC + ` + + dateFilter := "" + if !startDate.IsZero() { + dateFilter += fmt.Sprintf(" AND ils.summary_date >= '%s'", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + dateFilter += fmt.Sprintf(" AND ils.summary_date <= '%s'", endDate.Format("2006-01-02")) + } + + query = fmt.Sprintf(query, dateFilter) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + return nil, err + } + + return results, nil +} + +// GetApprovalMetrics gets approval-related metrics using summary tables +func (r *AnalyticsRepository) GetApprovalMetrics(ctx context.Context, startDate, endDate time.Time) (map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + metrics := make(map[string]interface{}) + + // Use summary table for better performance + query := db.Table("approval_sla_summary") + + if !startDate.IsZero() { + query = query.Where("summary_date >= ?", startDate) + } + if !endDate.IsZero() { + query = query.Where("summary_date <= ?", endDate) + } + + var result struct { + TotalApprovals int64 `gorm:"column:total_approvals"` + ApprovedCount int64 `gorm:"column:approved_count"` + RejectedCount int64 `gorm:"column:rejected_count"` + PendingCount int64 `gorm:"column:pending_count"` + AvgApprovalHours float64 `gorm:"column:avg_approval_hours"` + AvgApprovalSteps float64 `gorm:"column:avg_approval_steps"` + SLACompliance float64 `gorm:"column:sla_compliance"` + WithinSLA int64 `gorm:"column:within_sla"` + ExceededSLA int64 `gorm:"column:exceeded_sla"` + } + + query.Select(` + COALESCE(SUM(total_approvals), 0) as total_approvals, + COALESCE(SUM(approved_count), 0) as approved_count, + COALESCE(SUM(rejected_count), 0) as rejected_count, + COALESCE(SUM(pending_count), 0) as pending_count, + COALESCE(AVG(avg_approval_hours), 0) as avg_approval_hours, + COALESCE(AVG(avg_approval_steps), 0) as avg_approval_steps, + COALESCE(AVG(sla_compliance_rate), 0) as sla_compliance, + COALESCE(SUM(within_sla_count), 0) as within_sla, + COALESCE(SUM(exceeded_sla_count), 0) as exceeded_sla + `).Scan(&result) + + metrics["total_submitted"] = result.TotalApprovals + metrics["total_approved"] = result.ApprovedCount + metrics["total_rejected"] = result.RejectedCount + metrics["total_pending"] = result.PendingCount + metrics["avg_approval_time"] = result.AvgApprovalHours + metrics["avg_approval_steps"] = result.AvgApprovalSteps + metrics["sla_compliance_rate"] = result.SLACompliance + metrics["within_sla_count"] = result.WithinSLA + metrics["exceeded_sla_count"] = result.ExceededSLA + + // Calculate rates + if result.TotalApprovals > 0 { + metrics["approval_rate"] = float64(result.ApprovedCount) / float64(result.TotalApprovals) * 100 + metrics["rejection_rate"] = float64(result.RejectedCount) / float64(result.TotalApprovals) * 100 + } else { + metrics["approval_rate"] = float64(0) + metrics["rejection_rate"] = float64(0) + } + + return metrics, nil +} + +// GetDailyActivity gets daily activity data +func (r *AnalyticsRepository) GetDailyActivity(ctx context.Context, days int) ([]map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + var results []map[string]interface{} + + query := ` + WITH daily_data AS ( + SELECT + DATE(created_at) as date, + TO_CHAR(created_at, 'Day') as day_of_week, + COUNT(CASE WHEN type = 'incoming' THEN 1 END) as incoming_count, + COUNT(CASE WHEN type = 'outgoing' THEN 1 END) as outgoing_count, + 0 as approved_count, + 0 as rejected_count + FROM ( + SELECT created_at, 'incoming' as type FROM letters_incoming WHERE deleted_at IS NULL + UNION ALL + SELECT created_at, 'outgoing' as type FROM letters_outgoing WHERE deleted_at IS NULL + ) combined + WHERE created_at >= CURRENT_DATE - INTERVAL '%d days' + GROUP BY DATE(created_at), TO_CHAR(created_at, 'Day') + ), + approval_data AS ( + SELECT + DATE(acted_at) as date, + COUNT(CASE WHEN status = 'approved' THEN 1 END) as approved_count, + COUNT(CASE WHEN status = 'rejected' THEN 1 END) as rejected_count + FROM letter_outgoing_approvals + WHERE acted_at IS NOT NULL + AND acted_at >= CURRENT_DATE - INTERVAL '%d days' + GROUP BY DATE(acted_at) + ) + SELECT + d.date, + d.day_of_week, + d.incoming_count, + d.outgoing_count, + COALESCE(a.approved_count, 0) as approved_count, + COALESCE(a.rejected_count, 0) as rejected_count + FROM daily_data d + LEFT JOIN approval_data a ON a.date = d.date + ORDER BY d.date DESC + LIMIT %d + ` + + query = fmt.Sprintf(query, days, days, days) + + if err := db.Raw(query).Scan(&results).Error; err != nil { + return nil, err + } + + return results, nil +} + +// GetResponseTimeStats gets response time statistics +func (r *AnalyticsRepository) GetResponseTimeStats(ctx context.Context, startDate, endDate time.Time) (map[string]interface{}, error) { + db := DBFromContext(ctx, r.db) + stats := make(map[string]interface{}) + + query := ` + WITH response_times AS ( + SELECT + EXTRACT(EPOCH FROM (updated_at - created_at))/3600 as response_time_hours + FROM letters_outgoing + WHERE status IN ('approved', 'sent', 'archived') + AND deleted_at IS NULL + %s + ) + SELECT + MIN(response_time_hours) as min_response_time, + MAX(response_time_hours) as max_response_time, + AVG(response_time_hours) as avg_response_time, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY response_time_hours) as median_response_time, + PERCENTILE_CONT(0.95) WITHIN GROUP (ORDER BY response_time_hours) as p95_response_time, + PERCENTILE_CONT(0.99) WITHIN GROUP (ORDER BY response_time_hours) as p99_response_time + FROM response_times + ` + + dateFilter := "" + if !startDate.IsZero() { + dateFilter += fmt.Sprintf(" AND created_at >= '%s'", startDate.Format("2006-01-02")) + } + if !endDate.IsZero() { + dateFilter += fmt.Sprintf(" AND created_at <= '%s'", endDate.Format("2006-01-02")) + } + + query = fmt.Sprintf(query, dateFilter) + + if err := db.Raw(query).Scan(&stats).Error; err != nil { + return nil, err + } + + return stats, nil +} diff --git a/internal/repository/letter_outgoing_repository.go b/internal/repository/letter_outgoing_repository.go index 6309668..54c2741 100644 --- a/internal/repository/letter_outgoing_repository.go +++ b/internal/repository/letter_outgoing_repository.go @@ -54,12 +54,12 @@ func (r *LetterOutgoingRepository) SoftDelete(ctx context.Context, id uuid.UUID) func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid.UUID, relations []string) (*entities.LetterOutgoing, error) { db := DBFromContext(ctx, r.db) query := db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id) - + // Preload all specified relations for _, relation := range relations { query = query.Preload(relation) } - + var e entities.LetterOutgoing if err := query.First(&e).Error; err != nil { if err == gorm.ErrRecordNotFound { @@ -67,23 +67,28 @@ func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid } return nil, err } - + return &e, nil } type ListOutgoingLettersFilter struct { - Status *string - Query *string - CreatedBy *uuid.UUID + Status *string + Query *string + CreatedBy *uuid.UUID + DepartmentID *uuid.UUID + UserID *uuid.UUID ReceiverInstitutionID *uuid.UUID - FromDate *time.Time - ToDate *time.Time + FromDate *time.Time + ToDate *time.Time + PriorityID *uuid.UUID + SortBy *string + SortOrder *string } func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) { db := DBFromContext(ctx, r.db) query := db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("deleted_at IS NULL") - + if filter.Status != nil { query = query.Where("status = ?", *filter.Status) } @@ -94,31 +99,68 @@ func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoing if filter.CreatedBy != nil { query = query.Where("created_by = ?", *filter.CreatedBy) } + // Filter by UserID through recipients + if filter.UserID != nil { + query = query.Joins("LEFT JOIN letter_outgoing_recipients ON letter_outgoing_recipients.letter_id = letters_outgoing.id"). + Where("letter_outgoing_recipients.user_id = ?", *filter.UserID). + Distinct() + } if filter.ReceiverInstitutionID != nil { query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID) } + if filter.PriorityID != nil { + query = query.Where("priority_id = ?", *filter.PriorityID) + } if filter.FromDate != nil { query = query.Where("issue_date >= ?", *filter.FromDate) } if filter.ToDate != nil { query = query.Where("issue_date <= ?", *filter.ToDate) } - + var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } - + + orderBy := "created_at DESC" // default + if filter.SortBy != nil { + sortField := *filter.SortBy + sortDirection := "ASC" + if filter.SortOrder != nil && (*filter.SortOrder == "desc" || *filter.SortOrder == "DESC") { + sortDirection = "DESC" + } + + switch sortField { + case "letter_number": + orderBy = "letter_number " + sortDirection + case "subject": + orderBy = "subject " + sortDirection + case "issue_date": + orderBy = "issue_date " + sortDirection + case "status": + orderBy = "status " + sortDirection + case "created_at": + orderBy = "created_at " + sortDirection + default: + orderBy = "created_at " + sortDirection + } + } + var list []entities.LetterOutgoing if err := query. Preload("Priority"). Preload("ReceiverInstitution"). Preload("Creator"). + Preload("Creator.Profile"). + Preload("Creator.Departments"). Preload("Recipients"). + Preload("Recipients.User"). + Preload("Recipients.Department"). Preload("Attachments"). Preload("Approvals.Step"). Preload("Approvals.Approver"). - Order("created_at DESC"). + Order(orderBy). Limit(limit). Offset(offset). Find(&list).Error; err != nil { @@ -301,4 +343,4 @@ func (r *LetterOutgoingActivityLogRepository) ListByLetter(ctx context.Context, return nil, err } return list, nil -} \ No newline at end of file +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 5e0058f..5aa2b14 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -91,6 +91,7 @@ type LetterOutgoingHandler interface { SendOutgoingLetter(c *gin.Context) ArchiveOutgoingLetter(c *gin.Context) GetLetterApprovalInfo(c *gin.Context) + GetLetterApprovals(c *gin.Context) AddRecipients(c *gin.Context) UpdateRecipient(c *gin.Context) @@ -104,6 +105,7 @@ type LetterOutgoingHandler interface { DeleteDiscussion(c *gin.Context) GetApprovalDiscussions(c *gin.Context) + GetApprovalTimeline(c *gin.Context) } type AdminApprovalFlowHandler interface { @@ -134,3 +136,13 @@ type OnlyOfficeHandler interface { UnlockDocument(c *gin.Context) GetDocumentSession(c *gin.Context) } + +type AnalyticsHandler interface { + GetDashboard(c *gin.Context) + GetLetterVolume(c *gin.Context) + GetStatusDistribution(c *gin.Context) + GetPriorityDistribution(c *gin.Context) + GetDepartmentStats(c *gin.Context) + GetMonthlyTrend(c *gin.Context) + GetApprovalMetrics(c *gin.Context) +} diff --git a/internal/router/router.go b/internal/router/router.go index 5b8241b..04d4f74 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -21,6 +21,7 @@ type Router struct { adminApprovalFlowHandler AdminApprovalFlowHandler dispRouteHandler DispositionRouteHandler onlyOfficeHandler OnlyOfficeHandler + analyticsHandler AnalyticsHandler } func NewRouter( @@ -37,6 +38,7 @@ func NewRouter( adminApprovalFlowHandler AdminApprovalFlowHandler, dispRouteHandler DispositionRouteHandler, onlyOfficeHandler OnlyOfficeHandler, + analyticsHandler AnalyticsHandler, ) *Router { return &Router{ config: cfg, @@ -52,6 +54,7 @@ func NewRouter( adminApprovalFlowHandler: adminApprovalFlowHandler, dispRouteHandler: dispRouteHandler, onlyOfficeHandler: onlyOfficeHandler, + analyticsHandler: analyticsHandler, } } @@ -170,7 +173,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.POST("/outgoing/:id/reject", r.letterOutgoingHandler.RejectOutgoingLetter) lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter) lettersch.POST("/outgoing/:id/archive", r.letterOutgoingHandler.ArchiveOutgoingLetter) - lettersch.GET("/outgoing/:id/approval-info", r.letterOutgoingHandler.GetLetterApprovalInfo) + lettersch.GET("/outgoing/:id/cta", r.letterOutgoingHandler.GetLetterApprovalInfo) + lettersch.GET("/outgoing/:id/approvals", r.letterOutgoingHandler.GetLetterApprovals) lettersch.POST("/outgoing/:id/recipients", r.letterOutgoingHandler.AddRecipients) lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient) @@ -182,9 +186,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.POST("/outgoing/:id/discussions", r.letterOutgoingHandler.CreateDiscussion) lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion) lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion) - - // Get approvals and discussions for outgoing letter + lettersch.GET("/outgoing/:id/approval-discussions", r.letterOutgoingHandler.GetApprovalDiscussions) + lettersch.GET("/outgoing/:id/timeline", r.letterOutgoingHandler.GetApprovalTimeline) lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions) lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter) @@ -225,7 +229,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { { // Callback endpoint - no auth required (OnlyOffice will call this) onlyoffice.POST("/callback/:key", r.onlyOfficeHandler.ProcessCallback) - + // Protected endpoints onlyofficeAuth := onlyoffice.Group("") onlyofficeAuth.Use(r.authMiddleware.RequireAuth()) @@ -237,5 +241,18 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { onlyofficeAuth.GET("/session/:key", r.onlyOfficeHandler.GetDocumentSession) } } + + // Analytics routes + analytics := v1.Group("/analytics") + analytics.Use(r.authMiddleware.RequireAuth()) + { + analytics.GET("/dashboard", r.analyticsHandler.GetDashboard) + analytics.GET("/volume", r.analyticsHandler.GetLetterVolume) + analytics.GET("/status-distribution", r.analyticsHandler.GetStatusDistribution) + analytics.GET("/priority-distribution", r.analyticsHandler.GetPriorityDistribution) + analytics.GET("/department-stats", r.analyticsHandler.GetDepartmentStats) + analytics.GET("/monthly-trend", r.analyticsHandler.GetMonthlyTrend) + analytics.GET("/approval-metrics", r.analyticsHandler.GetApprovalMetrics) + } } } diff --git a/internal/service/analytics_service.go b/internal/service/analytics_service.go new file mode 100644 index 0000000..6f7cebf --- /dev/null +++ b/internal/service/analytics_service.go @@ -0,0 +1,413 @@ +package service + +import ( + "context" + "time" + + "eslogad-be/internal/appcontext" + "eslogad-be/internal/contract" + "eslogad-be/internal/repository" + + "github.com/google/uuid" +) + +type AnalyticsService interface { + GetDashboard(ctx context.Context, req *contract.AnalyticsDashboardRequest) (*contract.AnalyticsDashboardResponse, error) + GetLetterVolume(ctx context.Context) (*contract.LetterVolumeByTypeResponse, error) +} + +type AnalyticsServiceImpl struct { + analyticsRepo *repository.AnalyticsRepository +} + +func NewAnalyticsService(analyticsRepo *repository.AnalyticsRepository) *AnalyticsServiceImpl { + return &AnalyticsServiceImpl{ + analyticsRepo: analyticsRepo, + } +} + +func (s *AnalyticsServiceImpl) GetDashboard(ctx context.Context, req *contract.AnalyticsDashboardRequest) (*contract.AnalyticsDashboardResponse, error) { + // Parse dates + var startDate, endDate time.Time + if req.StartDate != "" { + if date, err := time.Parse("2006-01-02", req.StartDate); err == nil { + startDate = date + } + } else { + // Default to last 30 days + startDate = time.Now().AddDate(0, 0, -30) + } + + if req.EndDate != "" { + if date, err := time.Parse("2006-01-02", req.EndDate); err == nil { + endDate = date.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + } + } else { + endDate = time.Now() + } + + // Apply user context filters if not admin + var userID *uuid.UUID + + appCtx := appcontext.FromGinContext(ctx) + if appCtx != nil && appCtx.UserRole != "admin" && appCtx.UserRole != "superadmin" { + userID = &appCtx.UserID + } + + response := &contract.AnalyticsDashboardResponse{} + + // Get summary statistics - don't filter by department for overall stats + summaryData, err := s.analyticsRepo.GetLetterSummaryStats(ctx, startDate, endDate, userID, nil) + if err != nil { + return nil, err + } + response.Summary = s.mapSummaryStats(summaryData) + + // Calculate growth metrics + response.Summary.WeekOverWeekGrowth = s.calculateWeekOverWeekGrowth(ctx) + response.Summary.MonthOverMonthGrowth = s.calculateMonthOverMonthGrowth(ctx) + + // Get priority distribution + priorityData, err := s.analyticsRepo.GetPriorityDistribution(ctx, startDate, endDate) + if err != nil { + return nil, err + } + response.PriorityDistribution = s.mapPriorityDistribution(priorityData) + + // Get department statistics + deptData, err := s.analyticsRepo.GetDepartmentStats(ctx, startDate, endDate) + if err != nil { + return nil, err + } + response.DepartmentStats = s.mapDepartmentStats(deptData) + + // Get monthly trend (last 12 months) + monthlyData, err := s.analyticsRepo.GetMonthlyTrend(ctx, 12) + if err != nil { + return nil, err + } + response.MonthlyTrend = s.mapMonthlyTrend(monthlyData) + + // Get simplified department stats (departments_stats) + response.DepartmentsStats = s.getSimpleDepartmentStats(ctx, startDate, endDate) + + // Get institution statistics + instData, err := s.analyticsRepo.GetInstitutionStats(ctx, startDate, endDate) + if err != nil { + return nil, err + } + response.InstitutionStats = s.mapInstitutionStats(instData) + + // Get daily activity (last 7 days) + dailyData, err := s.analyticsRepo.GetDailyActivity(ctx, 7) + if err != nil { + return nil, err + } + response.DailyActivity = s.mapDailyActivity(dailyData) + + return response, nil +} + +func (s *AnalyticsServiceImpl) GetLetterVolume(ctx context.Context) (*contract.LetterVolumeByTypeResponse, error) { + // This would be implemented with specific queries for volume metrics + // For now, returning a placeholder + return &contract.LetterVolumeByTypeResponse{ + Incoming: contract.IncomingLetterVolume{ + Today: 0, + ThisWeek: 0, + ThisMonth: 0, + ThisYear: 0, + Total: 0, + }, + Outgoing: contract.OutgoingLetterVolume{ + Today: 0, + ThisWeek: 0, + ThisMonth: 0, + ThisYear: 0, + Total: 0, + }, + }, nil +} + +// Helper functions to map repository data to contract types + +func (s *AnalyticsServiceImpl) mapSummaryStats(data map[string]interface{}) contract.LetterSummaryStats { + return contract.LetterSummaryStats{ + TotalIncoming: getInt64Value(data["total_incoming"]), + TotalOutgoing: getInt64Value(data["total_outgoing"]), + } +} + +// calculateWeekOverWeekGrowth calculates the week over week growth rate +func (s *AnalyticsServiceImpl) calculateWeekOverWeekGrowth(ctx context.Context) float64 { + // Get this week's data + thisWeekStart := time.Now().AddDate(0, 0, -int(time.Now().Weekday())) + thisWeekEnd := time.Now() + + // Get last week's data + lastWeekStart := thisWeekStart.AddDate(0, 0, -7) + lastWeekEnd := thisWeekStart.AddDate(0, 0, -1) + + thisWeekData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, thisWeekStart, thisWeekEnd, nil, nil) + lastWeekData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, lastWeekStart, lastWeekEnd, nil, nil) + + thisWeekTotal := getInt64Value(thisWeekData["total_incoming"]) + getInt64Value(thisWeekData["total_outgoing"]) + lastWeekTotal := getInt64Value(lastWeekData["total_incoming"]) + getInt64Value(lastWeekData["total_outgoing"]) + + if lastWeekTotal > 0 { + return float64((thisWeekTotal - lastWeekTotal) * 100 / lastWeekTotal) + } + return 0 +} + +// calculateMonthOverMonthGrowth calculates the month over month growth rate +func (s *AnalyticsServiceImpl) calculateMonthOverMonthGrowth(ctx context.Context) float64 { + // Get this month's data + now := time.Now() + thisMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()) + thisMonthEnd := now + + // Get last month's data + lastMonthStart := thisMonthStart.AddDate(0, -1, 0) + lastMonthEnd := thisMonthStart.AddDate(0, 0, -1) + + thisMonthData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, thisMonthStart, thisMonthEnd, nil, nil) + lastMonthData, _ := s.analyticsRepo.GetLetterSummaryStats(ctx, lastMonthStart, lastMonthEnd, nil, nil) + + thisMonthTotal := getInt64Value(thisMonthData["total_incoming"]) + getInt64Value(thisMonthData["total_outgoing"]) + lastMonthTotal := getInt64Value(lastMonthData["total_incoming"]) + getInt64Value(lastMonthData["total_outgoing"]) + + if lastMonthTotal > 0 { + return float64((thisMonthTotal - lastMonthTotal) * 100 / lastMonthTotal) + } + return 0 +} + +// getSimpleDepartmentStats gets simplified department statistics +func (s *AnalyticsServiceImpl) getSimpleDepartmentStats(ctx context.Context, startDate, endDate time.Time) []contract.SimpleDepartmentStats { + // Get department stats with letter counts + deptData, err := s.analyticsRepo.GetDepartmentStats(ctx, startDate, endDate) + if err != nil { + return []contract.SimpleDepartmentStats{} + } + + result := make([]contract.SimpleDepartmentStats, 0, len(deptData)) + for _, item := range deptData { + deptIDStr := getStringValue(item["department_id"]) + deptID, err := uuid.Parse(deptIDStr) + if err != nil { + continue + } + + // Calculate total letter count (incoming + outgoing) + letterCount := getInt64Value(item["incoming_count"]) + getInt64Value(item["outgoing_count"]) + + result = append(result, contract.SimpleDepartmentStats{ + DepartmentID: deptID, + Department: getStringValue(item["department_name"]), + LetterCount: letterCount, + }) + } + + return result +} + +func (s *AnalyticsServiceImpl) mapStatusDistribution(data []map[string]interface{}) []contract.StatusDistribution { + result := make([]contract.StatusDistribution, 0, len(data)) + for _, item := range data { + result = append(result, contract.StatusDistribution{ + Status: getStringValue(item["status"]), + Count: getInt64Value(item["count"]), + Percentage: getFloat64Value(item["percentage"]), + Type: getStringValue(item["type"]), + }) + } + return result +} + +func (s *AnalyticsServiceImpl) mapPriorityDistribution(data []map[string]interface{}) []contract.PriorityDistribution { + result := make([]contract.PriorityDistribution, 0, len(data)) + for _, item := range data { + result = append(result, contract.PriorityDistribution{ + PriorityID: getStringValue(item["priority_id"]), + PriorityName: getStringValue(item["priority_name"]), + Level: getIntValue(item["level"]), + Count: getInt64Value(item["count"]), + Percentage: getFloat64Value(item["percentage"]), + AvgResponseTime: getFloat64Value(item["avg_response_time"]), + }) + } + return result +} + +func (s *AnalyticsServiceImpl) mapDepartmentStats(data []map[string]interface{}) []contract.DepartmentStats { + result := make([]contract.DepartmentStats, 0, len(data)) + for _, item := range data { + if deptID, err := uuid.Parse(getStringValue(item["department_id"])); err == nil { + result = append(result, contract.DepartmentStats{ + DepartmentID: deptID, + DepartmentName: getStringValue(item["department_name"]), + DepartmentCode: getStringValue(item["department_code"]), + IncomingCount: getInt64Value(item["incoming_count"]), + OutgoingCount: getInt64Value(item["outgoing_count"]), + PendingCount: getInt64Value(item["pending_count"]), + AvgResponseTime: getFloat64Value(item["avg_response_time"]), + CompletionRate: getFloat64Value(item["completion_rate"]), + }) + } + } + return result +} + +func (s *AnalyticsServiceImpl) mapMonthlyTrend(data []map[string]interface{}) []contract.MonthlyTrend { + result := make([]contract.MonthlyTrend, 0, len(data)) + for _, item := range data { + result = append(result, contract.MonthlyTrend{ + Month: getStringValue(item["month"]), + Year: getIntValue(item["year"]), + IncomingCount: getInt64Value(item["incoming_count"]), + OutgoingCount: getInt64Value(item["outgoing_count"]), + TotalCount: getInt64Value(item["total_count"]), + GrowthRate: getFloat64Value(item["growth_rate"]), + }) + } + return result +} + +func (s *AnalyticsServiceImpl) mapTopUsers(data []map[string]interface{}) []contract.TopUserStats { + result := make([]contract.TopUserStats, 0, len(data)) + for _, item := range data { + if userID, err := uuid.Parse(getStringValue(item["user_id"])); err == nil { + result = append(result, contract.TopUserStats{ + UserID: userID, + UserName: getStringValue(item["user_name"]), + UserEmail: getStringValue(item["user_email"]), + Department: getStringValue(item["department"]), + LetterCount: getInt64Value(item["letter_count"]), + AvgResponseTime: getFloat64Value(item["avg_response_time"]), + }) + } + } + return result +} + +func (s *AnalyticsServiceImpl) mapInstitutionStats(data []map[string]interface{}) []contract.InstitutionStats { + result := make([]contract.InstitutionStats, 0, len(data)) + for _, item := range data { + if instID, err := uuid.Parse(getStringValue(item["institution_id"])); err == nil { + stat := contract.InstitutionStats{ + InstitutionID: instID, + InstitutionName: getStringValue(item["institution_name"]), + InstitutionType: getStringValue(item["institution_type"]), + IncomingCount: getInt64Value(item["incoming_count"]), + OutgoingCount: getInt64Value(item["outgoing_count"]), + TotalCount: getInt64Value(item["total_count"]), + } + + if lastActivity, ok := item["last_activity"].(time.Time); ok { + stat.LastActivity = lastActivity + } + + result = append(result, stat) + } + } + return result +} + +func (s *AnalyticsServiceImpl) mapApprovalMetrics(data map[string]interface{}) contract.ApprovalMetrics { + return contract.ApprovalMetrics{ + TotalSubmitted: getInt64Value(data["total_submitted"]), + TotalApproved: getInt64Value(data["total_approved"]), + TotalRejected: getInt64Value(data["total_rejected"]), + TotalPending: getInt64Value(data["total_pending"]), + ApprovalRate: getFloat64Value(data["approval_rate"]), + RejectionRate: getFloat64Value(data["rejection_rate"]), + AvgApprovalTime: getFloat64Value(data["avg_approval_time"]), + AvgApprovalSteps: getFloat64Value(data["avg_approval_steps"]), + } +} + +func (s *AnalyticsServiceImpl) mapResponseTimeStats(data map[string]interface{}) contract.ResponseTimeStats { + return contract.ResponseTimeStats{ + MinResponseTime: getFloat64Value(data["min_response_time"]), + MaxResponseTime: getFloat64Value(data["max_response_time"]), + AvgResponseTime: getFloat64Value(data["avg_response_time"]), + MedianResponseTime: getFloat64Value(data["median_response_time"]), + P95ResponseTime: getFloat64Value(data["p95_response_time"]), + P99ResponseTime: getFloat64Value(data["p99_response_time"]), + } +} + +func (s *AnalyticsServiceImpl) mapDailyActivity(data []map[string]interface{}) []contract.DailyActivity { + result := make([]contract.DailyActivity, 0, len(data)) + for _, item := range data { + activity := contract.DailyActivity{ + Date: "", // Leave date empty as per requirement + DayOfWeek: getStringValue(item["day_of_week"]), + IncomingCount: getInt64Value(item["incoming_count"]), + OutgoingCount: getInt64Value(item["outgoing_count"]), + } + result = append(result, activity) + } + return result +} + +// Helper functions to safely extract values from interface{} + +func getStringValue(v interface{}) string { + if v == nil { + return "" + } + if str, ok := v.(string); ok { + return str + } + return "" +} + +func getInt64Value(v interface{}) int64 { + if v == nil { + return 0 + } + switch val := v.(type) { + case int64: + return val + case float64: + return int64(val) + case int: + return int64(val) + default: + return 0 + } +} + +func getIntValue(v interface{}) int { + if v == nil { + return 0 + } + switch val := v.(type) { + case int: + return val + case int64: + return int(val) + case float64: + return int(val) + default: + return 0 + } +} + +func getFloat64Value(v interface{}) float64 { + if v == nil { + return 0 + } + switch val := v.(type) { + case float64: + return val + case int64: + return float64(val) + case int: + return float64(val) + default: + return 0 + } +} \ No newline at end of file diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index ff6e820..0453734 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -3,6 +3,8 @@ package service import ( "context" "fmt" + "sort" + "time" "eslogad-be/internal/appcontext" "eslogad-be/internal/contract" @@ -39,9 +41,15 @@ type LetterOutgoingService interface { DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) - + + // GetLetterApprovals returns all approvals and their status for a letter + GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) + // GetApprovalDiscussions returns both approvals and discussions for an outgoing letter GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) + + // GetApprovalTimeline returns a chronological timeline of all events for a letter + GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) } type LetterOutgoingServiceImpl struct { @@ -106,16 +114,48 @@ func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, i } func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) { - filter := repository.ListOutgoingLettersFilter{ - Status: req.Status, - Query: req.Query, - CreatedBy: req.CreatedBy, - ReceiverInstitutionID: req.ReceiverInstitutionID, - FromDate: req.FromDate, - ToDate: req.ToDate, + offset := (req.Page - 1) * req.Limit + if offset < 0 { + offset = 0 } - letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, req.Offset) + filter := repository.ListOutgoingLettersFilter{ + CreatedBy: req.CreatedBy, + DepartmentID: req.DepartmentID, + ReceiverInstitutionID: req.ReceiverInstitutionID, + PriorityID: req.PriorityID, + } + + if req.Status != "" { + filter.Status = &req.Status + } + if req.Query != "" { + filter.Query = &req.Query + } + if req.SortBy != "" { + filter.SortBy = &req.SortBy + } + if req.SortOrder != "" { + filter.SortOrder = &req.SortOrder + } + + if req.FromDate != "" { + if date, err := time.Parse("2006-01-02", req.FromDate); err == nil { + filter.FromDate = &date + } + } + + if req.ToDate != "" { + if date, err := time.Parse("2006-01-02", req.ToDate); err == nil { + endOfDay := date.Add(23*time.Hour + 59*time.Minute + 59*time.Second) + filter.ToDate = &endOfDay + } + } + + // Apply access control overrides based on user context + ApplyLetterFilterOverrides(ctx, &filter) + + letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, offset) if err != nil { return nil, err } @@ -523,67 +563,104 @@ func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discus func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) { userID := getUserIDFromContext(ctx) - _, err := s.processor.GetOutgoingLetterByID(ctx, letterID) + // Verify letter exists + letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) if err != nil { return nil, err } + // Get all approvals for this letter approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) if err != nil { return nil, err } - var currentApproval *entities.LetterOutgoingApproval + // Group approvals by step order to understand the workflow + approvalsByStep := make(map[int][]entities.LetterOutgoingApproval) + for _, approval := range approvals { + approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval) + } + + // Find the current active step (lowest step order with pending approvals) + var currentStepOrder int = -1 + var userApproval *entities.LetterOutgoingApproval var isApproverOnActiveStep bool var canApprove bool - for _, approval := range approvals { - if approval.Status == entities.ApprovalStatusPending { - currentApproval = &approval - break + // Find the minimum step order that has pending approvals + for stepOrder, stepApprovals := range approvalsByStep { + hasPending := false + for _, approval := range stepApprovals { + if approval.Status == entities.ApprovalStatusPending { + hasPending = true + // Check if this user is an approver for this pending approval + if approval.ApproverID != nil && *approval.ApproverID == userID { + if currentStepOrder == -1 || stepOrder < currentStepOrder { + currentStepOrder = stepOrder + userApproval = &approval + isApproverOnActiveStep = true + } + } + } + } + // Track the lowest pending step + if hasPending && (currentStepOrder == -1 || stepOrder < currentStepOrder) { + currentStepOrder = stepOrder } } - // Check if current user is the approver for the active step - if currentApproval != nil && currentApproval.Step != nil { - step := currentApproval.Step - - // Check if user is the specific approver - if step.ApproverUserID != nil && *step.ApproverUserID == userID { - isApproverOnActiveStep = true - canApprove = true - } - // Note: Role-based approval check would require additional implementation - // For now, we only support user-specific approvers + // User can approve if they have a pending approval on the current active step + if isApproverOnActiveStep && userApproval != nil && userApproval.Status == entities.ApprovalStatusPending { + canApprove = true } - // Build actions based on current status + // Build actions based on eligibility var actions []contract.ApprovalAction - if canApprove && currentApproval != nil { + if canApprove && userApproval != nil { actions = []contract.ApprovalAction{ { Type: "APPROVE", - Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID), + Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/approve", letterID), Method: "POST", }, { Type: "REJECT", - Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID), + Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/reject", letterID), Method: "POST", }, } } - // Determine decision status + // Determine overall decision status decisionStatus := "PENDING" - if currentApproval == nil { + + // Check if all required approvals are completed + allCompleted := true + hasRejection := false + for _, approval := range approvals { + // Check required approvals only + if approval.IsRequired { + if approval.Status == entities.ApprovalStatusPending || approval.Status == entities.ApprovalStatusNotStarted { + allCompleted = false + } + if approval.Status == entities.ApprovalStatusRejected { + hasRejection = true + } + } + } + + if hasRejection { + decisionStatus = "REJECTED" + } else if allCompleted { decisionStatus = "COMPLETED" + } else if letter.Status == entities.LetterOutgoingStatusPendingApproval { + decisionStatus = "PENDING" } // Determine notes visibility - notesVisibility := "FULL" - if !isApproverOnActiveStep { - notesVisibility = "READONLY" + notesVisibility := "READONLY" + if canApprove { + notesVisibility = "FULL" } info := &contract.LetterApprovalInfoResponse{ @@ -597,6 +674,127 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l return info, nil } +func (s *LetterOutgoingServiceImpl) GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) { + // Get letter details + letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) + if err != nil { + return nil, err + } + + // Get all approvals for this letter + approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID) + if err != nil { + return nil, err + } + + // Sort approvals by step order and parallel group + sort.Slice(approvals, func(i, j int) bool { + if approvals[i].StepOrder != approvals[j].StepOrder { + return approvals[i].StepOrder < approvals[j].StepOrder + } + return approvals[i].ParallelGroup < approvals[j].ParallelGroup + }) + + // Transform to response format + approvalResponses := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(approvals)) + totalSteps := 0 + currentStep := 0 + stepOrdersSeen := make(map[int]bool) + + for _, approval := range approvals { + // Count unique step orders for total steps + if !stepOrdersSeen[approval.StepOrder] { + stepOrdersSeen[approval.StepOrder] = true + totalSteps++ + } + + // Determine current step (lowest step with pending/not_started status) + if approval.Status == entities.ApprovalStatusPending && (currentStep == 0 || approval.StepOrder < currentStep) { + currentStep = approval.StepOrder + } + + approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ + ID: approval.ID, + LetterID: approval.LetterID, + StepID: approval.StepID, + StepOrder: approval.StepOrder, + ParallelGroup: approval.ParallelGroup, + IsRequired: approval.IsRequired, + ApproverID: approval.ApproverID, + Status: string(approval.Status), + Remarks: approval.Remarks, + ActedAt: approval.ActedAt, + CreatedAt: approval.CreatedAt, + } + + // Add step details if available + if approval.Step != nil { + approvalResp.Step = &contract.ApprovalFlowStepResponse{ + ID: approval.Step.ID, + StepOrder: approval.Step.StepOrder, + ParallelGroup: approval.Step.ParallelGroup, + Required: approval.Step.Required, + CreatedAt: approval.Step.CreatedAt, + UpdatedAt: approval.Step.UpdatedAt, + } + + // Add approver role if available + if approval.Step.ApproverRole != nil { + approvalResp.Step.ApproverRole = &contract.RoleResponse{ + ID: approval.Step.ApproverRole.ID, + Name: approval.Step.ApproverRole.Name, + Code: approval.Step.ApproverRole.Code, + } + } + + // Add approver user if available + if approval.Step.ApproverUser != nil { + approvalResp.Step.ApproverUser = &contract.UserResponse{ + ID: approval.Step.ApproverUser.ID, + Name: approval.Step.ApproverUser.Name, + Email: approval.Step.ApproverUser.Email, + } + } + } + + // Add approver details if available + if approval.Approver != nil { + approvalResp.Approver = &contract.UserResponse{ + ID: approval.Approver.ID, + Name: approval.Approver.Name, + Email: approval.Approver.Email, + } + } + + approvalResponses = append(approvalResponses, approvalResp) + } + + // If no current step found but there are approvals, check if all are completed + if currentStep == 0 && len(approvals) > 0 { + allCompleted := true + for _, approval := range approvals { + if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved { + allCompleted = false + break + } + } + if allCompleted { + currentStep = totalSteps // All steps completed + } + } + + response := &contract.GetLetterApprovalsResponse{ + LetterID: letter.ID, + LetterNumber: letter.LetterNumber, + LetterStatus: string(letter.Status), + TotalSteps: totalSteps, + CurrentStep: currentStep, + Approvals: approvalResponses, + } + + return response, nil +} + func getUserIDFromContext(ctx context.Context) uuid.UUID { appCtx := appcontext.FromGinContext(ctx) if appCtx != nil { @@ -628,14 +826,17 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals)) for _, approval := range letter.Approvals { approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{ - ID: approval.ID, - LetterID: approval.LetterID, - StepID: approval.StepID, - ApproverID: approval.ApproverID, - Status: string(approval.Status), - Remarks: approval.Remarks, - ActedAt: approval.ActedAt, - CreatedAt: approval.CreatedAt, + ID: approval.ID, + LetterID: approval.LetterID, + StepID: approval.StepID, + StepOrder: approval.StepOrder, + ParallelGroup: approval.ParallelGroup, + IsRequired: approval.IsRequired, + ApproverID: approval.ApproverID, + Status: string(approval.Status), + Remarks: approval.Remarks, + ActedAt: approval.ActedAt, + CreatedAt: approval.CreatedAt, } // Add step details if available @@ -648,14 +849,14 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, CreatedAt: approval.Step.CreatedAt, UpdatedAt: approval.Step.UpdatedAt, } - + if approval.Step.ApproverRoleID != nil { approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID } if approval.Step.ApproverUserID != nil { approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID } - + // Add role information if available if approval.Step.ApproverRole != nil { approvalResp.Step.ApproverRole = &contract.RoleResponse{ @@ -664,7 +865,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, Code: approval.Step.ApproverRole.Code, } } - + // Add user information if available if approval.Step.ApproverUser != nil { approvalResp.Step.ApproverUser = &contract.UserResponse{ @@ -682,7 +883,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, Name: approval.Approver.Name, Email: approval.Approver.Email, } - + // Add profile if available if approval.Approver.Profile != nil { approvalResp.Approver.Profile = &contract.UserProfileResponse{ @@ -708,7 +909,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, for _, discussion := range letter.Discussions { // Extract mentioned user IDs from mentions mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions) - + discussionResp := contract.OutgoingLetterDiscussionResponse{ ID: discussion.ID, LetterID: discussion.LetterID, @@ -731,7 +932,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, CreatedAt: discussion.User.CreatedAt, UpdatedAt: discussion.User.UpdatedAt, } - + // Add profile if available if discussion.User.Profile != nil { discussionResp.User.Profile = &contract.UserProfileResponse{ @@ -761,7 +962,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, } - + if user.Profile != nil { mentionedUserResp.Profile = &contract.UserProfileResponse{ UserID: user.Profile.UserID, @@ -771,7 +972,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, Locale: user.Profile.Locale, } } - + discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp) } } @@ -802,11 +1003,11 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, // Helper function to extract user IDs from mentions func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID { var userIDs []uuid.UUID - + if mentions == nil { return userIDs } - + if userIDsInterface, ok := mentions["user_ids"]; ok { if userIDsList, ok := userIDsInterface.([]interface{}); ok { for _, id := range userIDsList { @@ -818,7 +1019,7 @@ func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID { } } } - + return userIDs } @@ -916,17 +1117,15 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals)) for i, approval := range letter.Approvals { approvalResp := contract.OutgoingLetterApprovalResponse{ - ID: approval.ID, - ApproverID: approval.ApproverID, - Status: string(approval.Status), - Remarks: approval.Remarks, - ActedAt: approval.ActedAt, - CreatedAt: approval.CreatedAt, - } - - // Include step order if step is loaded - if approval.Step != nil { - approvalResp.StepOrder = approval.Step.StepOrder + ID: approval.ID, + StepOrder: approval.StepOrder, + ParallelGroup: approval.ParallelGroup, + IsRequired: approval.IsRequired, + ApproverID: approval.ApproverID, + Status: string(approval.Status), + Remarks: approval.Remarks, + ActedAt: approval.ActedAt, + CreatedAt: approval.CreatedAt, } resp.Approvals[i] = approvalResp @@ -945,3 +1144,220 @@ func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion UpdatedAt: discussion.UpdatedAt, } } + +// GetApprovalTimeline generates a chronological timeline of all events for a letter +func (s *LetterOutgoingServiceImpl) GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) { + // Get letter details + letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID) + if err != nil { + return nil, err + } + + // Get approvals and discussions + approvalDiscussions, err := s.GetApprovalDiscussions(ctx, letterID) + if err != nil { + return nil, err + } + + // Create timeline events + timeline := make([]contract.TimelineEvent, 0) + + // Add letter creation event + timeline = append(timeline, contract.TimelineEvent{ + ID: letter.ID.String(), + Type: "submission", + Timestamp: letter.CreatedAt, + Actor: nil, // Could add creator info here if needed + Action: "created", + Description: "Letter was created", + Status: "created", + }) + + // Add approval events + for _, approval := range approvalDiscussions.Approvals { + if approval.ActedAt != nil { + eventType := "approval" + action := "approved" + status := "approved" + + if approval.Status == "rejected" { + eventType = "rejection" + action = "rejected" + status = "rejected" + } else if approval.Status == "pending" { + continue // Skip pending approvals as they haven't happened yet + } + + description := fmt.Sprintf("Step %d: %s by %s", + approval.StepOrder, + action, + getApproverName(approval.Approver)) + + timeline = append(timeline, contract.TimelineEvent{ + ID: approval.ID.String(), + Type: eventType, + Timestamp: *approval.ActedAt, + Actor: approval.Approver, + Action: action, + Description: description, + Status: status, + StepOrder: approval.StepOrder, + Message: getLetterStringValue(approval.Remarks), + Data: approval, + }) + } + } + + // Add discussion events + for _, discussion := range approvalDiscussions.Discussions { + timeline = append(timeline, contract.TimelineEvent{ + ID: discussion.ID.String(), + Type: "discussion", + Timestamp: discussion.CreatedAt, + Actor: discussion.User, + Action: "commented", + Description: fmt.Sprintf("%s added a comment", getUserName(discussion.User)), + Message: discussion.Message, + Data: discussion, + }) + } + + // Sort timeline by timestamp + sort.Slice(timeline, func(i, j int) bool { + return timeline[i].Timestamp.Before(timeline[j].Timestamp) + }) + + // Calculate summary statistics + summary := s.calculateTimelineSummary(letter, approvalDiscussions.Approvals, timeline) + + return &contract.ApprovalTimelineResponse{ + LetterID: letter.ID, + LetterNumber: letter.LetterNumber, + Subject: letter.Subject, + Status: string(letter.Status), + CreatedAt: letter.CreatedAt, + Timeline: timeline, + Summary: summary, + }, nil +} + +func (s *LetterOutgoingServiceImpl) calculateTimelineSummary( + letter *entities.LetterOutgoing, + approvals []contract.EnhancedOutgoingLetterApprovalResponse, + timeline []contract.TimelineEvent, +) contract.TimelineSummary { + totalSteps := 0 + completedSteps := 0 + pendingSteps := 0 + currentStep := 0 + + // Count unique step orders + stepMap := make(map[int]string) + for _, approval := range approvals { + if _, exists := stepMap[approval.StepOrder]; !exists { + stepMap[approval.StepOrder] = approval.Status + totalSteps++ + } + + switch approval.Status { + case "approved": + if stepMap[approval.StepOrder] == "approved" { + completedSteps++ + currentStep = approval.StepOrder + 1 + } + case "pending": + pendingSteps++ + if currentStep == 0 { + currentStep = approval.StepOrder + } + } + } + + // Calculate duration + totalDuration := "" + averageStepTime := "" + + if len(timeline) > 0 { + lastEvent := timeline[len(timeline)-1] + duration := lastEvent.Timestamp.Sub(letter.CreatedAt) + totalDuration = formatDuration(duration) + + if completedSteps > 0 { + avgDuration := duration / time.Duration(completedSteps) + averageStepTime = formatDuration(avgDuration) + } + } + + status := "in_progress" + if letter.Status == entities.LetterOutgoingStatusApproved { + status = "completed" + } else if letter.Status == "rejected" { + status = "rejected" + } + + return contract.TimelineSummary{ + TotalSteps: totalSteps, + CompletedSteps: completedSteps, + PendingSteps: pendingSteps, + CurrentStep: currentStep, + TotalDuration: totalDuration, + AverageStepTime: averageStepTime, + Status: status, + } +} + +func getApproverName(user *contract.UserResponse) string { + if user == nil { + return "Unknown" + } + if user.Name != "" { + return user.Name + } + return user.Email +} + +func getUserName(user *contract.UserResponse) string { + if user == nil { + return "Unknown" + } + if user.Name != "" { + return user.Name + } + return user.Email +} + +func getLetterStringValue(s *string) string { + if s == nil { + return "" + } + return *s +} + +func formatDuration(d time.Duration) string { + days := int(d.Hours() / 24) + hours := int(d.Hours()) % 24 + minutes := int(d.Minutes()) % 60 + + if days > 0 { + return fmt.Sprintf("%dd %dh %dm", days, hours, minutes) + } else if hours > 0 { + return fmt.Sprintf("%dh %dm", hours, minutes) + } + return fmt.Sprintf("%dm", minutes) +} + +func ApplyLetterFilterOverrides(ctx context.Context, filter *repository.ListOutgoingLettersFilter) { + appCtx := appcontext.FromGinContext(ctx) + if appCtx == nil { + return + } + + isSuperAdmin := false + if appCtx.UserRole == "superadmin" || appCtx.UserRole == "admin" { + isSuperAdmin = true + } + + if !isSuperAdmin && appCtx.UserID != uuid.Nil { + filter.UserID = &appCtx.UserID + } +} diff --git a/internal/service/onlyoffice_service.go b/internal/service/onlyoffice_service.go index 3e46351..052c21f 100644 --- a/internal/service/onlyoffice_service.go +++ b/internal/service/onlyoffice_service.go @@ -498,7 +498,7 @@ func (s *OnlyOfficeServiceImpl) GetEditorConfig(ctx context.Context, req *contra Mode: req.Mode, User: &contract.OnlyOfficeUserConfig{ ID: userCtx.UserID.String(), - Name: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]), + Name: userCtx.UserName, }, Customization: &contract.OnlyOfficeCustomization{ Autosave: true, diff --git a/migrations/000025_add_not_started_approval_status.down.sql b/migrations/000025_add_not_started_approval_status.down.sql new file mode 100644 index 0000000..0c71630 --- /dev/null +++ b/migrations/000025_add_not_started_approval_status.down.sql @@ -0,0 +1,13 @@ +-- Revert to original status check constraint without 'not_started' +-- First update any 'not_started' statuses to 'pending' +UPDATE letter_outgoing_approvals +SET status = 'pending' +WHERE status = 'not_started'; + +-- Drop and recreate the constraint +ALTER TABLE letter_outgoing_approvals +DROP CONSTRAINT IF EXISTS letter_outgoing_approvals_status_check; + +ALTER TABLE letter_outgoing_approvals +ADD CONSTRAINT letter_outgoing_approvals_status_check +CHECK (status IN ('pending', 'approved', 'rejected')); \ No newline at end of file diff --git a/migrations/000025_add_not_started_approval_status.up.sql b/migrations/000025_add_not_started_approval_status.up.sql new file mode 100644 index 0000000..bae2307 --- /dev/null +++ b/migrations/000025_add_not_started_approval_status.up.sql @@ -0,0 +1,10 @@ +-- Add 'not_started' status to letter_outgoing_approvals status check constraint +ALTER TABLE letter_outgoing_approvals +DROP CONSTRAINT IF EXISTS letter_outgoing_approvals_status_check; + +ALTER TABLE letter_outgoing_approvals +ADD CONSTRAINT letter_outgoing_approvals_status_check +CHECK (status IN ('not_started', 'pending', 'approved', 'rejected')); + +-- Update default value for new approvals (optional, keeping 'pending' as default) +-- ALTER TABLE letter_outgoing_approvals ALTER COLUMN status SET DEFAULT 'not_started'; \ No newline at end of file diff --git a/migrations/000026_add_approval_step_details.down.sql b/migrations/000026_add_approval_step_details.down.sql new file mode 100644 index 0000000..e2fca3d --- /dev/null +++ b/migrations/000026_add_approval_step_details.down.sql @@ -0,0 +1,9 @@ +-- Remove indexes +DROP INDEX IF EXISTS idx_letter_outgoing_approvals_step_order; +DROP INDEX IF EXISTS idx_letter_outgoing_approvals_parallel_group; + +-- Remove columns +ALTER TABLE letter_outgoing_approvals +DROP COLUMN IF EXISTS step_order, +DROP COLUMN IF EXISTS parallel_group, +DROP COLUMN IF EXISTS is_required; \ No newline at end of file diff --git a/migrations/000026_add_approval_step_details.up.sql b/migrations/000026_add_approval_step_details.up.sql new file mode 100644 index 0000000..e4c997c --- /dev/null +++ b/migrations/000026_add_approval_step_details.up.sql @@ -0,0 +1,17 @@ +-- Add step details to letter_outgoing_approvals table +ALTER TABLE letter_outgoing_approvals +ADD COLUMN IF NOT EXISTS step_order INT NOT NULL DEFAULT 1, +ADD COLUMN IF NOT EXISTS parallel_group INT DEFAULT 1, +ADD COLUMN IF NOT EXISTS is_required BOOLEAN DEFAULT true; + +-- Add indexes for better query performance +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_step_order +ON letter_outgoing_approvals(letter_id, step_order); + +CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_parallel_group +ON letter_outgoing_approvals(letter_id, parallel_group); + +-- Add comments for documentation +COMMENT ON COLUMN letter_outgoing_approvals.step_order IS 'The order in which this approval step should be processed'; +COMMENT ON COLUMN letter_outgoing_approvals.parallel_group IS 'Steps with the same parallel_group value can be processed simultaneously'; +COMMENT ON COLUMN letter_outgoing_approvals.is_required IS 'Whether this approval step is required for the letter to proceed'; \ No newline at end of file diff --git a/migrations/000027_create_analytics_summary_tables.down.sql b/migrations/000027_create_analytics_summary_tables.down.sql new file mode 100644 index 0000000..0bf8929 --- /dev/null +++ b/migrations/000027_create_analytics_summary_tables.down.sql @@ -0,0 +1,23 @@ +-- Drop triggers +DROP TRIGGER IF EXISTS update_letter_summary_on_incoming ON letters_incoming; +DROP TRIGGER IF EXISTS update_letter_summary_on_outgoing ON letters_outgoing; + +-- Drop functions +DROP FUNCTION IF EXISTS update_letter_summary(); +DROP FUNCTION IF EXISTS populate_letter_summary_history(); + +-- Drop indexes +DROP INDEX IF EXISTS idx_letter_summary_date; +DROP INDEX IF EXISTS idx_letter_summary_type_date; +DROP INDEX IF EXISTS idx_dept_letter_summary_dept_date; +DROP INDEX IF EXISTS idx_dept_letter_summary_date; +DROP INDEX IF EXISTS idx_inst_letter_summary_inst_date; +DROP INDEX IF EXISTS idx_inst_letter_summary_date; +DROP INDEX IF EXISTS idx_approval_sla_summary_date; +DROP INDEX IF EXISTS idx_approval_sla_summary_dept_date; + +-- Drop tables +DROP TABLE IF EXISTS approval_sla_summary; +DROP TABLE IF EXISTS institution_letter_summary; +DROP TABLE IF EXISTS department_letter_summary; +DROP TABLE IF EXISTS letter_summary; \ No newline at end of file diff --git a/migrations/000027_create_analytics_summary_tables.up.sql b/migrations/000027_create_analytics_summary_tables.up.sql new file mode 100644 index 0000000..8ab2024 --- /dev/null +++ b/migrations/000027_create_analytics_summary_tables.up.sql @@ -0,0 +1,389 @@ +-- Create letter_summary table for daily aggregated statistics +CREATE TABLE IF NOT EXISTS letter_summary ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + summary_date DATE NOT NULL, + letter_type VARCHAR(20) NOT NULL, -- 'incoming' or 'outgoing' + total_count INTEGER DEFAULT 0, + pending_count INTEGER DEFAULT 0, + approved_count INTEGER DEFAULT 0, + rejected_count INTEGER DEFAULT 0, + archived_count INTEGER DEFAULT 0, + sent_count INTEGER DEFAULT 0, + avg_processing_hours DECIMAL(10,2), + min_processing_hours DECIMAL(10,2), + max_processing_hours DECIMAL(10,2), + median_processing_hours DECIMAL(10,2), + p95_processing_hours DECIMAL(10,2), + p99_processing_hours DECIMAL(10,2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(summary_date, letter_type) +); + +-- Create department_letter_summary table +CREATE TABLE IF NOT EXISTS department_letter_summary ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + department_id UUID NOT NULL REFERENCES departments(id), + summary_date DATE NOT NULL, + incoming_count INTEGER DEFAULT 0, + outgoing_count INTEGER DEFAULT 0, + pending_incoming INTEGER DEFAULT 0, + pending_outgoing INTEGER DEFAULT 0, + approved_outgoing INTEGER DEFAULT 0, + rejected_outgoing INTEGER DEFAULT 0, + avg_response_hours DECIMAL(10,2), + completion_rate DECIMAL(5,2), + total_recipients INTEGER DEFAULT 0, + unique_senders INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(department_id, summary_date) +); + +-- Create institution_letter_summary table +CREATE TABLE IF NOT EXISTS institution_letter_summary ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + institution_id UUID NOT NULL REFERENCES institutions(id), + summary_date DATE NOT NULL, + incoming_sent INTEGER DEFAULT 0, + outgoing_received INTEGER DEFAULT 0, + total_correspondence INTEGER DEFAULT 0, + avg_turnaround_hours DECIMAL(10,2), + last_activity_at TIMESTAMP, + priority_high_count INTEGER DEFAULT 0, + priority_medium_count INTEGER DEFAULT 0, + priority_low_count INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(institution_id, summary_date) +); + +-- Create approval_sla_summary table +CREATE TABLE IF NOT EXISTS approval_sla_summary ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + summary_date DATE NOT NULL, + department_id UUID REFERENCES departments(id), + total_approvals INTEGER DEFAULT 0, + approved_count INTEGER DEFAULT 0, + rejected_count INTEGER DEFAULT 0, + pending_count INTEGER DEFAULT 0, + avg_approval_hours DECIMAL(10,2), + min_approval_hours DECIMAL(10,2), + max_approval_hours DECIMAL(10,2), + median_approval_hours DECIMAL(10,2), + within_sla_count INTEGER DEFAULT 0, + exceeded_sla_count INTEGER DEFAULT 0, + sla_compliance_rate DECIMAL(5,2), + avg_approval_steps DECIMAL(5,2), + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + UNIQUE(summary_date, department_id) +); + +-- Create indexes for better query performance +CREATE INDEX idx_letter_summary_date ON letter_summary(summary_date DESC); +CREATE INDEX idx_letter_summary_type_date ON letter_summary(letter_type, summary_date DESC); + +CREATE INDEX idx_dept_letter_summary_dept_date ON department_letter_summary(department_id, summary_date DESC); +CREATE INDEX idx_dept_letter_summary_date ON department_letter_summary(summary_date DESC); + +CREATE INDEX idx_inst_letter_summary_inst_date ON institution_letter_summary(institution_id, summary_date DESC); +CREATE INDEX idx_inst_letter_summary_date ON institution_letter_summary(summary_date DESC); + +CREATE INDEX idx_approval_sla_summary_date ON approval_sla_summary(summary_date DESC); +CREATE INDEX idx_approval_sla_summary_dept_date ON approval_sla_summary(department_id, summary_date DESC); + +-- Create function to update letter_summary +CREATE OR REPLACE FUNCTION update_letter_summary() +RETURNS TRIGGER AS $$ +DECLARE + v_date DATE; + v_type VARCHAR(20); +BEGIN + -- Determine date and type + IF TG_TABLE_NAME = 'letters_incoming' THEN + v_type := 'incoming'; + v_date := DATE(COALESCE(NEW.created_at, OLD.created_at)); + ELSE + v_type := 'outgoing'; + v_date := DATE(COALESCE(NEW.created_at, OLD.created_at)); + END IF; + + -- Update or insert summary + INSERT INTO letter_summary ( + summary_date, + letter_type, + total_count, + pending_count, + approved_count, + rejected_count, + archived_count, + sent_count + ) + SELECT + v_date, + v_type, + COUNT(*) as total_count, + COUNT(*) FILTER (WHERE status = 'pending' OR status = 'pending_approval') as pending_count, + COUNT(*) FILTER (WHERE status = 'approved') as approved_count, + COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, + COUNT(*) FILTER (WHERE status = 'archived') as archived_count, + COUNT(*) FILTER (WHERE status = 'sent') as sent_count + FROM ( + SELECT status, created_at FROM letters_incoming WHERE DATE(created_at) = v_date AND v_type = 'incoming' + UNION ALL + SELECT status, created_at FROM letters_outgoing WHERE DATE(created_at) = v_date AND v_type = 'outgoing' + ) t + ON CONFLICT (summary_date, letter_type) + DO UPDATE SET + total_count = EXCLUDED.total_count, + pending_count = EXCLUDED.pending_count, + approved_count = EXCLUDED.approved_count, + rejected_count = EXCLUDED.rejected_count, + archived_count = EXCLUDED.archived_count, + sent_count = EXCLUDED.sent_count, + updated_at = CURRENT_TIMESTAMP; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Create triggers for automatic updates +CREATE TRIGGER update_letter_summary_on_incoming + AFTER INSERT OR UPDATE OR DELETE ON letters_incoming + FOR EACH ROW + EXECUTE FUNCTION update_letter_summary(); + +CREATE TRIGGER update_letter_summary_on_outgoing + AFTER INSERT OR UPDATE OR DELETE ON letters_outgoing + FOR EACH ROW + EXECUTE FUNCTION update_letter_summary(); + +-- Function to populate historical data +CREATE OR REPLACE FUNCTION populate_letter_summary_history() +RETURNS void AS $$ +BEGIN + -- Populate letter_summary with historical data + INSERT INTO letter_summary ( + summary_date, + letter_type, + total_count, + pending_count, + approved_count, + rejected_count, + archived_count, + sent_count, + avg_processing_hours + ) + SELECT + DATE(created_at) as summary_date, + 'incoming' as letter_type, + COUNT(*) as total_count, + COUNT(*) FILTER (WHERE status = 'pending') as pending_count, + COUNT(*) FILTER (WHERE status = 'approved') as approved_count, + COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, + COUNT(*) FILTER (WHERE status = 'archived') as archived_count, + 0 as sent_count, + AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_processing_hours + FROM letters_incoming + WHERE deleted_at IS NULL + GROUP BY DATE(created_at) + UNION ALL + SELECT + DATE(created_at) as summary_date, + 'outgoing' as letter_type, + COUNT(*) as total_count, + COUNT(*) FILTER (WHERE status = 'pending_approval') as pending_count, + COUNT(*) FILTER (WHERE status = 'approved') as approved_count, + COUNT(*) FILTER (WHERE status = 'rejected') as rejected_count, + COUNT(*) FILTER (WHERE status = 'archived') as archived_count, + COUNT(*) FILTER (WHERE status = 'sent') as sent_count, + AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_processing_hours + FROM letters_outgoing + WHERE deleted_at IS NULL + GROUP BY DATE(created_at) + ON CONFLICT (summary_date, letter_type) DO NOTHING; + + -- Populate department_letter_summary + INSERT INTO department_letter_summary ( + department_id, + summary_date, + incoming_count, + outgoing_count, + pending_incoming, + pending_outgoing, + approved_outgoing, + rejected_outgoing, + avg_response_hours, + completion_rate + ) + SELECT + d.id as department_id, + dates.summary_date, + COALESCE(inc.count, 0) as incoming_count, + COALESCE(outg.count, 0) as outgoing_count, + COALESCE(inc.pending, 0) as pending_incoming, + COALESCE(outg.pending, 0) as pending_outgoing, + COALESCE(outg.approved, 0) as approved_outgoing, + COALESCE(outg.rejected, 0) as rejected_outgoing, + COALESCE(outg.avg_hours, 0) as avg_response_hours, + CASE + WHEN COALESCE(outg.count, 0) > 0 + THEN (COALESCE(outg.completed, 0)::DECIMAL / outg.count::DECIMAL) * 100 + ELSE 0 + END as completion_rate + FROM departments d + CROSS JOIN ( + SELECT DISTINCT DATE(created_at) as summary_date + FROM letters_incoming + UNION + SELECT DISTINCT DATE(created_at) + FROM letters_outgoing + ) dates + LEFT JOIN ( + SELECT + lir.recipient_department_id as dept_id, + DATE(li.created_at) as date, + COUNT(DISTINCT li.id) as count, + COUNT(DISTINCT li.id) FILTER (WHERE li.status = 'pending') as pending + FROM letters_incoming li + JOIN letter_incoming_recipients lir ON lir.letter_id = li.id + WHERE li.deleted_at IS NULL + GROUP BY lir.recipient_department_id, DATE(li.created_at) + ) inc ON inc.dept_id = d.id AND inc.date = dates.summary_date + LEFT JOIN ( + SELECT + lor.department_id as dept_id, + DATE(lo.created_at) as date, + COUNT(DISTINCT lo.id) as count, + COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'pending_approval') as pending, + COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'approved') as approved, + COUNT(DISTINCT lo.id) FILTER (WHERE lo.status = 'rejected') as rejected, + COUNT(DISTINCT lo.id) FILTER (WHERE lo.status IN ('sent', 'archived')) as completed, + AVG(EXTRACT(EPOCH FROM (lo.updated_at - lo.created_at))/3600) as avg_hours + FROM letters_outgoing lo + JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id + WHERE lo.deleted_at IS NULL + GROUP BY lor.department_id, DATE(lo.created_at) + ) outg ON outg.dept_id = d.id AND outg.date = dates.summary_date + WHERE inc.count > 0 OR outg.count > 0 + ON CONFLICT (department_id, summary_date) DO NOTHING; + + -- Populate institution_letter_summary + INSERT INTO institution_letter_summary ( + institution_id, + summary_date, + incoming_sent, + outgoing_received, + total_correspondence, + avg_turnaround_hours, + last_activity_at, + priority_high_count, + priority_medium_count, + priority_low_count + ) + SELECT + i.id as institution_id, + dates.summary_date, + COALESCE(inc.count, 0) as incoming_sent, + COALESCE(outg.count, 0) as outgoing_received, + COALESCE(inc.count, 0) + COALESCE(outg.count, 0) as total_correspondence, + COALESCE((inc.avg_hours + outg.avg_hours) / 2, 0) as avg_turnaround_hours, + GREATEST(inc.last_activity, outg.last_activity) as last_activity_at, + COALESCE(inc.high_priority, 0) + COALESCE(outg.high_priority, 0) as priority_high_count, + COALESCE(inc.medium_priority, 0) + COALESCE(outg.medium_priority, 0) as priority_medium_count, + COALESCE(inc.low_priority, 0) + COALESCE(outg.low_priority, 0) as priority_low_count + FROM institutions i + CROSS JOIN ( + SELECT DISTINCT DATE(created_at) as summary_date + FROM letters_incoming + UNION + SELECT DISTINCT DATE(created_at) + FROM letters_outgoing + ) dates + LEFT JOIN ( + SELECT + sender_institution_id as inst_id, + DATE(created_at) as date, + COUNT(*) as count, + AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_hours, + MAX(created_at) as last_activity, + COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 1)) as high_priority, + COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 2)) as medium_priority, + COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 3)) as low_priority + FROM letters_incoming + WHERE deleted_at IS NULL AND sender_institution_id IS NOT NULL + GROUP BY sender_institution_id, DATE(created_at) + ) inc ON inc.inst_id = i.id AND inc.date = dates.summary_date + LEFT JOIN ( + SELECT + receiver_institution_id as inst_id, + DATE(created_at) as date, + COUNT(*) as count, + AVG(EXTRACT(EPOCH FROM (updated_at - created_at))/3600) as avg_hours, + MAX(created_at) as last_activity, + COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 1)) as high_priority, + COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 2)) as medium_priority, + COUNT(*) FILTER (WHERE priority_id IN (SELECT id FROM priorities WHERE level = 3)) as low_priority + FROM letters_outgoing + WHERE deleted_at IS NULL AND receiver_institution_id IS NOT NULL + GROUP BY receiver_institution_id, DATE(created_at) + ) outg ON outg.inst_id = i.id AND outg.date = dates.summary_date + WHERE inc.count > 0 OR outg.count > 0 + ON CONFLICT (institution_id, summary_date) DO NOTHING; + + -- Populate approval_sla_summary + INSERT INTO approval_sla_summary ( + summary_date, + department_id, + total_approvals, + approved_count, + rejected_count, + pending_count, + avg_approval_hours, + min_approval_hours, + max_approval_hours, + median_approval_hours, + within_sla_count, + exceeded_sla_count, + sla_compliance_rate, + avg_approval_steps + ) + SELECT + DATE(loa.created_at) as summary_date, + d.id as department_id, + COUNT(loa.id) as total_approvals, + COUNT(loa.id) FILTER (WHERE loa.status = 'approved') as approved_count, + COUNT(loa.id) FILTER (WHERE loa.status = 'rejected') as rejected_count, + COUNT(loa.id) FILTER (WHERE loa.status = 'pending') as pending_count, + AVG(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as avg_approval_hours, + MIN(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as min_approval_hours, + MAX(EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as max_approval_hours, + PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600) FILTER (WHERE loa.acted_at IS NOT NULL) as median_approval_hours, + COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 <= 24 AND loa.acted_at IS NOT NULL) as within_sla_count, + COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 > 24 AND loa.acted_at IS NOT NULL) as exceeded_sla_count, + CASE + WHEN COUNT(loa.id) FILTER (WHERE loa.acted_at IS NOT NULL) > 0 + THEN (COUNT(loa.id) FILTER (WHERE EXTRACT(EPOCH FROM (loa.acted_at - loa.created_at))/3600 <= 24 AND loa.acted_at IS NOT NULL)::DECIMAL / + COUNT(loa.id) FILTER (WHERE loa.acted_at IS NOT NULL)::DECIMAL) * 100 + ELSE 0 + END as sla_compliance_rate, + AVG(step_counts.step_count) as avg_approval_steps + FROM letter_outgoing_approvals loa + JOIN letters_outgoing lo ON lo.id = loa.letter_id + JOIN letter_outgoing_recipients lor ON lor.letter_id = lo.id + JOIN departments d ON d.id = lor.department_id + LEFT JOIN ( + SELECT letter_id, COUNT(*) as step_count + FROM letter_outgoing_approvals + GROUP BY letter_id + ) step_counts ON step_counts.letter_id = loa.letter_id + WHERE lo.deleted_at IS NULL + GROUP BY DATE(loa.created_at), d.id + ON CONFLICT (summary_date, department_id) DO NOTHING; + +END; +$$ LANGUAGE plpgsql; + +-- Execute the function to populate historical data +SELECT populate_letter_summary_history(); \ No newline at end of file diff --git a/server b/server index 6593902..1076ea1 100755 Binary files a/server and b/server differ