This commit is contained in:
Aditya Siregar 2025-09-01 12:06:14 +07:00
parent 2bdce63852
commit aa662a321f
25 changed files with 2923 additions and 189 deletions

BIN
eslogad-backend Executable file

Binary file not shown.

View File

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

View File

@ -21,6 +21,7 @@ const (
DeviceOSKey = key("deviceOS")
UserLocaleKey = key("userLocale")
UserRoleKey = key("userRole")
UserNameKey = key("UserName")
)
func LogFields(ctx interface{}) map[string]interface{} {

View File

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

View File

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

View File

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

View File

@ -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"`

View File

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

View File

@ -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"))
@ -462,3 +458,24 @@ func (h *LetterOutgoingHandler) GetApprovalDiscussions(c *gin.Context) {
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
// 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))
}

View File

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

View File

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

View File

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

View File

@ -72,12 +72,17 @@ func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid
}
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) {
@ -94,9 +99,18 @@ 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)
}
@ -109,16 +123,44 @@ func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoing
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 {

View File

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

View File

@ -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)
@ -183,8 +187,8 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
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)
@ -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)
}
}
}

View File

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

View File

@ -3,6 +3,8 @@ package service
import (
"context"
"fmt"
"sort"
"time"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
@ -40,8 +42,14 @@ type LetterOutgoingService interface {
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
@ -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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

BIN
server

Binary file not shown.