1105 lines
36 KiB
Markdown
1105 lines
36 KiB
Markdown
# Contoh Implementasi Kode - Sistem Approval Artikel Dinamis
|
|
|
|
## 1. Database Entities
|
|
|
|
### ApprovalWorkflows Entity
|
|
|
|
```go
|
|
// app/database/entity/approval_workflows.entity.go
|
|
package entity
|
|
|
|
import (
|
|
"github.com/google/uuid"
|
|
"time"
|
|
)
|
|
|
|
type ApprovalWorkflows struct {
|
|
ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"`
|
|
Name string `json:"name" gorm:"type:varchar;not null"`
|
|
Description *string `json:"description" gorm:"type:text"`
|
|
IsActive *bool `json:"is_active" gorm:"type:bool;default:true"`
|
|
ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"`
|
|
CreatedAt time.Time `json:"created_at" gorm:"default:now()"`
|
|
UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"`
|
|
|
|
// Relations
|
|
Steps []ApprovalWorkflowSteps `json:"steps" gorm:"foreignKey:WorkflowId;references:ID"`
|
|
}
|
|
```
|
|
|
|
### ApprovalWorkflowSteps Entity
|
|
|
|
```go
|
|
// app/database/entity/approval_workflow_steps.entity.go
|
|
package entity
|
|
|
|
import (
|
|
"github.com/google/uuid"
|
|
"time"
|
|
)
|
|
|
|
type ApprovalWorkflowSteps struct {
|
|
ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"`
|
|
WorkflowId uint `json:"workflow_id" gorm:"type:int4;not null"`
|
|
StepOrder int `json:"step_order" gorm:"type:int4;not null"`
|
|
UserLevelId uint `json:"user_level_id" gorm:"type:int4;not null"`
|
|
IsRequired *bool `json:"is_required" gorm:"type:bool;default:true"`
|
|
CanSkip *bool `json:"can_skip" gorm:"type:bool;default:false"`
|
|
ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"`
|
|
CreatedAt time.Time `json:"created_at" gorm:"default:now()"`
|
|
UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"`
|
|
|
|
// Relations
|
|
Workflow *ApprovalWorkflows `json:"workflow" gorm:"foreignKey:WorkflowId;references:ID"`
|
|
UserLevel *UserLevels `json:"user_level" gorm:"foreignKey:UserLevelId;references:ID"`
|
|
}
|
|
```
|
|
|
|
### ArticleApprovalFlows Entity
|
|
|
|
```go
|
|
// app/database/entity/article_approval_flows.entity.go
|
|
package entity
|
|
|
|
import (
|
|
"github.com/google/uuid"
|
|
"time"
|
|
)
|
|
|
|
type ArticleApprovalFlows struct {
|
|
ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"`
|
|
ArticleId uint `json:"article_id" gorm:"type:int4;not null"`
|
|
WorkflowId uint `json:"workflow_id" gorm:"type:int4;not null"`
|
|
CurrentStep int `json:"current_step" gorm:"type:int4;default:1"`
|
|
StatusId int `json:"status_id" gorm:"type:int4;default:1"` // 1=In Progress, 2=Completed, 3=Rejected
|
|
ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"`
|
|
CreatedAt time.Time `json:"created_at" gorm:"default:now()"`
|
|
UpdatedAt time.Time `json:"updated_at" gorm:"default:now()"`
|
|
|
|
// Relations
|
|
Article *Articles `json:"article" gorm:"foreignKey:ArticleId;references:ID"`
|
|
Workflow *ApprovalWorkflows `json:"workflow" gorm:"foreignKey:WorkflowId;references:ID"`
|
|
StepLogs []ArticleApprovalStepLogs `json:"step_logs" gorm:"foreignKey:ArticleFlowId;references:ID"`
|
|
}
|
|
```
|
|
|
|
### ArticleApprovalStepLogs Entity
|
|
|
|
```go
|
|
// app/database/entity/article_approval_step_logs.entity.go
|
|
package entity
|
|
|
|
import (
|
|
"github.com/google/uuid"
|
|
"time"
|
|
users "web-qudo-be/app/database/entity/users"
|
|
)
|
|
|
|
type ArticleApprovalStepLogs struct {
|
|
ID uint `json:"id" gorm:"primaryKey;type:int4;autoIncrement"`
|
|
ArticleFlowId uint `json:"article_flow_id" gorm:"type:int4;not null"`
|
|
StepOrder int `json:"step_order" gorm:"type:int4;not null"`
|
|
UserLevelId uint `json:"user_level_id" gorm:"type:int4;not null"`
|
|
ApprovedBy uint `json:"approved_by" gorm:"type:int4;not null"`
|
|
Action string `json:"action" gorm:"type:varchar(50);not null"` // 'approved', 'rejected', 'revision_requested'
|
|
Message *string `json:"message" gorm:"type:text"`
|
|
ApprovedAt *time.Time `json:"approved_at" gorm:"type:timestamp"`
|
|
ClientId *uuid.UUID `json:"client_id" gorm:"type:UUID"`
|
|
CreatedAt time.Time `json:"created_at" gorm:"default:now()"`
|
|
|
|
// Relations
|
|
ArticleFlow *ArticleApprovalFlows `json:"article_flow" gorm:"foreignKey:ArticleFlowId;references:ID"`
|
|
UserLevel *UserLevels `json:"user_level" gorm:"foreignKey:UserLevelId;references:ID"`
|
|
Approver *users.Users `json:"approver" gorm:"foreignKey:ApprovedBy;references:ID"`
|
|
}
|
|
```
|
|
|
|
## 2. Service Layer Implementation
|
|
|
|
### ApprovalWorkflowService
|
|
|
|
```go
|
|
// app/module/approval_workflows/service/approval_workflows.service.go
|
|
package service
|
|
|
|
import (
|
|
"errors"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"web-qudo-be/app/database/entity"
|
|
"web-qudo-be/app/module/approval_workflows/repository"
|
|
"web-qudo-be/app/module/approval_workflows/request"
|
|
"web-qudo-be/app/module/approval_workflows/response"
|
|
"web-qudo-be/utils/paginator"
|
|
)
|
|
|
|
type approvalWorkflowService struct {
|
|
Repo repository.ApprovalWorkflowRepository
|
|
Log zerolog.Logger
|
|
}
|
|
|
|
type ApprovalWorkflowService interface {
|
|
All(clientId *uuid.UUID, req request.ApprovalWorkflowQueryRequest) ([]*response.ApprovalWorkflowResponse, paginator.Pagination, error)
|
|
Show(clientId *uuid.UUID, id uint) (*response.ApprovalWorkflowResponse, error)
|
|
Save(clientId *uuid.UUID, req request.ApprovalWorkflowCreateRequest) (*entity.ApprovalWorkflows, error)
|
|
Update(clientId *uuid.UUID, id uint, req request.ApprovalWorkflowUpdateRequest) error
|
|
Delete(clientId *uuid.UUID, id uint) error
|
|
GetActiveWorkflow(clientId *uuid.UUID) (*entity.ApprovalWorkflows, error)
|
|
ValidateWorkflow(workflowId uint) error
|
|
}
|
|
|
|
func NewApprovalWorkflowService(repo repository.ApprovalWorkflowRepository, log zerolog.Logger) ApprovalWorkflowService {
|
|
return &approvalWorkflowService{
|
|
Repo: repo,
|
|
Log: log,
|
|
}
|
|
}
|
|
|
|
func (s *approvalWorkflowService) Save(clientId *uuid.UUID, req request.ApprovalWorkflowCreateRequest) (*entity.ApprovalWorkflows, error) {
|
|
// Validate workflow steps
|
|
if len(req.Steps) == 0 {
|
|
return nil, errors.New("workflow must have at least one step")
|
|
}
|
|
|
|
// Validate step order sequence
|
|
for i, step := range req.Steps {
|
|
if step.StepOrder != i+1 {
|
|
return nil, errors.New("step order must be sequential starting from 1")
|
|
}
|
|
}
|
|
|
|
workflow := req.ToEntity()
|
|
workflow.ClientId = clientId
|
|
|
|
return s.Repo.Create(workflow)
|
|
}
|
|
|
|
func (s *approvalWorkflowService) ValidateWorkflow(workflowId uint) error {
|
|
workflow, err := s.Repo.FindOneWithSteps(workflowId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if workflow.IsActive == nil || !*workflow.IsActive {
|
|
return errors.New("workflow is not active")
|
|
}
|
|
|
|
if len(workflow.Steps) == 0 {
|
|
return errors.New("workflow has no steps defined")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
```
|
|
|
|
### Dynamic Article Approval Service
|
|
|
|
```go
|
|
// app/module/article_approval_flows/service/article_approval_flows.service.go
|
|
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"github.com/google/uuid"
|
|
"github.com/rs/zerolog"
|
|
"time"
|
|
"web-qudo-be/app/database/entity"
|
|
users "web-qudo-be/app/database/entity/users"
|
|
"web-qudo-be/app/module/article_approval_flows/repository"
|
|
"web-qudo-be/app/module/articles/repository" as articlesRepo
|
|
"web-qudo-be/app/module/approval_workflows/service" as workflowService
|
|
)
|
|
|
|
type articleApprovalFlowService struct {
|
|
Repo repository.ArticleApprovalFlowRepository
|
|
ArticlesRepo articlesRepo.ArticlesRepository
|
|
WorkflowService workflowService.ApprovalWorkflowService
|
|
Log zerolog.Logger
|
|
}
|
|
|
|
type ArticleApprovalFlowService interface {
|
|
InitiateApprovalFlow(clientId *uuid.UUID, articleId uint, workflowId uint) (*entity.ArticleApprovalFlows, error)
|
|
ProcessApprovalStep(clientId *uuid.UUID, articleId uint, action string, message *string, approver *users.Users) error
|
|
GetPendingApprovals(clientId *uuid.UUID, userLevelId uint, filters ApprovalFilters) ([]*entity.ArticleApprovalFlows, paginator.Pagination, error)
|
|
GetMyApprovalQueue(clientId *uuid.UUID, userLevelId uint, filters ApprovalFilters) ([]*DetailedApprovalFlow, paginator.Pagination, error)
|
|
GetApprovalHistory(clientId *uuid.UUID, articleId uint) ([]*entity.ArticleApprovalStepLogs, error)
|
|
GetApprovalStatistics(clientId *uuid.UUID, userLevelId uint) (*ApprovalStatistics, error)
|
|
GetWorkloadDistribution(clientId *uuid.UUID, userLevelId uint) (*WorkloadDistribution, error)
|
|
BulkApprovalAction(clientId *uuid.UUID, articleIds []uint, action string, message *string, approver *users.Users) (*BulkActionResult, error)
|
|
}
|
|
|
|
// Supporting structs for enhanced approver dashboard
|
|
type ApprovalFilters struct {
|
|
Page int
|
|
Limit int
|
|
Priority *string
|
|
CategoryId *uint
|
|
Search *string
|
|
DateFrom *time.Time
|
|
DateTo *time.Time
|
|
SortBy string
|
|
SortOrder string
|
|
WorkflowId *uint
|
|
UrgencyOnly bool
|
|
IncludePreview bool
|
|
}
|
|
|
|
type DetailedApprovalFlow struct {
|
|
ID uint `json:"id"`
|
|
Article DetailedArticleInfo `json:"article"`
|
|
ApprovalContext ApprovalContext `json:"approval_context"`
|
|
WorkflowProgress WorkflowProgress `json:"workflow_progress"`
|
|
}
|
|
|
|
type DetailedArticleInfo struct {
|
|
ID uint `json:"id"`
|
|
Title string `json:"title"`
|
|
ContentPreview *string `json:"content_preview"`
|
|
FullContentAvailable bool `json:"full_content_available"`
|
|
Author AuthorInfo `json:"author"`
|
|
Category CategoryInfo `json:"category"`
|
|
SubmissionNotes *string `json:"submission_notes"`
|
|
Tags []string `json:"tags"`
|
|
WordCount int `json:"word_count"`
|
|
EstimatedReadTime string `json:"estimated_read_time"`
|
|
SEOScore int `json:"seo_score"`
|
|
ReadabilityScore string `json:"readability_score"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
SubmittedAt time.Time `json:"submitted_at"`
|
|
}
|
|
|
|
type AuthorInfo struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Email string `json:"email"`
|
|
ProfilePicture *string `json:"profile_picture"`
|
|
ReputationScore float64 `json:"reputation_score"`
|
|
ArticlesPublished int `json:"articles_published"`
|
|
ApprovalSuccessRate float64 `json:"approval_success_rate"`
|
|
}
|
|
|
|
type CategoryInfo struct {
|
|
ID uint `json:"id"`
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
|
|
type ApprovalContext struct {
|
|
MyRoleInWorkflow string `json:"my_role_in_workflow"`
|
|
StepDescription string `json:"step_description"`
|
|
ExpectedAction string `json:"expected_action"`
|
|
Deadline *time.Time `json:"deadline"`
|
|
IsOverdue bool `json:"is_overdue"`
|
|
EscalationAvailable bool `json:"escalation_available"`
|
|
}
|
|
|
|
type WorkflowProgress struct {
|
|
CompletedSteps int `json:"completed_steps"`
|
|
TotalSteps int `json:"total_steps"`
|
|
ProgressPercentage float64 `json:"progress_percentage"`
|
|
NextApprover string `json:"next_approver"`
|
|
}
|
|
|
|
type ApprovalStatistics struct {
|
|
PendingCount int `json:"pending_count"`
|
|
OverdueCount int `json:"overdue_count"`
|
|
ApprovedToday int `json:"approved_today"`
|
|
ApprovedThisWeek int `json:"approved_this_week"`
|
|
ApprovedThisMonth int `json:"approved_this_month"`
|
|
RejectedThisMonth int `json:"rejected_this_month"`
|
|
RevisionRequestsThisMonth int `json:"revision_requests_this_month"`
|
|
AverageApprovalTimeHours float64 `json:"average_approval_time_hours"`
|
|
FastestApprovalMinutes int `json:"fastest_approval_minutes"`
|
|
SlowestApprovalHours int `json:"slowest_approval_hours"`
|
|
ApprovalRatePercentage float64 `json:"approval_rate_percentage"`
|
|
CategoriesBreakdown []CategoryBreakdown `json:"categories_breakdown"`
|
|
}
|
|
|
|
type CategoryBreakdown struct {
|
|
CategoryName string `json:"category_name"`
|
|
Pending int `json:"pending"`
|
|
ApprovedThisMonth int `json:"approved_this_month"`
|
|
}
|
|
|
|
type WorkloadDistribution struct {
|
|
MyLevel LevelWorkload `json:"my_level"`
|
|
TeamComparison []TeamMember `json:"team_comparison"`
|
|
Bottlenecks []BottleneckInfo `json:"bottlenecks"`
|
|
}
|
|
|
|
type LevelWorkload struct {
|
|
LevelName string `json:"level_name"`
|
|
LevelNumber int `json:"level_number"`
|
|
PendingArticles int `json:"pending_articles"`
|
|
AvgDailyApprovals float64 `json:"avg_daily_approvals"`
|
|
}
|
|
|
|
type TeamMember struct {
|
|
ApproverName string `json:"approver_name"`
|
|
LevelName string `json:"level_name"`
|
|
PendingArticles int `json:"pending_articles"`
|
|
AvgResponseTimeHours float64 `json:"avg_response_time_hours"`
|
|
}
|
|
|
|
type BottleneckInfo struct {
|
|
LevelName string `json:"level_name"`
|
|
PendingCount int `json:"pending_count"`
|
|
AvgWaitingTimeHours float64 `json:"avg_waiting_time_hours"`
|
|
IsBottleneck bool `json:"is_bottleneck"`
|
|
}
|
|
|
|
type BulkActionResult struct {
|
|
ProcessedCount int `json:"processed_count"`
|
|
SuccessfulCount int `json:"successful_count"`
|
|
FailedCount int `json:"failed_count"`
|
|
Results []BulkActionItemResult `json:"results"`
|
|
}
|
|
|
|
type BulkActionItemResult struct {
|
|
ArticleId uint `json:"article_id"`
|
|
Status string `json:"status"`
|
|
NextStep string `json:"next_step"`
|
|
Error *string `json:"error,omitempty"`
|
|
}
|
|
|
|
func NewArticleApprovalFlowService(
|
|
repo repository.ArticleApprovalFlowRepository,
|
|
articlesRepo articlesRepo.ArticlesRepository,
|
|
workflowService workflowService.ApprovalWorkflowService,
|
|
log zerolog.Logger,
|
|
) ArticleApprovalFlowService {
|
|
return &articleApprovalFlowService{
|
|
Repo: repo,
|
|
ArticlesRepo: articlesRepo,
|
|
WorkflowService: workflowService,
|
|
Log: log,
|
|
}
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) InitiateApprovalFlow(clientId *uuid.UUID, articleId uint, workflowId uint) (*entity.ArticleApprovalFlows, error) {
|
|
// Validate workflow
|
|
err := s.WorkflowService.ValidateWorkflow(workflowId)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid workflow: %v", err)
|
|
}
|
|
|
|
// Check if article already has active approval flow
|
|
existingFlow, _ := s.Repo.FindActiveByArticleId(articleId)
|
|
if existingFlow != nil {
|
|
return nil, errors.New("article already has active approval flow")
|
|
}
|
|
|
|
// Create new approval flow
|
|
approvalFlow := &entity.ArticleApprovalFlows{
|
|
ArticleId: articleId,
|
|
WorkflowId: workflowId,
|
|
CurrentStep: 1,
|
|
StatusId: 1, // In Progress
|
|
ClientId: clientId,
|
|
}
|
|
|
|
// Update article status
|
|
statusId := 1 // Pending approval
|
|
err = s.ArticlesRepo.UpdateSkipNull(clientId, articleId, &entity.Articles{
|
|
StatusId: &statusId,
|
|
WorkflowId: &workflowId,
|
|
CurrentApprovalStep: &[]int{1}[0],
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to update article status: %v", err)
|
|
}
|
|
|
|
return s.Repo.Create(approvalFlow)
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) ProcessApprovalStep(clientId *uuid.UUID, articleId uint, action string, message *string, approver *users.Users) error {
|
|
// Get active approval flow
|
|
approvalFlow, err := s.Repo.FindActiveByArticleId(articleId)
|
|
if err != nil {
|
|
return fmt.Errorf("approval flow not found: %v", err)
|
|
}
|
|
|
|
// Get workflow with steps
|
|
workflow, err := s.WorkflowService.GetWorkflowWithSteps(approvalFlow.WorkflowId)
|
|
if err != nil {
|
|
return fmt.Errorf("workflow not found: %v", err)
|
|
}
|
|
|
|
// Find current step
|
|
var currentStep *entity.ApprovalWorkflowSteps
|
|
for _, step := range workflow.Steps {
|
|
if step.StepOrder == approvalFlow.CurrentStep {
|
|
currentStep = &step
|
|
break
|
|
}
|
|
}
|
|
|
|
if currentStep == nil {
|
|
return errors.New("current step not found in workflow")
|
|
}
|
|
|
|
// Validate approver has permission for this step
|
|
if approver.UserLevelId != currentStep.UserLevelId {
|
|
return errors.New("approver does not have permission for this approval step")
|
|
}
|
|
|
|
// Create step log
|
|
now := time.Now()
|
|
stepLog := &entity.ArticleApprovalStepLogs{
|
|
ArticleFlowId: approvalFlow.ID,
|
|
StepOrder: approvalFlow.CurrentStep,
|
|
UserLevelId: currentStep.UserLevelId,
|
|
ApprovedBy: approver.ID,
|
|
Action: action,
|
|
Message: message,
|
|
ApprovedAt: &now,
|
|
ClientId: clientId,
|
|
}
|
|
|
|
err = s.Repo.CreateStepLog(stepLog)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to create step log: %v", err)
|
|
}
|
|
|
|
// Process action
|
|
switch action {
|
|
case "approved":
|
|
return s.processApproval(clientId, approvalFlow, workflow)
|
|
case "rejected":
|
|
return s.processRejection(clientId, approvalFlow)
|
|
case "revision_requested":
|
|
return s.processRevisionRequest(clientId, approvalFlow)
|
|
default:
|
|
return errors.New("invalid action")
|
|
}
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) processApproval(clientId *uuid.UUID, approvalFlow *entity.ArticleApprovalFlows, workflow *entity.ApprovalWorkflows) error {
|
|
// Check if this is the last step
|
|
maxStep := 0
|
|
for _, step := range workflow.Steps {
|
|
if step.StepOrder > maxStep {
|
|
maxStep = step.StepOrder
|
|
}
|
|
}
|
|
|
|
if approvalFlow.CurrentStep >= maxStep {
|
|
// Final approval - publish article
|
|
return s.finalizeApproval(clientId, approvalFlow)
|
|
} else {
|
|
// Move to next step
|
|
return s.moveToNextStep(clientId, approvalFlow)
|
|
}
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) finalizeApproval(clientId *uuid.UUID, approvalFlow *entity.ArticleApprovalFlows) error {
|
|
// Update approval flow status
|
|
approvalFlow.StatusId = 2 // Completed
|
|
err := s.Repo.Update(approvalFlow.ID, approvalFlow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update article status to published
|
|
isPublish := true
|
|
statusId := 2 // Published
|
|
now := time.Now()
|
|
|
|
err = s.ArticlesRepo.UpdateSkipNull(clientId, approvalFlow.ArticleId, &entity.Articles{
|
|
StatusId: &statusId,
|
|
IsPublish: &isPublish,
|
|
PublishedAt: &now,
|
|
CurrentApprovalStep: nil, // Clear approval step
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) moveToNextStep(clientId *uuid.UUID, approvalFlow *entity.ArticleApprovalFlows) error {
|
|
// Move to next step
|
|
approvalFlow.CurrentStep++
|
|
err := s.Repo.Update(approvalFlow.ID, approvalFlow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update article current step
|
|
err = s.ArticlesRepo.UpdateSkipNull(clientId, approvalFlow.ArticleId, &entity.Articles{
|
|
CurrentApprovalStep: &approvalFlow.CurrentStep,
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) processRejection(clientId *uuid.UUID, approvalFlow *entity.ArticleApprovalFlows) error {
|
|
// Update approval flow status
|
|
approvalFlow.StatusId = 3 // Rejected
|
|
err := s.Repo.Update(approvalFlow.ID, approvalFlow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update article status
|
|
statusId := 3 // Rejected
|
|
err = s.ArticlesRepo.UpdateSkipNull(clientId, approvalFlow.ArticleId, &entity.Articles{
|
|
StatusId: &statusId,
|
|
CurrentApprovalStep: nil,
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) processRevisionRequest(clientId *uuid.UUID, approvalFlow *entity.ArticleApprovalFlows) error {
|
|
// Reset approval flow to step 0 (back to contributor)
|
|
approvalFlow.CurrentStep = 0
|
|
approvalFlow.StatusId = 4 // Revision Requested
|
|
err := s.Repo.Update(approvalFlow.ID, approvalFlow)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Update article status
|
|
statusId := 4 // Revision Requested
|
|
err = s.ArticlesRepo.UpdateSkipNull(clientId, approvalFlow.ArticleId, &entity.Articles{
|
|
StatusId: &statusId,
|
|
CurrentApprovalStep: &[]int{0}[0],
|
|
})
|
|
|
|
return err
|
|
}
|
|
|
|
// Enhanced methods for approver dashboard
|
|
func (s *articleApprovalFlowService) GetMyApprovalQueue(clientId *uuid.UUID, userLevelId uint, filters ApprovalFilters) ([]*DetailedApprovalFlow, paginator.Pagination, error) {
|
|
// Build query with filters
|
|
query := s.Repo.BuildApprovalQueueQuery(clientId, userLevelId, filters)
|
|
|
|
// Get paginated results
|
|
approvalFlows, pagination, err := s.Repo.GetPaginatedApprovalQueue(query, filters.Page, filters.Limit)
|
|
if err != nil {
|
|
return nil, paginator.Pagination{}, err
|
|
}
|
|
|
|
// Transform to detailed approval flows
|
|
detailedFlows := make([]*DetailedApprovalFlow, 0, len(approvalFlows))
|
|
for _, flow := range approvalFlows {
|
|
detailedFlow, err := s.transformToDetailedFlow(flow, filters.IncludePreview)
|
|
if err != nil {
|
|
s.Log.Error().Err(err).Uint("flow_id", flow.ID).Msg("Failed to transform approval flow")
|
|
continue
|
|
}
|
|
detailedFlows = append(detailedFlows, detailedFlow)
|
|
}
|
|
|
|
return detailedFlows, pagination, nil
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) GetApprovalStatistics(clientId *uuid.UUID, userLevelId uint) (*ApprovalStatistics, error) {
|
|
stats := &ApprovalStatistics{}
|
|
|
|
// Get pending count
|
|
pendingCount, err := s.Repo.GetPendingCountByLevel(clientId, userLevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats.PendingCount = pendingCount
|
|
|
|
// Get overdue count
|
|
overdueCount, err := s.Repo.GetOverdueCountByLevel(clientId, userLevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stats.OverdueCount = overdueCount
|
|
|
|
// Get approval counts by time period
|
|
now := time.Now()
|
|
startOfDay := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
|
startOfWeek := startOfDay.AddDate(0, 0, -int(now.Weekday()))
|
|
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
|
|
|
stats.ApprovedToday, _ = s.Repo.GetApprovedCountByPeriod(clientId, userLevelId, startOfDay, now)
|
|
stats.ApprovedThisWeek, _ = s.Repo.GetApprovedCountByPeriod(clientId, userLevelId, startOfWeek, now)
|
|
stats.ApprovedThisMonth, _ = s.Repo.GetApprovedCountByPeriod(clientId, userLevelId, startOfMonth, now)
|
|
stats.RejectedThisMonth, _ = s.Repo.GetRejectedCountByPeriod(clientId, userLevelId, startOfMonth, now)
|
|
stats.RevisionRequestsThisMonth, _ = s.Repo.GetRevisionRequestCountByPeriod(clientId, userLevelId, startOfMonth, now)
|
|
|
|
// Get timing statistics
|
|
timingStats, err := s.Repo.GetApprovalTimingStats(clientId, userLevelId)
|
|
if err == nil {
|
|
stats.AverageApprovalTimeHours = timingStats.AverageHours
|
|
stats.FastestApprovalMinutes = timingStats.FastestMinutes
|
|
stats.SlowestApprovalHours = timingStats.SlowestHours
|
|
}
|
|
|
|
// Calculate approval rate
|
|
totalProcessed := stats.ApprovedThisMonth + stats.RejectedThisMonth
|
|
if totalProcessed > 0 {
|
|
stats.ApprovalRatePercentage = float64(stats.ApprovedThisMonth) / float64(totalProcessed) * 100
|
|
}
|
|
|
|
// Get categories breakdown
|
|
categoriesBreakdown, err := s.Repo.GetCategoriesBreakdown(clientId, userLevelId)
|
|
if err == nil {
|
|
stats.CategoriesBreakdown = categoriesBreakdown
|
|
}
|
|
|
|
return stats, nil
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) GetWorkloadDistribution(clientId *uuid.UUID, userLevelId uint) (*WorkloadDistribution, error) {
|
|
distribution := &WorkloadDistribution{}
|
|
|
|
// Get my level workload
|
|
myLevel, err := s.Repo.GetLevelWorkload(clientId, userLevelId)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
distribution.MyLevel = *myLevel
|
|
|
|
// Get team comparison (same level users)
|
|
teamMembers, err := s.Repo.GetTeamWorkloadComparison(clientId, userLevelId)
|
|
if err == nil {
|
|
distribution.TeamComparison = teamMembers
|
|
}
|
|
|
|
// Identify bottlenecks
|
|
bottlenecks, err := s.Repo.GetWorkflowBottlenecks(clientId)
|
|
if err == nil {
|
|
distribution.Bottlenecks = bottlenecks
|
|
}
|
|
|
|
return distribution, nil
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) BulkApprovalAction(clientId *uuid.UUID, articleIds []uint, action string, message *string, approver *users.Users) (*BulkActionResult, error) {
|
|
result := &BulkActionResult{
|
|
ProcessedCount: len(articleIds),
|
|
Results: make([]BulkActionItemResult, 0, len(articleIds)),
|
|
}
|
|
|
|
for _, articleId := range articleIds {
|
|
itemResult := BulkActionItemResult{
|
|
ArticleId: articleId,
|
|
}
|
|
|
|
err := s.ProcessApprovalStep(clientId, articleId, action, message, approver)
|
|
if err != nil {
|
|
itemResult.Status = "failed"
|
|
errorMsg := err.Error()
|
|
itemResult.Error = &errorMsg
|
|
result.FailedCount++
|
|
} else {
|
|
itemResult.Status = "success"
|
|
|
|
// Get next step info
|
|
approvalFlow, err := s.Repo.FindActiveByArticleId(articleId)
|
|
if err == nil {
|
|
if approvalFlow.StatusId == 2 {
|
|
itemResult.NextStep = "published"
|
|
} else {
|
|
itemResult.NextStep = fmt.Sprintf("%d", approvalFlow.CurrentStep)
|
|
}
|
|
}
|
|
|
|
result.SuccessfulCount++
|
|
}
|
|
|
|
result.Results = append(result.Results, itemResult)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
func (s *articleApprovalFlowService) transformToDetailedFlow(flow *entity.ArticleApprovalFlows, includePreview bool) (*DetailedApprovalFlow, error) {
|
|
// This method would transform the basic approval flow to detailed view
|
|
// Implementation would include:
|
|
// 1. Fetch article details with author info
|
|
// 2. Calculate approval context (deadlines, role description)
|
|
// 3. Build workflow progress information
|
|
// 4. Add content preview if requested
|
|
|
|
detailedFlow := &DetailedApprovalFlow{
|
|
ID: flow.ID,
|
|
// Article details would be populated from database joins
|
|
// ApprovalContext would be calculated based on workflow step
|
|
// WorkflowProgress would show current position in workflow
|
|
}
|
|
|
|
return detailedFlow, nil
|
|
}
|
|
```
|
|
|
|
## 3. Controller Implementation
|
|
|
|
### Article Approval Controller
|
|
|
|
```go
|
|
// app/module/articles/controller/articles_approval.controller.go
|
|
package controller
|
|
|
|
import (
|
|
"github.com/gofiber/fiber/v2"
|
|
"github.com/rs/zerolog"
|
|
"strconv"
|
|
"web-qudo-be/app/middleware"
|
|
"web-qudo-be/app/module/article_approval_flows/service"
|
|
"web-qudo-be/app/module/articles/request"
|
|
usersRepository "web-qudo-be/app/module/users/repository"
|
|
utilRes "web-qudo-be/utils/response"
|
|
utilSvc "web-qudo-be/utils/service"
|
|
utilVal "web-qudo-be/utils/validator"
|
|
)
|
|
|
|
type articleApprovalController struct {
|
|
approvalFlowService service.ArticleApprovalFlowService
|
|
usersRepo usersRepository.UsersRepository
|
|
log zerolog.Logger
|
|
}
|
|
|
|
type ArticleApprovalController interface {
|
|
SubmitForApproval(c *fiber.Ctx) error
|
|
ApproveStep(c *fiber.Ctx) error
|
|
RejectArticle(c *fiber.Ctx) error
|
|
RequestRevision(c *fiber.Ctx) error
|
|
GetPendingApprovals(c *fiber.Ctx) error
|
|
GetApprovalHistory(c *fiber.Ctx) error
|
|
}
|
|
|
|
func NewArticleApprovalController(
|
|
approvalFlowService service.ArticleApprovalFlowService,
|
|
usersRepo usersRepository.UsersRepository,
|
|
log zerolog.Logger,
|
|
) ArticleApprovalController {
|
|
return &articleApprovalController{
|
|
approvalFlowService: approvalFlowService,
|
|
usersRepo: usersRepo,
|
|
log: log,
|
|
}
|
|
}
|
|
|
|
// SubmitForApproval submit article for approval
|
|
// @Summary Submit article for approval
|
|
// @Description API for submitting article for approval workflow
|
|
// @Tags Articles
|
|
// @Security Bearer
|
|
// @Param id path int true "Article ID"
|
|
// @Param payload body request.SubmitApprovalRequest true "Required payload"
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 400 {object} response.BadRequestError
|
|
// @Failure 401 {object} response.UnauthorizedError
|
|
// @Failure 500 {object} response.InternalServerError
|
|
// @Router /articles/{id}/submit-approval [post]
|
|
func (ctrl *articleApprovalController) SubmitForApproval(c *fiber.Ctx) error {
|
|
clientId := middleware.GetClientId(c)
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := new(request.SubmitApprovalRequest)
|
|
if err := utilVal.ParseAndValidate(c, req); err != nil {
|
|
return err
|
|
}
|
|
|
|
approvalFlow, err := ctrl.approvalFlowService.InitiateApprovalFlow(clientId, uint(id), req.WorkflowId)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return utilRes.Resp(c, utilRes.Response{
|
|
Success: true,
|
|
Messages: utilRes.Messages{"Article submitted for approval successfully"},
|
|
Data: approvalFlow,
|
|
})
|
|
}
|
|
|
|
// ApproveStep approve current approval step
|
|
// @Summary Approve article step
|
|
// @Description API for approving current approval step
|
|
// @Tags Articles
|
|
// @Security Bearer
|
|
// @Param id path int true "Article ID"
|
|
// @Param payload body request.ApprovalActionRequest true "Required payload"
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 400 {object} response.BadRequestError
|
|
// @Failure 401 {object} response.UnauthorizedError
|
|
// @Failure 500 {object} response.InternalServerError
|
|
// @Router /articles/{id}/approve [post]
|
|
func (ctrl *articleApprovalController) ApproveStep(c *fiber.Ctx) error {
|
|
clientId := middleware.GetClientId(c)
|
|
authToken := c.Get("Authorization")
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := new(request.ApprovalActionRequest)
|
|
if err := utilVal.ParseAndValidate(c, req); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current user info
|
|
approver := utilSvc.GetUserInfo(ctrl.log, ctrl.usersRepo, authToken)
|
|
|
|
err = ctrl.approvalFlowService.ProcessApprovalStep(clientId, uint(id), "approved", req.Message, approver)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return utilRes.Resp(c, utilRes.Response{
|
|
Success: true,
|
|
Messages: utilRes.Messages{"Article approved successfully"},
|
|
})
|
|
}
|
|
|
|
// RequestRevision request revision for article
|
|
// @Summary Request article revision
|
|
// @Description API for requesting article revision
|
|
// @Tags Articles
|
|
// @Security Bearer
|
|
// @Param id path int true "Article ID"
|
|
// @Param payload body request.ApprovalActionRequest true "Required payload"
|
|
// @Success 200 {object} response.Response
|
|
// @Failure 400 {object} response.BadRequestError
|
|
// @Failure 401 {object} response.UnauthorizedError
|
|
// @Failure 500 {object} response.InternalServerError
|
|
// @Router /articles/{id}/request-revision [post]
|
|
func (ctrl *articleApprovalController) RequestRevision(c *fiber.Ctx) error {
|
|
clientId := middleware.GetClientId(c)
|
|
authToken := c.Get("Authorization")
|
|
|
|
id, err := strconv.ParseUint(c.Params("id"), 10, 0)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
req := new(request.ApprovalActionRequest)
|
|
if err := utilVal.ParseAndValidate(c, req); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get current user info
|
|
approver := utilSvc.GetUserInfo(ctrl.log, ctrl.usersRepo, authToken)
|
|
|
|
err = ctrl.approvalFlowService.ProcessApprovalStep(clientId, uint(id), "revision_requested", req.Message, approver)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return utilRes.Resp(c, utilRes.Response{
|
|
Success: true,
|
|
Messages: utilRes.Messages{"Revision requested successfully"},
|
|
})
|
|
}
|
|
```
|
|
|
|
## 4. Request/Response Models
|
|
|
|
### Request Models
|
|
|
|
```go
|
|
// app/module/articles/request/approval.request.go
|
|
package request
|
|
|
|
type SubmitApprovalRequest struct {
|
|
WorkflowId uint `json:"workflowId" validate:"required"`
|
|
}
|
|
|
|
type ApprovalActionRequest struct {
|
|
Message *string `json:"message"`
|
|
}
|
|
|
|
// app/module/approval_workflows/request/approval_workflows.request.go
|
|
package request
|
|
|
|
import "web-qudo-be/app/database/entity"
|
|
|
|
type ApprovalWorkflowCreateRequest struct {
|
|
Name string `json:"name" validate:"required"`
|
|
Description *string `json:"description"`
|
|
Steps []ApprovalWorkflowStepRequest `json:"steps" validate:"required,min=1"`
|
|
}
|
|
|
|
type ApprovalWorkflowStepRequest struct {
|
|
StepOrder int `json:"stepOrder" validate:"required,min=1"`
|
|
UserLevelId uint `json:"userLevelId" validate:"required"`
|
|
IsRequired bool `json:"isRequired"`
|
|
CanSkip bool `json:"canSkip"`
|
|
}
|
|
|
|
func (req ApprovalWorkflowCreateRequest) ToEntity() *entity.ApprovalWorkflows {
|
|
workflow := &entity.ApprovalWorkflows{
|
|
Name: req.Name,
|
|
Description: req.Description,
|
|
IsActive: &[]bool{true}[0],
|
|
}
|
|
|
|
for _, stepReq := range req.Steps {
|
|
step := entity.ApprovalWorkflowSteps{
|
|
StepOrder: stepReq.StepOrder,
|
|
UserLevelId: stepReq.UserLevelId,
|
|
IsRequired: &stepReq.IsRequired,
|
|
CanSkip: &stepReq.CanSkip,
|
|
}
|
|
workflow.Steps = append(workflow.Steps, step)
|
|
}
|
|
|
|
return workflow
|
|
}
|
|
```
|
|
|
|
## 5. Migration Scripts
|
|
|
|
### Database Migration
|
|
|
|
```sql
|
|
-- migrations/001_create_approval_system_tables.sql
|
|
|
|
-- Create approval_workflows table
|
|
CREATE TABLE approval_workflows (
|
|
id SERIAL PRIMARY KEY,
|
|
name VARCHAR(255) NOT NULL,
|
|
description TEXT,
|
|
is_active BOOLEAN DEFAULT true,
|
|
client_id UUID,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
-- Create approval_workflow_steps table
|
|
CREATE TABLE approval_workflow_steps (
|
|
id SERIAL PRIMARY KEY,
|
|
workflow_id INT NOT NULL REFERENCES approval_workflows(id) ON DELETE CASCADE,
|
|
step_order INT NOT NULL,
|
|
user_level_id INT NOT NULL REFERENCES user_levels(id),
|
|
is_required BOOLEAN DEFAULT true,
|
|
can_skip BOOLEAN DEFAULT false,
|
|
client_id UUID,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW(),
|
|
UNIQUE(workflow_id, step_order)
|
|
);
|
|
|
|
-- Create article_approval_flows table
|
|
CREATE TABLE article_approval_flows (
|
|
id SERIAL PRIMARY KEY,
|
|
article_id INT NOT NULL REFERENCES articles(id) ON DELETE CASCADE,
|
|
workflow_id INT NOT NULL REFERENCES approval_workflows(id),
|
|
current_step INT DEFAULT 1,
|
|
status_id INT DEFAULT 1, -- 1=In Progress, 2=Completed, 3=Rejected, 4=Revision Requested
|
|
client_id UUID,
|
|
created_at TIMESTAMP DEFAULT NOW(),
|
|
updated_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
-- Create article_approval_step_logs table
|
|
CREATE TABLE article_approval_step_logs (
|
|
id SERIAL PRIMARY KEY,
|
|
article_flow_id INT NOT NULL REFERENCES article_approval_flows(id) ON DELETE CASCADE,
|
|
step_order INT NOT NULL,
|
|
user_level_id INT NOT NULL REFERENCES user_levels(id),
|
|
approved_by INT NOT NULL REFERENCES users(id),
|
|
action VARCHAR(50) NOT NULL CHECK (action IN ('approved', 'rejected', 'revision_requested')),
|
|
message TEXT,
|
|
approved_at TIMESTAMP,
|
|
client_id UUID,
|
|
created_at TIMESTAMP DEFAULT NOW()
|
|
);
|
|
|
|
-- Add indexes for performance
|
|
CREATE INDEX idx_approval_workflows_client_id ON approval_workflows(client_id);
|
|
CREATE INDEX idx_approval_workflows_is_active ON approval_workflows(is_active);
|
|
CREATE INDEX idx_approval_workflow_steps_workflow_id ON approval_workflow_steps(workflow_id);
|
|
CREATE INDEX idx_article_approval_flows_article_id ON article_approval_flows(article_id);
|
|
CREATE INDEX idx_article_approval_flows_status_id ON article_approval_flows(status_id);
|
|
CREATE INDEX idx_article_approval_step_logs_article_flow_id ON article_approval_step_logs(article_flow_id);
|
|
CREATE INDEX idx_article_approval_step_logs_approved_by ON article_approval_step_logs(approved_by);
|
|
|
|
-- Modify articles table
|
|
ALTER TABLE articles ADD COLUMN workflow_id INT REFERENCES approval_workflows(id);
|
|
ALTER TABLE articles ADD COLUMN current_approval_step INT DEFAULT 0;
|
|
|
|
-- Create default workflow for backward compatibility
|
|
INSERT INTO approval_workflows (name, description, is_active)
|
|
VALUES ('Legacy 3-Level Approval', 'Default 3-level approval system for backward compatibility', true);
|
|
|
|
-- Get the workflow ID (assuming it's 1 for the first insert)
|
|
INSERT INTO approval_workflow_steps (workflow_id, step_order, user_level_id, is_required, can_skip)
|
|
VALUES
|
|
(1, 1, 3, true, false), -- Level 3 approver (first step)
|
|
(1, 2, 2, true, false), -- Level 2 approver (second step)
|
|
(1, 3, 1, true, false); -- Level 1 approver (final step)
|
|
|
|
-- Update existing articles to use default workflow
|
|
UPDATE articles SET workflow_id = 1 WHERE workflow_id IS NULL;
|
|
```
|
|
|
|
## 6. Usage Examples
|
|
|
|
### Creating a Custom Workflow
|
|
|
|
```bash
|
|
# Create a 2-level approval workflow
|
|
curl -X POST http://localhost:8800/api/approval-workflows \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer <token>" \
|
|
-d '{
|
|
"name": "Simple 2-Level Approval",
|
|
"description": "Quick approval for simple articles",
|
|
"steps": [
|
|
{
|
|
"stepOrder": 1,
|
|
"userLevelId": 2,
|
|
"isRequired": true,
|
|
"canSkip": false
|
|
},
|
|
{
|
|
"stepOrder": 2,
|
|
"userLevelId": 1,
|
|
"isRequired": true,
|
|
"canSkip": false
|
|
}
|
|
]
|
|
}'
|
|
```
|
|
|
|
### Submitting Article for Approval
|
|
|
|
```bash
|
|
# Submit article ID 123 for approval using workflow ID 2
|
|
curl -X POST http://localhost:8800/api/articles/123/submit-approval \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer <token>" \
|
|
-d '{
|
|
"workflowId": 2
|
|
}'
|
|
```
|
|
|
|
### Approving an Article
|
|
|
|
```bash
|
|
# Approve article ID 123
|
|
curl -X POST http://localhost:8800/api/articles/123/approve \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer <token>" \
|
|
-d '{
|
|
"message": "Article looks good, approved for next level"
|
|
}'
|
|
```
|
|
|
|
### Requesting Revision
|
|
|
|
```bash
|
|
# Request revision for article ID 123
|
|
curl -X POST http://localhost:8800/api/articles/123/request-revision \
|
|
-H "Content-Type: application/json" \
|
|
-H "Authorization: Bearer <token>" \
|
|
-d '{
|
|
"message": "Please fix the grammar issues in paragraph 2"
|
|
}'
|
|
```
|
|
|
|
Dengan implementasi ini, sistem approval artikel menjadi sangat fleksibel dan dapat disesuaikan dengan kebutuhan organisasi yang berbeda-beda. Workflow dapat dibuat, dimodifikasi, dan dinonaktifkan tanpa mengubah kode aplikasi.
|