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
+413
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
}
}
+481 -65
View File
@@ -3,6 +3,8 @@ package service
import (
"context"
"fmt"
"sort"
"time"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
@@ -39,9 +41,15 @@ type LetterOutgoingService interface {
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error)
// GetLetterApprovals returns all approvals and their status for a letter
GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error)
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
// GetApprovalTimeline returns a chronological timeline of all events for a letter
GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error)
}
type LetterOutgoingServiceImpl struct {
@@ -106,16 +114,48 @@ func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, i
}
func (s *LetterOutgoingServiceImpl) ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error) {
filter := repository.ListOutgoingLettersFilter{
Status: req.Status,
Query: req.Query,
CreatedBy: req.CreatedBy,
ReceiverInstitutionID: req.ReceiverInstitutionID,
FromDate: req.FromDate,
ToDate: req.ToDate,
offset := (req.Page - 1) * req.Limit
if offset < 0 {
offset = 0
}
letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, req.Offset)
filter := repository.ListOutgoingLettersFilter{
CreatedBy: req.CreatedBy,
DepartmentID: req.DepartmentID,
ReceiverInstitutionID: req.ReceiverInstitutionID,
PriorityID: req.PriorityID,
}
if req.Status != "" {
filter.Status = &req.Status
}
if req.Query != "" {
filter.Query = &req.Query
}
if req.SortBy != "" {
filter.SortBy = &req.SortBy
}
if req.SortOrder != "" {
filter.SortOrder = &req.SortOrder
}
if req.FromDate != "" {
if date, err := time.Parse("2006-01-02", req.FromDate); err == nil {
filter.FromDate = &date
}
}
if req.ToDate != "" {
if date, err := time.Parse("2006-01-02", req.ToDate); err == nil {
endOfDay := date.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
filter.ToDate = &endOfDay
}
}
// Apply access control overrides based on user context
ApplyLetterFilterOverrides(ctx, &filter)
letters, total, err := s.processor.ListOutgoingLetters(ctx, filter, req.Limit, offset)
if err != nil {
return nil, err
}
@@ -523,67 +563,104 @@ func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discus
func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) {
userID := getUserIDFromContext(ctx)
_, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
// Verify letter exists
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
if err != nil {
return nil, err
}
// Get all approvals for this letter
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
if err != nil {
return nil, err
}
var currentApproval *entities.LetterOutgoingApproval
// Group approvals by step order to understand the workflow
approvalsByStep := make(map[int][]entities.LetterOutgoingApproval)
for _, approval := range approvals {
approvalsByStep[approval.StepOrder] = append(approvalsByStep[approval.StepOrder], approval)
}
// Find the current active step (lowest step order with pending approvals)
var currentStepOrder int = -1
var userApproval *entities.LetterOutgoingApproval
var isApproverOnActiveStep bool
var canApprove bool
for _, approval := range approvals {
if approval.Status == entities.ApprovalStatusPending {
currentApproval = &approval
break
// Find the minimum step order that has pending approvals
for stepOrder, stepApprovals := range approvalsByStep {
hasPending := false
for _, approval := range stepApprovals {
if approval.Status == entities.ApprovalStatusPending {
hasPending = true
// Check if this user is an approver for this pending approval
if approval.ApproverID != nil && *approval.ApproverID == userID {
if currentStepOrder == -1 || stepOrder < currentStepOrder {
currentStepOrder = stepOrder
userApproval = &approval
isApproverOnActiveStep = true
}
}
}
}
// Track the lowest pending step
if hasPending && (currentStepOrder == -1 || stepOrder < currentStepOrder) {
currentStepOrder = stepOrder
}
}
// Check if current user is the approver for the active step
if currentApproval != nil && currentApproval.Step != nil {
step := currentApproval.Step
// Check if user is the specific approver
if step.ApproverUserID != nil && *step.ApproverUserID == userID {
isApproverOnActiveStep = true
canApprove = true
}
// Note: Role-based approval check would require additional implementation
// For now, we only support user-specific approvers
// User can approve if they have a pending approval on the current active step
if isApproverOnActiveStep && userApproval != nil && userApproval.Status == entities.ApprovalStatusPending {
canApprove = true
}
// Build actions based on current status
// Build actions based on eligibility
var actions []contract.ApprovalAction
if canApprove && currentApproval != nil {
if canApprove && userApproval != nil {
actions = []contract.ApprovalAction{
{
Type: "APPROVE",
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/approve", letterID),
Method: "POST",
},
{
Type: "REJECT",
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
Href: fmt.Sprintf("/api/v1/letters/outgoing/%s/reject", letterID),
Method: "POST",
},
}
}
// Determine decision status
// Determine overall decision status
decisionStatus := "PENDING"
if currentApproval == nil {
// Check if all required approvals are completed
allCompleted := true
hasRejection := false
for _, approval := range approvals {
// Check required approvals only
if approval.IsRequired {
if approval.Status == entities.ApprovalStatusPending || approval.Status == entities.ApprovalStatusNotStarted {
allCompleted = false
}
if approval.Status == entities.ApprovalStatusRejected {
hasRejection = true
}
}
}
if hasRejection {
decisionStatus = "REJECTED"
} else if allCompleted {
decisionStatus = "COMPLETED"
} else if letter.Status == entities.LetterOutgoingStatusPendingApproval {
decisionStatus = "PENDING"
}
// Determine notes visibility
notesVisibility := "FULL"
if !isApproverOnActiveStep {
notesVisibility = "READONLY"
notesVisibility := "READONLY"
if canApprove {
notesVisibility = "FULL"
}
info := &contract.LetterApprovalInfoResponse{
@@ -597,6 +674,127 @@ func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, l
return info, nil
}
func (s *LetterOutgoingServiceImpl) GetLetterApprovals(ctx context.Context, letterID uuid.UUID) (*contract.GetLetterApprovalsResponse, error) {
// Get letter details
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
if err != nil {
return nil, err
}
// Get all approvals for this letter
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
if err != nil {
return nil, err
}
// Sort approvals by step order and parallel group
sort.Slice(approvals, func(i, j int) bool {
if approvals[i].StepOrder != approvals[j].StepOrder {
return approvals[i].StepOrder < approvals[j].StepOrder
}
return approvals[i].ParallelGroup < approvals[j].ParallelGroup
})
// Transform to response format
approvalResponses := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(approvals))
totalSteps := 0
currentStep := 0
stepOrdersSeen := make(map[int]bool)
for _, approval := range approvals {
// Count unique step orders for total steps
if !stepOrdersSeen[approval.StepOrder] {
stepOrdersSeen[approval.StepOrder] = true
totalSteps++
}
// Determine current step (lowest step with pending/not_started status)
if approval.Status == entities.ApprovalStatusPending && (currentStep == 0 || approval.StepOrder < currentStep) {
currentStep = approval.StepOrder
}
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
ID: approval.ID,
LetterID: approval.LetterID,
StepID: approval.StepID,
StepOrder: approval.StepOrder,
ParallelGroup: approval.ParallelGroup,
IsRequired: approval.IsRequired,
ApproverID: approval.ApproverID,
Status: string(approval.Status),
Remarks: approval.Remarks,
ActedAt: approval.ActedAt,
CreatedAt: approval.CreatedAt,
}
// Add step details if available
if approval.Step != nil {
approvalResp.Step = &contract.ApprovalFlowStepResponse{
ID: approval.Step.ID,
StepOrder: approval.Step.StepOrder,
ParallelGroup: approval.Step.ParallelGroup,
Required: approval.Step.Required,
CreatedAt: approval.Step.CreatedAt,
UpdatedAt: approval.Step.UpdatedAt,
}
// Add approver role if available
if approval.Step.ApproverRole != nil {
approvalResp.Step.ApproverRole = &contract.RoleResponse{
ID: approval.Step.ApproverRole.ID,
Name: approval.Step.ApproverRole.Name,
Code: approval.Step.ApproverRole.Code,
}
}
// Add approver user if available
if approval.Step.ApproverUser != nil {
approvalResp.Step.ApproverUser = &contract.UserResponse{
ID: approval.Step.ApproverUser.ID,
Name: approval.Step.ApproverUser.Name,
Email: approval.Step.ApproverUser.Email,
}
}
}
// Add approver details if available
if approval.Approver != nil {
approvalResp.Approver = &contract.UserResponse{
ID: approval.Approver.ID,
Name: approval.Approver.Name,
Email: approval.Approver.Email,
}
}
approvalResponses = append(approvalResponses, approvalResp)
}
// If no current step found but there are approvals, check if all are completed
if currentStep == 0 && len(approvals) > 0 {
allCompleted := true
for _, approval := range approvals {
if approval.IsRequired && approval.Status != entities.ApprovalStatusApproved {
allCompleted = false
break
}
}
if allCompleted {
currentStep = totalSteps // All steps completed
}
}
response := &contract.GetLetterApprovalsResponse{
LetterID: letter.ID,
LetterNumber: letter.LetterNumber,
LetterStatus: string(letter.Status),
TotalSteps: totalSteps,
CurrentStep: currentStep,
Approvals: approvalResponses,
}
return response, nil
}
func getUserIDFromContext(ctx context.Context) uuid.UUID {
appCtx := appcontext.FromGinContext(ctx)
if appCtx != nil {
@@ -628,14 +826,17 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals))
for _, approval := range letter.Approvals {
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
ID: approval.ID,
LetterID: approval.LetterID,
StepID: approval.StepID,
ApproverID: approval.ApproverID,
Status: string(approval.Status),
Remarks: approval.Remarks,
ActedAt: approval.ActedAt,
CreatedAt: approval.CreatedAt,
ID: approval.ID,
LetterID: approval.LetterID,
StepID: approval.StepID,
StepOrder: approval.StepOrder,
ParallelGroup: approval.ParallelGroup,
IsRequired: approval.IsRequired,
ApproverID: approval.ApproverID,
Status: string(approval.Status),
Remarks: approval.Remarks,
ActedAt: approval.ActedAt,
CreatedAt: approval.CreatedAt,
}
// Add step details if available
@@ -648,14 +849,14 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
CreatedAt: approval.Step.CreatedAt,
UpdatedAt: approval.Step.UpdatedAt,
}
if approval.Step.ApproverRoleID != nil {
approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID
}
if approval.Step.ApproverUserID != nil {
approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID
}
// Add role information if available
if approval.Step.ApproverRole != nil {
approvalResp.Step.ApproverRole = &contract.RoleResponse{
@@ -664,7 +865,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
Code: approval.Step.ApproverRole.Code,
}
}
// Add user information if available
if approval.Step.ApproverUser != nil {
approvalResp.Step.ApproverUser = &contract.UserResponse{
@@ -682,7 +883,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
Name: approval.Approver.Name,
Email: approval.Approver.Email,
}
// Add profile if available
if approval.Approver.Profile != nil {
approvalResp.Approver.Profile = &contract.UserProfileResponse{
@@ -708,7 +909,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
for _, discussion := range letter.Discussions {
// Extract mentioned user IDs from mentions
mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions)
discussionResp := contract.OutgoingLetterDiscussionResponse{
ID: discussion.ID,
LetterID: discussion.LetterID,
@@ -731,7 +932,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
CreatedAt: discussion.User.CreatedAt,
UpdatedAt: discussion.User.UpdatedAt,
}
// Add profile if available
if discussion.User.Profile != nil {
discussionResp.User.Profile = &contract.UserProfileResponse{
@@ -761,7 +962,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
if user.Profile != nil {
mentionedUserResp.Profile = &contract.UserProfileResponse{
UserID: user.Profile.UserID,
@@ -771,7 +972,7 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
Locale: user.Profile.Locale,
}
}
discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp)
}
}
@@ -802,11 +1003,11 @@ func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context,
// Helper function to extract user IDs from mentions
func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID {
var userIDs []uuid.UUID
if mentions == nil {
return userIDs
}
if userIDsInterface, ok := mentions["user_ids"]; ok {
if userIDsList, ok := userIDsInterface.([]interface{}); ok {
for _, id := range userIDsList {
@@ -818,7 +1019,7 @@ func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID {
}
}
}
return userIDs
}
@@ -916,17 +1117,15 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi
resp.Approvals = make([]contract.OutgoingLetterApprovalResponse, len(letter.Approvals))
for i, approval := range letter.Approvals {
approvalResp := contract.OutgoingLetterApprovalResponse{
ID: approval.ID,
ApproverID: approval.ApproverID,
Status: string(approval.Status),
Remarks: approval.Remarks,
ActedAt: approval.ActedAt,
CreatedAt: approval.CreatedAt,
}
// Include step order if step is loaded
if approval.Step != nil {
approvalResp.StepOrder = approval.Step.StepOrder
ID: approval.ID,
StepOrder: approval.StepOrder,
ParallelGroup: approval.ParallelGroup,
IsRequired: approval.IsRequired,
ApproverID: approval.ApproverID,
Status: string(approval.Status),
Remarks: approval.Remarks,
ActedAt: approval.ActedAt,
CreatedAt: approval.CreatedAt,
}
resp.Approvals[i] = approvalResp
@@ -945,3 +1144,220 @@ func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion
UpdatedAt: discussion.UpdatedAt,
}
}
// GetApprovalTimeline generates a chronological timeline of all events for a letter
func (s *LetterOutgoingServiceImpl) GetApprovalTimeline(ctx context.Context, letterID uuid.UUID) (*contract.ApprovalTimelineResponse, error) {
// Get letter details
letter, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
if err != nil {
return nil, err
}
// Get approvals and discussions
approvalDiscussions, err := s.GetApprovalDiscussions(ctx, letterID)
if err != nil {
return nil, err
}
// Create timeline events
timeline := make([]contract.TimelineEvent, 0)
// Add letter creation event
timeline = append(timeline, contract.TimelineEvent{
ID: letter.ID.String(),
Type: "submission",
Timestamp: letter.CreatedAt,
Actor: nil, // Could add creator info here if needed
Action: "created",
Description: "Letter was created",
Status: "created",
})
// Add approval events
for _, approval := range approvalDiscussions.Approvals {
if approval.ActedAt != nil {
eventType := "approval"
action := "approved"
status := "approved"
if approval.Status == "rejected" {
eventType = "rejection"
action = "rejected"
status = "rejected"
} else if approval.Status == "pending" {
continue // Skip pending approvals as they haven't happened yet
}
description := fmt.Sprintf("Step %d: %s by %s",
approval.StepOrder,
action,
getApproverName(approval.Approver))
timeline = append(timeline, contract.TimelineEvent{
ID: approval.ID.String(),
Type: eventType,
Timestamp: *approval.ActedAt,
Actor: approval.Approver,
Action: action,
Description: description,
Status: status,
StepOrder: approval.StepOrder,
Message: getLetterStringValue(approval.Remarks),
Data: approval,
})
}
}
// Add discussion events
for _, discussion := range approvalDiscussions.Discussions {
timeline = append(timeline, contract.TimelineEvent{
ID: discussion.ID.String(),
Type: "discussion",
Timestamp: discussion.CreatedAt,
Actor: discussion.User,
Action: "commented",
Description: fmt.Sprintf("%s added a comment", getUserName(discussion.User)),
Message: discussion.Message,
Data: discussion,
})
}
// Sort timeline by timestamp
sort.Slice(timeline, func(i, j int) bool {
return timeline[i].Timestamp.Before(timeline[j].Timestamp)
})
// Calculate summary statistics
summary := s.calculateTimelineSummary(letter, approvalDiscussions.Approvals, timeline)
return &contract.ApprovalTimelineResponse{
LetterID: letter.ID,
LetterNumber: letter.LetterNumber,
Subject: letter.Subject,
Status: string(letter.Status),
CreatedAt: letter.CreatedAt,
Timeline: timeline,
Summary: summary,
}, nil
}
func (s *LetterOutgoingServiceImpl) calculateTimelineSummary(
letter *entities.LetterOutgoing,
approvals []contract.EnhancedOutgoingLetterApprovalResponse,
timeline []contract.TimelineEvent,
) contract.TimelineSummary {
totalSteps := 0
completedSteps := 0
pendingSteps := 0
currentStep := 0
// Count unique step orders
stepMap := make(map[int]string)
for _, approval := range approvals {
if _, exists := stepMap[approval.StepOrder]; !exists {
stepMap[approval.StepOrder] = approval.Status
totalSteps++
}
switch approval.Status {
case "approved":
if stepMap[approval.StepOrder] == "approved" {
completedSteps++
currentStep = approval.StepOrder + 1
}
case "pending":
pendingSteps++
if currentStep == 0 {
currentStep = approval.StepOrder
}
}
}
// Calculate duration
totalDuration := ""
averageStepTime := ""
if len(timeline) > 0 {
lastEvent := timeline[len(timeline)-1]
duration := lastEvent.Timestamp.Sub(letter.CreatedAt)
totalDuration = formatDuration(duration)
if completedSteps > 0 {
avgDuration := duration / time.Duration(completedSteps)
averageStepTime = formatDuration(avgDuration)
}
}
status := "in_progress"
if letter.Status == entities.LetterOutgoingStatusApproved {
status = "completed"
} else if letter.Status == "rejected" {
status = "rejected"
}
return contract.TimelineSummary{
TotalSteps: totalSteps,
CompletedSteps: completedSteps,
PendingSteps: pendingSteps,
CurrentStep: currentStep,
TotalDuration: totalDuration,
AverageStepTime: averageStepTime,
Status: status,
}
}
func getApproverName(user *contract.UserResponse) string {
if user == nil {
return "Unknown"
}
if user.Name != "" {
return user.Name
}
return user.Email
}
func getUserName(user *contract.UserResponse) string {
if user == nil {
return "Unknown"
}
if user.Name != "" {
return user.Name
}
return user.Email
}
func getLetterStringValue(s *string) string {
if s == nil {
return ""
}
return *s
}
func formatDuration(d time.Duration) string {
days := int(d.Hours() / 24)
hours := int(d.Hours()) % 24
minutes := int(d.Minutes()) % 60
if days > 0 {
return fmt.Sprintf("%dd %dh %dm", days, hours, minutes)
} else if hours > 0 {
return fmt.Sprintf("%dh %dm", hours, minutes)
}
return fmt.Sprintf("%dm", minutes)
}
func ApplyLetterFilterOverrides(ctx context.Context, filter *repository.ListOutgoingLettersFilter) {
appCtx := appcontext.FromGinContext(ctx)
if appCtx == nil {
return
}
isSuperAdmin := false
if appCtx.UserRole == "superadmin" || appCtx.UserRole == "admin" {
isSuperAdmin = true
}
if !isSuperAdmin && appCtx.UserID != uuid.Nil {
filter.UserID = &appCtx.UserID
}
}
+1 -1
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,