36 KiB
36 KiB
Contoh Implementasi Kode - Sistem Approval Artikel Dinamis
1. Database Entities
ApprovalWorkflows Entity
// 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
// 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
// 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
// app/database/entity/article_approval_step_logs.entity.go
package entity
import (
"github.com/google/uuid"
"time"
users "netidhub-saas-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
// app/module/approval_workflows/service/approval_workflows.service.go
package service
import (
"errors"
"github.com/google/uuid"
"github.com/rs/zerolog"
"netidhub-saas-be/app/database/entity"
"netidhub-saas-be/app/module/approval_workflows/repository"
"netidhub-saas-be/app/module/approval_workflows/request"
"netidhub-saas-be/app/module/approval_workflows/response"
"netidhub-saas-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
// 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"
"netidhub-saas-be/app/database/entity"
users "netidhub-saas-be/app/database/entity/users"
"netidhub-saas-be/app/module/article_approval_flows/repository"
"netidhub-saas-be/app/module/articles/repository" as articlesRepo
"netidhub-saas-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
// app/module/articles/controller/articles_approval.controller.go
package controller
import (
"github.com/gofiber/fiber/v2"
"github.com/rs/zerolog"
"strconv"
"netidhub-saas-be/app/middleware"
"netidhub-saas-be/app/module/article_approval_flows/service"
"netidhub-saas-be/app/module/articles/request"
usersRepository "netidhub-saas-be/app/module/users/repository"
utilRes "netidhub-saas-be/utils/response"
utilSvc "netidhub-saas-be/utils/service"
utilVal "netidhub-saas-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
// 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 "netidhub-saas-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
-- 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
# 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
# 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
# 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
# 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.