Add letter and user id

This commit is contained in:
Aditya Siregar 2025-08-19 00:31:04 +07:00
parent 1964fe50de
commit f41daa63da
16 changed files with 3188 additions and 21 deletions

View File

@ -46,6 +46,8 @@ func (a *App) Initialize(cfg *config.Config) error {
rbacHandler := handler.NewRBACHandler(services.rbacService) rbacHandler := handler.NewRBACHandler(services.rbacService)
masterHandler := handler.NewMasterHandler(services.masterService) masterHandler := handler.NewMasterHandler(services.masterService)
letterHandler := handler.NewLetterHandler(services.letterService) letterHandler := handler.NewLetterHandler(services.letterService)
letterOutgoingHandler := handler.NewLetterOutgoingHandler(services.letterOutgoingService)
adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService)
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService) dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
a.router = router.NewRouter( a.router = router.NewRouter(
@ -58,6 +60,8 @@ func (a *App) Initialize(cfg *config.Config) error {
rbacHandler, rbacHandler,
masterHandler, masterHandler,
letterHandler, letterHandler,
letterOutgoingHandler,
adminApprovalFlowHandler,
dispositionRouteHandler, dispositionRouteHandler,
) )
@ -126,6 +130,15 @@ type repositories struct {
recipientRepo *repository.LetterIncomingRecipientRepository recipientRepo *repository.LetterIncomingRecipientRepository
departmentRepo *repository.DepartmentRepository departmentRepo *repository.DepartmentRepository
userDeptRepo *repository.UserDepartmentRepository userDeptRepo *repository.UserDepartmentRepository
// letter outgoing repos
letterOutgoingRepo *repository.LetterOutgoingRepository
letterOutgoingAttachmentRepo *repository.LetterOutgoingAttachmentRepository
letterOutgoingRecipientRepo *repository.LetterOutgoingRecipientRepository
letterOutgoingDiscussionRepo *repository.LetterOutgoingDiscussionRepository
letterOutgoingDiscussionAttachRepo *repository.LetterOutgoingDiscussionAttachmentRepository
letterOutgoingActivityLogRepo *repository.LetterOutgoingActivityLogRepository
approvalFlowRepo *repository.ApprovalFlowRepository
letterOutgoingApprovalRepo *repository.LetterOutgoingApprovalRepository
} }
func (a *App) initRepositories() *repositories { func (a *App) initRepositories() *repositories {
@ -151,6 +164,15 @@ func (a *App) initRepositories() *repositories {
recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db), recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db),
departmentRepo: repository.NewDepartmentRepository(a.db), departmentRepo: repository.NewDepartmentRepository(a.db),
userDeptRepo: repository.NewUserDepartmentRepository(a.db), userDeptRepo: repository.NewUserDepartmentRepository(a.db),
// letter outgoing repos
letterOutgoingRepo: repository.NewLetterOutgoingRepository(a.db),
letterOutgoingAttachmentRepo: repository.NewLetterOutgoingAttachmentRepository(a.db),
letterOutgoingRecipientRepo: repository.NewLetterOutgoingRecipientRepository(a.db),
letterOutgoingDiscussionRepo: repository.NewLetterOutgoingDiscussionRepository(a.db),
letterOutgoingDiscussionAttachRepo: repository.NewLetterOutgoingDiscussionAttachmentRepository(a.db),
letterOutgoingActivityLogRepo: repository.NewLetterOutgoingActivityLogRepository(a.db),
approvalFlowRepo: repository.NewApprovalFlowRepository(a.db),
letterOutgoingApprovalRepo: repository.NewLetterOutgoingApprovalRepository(a.db),
} }
} }
@ -177,6 +199,8 @@ type services struct {
rbacService *service.RBACServiceImpl rbacService *service.RBACServiceImpl
masterService *service.MasterServiceImpl masterService *service.MasterServiceImpl
letterService *service.LetterServiceImpl letterService *service.LetterServiceImpl
letterOutgoingService *service.LetterOutgoingServiceImpl
approvalFlowService *service.ApprovalFlowServiceImpl
dispositionRouteService *service.DispositionRouteServiceImpl dispositionRouteService *service.DispositionRouteServiceImpl
} }
@ -197,6 +221,28 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
letterSvc := service.NewLetterService(processors.letterProcessor) letterSvc := service.NewLetterService(processors.letterProcessor)
dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo) dispRouteSvc := service.NewDispositionRouteService(repos.dispositionRouteRepo)
txManager := repository.NewTxManager(a.db)
letterOutgoingSvc := service.NewLetterOutgoingService(
a.db,
repos.letterOutgoingRepo,
repos.letterOutgoingAttachmentRepo,
repos.letterOutgoingRecipientRepo,
repos.letterOutgoingDiscussionRepo,
repos.letterOutgoingDiscussionAttachRepo,
repos.letterOutgoingActivityLogRepo,
repos.approvalFlowRepo,
repos.letterOutgoingApprovalRepo,
txManager,
)
approvalFlowStepRepo := repository.NewApprovalFlowStepRepository(a.db)
approvalFlowSvc := service.NewApprovalFlowService(
a.db,
repos.approvalFlowRepo,
approvalFlowStepRepo,
txManager,
)
return &services{ return &services{
userService: userSvc, userService: userSvc,
@ -205,6 +251,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
rbacService: rbacSvc, rbacService: rbacSvc,
masterService: masterSvc, masterService: masterSvc,
letterService: letterSvc, letterService: letterSvc,
letterOutgoingService: letterOutgoingSvc,
approvalFlowService: approvalFlowSvc,
dispositionRouteService: dispRouteSvc, dispositionRouteService: dispRouteSvc,
} }
} }

View File

@ -0,0 +1,213 @@
package contract
import (
"time"
"github.com/google/uuid"
)
type CreateOutgoingLetterRecipient struct {
Name string `json:"name" validate:"required"`
Email *string `json:"email,omitempty"`
Position *string `json:"position,omitempty"`
Institution *string `json:"institution,omitempty"`
IsPrimary bool `json:"is_primary"`
}
type CreateOutgoingLetterAttachment struct {
FileURL string `json:"file_url" validate:"required"`
FileName string `json:"file_name" validate:"required"`
FileType string `json:"file_type" validate:"required"`
}
type CreateOutgoingLetterRequest struct {
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `json:"subject" validate:"required"`
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
IssueDate time.Time `json:"issue_date" validate:"required"`
ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"`
Recipients []CreateOutgoingLetterRecipient `json:"recipients,omitempty"`
Attachments []CreateOutgoingLetterAttachment `json:"attachments,omitempty"`
}
type OutgoingLetterRecipientResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email *string `json:"email,omitempty"`
Position *string `json:"position,omitempty"`
Institution *string `json:"institution,omitempty"`
IsPrimary bool `json:"is_primary"`
CreatedAt time.Time `json:"created_at"`
}
type OutgoingLetterAttachmentResponse struct {
ID uuid.UUID `json:"id"`
FileURL string `json:"file_url"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
UploadedAt time.Time `json:"uploaded_at"`
}
type OutgoingLetterApprovalResponse struct {
ID uuid.UUID `json:"id"`
StepOrder int `json:"step_order"`
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
Status string `json:"status"`
Remarks *string `json:"remarks,omitempty"`
ActedAt *time.Time `json:"acted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type OutgoingLetterResponse struct {
ID uuid.UUID `json:"id"`
LetterNumber string `json:"letter_number"`
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `json:"subject"`
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
Priority *PriorityResponse `json:"priority,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
ReceiverInstitution *InstitutionResponse `json:"receiver_institution,omitempty"`
IssueDate time.Time `json:"issue_date"`
Status string `json:"status"`
ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"`
CreatedBy uuid.UUID `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Recipients []OutgoingLetterRecipientResponse `json:"recipients,omitempty"`
Attachments []OutgoingLetterAttachmentResponse `json:"attachments,omitempty"`
Approvals []OutgoingLetterApprovalResponse `json:"approvals,omitempty"`
}
type UpdateOutgoingLetterRequest struct {
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject *string `json:"subject,omitempty"`
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
IssueDate *time.Time `json:"issue_date,omitempty"`
}
type ListOutgoingLettersRequest struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
Status *string `json:"status,omitempty"`
Query *string `json:"query,omitempty"`
CreatedBy *uuid.UUID `json:"created_by,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
FromDate *time.Time `json:"from_date,omitempty"`
ToDate *time.Time `json:"to_date,omitempty"`
}
type ListOutgoingLettersResponse struct {
Items []*OutgoingLetterResponse `json:"items"`
Total int64 `json:"total"`
}
type ApproveLetterRequest struct {
Remarks *string `json:"remarks,omitempty"`
}
type RejectLetterRequest struct {
Reason string `json:"reason" validate:"required"`
}
type AddRecipientsRequest struct {
Recipients []CreateOutgoingLetterRecipient `json:"recipients" validate:"required,dive"`
}
type UpdateRecipientRequest struct {
Name string `json:"name" validate:"required"`
Email *string `json:"email,omitempty"`
Position *string `json:"position,omitempty"`
Institution *string `json:"institution,omitempty"`
IsPrimary bool `json:"is_primary"`
}
type AddAttachmentsRequest struct {
Attachments []CreateOutgoingLetterAttachment `json:"attachments" validate:"required,dive"`
}
type CreateDiscussionAttachment struct {
FileURL string `json:"file_url" validate:"required"`
FileName string `json:"file_name" validate:"required"`
FileType string `json:"file_type" validate:"required"`
}
type CreateDiscussionRequest struct {
ParentID *uuid.UUID `json:"parent_id,omitempty"`
Message string `json:"message" validate:"required"`
Mentions map[string]interface{} `json:"mentions,omitempty"`
Attachments []CreateDiscussionAttachment `json:"attachments,omitempty"`
}
type UpdateDiscussionRequest struct {
Message string `json:"message" validate:"required"`
Mentions map[string]interface{} `json:"mentions,omitempty"`
}
type DiscussionResponse struct {
ID uuid.UUID `json:"id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
UserID uuid.UUID `json:"user_id"`
Message string `json:"message"`
Mentions map[string]interface{} `json:"mentions,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
}
type ApprovalFlowRequest struct {
DepartmentID uuid.UUID `json:"department_id" validate:"required"`
Name string `json:"name" validate:"required"`
Description *string `json:"description,omitempty"`
IsActive bool `json:"is_active"`
Steps []ApprovalFlowStepRequest `json:"steps" validate:"required,dive"`
}
type ApprovalFlowStepRequest struct {
StepOrder int `json:"step_order" validate:"required,min=1"`
ParallelGroup int `json:"parallel_group" validate:"min=1"`
ApproverRoleID *uuid.UUID `json:"approver_role_id,omitempty"`
ApproverUserID *uuid.UUID `json:"approver_user_id,omitempty"`
Required bool `json:"required"`
}
type ApprovalFlowResponse struct {
ID uuid.UUID `json:"id"`
DepartmentID uuid.UUID `json:"department_id"`
Department *DepartmentResponse `json:"department,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
IsActive bool `json:"is_active"`
Steps []ApprovalFlowStepResponse `json:"steps,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ApprovalFlowStepResponse struct {
ID uuid.UUID `json:"id"`
StepOrder int `json:"step_order"`
ParallelGroup int `json:"parallel_group"`
ApproverRoleID *uuid.UUID `json:"approver_role_id,omitempty"`
ApproverRole *RoleResponse `json:"approver_role,omitempty"`
ApproverUserID *uuid.UUID `json:"approver_user_id,omitempty"`
ApproverUser *UserResponse `json:"approver_user,omitempty"`
Required bool `json:"required"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type ListApprovalFlowsRequest struct {
Limit int `json:"limit"`
Offset int `json:"offset"`
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
IsActive *bool `json:"is_active,omitempty"`
}
type ListApprovalFlowsResponse struct {
Items []*ApprovalFlowResponse `json:"items"`
Total int64 `json:"total"`
}

View File

@ -0,0 +1,68 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type ApprovalFlow struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
DepartmentID uuid.UUID `gorm:"type:uuid;not null" json:"department_id"`
Name string `gorm:"not null" json:"name"`
Description *string `json:"description,omitempty"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relations
Department *Department `gorm:"foreignKey:DepartmentID" json:"department,omitempty"`
Steps []ApprovalFlowStep `gorm:"foreignKey:FlowID" json:"steps,omitempty"`
}
func (ApprovalFlow) TableName() string { return "approval_flows" }
type ApprovalFlowStep struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
FlowID uuid.UUID `gorm:"type:uuid;not null" json:"flow_id"`
StepOrder int `gorm:"not null" json:"step_order"`
ParallelGroup int `gorm:"default:1" json:"parallel_group"`
ApproverRoleID *uuid.UUID `json:"approver_role_id,omitempty"`
ApproverUserID *uuid.UUID `json:"approver_user_id,omitempty"`
Required bool `gorm:"default:true" json:"required"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
// Relations
ApprovalFlow *ApprovalFlow `gorm:"foreignKey:FlowID" json:"approval_flow,omitempty"`
ApproverRole *Role `gorm:"foreignKey:ApproverRoleID" json:"approver_role,omitempty"`
ApproverUser *User `gorm:"foreignKey:ApproverUserID" json:"approver_user,omitempty"`
}
func (ApprovalFlowStep) TableName() string { return "approval_flow_steps" }
type ApprovalStatus string
const (
ApprovalStatusPending ApprovalStatus = "pending"
ApprovalStatusApproved ApprovalStatus = "approved"
ApprovalStatusRejected ApprovalStatus = "rejected"
)
type LetterOutgoingApproval struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
StepID uuid.UUID `gorm:"type:uuid;not null" json:"step_id"`
ApproverID *uuid.UUID `json:"approver_id,omitempty"`
Status ApprovalStatus `gorm:"not null;default:'pending'" json:"status"`
Remarks *string `json:"remarks,omitempty"`
ActedAt *time.Time `json:"acted_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
// Relations
Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"`
Step *ApprovalFlowStep `gorm:"foreignKey:StepID" json:"step,omitempty"`
Approver *User `gorm:"foreignKey:ApproverID" json:"approver,omitempty"`
}
func (LetterOutgoingApproval) TableName() string { return "letter_outgoing_approvals" }

View File

@ -0,0 +1,105 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type LetterOutgoingStatus string
const (
LetterOutgoingStatusDraft LetterOutgoingStatus = "draft"
LetterOutgoingStatusPendingApproval LetterOutgoingStatus = "pending_approval"
LetterOutgoingStatusApproved LetterOutgoingStatus = "approved"
LetterOutgoingStatusSent LetterOutgoingStatus = "sent"
LetterOutgoingStatusArchived LetterOutgoingStatus = "archived"
)
type LetterOutgoing struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterNumber string `gorm:"uniqueIndex;not null" json:"letter_number"`
ReferenceNumber *string `json:"reference_number,omitempty"`
Subject string `gorm:"not null" json:"subject"`
Description *string `json:"description,omitempty"`
PriorityID *uuid.UUID `json:"priority_id,omitempty"`
ReceiverInstitutionID *uuid.UUID `json:"receiver_institution_id,omitempty"`
IssueDate time.Time `json:"issue_date"`
Status LetterOutgoingStatus `gorm:"not null;default:'draft'" json:"status"`
ApprovalFlowID *uuid.UUID `json:"approval_flow_id,omitempty"`
CreatedBy uuid.UUID `gorm:"not null" json:"created_by"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
DeletedAt *time.Time `gorm:"index" json:"deleted_at,omitempty"`
// Relations
Priority *Priority `gorm:"foreignKey:PriorityID" json:"priority,omitempty"`
ReceiverInstitution *Institution `gorm:"foreignKey:ReceiverInstitutionID" json:"receiver_institution,omitempty"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
ApprovalFlow *ApprovalFlow `gorm:"foreignKey:ApprovalFlowID" json:"approval_flow,omitempty"`
Recipients []LetterOutgoingRecipient `gorm:"foreignKey:LetterID" json:"recipients,omitempty"`
Attachments []LetterOutgoingAttachment `gorm:"foreignKey:LetterID" json:"attachments,omitempty"`
Approvals []LetterOutgoingApproval `gorm:"foreignKey:LetterID" json:"approvals,omitempty"`
Discussions []LetterOutgoingDiscussion `gorm:"foreignKey:LetterID" json:"discussions,omitempty"`
ActivityLogs []LetterOutgoingActivityLog `gorm:"foreignKey:LetterID" json:"activity_logs,omitempty"`
}
func (LetterOutgoing) TableName() string { return "letters_outgoing" }
type LetterOutgoingRecipient struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
RecipientName string `gorm:"not null" json:"recipient_name"`
RecipientEmail *string `json:"recipient_email,omitempty"`
RecipientPosition *string `json:"recipient_position,omitempty"`
RecipientInstitution *string `json:"recipient_institution,omitempty"`
IsPrimary bool `gorm:"default:false" json:"is_primary"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
}
func (LetterOutgoingRecipient) TableName() string { return "letter_outgoing_recipients" }
type LetterOutgoingAttachment struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
FileURL string `gorm:"not null" json:"file_url"`
FileName string `gorm:"not null" json:"file_name"`
FileType string `gorm:"not null" json:"file_type"`
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"`
}
func (LetterOutgoingAttachment) TableName() string { return "letter_outgoing_attachments" }
type LetterOutgoingDiscussion struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
ParentID *uuid.UUID `json:"parent_id,omitempty"`
UserID uuid.UUID `gorm:"not null" json:"user_id"`
Message string `gorm:"not null" json:"message"`
Mentions JSONB `gorm:"type:jsonb" json:"mentions,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
EditedAt *time.Time `json:"edited_at,omitempty"`
// Relations
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
Attachments []LetterOutgoingDiscussionAttachment `gorm:"foreignKey:DiscussionID" json:"attachments,omitempty"`
Replies []LetterOutgoingDiscussion `gorm:"foreignKey:ParentID" json:"replies,omitempty"`
}
func (LetterOutgoingDiscussion) TableName() string { return "letter_outgoing_discussions" }
type LetterOutgoingDiscussionAttachment struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
DiscussionID uuid.UUID `gorm:"type:uuid;not null" json:"discussion_id"`
FileURL string `gorm:"not null" json:"file_url"`
FileName string `gorm:"not null" json:"file_name"`
FileType string `gorm:"not null" json:"file_type"`
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
UploadedAt time.Time `gorm:"autoCreateTime" json:"uploaded_at"`
}
func (LetterOutgoingDiscussionAttachment) TableName() string {
return "letter_outgoing_discussion_attachments"
}

View File

@ -0,0 +1,46 @@
package entities
import (
"time"
"github.com/google/uuid"
)
type LetterOutgoingActivityLog struct {
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
LetterID uuid.UUID `gorm:"type:uuid;not null" json:"letter_id"`
ActionType string `gorm:"not null" json:"action_type"`
ActorUserID *uuid.UUID `json:"actor_user_id,omitempty"`
ActorDepartmentID *uuid.UUID `json:"actor_department_id,omitempty"`
TargetType *string `json:"target_type,omitempty"`
TargetID *uuid.UUID `json:"target_id,omitempty"`
FromStatus *string `json:"from_status,omitempty"`
ToStatus *string `json:"to_status,omitempty"`
Context JSONB `gorm:"type:jsonb" json:"context,omitempty"`
OccurredAt time.Time `gorm:"autoCreateTime" json:"occurred_at"`
// Relations
Letter *LetterOutgoing `gorm:"foreignKey:LetterID" json:"letter,omitempty"`
ActorUser *User `gorm:"foreignKey:ActorUserID" json:"actor_user,omitempty"`
ActorDepartment *Department `gorm:"foreignKey:ActorDepartmentID" json:"actor_department,omitempty"`
}
func (LetterOutgoingActivityLog) TableName() string { return "letter_outgoing_activity_logs" }
// Action types for letter outgoing activity logs
const (
LetterOutgoingActionCreated = "created"
LetterOutgoingActionUpdated = "updated"
LetterOutgoingActionDeleted = "deleted"
LetterOutgoingActionStatusChanged = "status_changed"
LetterOutgoingActionSubmittedApproval = "submitted_for_approval"
LetterOutgoingActionApproved = "approved"
LetterOutgoingActionRejected = "rejected"
LetterOutgoingActionSent = "sent"
LetterOutgoingActionArchived = "archived"
LetterOutgoingActionAttachmentAdded = "attachment_added"
LetterOutgoingActionAttachmentRemoved = "attachment_removed"
LetterOutgoingActionRecipientAdded = "recipient_added"
LetterOutgoingActionRecipientRemoved = "recipient_removed"
LetterOutgoingActionDiscussionAdded = "discussion_added"
)

View File

@ -0,0 +1,344 @@
package handler
import (
"context"
"net/http"
"strconv"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type ApprovalFlowService interface {
CreateApprovalFlow(ctx context.Context, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error)
GetApprovalFlow(ctx context.Context, id uuid.UUID) (*contract.ApprovalFlowResponse, error)
GetApprovalFlowByDepartment(ctx context.Context, departmentID uuid.UUID) (*contract.ApprovalFlowResponse, error)
UpdateApprovalFlow(ctx context.Context, id uuid.UUID, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error)
DeleteApprovalFlow(ctx context.Context, id uuid.UUID) error
ListApprovalFlows(ctx context.Context, req *contract.ListApprovalFlowsRequest) (*contract.ListApprovalFlowsResponse, error)
}
type AdminApprovalFlowHandler struct {
svc ApprovalFlowService
}
func NewAdminApprovalFlowHandler(svc ApprovalFlowService) *AdminApprovalFlowHandler {
return &AdminApprovalFlowHandler{svc: svc}
}
func (h *AdminApprovalFlowHandler) CreateApprovalFlow(c *gin.Context) {
var req contract.ApprovalFlowRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
// Validate that at least one step is provided
if len(req.Steps) == 0 {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "at least one approval step is required", Code: http.StatusBadRequest})
return
}
// Validate each step has either a role or user as approver
for i, step := range req.Steps {
if step.ApproverRoleID == nil && step.ApproverUserID == nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
Error: "step " + strconv.Itoa(i+1) + " must have either approver_role_id or approver_user_id",
Code: http.StatusBadRequest,
})
return
}
if step.ApproverRoleID != nil && step.ApproverUserID != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
Error: "step " + strconv.Itoa(i+1) + " cannot have both approver_role_id and approver_user_id",
Code: http.StatusBadRequest,
})
return
}
}
resp, err := h.svc.CreateApprovalFlow(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) GetApprovalFlow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.GetApprovalFlow(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) GetApprovalFlowByDepartment(c *gin.Context) {
departmentID, err := uuid.Parse(c.Param("department_id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid department_id", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.GetApprovalFlowByDepartment(c.Request.Context(), departmentID)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) UpdateApprovalFlow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var req contract.ApprovalFlowRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
// Validate that at least one step is provided
if len(req.Steps) == 0 {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "at least one approval step is required", Code: http.StatusBadRequest})
return
}
// Validate each step has either a role or user as approver
for i, step := range req.Steps {
if step.ApproverRoleID == nil && step.ApproverUserID == nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
Error: "step " + strconv.Itoa(i+1) + " must have either approver_role_id or approver_user_id",
Code: http.StatusBadRequest,
})
return
}
if step.ApproverRoleID != nil && step.ApproverUserID != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
Error: "step " + strconv.Itoa(i+1) + " cannot have both approver_role_id and approver_user_id",
Code: http.StatusBadRequest,
})
return
}
}
resp, err := h.svc.UpdateApprovalFlow(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) DeleteApprovalFlow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
if err := h.svc.DeleteApprovalFlow(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "approval flow deleted"})
}
func (h *AdminApprovalFlowHandler) ListApprovalFlows(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset := (page - 1) * limit
departmentIDStr := c.Query("department_id")
isActiveStr := c.Query("is_active")
var departmentID *uuid.UUID
var isActive *bool
if departmentIDStr != "" {
if id, err := uuid.Parse(departmentIDStr); err == nil {
departmentID = &id
}
}
if isActiveStr != "" {
if isActiveStr == "true" {
active := true
isActive = &active
} else if isActiveStr == "false" {
active := false
isActive = &active
}
}
req := &contract.ListApprovalFlowsRequest{
Limit: limit,
Offset: offset,
DepartmentID: departmentID,
IsActive: isActive,
}
resp, err := h.svc.ListApprovalFlows(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) ActivateApprovalFlow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
// Get the current flow
flow, err := h.svc.GetApprovalFlow(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
// Update only the IsActive field
req := &contract.ApprovalFlowRequest{
DepartmentID: flow.DepartmentID,
Name: flow.Name,
Description: flow.Description,
IsActive: true,
Steps: make([]contract.ApprovalFlowStepRequest, len(flow.Steps)),
}
// Copy existing steps
for i, step := range flow.Steps {
req.Steps[i] = contract.ApprovalFlowStepRequest{
StepOrder: step.StepOrder,
ParallelGroup: step.ParallelGroup,
ApproverRoleID: step.ApproverRoleID,
ApproverUserID: step.ApproverUserID,
Required: step.Required,
}
}
resp, err := h.svc.UpdateApprovalFlow(c.Request.Context(), id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) DeactivateApprovalFlow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
// Get the current flow
flow, err := h.svc.GetApprovalFlow(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
// Update only the IsActive field
req := &contract.ApprovalFlowRequest{
DepartmentID: flow.DepartmentID,
Name: flow.Name,
Description: flow.Description,
IsActive: false,
Steps: make([]contract.ApprovalFlowStepRequest, len(flow.Steps)),
}
// Copy existing steps
for i, step := range flow.Steps {
req.Steps[i] = contract.ApprovalFlowStepRequest{
StepOrder: step.StepOrder,
ParallelGroup: step.ParallelGroup,
ApproverRoleID: step.ApproverRoleID,
ApproverUserID: step.ApproverUserID,
Required: step.Required,
}
}
resp, err := h.svc.UpdateApprovalFlow(c.Request.Context(), id, req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *AdminApprovalFlowHandler) CloneApprovalFlow(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var cloneReq struct {
DepartmentID uuid.UUID `json:"department_id" validate:"required"`
Name string `json:"name" validate:"required"`
}
if err := c.ShouldBindJSON(&cloneReq); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
// Get the source flow
sourceFlow, err := h.svc.GetApprovalFlow(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
// Create new flow request with cloned data
req := &contract.ApprovalFlowRequest{
DepartmentID: cloneReq.DepartmentID,
Name: cloneReq.Name,
Description: sourceFlow.Description,
IsActive: false, // New cloned flow starts as inactive
Steps: make([]contract.ApprovalFlowStepRequest, len(sourceFlow.Steps)),
}
// Copy steps from source flow
for i, step := range sourceFlow.Steps {
req.Steps[i] = contract.ApprovalFlowStepRequest{
StepOrder: step.StepOrder,
ParallelGroup: step.ParallelGroup,
ApproverRoleID: step.ApproverRoleID,
ApproverUserID: step.ApproverUserID,
Required: step.Required,
}
}
resp, err := h.svc.CreateApprovalFlow(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}

View File

@ -0,0 +1,420 @@
package handler
import (
"context"
"net/http"
"strconv"
"eslogad-be/internal/contract"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
)
type LetterOutgoingService interface {
CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error)
ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error)
UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error
SubmitForApproval(ctx context.Context, letterID uuid.UUID) error
ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error
RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error
SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error
ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error
AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error
UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error
RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error
AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error
RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error
CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error)
UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
}
type LetterOutgoingHandler struct {
svc LetterOutgoingService
}
func NewLetterOutgoingHandler(svc LetterOutgoingService) *LetterOutgoingHandler {
return &LetterOutgoingHandler{svc: svc}
}
func (h *LetterOutgoingHandler) CreateOutgoingLetter(c *gin.Context) {
var req contract.CreateOutgoingLetterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.CreateOutgoingLetter(c.Request.Context(), &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *LetterOutgoingHandler) GetOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.GetOutgoingLetterByID(c.Request.Context(), id)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *LetterOutgoingHandler) ListOutgoingLetters(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
offset := (page - 1) * limit
status := c.Query("status")
query := c.Query("q")
createdByStr := c.Query("created_by")
receiverInstitutionStr := c.Query("receiver_institution_id")
var statusPtr *string
var queryPtr *string
var createdByPtr *uuid.UUID
var receiverInstitutionPtr *uuid.UUID
if status != "" {
statusPtr = &status
}
if query != "" {
queryPtr = &query
}
if createdByStr != "" {
if createdBy, err := uuid.Parse(createdByStr); err == nil {
createdByPtr = &createdBy
}
}
if receiverInstitutionStr != "" {
if receiverInstitution, err := uuid.Parse(receiverInstitutionStr); err == nil {
receiverInstitutionPtr = &receiverInstitution
}
}
req := &contract.ListOutgoingLettersRequest{
Limit: limit,
Offset: offset,
Status: statusPtr,
Query: queryPtr,
CreatedBy: createdByPtr,
ReceiverInstitutionID: receiverInstitutionPtr,
}
resp, err := h.svc.ListOutgoingLetters(c.Request.Context(), req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *LetterOutgoingHandler) UpdateOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var req contract.UpdateOutgoingLetterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.UpdateOutgoingLetter(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
}
func (h *LetterOutgoingHandler) DeleteOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
if err := h.svc.DeleteOutgoingLetter(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "deleted"})
}
func (h *LetterOutgoingHandler) SubmitForApproval(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
if err := h.svc.SubmitForApproval(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "submitted for approval"})
}
func (h *LetterOutgoingHandler) ApproveOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var req contract.ApproveLetterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
if err := h.svc.ApproveOutgoingLetter(c.Request.Context(), id, &req); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "approved"})
}
func (h *LetterOutgoingHandler) RejectOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var req contract.RejectLetterRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
if err := h.svc.RejectOutgoingLetter(c.Request.Context(), id, &req); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "rejected"})
}
func (h *LetterOutgoingHandler) SendOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
if err := h.svc.SendOutgoingLetter(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "sent"})
}
func (h *LetterOutgoingHandler) ArchiveOutgoingLetter(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
if err := h.svc.ArchiveOutgoingLetter(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "archived"})
}
func (h *LetterOutgoingHandler) AddRecipients(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var req contract.AddRecipientsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
if err := h.svc.AddRecipients(c.Request.Context(), id, &req); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "recipients added"})
}
func (h *LetterOutgoingHandler) UpdateRecipient(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest})
return
}
recipientID, err := uuid.Parse(c.Param("recipient_id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid recipient id", Code: http.StatusBadRequest})
return
}
var req contract.UpdateRecipientRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
if err := h.svc.UpdateRecipient(c.Request.Context(), id, recipientID, &req); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "recipient updated"})
}
func (h *LetterOutgoingHandler) RemoveRecipient(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest})
return
}
recipientID, err := uuid.Parse(c.Param("recipient_id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid recipient id", Code: http.StatusBadRequest})
return
}
if err := h.svc.RemoveRecipient(c.Request.Context(), id, recipientID); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "recipient removed"})
}
func (h *LetterOutgoingHandler) AddAttachments(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid id", Code: http.StatusBadRequest})
return
}
var req contract.AddAttachmentsRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
if err := h.svc.AddAttachments(c.Request.Context(), id, &req); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "attachments added"})
}
func (h *LetterOutgoingHandler) RemoveAttachment(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest})
return
}
attachmentID, err := uuid.Parse(c.Param("attachment_id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid attachment id", Code: http.StatusBadRequest})
return
}
if err := h.svc.RemoveAttachment(c.Request.Context(), id, attachmentID); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "attachment removed"})
}
func (h *LetterOutgoingHandler) CreateDiscussion(c *gin.Context) {
id, err := uuid.Parse(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid letter id", Code: http.StatusBadRequest})
return
}
var req contract.CreateDiscussionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
resp, err := h.svc.CreateDiscussion(c.Request.Context(), id, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusCreated, contract.BuildSuccessResponse(resp))
}
func (h *LetterOutgoingHandler) UpdateDiscussion(c *gin.Context) {
discussionID, err := uuid.Parse(c.Param("discussion_id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid discussion id", Code: http.StatusBadRequest})
return
}
var req contract.UpdateDiscussionRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid body", Code: http.StatusBadRequest})
return
}
if err := h.svc.UpdateDiscussion(c.Request.Context(), discussionID, &req); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "discussion updated"})
}
func (h *LetterOutgoingHandler) DeleteDiscussion(c *gin.Context) {
discussionID, err := uuid.Parse(c.Param("discussion_id"))
if err != nil {
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{Error: "invalid discussion id", Code: http.StatusBadRequest})
return
}
if err := h.svc.DeleteDiscussion(c.Request.Context(), discussionID); err != nil {
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
return
}
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "discussion deleted"})
}

View File

@ -296,6 +296,33 @@ func (h *UserHandler) ListTitles(c *gin.Context) {
c.JSON(http.StatusOK, titles) c.JSON(http.StatusOK, titles)
} }
func (h *UserHandler) GetUserProfile(c *gin.Context) {
userIDStr := c.Param("id")
userID, err := uuid.Parse(userIDStr)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetUserProfile -> Invalid user ID")
h.sendValidationErrorResponse(c, "Invalid user ID", constants.MalformedFieldErrorCode)
return
}
validationError, validationErrorCode := h.userValidator.ValidateUserID(userID)
if validationError != nil {
logger.FromContext(c).WithError(validationError).Error("UserHandler::GetUserProfile -> user ID validation failed")
h.sendValidationErrorResponse(c, validationError.Error(), validationErrorCode)
return
}
profile, err := h.userService.GetProfile(c.Request.Context(), userID)
if err != nil {
logger.FromContext(c).WithError(err).Error("UserHandler::GetUserProfile -> Failed to get user profile from service")
h.sendErrorResponse(c, err.Error(), http.StatusInternalServerError)
return
}
logger.FromContext(c).Infof("UserHandler::GetUserProfile -> Successfully retrieved user profile for user ID = %s", userID)
c.JSON(http.StatusOK, contract.BuildSuccessResponse(profile))
}
func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) { func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) {
search := c.Query("search") search := c.Query("search")
limitStr := c.DefaultQuery("limit", "50") limitStr := c.DefaultQuery("limit", "50")
@ -307,7 +334,7 @@ func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) {
if limit > 100 { if limit > 100 {
limit = 100 limit = 100
} }
var searchPtr *string var searchPtr *string
if search != "" { if search != "" {
searchPtr = &search searchPtr = &search

View File

@ -0,0 +1,229 @@
package repository
import (
"context"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ApprovalFlowRepository struct{ db *gorm.DB }
func NewApprovalFlowRepository(db *gorm.DB) *ApprovalFlowRepository {
return &ApprovalFlowRepository{db: db}
}
func (r *ApprovalFlowRepository) Create(ctx context.Context, e *entities.ApprovalFlow) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *ApprovalFlowRepository) Get(ctx context.Context, id uuid.UUID) (*entities.ApprovalFlow, error) {
db := DBFromContext(ctx, r.db)
var e entities.ApprovalFlow
if err := db.WithContext(ctx).
Preload("Department").
Preload("Steps", func(db *gorm.DB) *gorm.DB {
return db.Order("step_order ASC, parallel_group ASC")
}).
Preload("Steps.ApproverRole").
Preload("Steps.ApproverUser").
Where("id = ?", id).
First(&e).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *ApprovalFlowRepository) GetByDepartment(ctx context.Context, departmentID uuid.UUID) (*entities.ApprovalFlow, error) {
db := DBFromContext(ctx, r.db)
var e entities.ApprovalFlow
if err := db.WithContext(ctx).
Preload("Department").
Preload("Steps", func(db *gorm.DB) *gorm.DB {
return db.Order("step_order ASC, parallel_group ASC")
}).
Preload("Steps.ApproverRole").
Preload("Steps.ApproverUser").
Where("department_id = ? AND is_active = true", departmentID).
First(&e).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *ApprovalFlowRepository) Update(ctx context.Context, e *entities.ApprovalFlow) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.ApprovalFlow{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *ApprovalFlowRepository) Delete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.ApprovalFlow{}).Error
}
type ListApprovalFlowsFilter struct {
DepartmentID *uuid.UUID
IsActive *bool
}
func (r *ApprovalFlowRepository) List(ctx context.Context, filter ListApprovalFlowsFilter, limit, offset int) ([]entities.ApprovalFlow, int64, error) {
db := DBFromContext(ctx, r.db)
query := db.WithContext(ctx).Model(&entities.ApprovalFlow{})
if filter.DepartmentID != nil {
query = query.Where("department_id = ?", *filter.DepartmentID)
}
if filter.IsActive != nil {
query = query.Where("is_active = ?", *filter.IsActive)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []entities.ApprovalFlow
if err := query.
Preload("Department").
Preload("Steps", func(db *gorm.DB) *gorm.DB {
return db.Order("step_order ASC, parallel_group ASC")
}).
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}
type ApprovalFlowStepRepository struct{ db *gorm.DB }
func NewApprovalFlowStepRepository(db *gorm.DB) *ApprovalFlowStepRepository {
return &ApprovalFlowStepRepository{db: db}
}
func (r *ApprovalFlowStepRepository) Create(ctx context.Context, e *entities.ApprovalFlowStep) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *ApprovalFlowStepRepository) CreateBulk(ctx context.Context, list []entities.ApprovalFlowStep) error {
db := DBFromContext(ctx, r.db)
if len(list) == 0 {
return nil
}
return db.WithContext(ctx).Create(&list).Error
}
func (r *ApprovalFlowStepRepository) Update(ctx context.Context, e *entities.ApprovalFlowStep) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.ApprovalFlowStep{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *ApprovalFlowStepRepository) Delete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.ApprovalFlowStep{}).Error
}
func (r *ApprovalFlowStepRepository) DeleteByFlow(ctx context.Context, flowID uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("flow_id = ?", flowID).Delete(&entities.ApprovalFlowStep{}).Error
}
func (r *ApprovalFlowStepRepository) ListByFlow(ctx context.Context, flowID uuid.UUID) ([]entities.ApprovalFlowStep, error) {
db := DBFromContext(ctx, r.db)
var list []entities.ApprovalFlowStep
if err := db.WithContext(ctx).
Preload("ApproverRole").
Preload("ApproverUser").
Where("flow_id = ?", flowID).
Order("step_order ASC, parallel_group ASC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
type LetterOutgoingApprovalRepository struct{ db *gorm.DB }
func NewLetterOutgoingApprovalRepository(db *gorm.DB) *LetterOutgoingApprovalRepository {
return &LetterOutgoingApprovalRepository{db: db}
}
func (r *LetterOutgoingApprovalRepository) Create(ctx context.Context, e *entities.LetterOutgoingApproval) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterOutgoingApprovalRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingApproval) error {
db := DBFromContext(ctx, r.db)
if len(list) == 0 {
return nil
}
return db.WithContext(ctx).Create(&list).Error
}
func (r *LetterOutgoingApprovalRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoingApproval, error) {
db := DBFromContext(ctx, r.db)
var e entities.LetterOutgoingApproval
if err := db.WithContext(ctx).
Preload("Letter").
Preload("Step").
Preload("Approver").
Where("id = ?", id).
First(&e).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *LetterOutgoingApprovalRepository) GetByLetterAndStep(ctx context.Context, letterID, stepID uuid.UUID) (*entities.LetterOutgoingApproval, error) {
db := DBFromContext(ctx, r.db)
var e entities.LetterOutgoingApproval
if err := db.WithContext(ctx).
Where("letter_id = ? AND step_id = ?", letterID, stepID).
First(&e).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *LetterOutgoingApprovalRepository) Update(ctx context.Context, e *entities.LetterOutgoingApproval) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.LetterOutgoingApproval{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *LetterOutgoingApprovalRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterOutgoingApproval
if err := db.WithContext(ctx).
Preload("Step.ApproverRole").
Preload("Step.ApproverUser").
Preload("Approver").
Where("letter_id = ?", letterID).
Order("created_at ASC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *LetterOutgoingApprovalRepository) GetPendingApprovals(ctx context.Context, userID uuid.UUID) ([]entities.LetterOutgoingApproval, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterOutgoingApproval
if err := db.WithContext(ctx).
Preload("Letter").
Preload("Step").
Joins("JOIN approval_flow_steps afs ON afs.id = letter_outgoing_approvals.step_id").
Where("letter_outgoing_approvals.status = ? AND (afs.approver_user_id = ? OR afs.approver_role_id IN (SELECT role_id FROM user_roles WHERE user_id = ?))",
entities.ApprovalStatusPending, userID, userID).
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}

View File

@ -0,0 +1,275 @@
package repository
import (
"context"
"time"
"eslogad-be/internal/entities"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LetterOutgoingRepository struct{ db *gorm.DB }
func NewLetterOutgoingRepository(db *gorm.DB) *LetterOutgoingRepository {
return &LetterOutgoingRepository{db: db}
}
func (r *LetterOutgoingRepository) Create(ctx context.Context, e *entities.LetterOutgoing) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterOutgoingRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoing, error) {
db := DBFromContext(ctx, r.db)
var e entities.LetterOutgoing
if err := db.WithContext(ctx).
Preload("Priority").
Preload("ReceiverInstitution").
Preload("Creator").
Preload("ApprovalFlow").
Preload("Recipients").
Preload("Attachments").
Preload("Approvals.Step").
Preload("Approvals.Approver").
Where("id = ? AND deleted_at IS NULL", id).
First(&e).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *LetterOutgoingRepository) Update(ctx context.Context, e *entities.LetterOutgoing) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", e.ID).Updates(e).Error
}
func (r *LetterOutgoingRepository) SoftDelete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
now := time.Now()
return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error
}
type ListOutgoingLettersFilter struct {
Status *string
Query *string
CreatedBy *uuid.UUID
ReceiverInstitutionID *uuid.UUID
FromDate *time.Time
ToDate *time.Time
}
func (r *LetterOutgoingRepository) List(ctx context.Context, filter ListOutgoingLettersFilter, limit, offset int) ([]entities.LetterOutgoing, int64, error) {
db := DBFromContext(ctx, r.db)
query := db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("deleted_at IS NULL")
if filter.Status != nil {
query = query.Where("status = ?", *filter.Status)
}
if filter.Query != nil {
q := "%" + *filter.Query + "%"
query = query.Where("subject ILIKE ? OR reference_number ILIKE ? OR letter_number ILIKE ?", q, q, q)
}
if filter.CreatedBy != nil {
query = query.Where("created_by = ?", *filter.CreatedBy)
}
if filter.ReceiverInstitutionID != nil {
query = query.Where("receiver_institution_id = ?", *filter.ReceiverInstitutionID)
}
if filter.FromDate != nil {
query = query.Where("issue_date >= ?", *filter.FromDate)
}
if filter.ToDate != nil {
query = query.Where("issue_date <= ?", *filter.ToDate)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var list []entities.LetterOutgoing
if err := query.
Preload("Priority").
Preload("ReceiverInstitution").
Preload("Creator").
Order("created_at DESC").
Limit(limit).
Offset(offset).
Find(&list).Error; err != nil {
return nil, 0, err
}
return list, total, nil
}
func (r *LetterOutgoingRepository) UpdateStatus(ctx context.Context, id uuid.UUID, status entities.LetterOutgoingStatus) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("status", status).Error
}
type LetterOutgoingAttachmentRepository struct{ db *gorm.DB }
func NewLetterOutgoingAttachmentRepository(db *gorm.DB) *LetterOutgoingAttachmentRepository {
return &LetterOutgoingAttachmentRepository{db: db}
}
func (r *LetterOutgoingAttachmentRepository) Create(ctx context.Context, e *entities.LetterOutgoingAttachment) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterOutgoingAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingAttachment) error {
db := DBFromContext(ctx, r.db)
if len(list) == 0 {
return nil
}
return db.WithContext(ctx).Create(&list).Error
}
func (r *LetterOutgoingAttachmentRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingAttachment, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterOutgoingAttachment
if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("uploaded_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *LetterOutgoingAttachmentRepository) Delete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingAttachment{}).Error
}
type LetterOutgoingRecipientRepository struct{ db *gorm.DB }
func NewLetterOutgoingRecipientRepository(db *gorm.DB) *LetterOutgoingRecipientRepository {
return &LetterOutgoingRecipientRepository{db: db}
}
func (r *LetterOutgoingRecipientRepository) Create(ctx context.Context, e *entities.LetterOutgoingRecipient) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterOutgoingRecipientRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingRecipient) error {
db := DBFromContext(ctx, r.db)
if len(list) == 0 {
return nil
}
return db.WithContext(ctx).Create(&list).Error
}
func (r *LetterOutgoingRecipientRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingRecipient, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterOutgoingRecipient
if err := db.WithContext(ctx).Where("letter_id = ?", letterID).Order("is_primary DESC, created_at ASC").Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *LetterOutgoingRecipientRepository) Update(ctx context.Context, e *entities.LetterOutgoingRecipient) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Model(&entities.LetterOutgoingRecipient{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *LetterOutgoingRecipientRepository) Delete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingRecipient{}).Error
}
func (r *LetterOutgoingRecipientRepository) DeleteByLetter(ctx context.Context, letterID uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("letter_id = ?", letterID).Delete(&entities.LetterOutgoingRecipient{}).Error
}
type LetterOutgoingDiscussionRepository struct{ db *gorm.DB }
func NewLetterOutgoingDiscussionRepository(db *gorm.DB) *LetterOutgoingDiscussionRepository {
return &LetterOutgoingDiscussionRepository{db: db}
}
func (r *LetterOutgoingDiscussionRepository) Create(ctx context.Context, e *entities.LetterOutgoingDiscussion) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterOutgoingDiscussionRepository) Get(ctx context.Context, id uuid.UUID) (*entities.LetterOutgoingDiscussion, error) {
db := DBFromContext(ctx, r.db)
var e entities.LetterOutgoingDiscussion
if err := db.WithContext(ctx).
Preload("User").
Preload("Attachments").
Where("id = ?", id).
First(&e).Error; err != nil {
return nil, err
}
return &e, nil
}
func (r *LetterOutgoingDiscussionRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingDiscussion, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterOutgoingDiscussion
if err := db.WithContext(ctx).
Preload("User").
Preload("Attachments").
Preload("Replies.User").
Where("letter_id = ? AND parent_id IS NULL", letterID).
Order("created_at DESC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}
func (r *LetterOutgoingDiscussionRepository) Update(ctx context.Context, e *entities.LetterOutgoingDiscussion) error {
db := DBFromContext(ctx, r.db)
now := time.Now()
e.EditedAt = &now
return db.WithContext(ctx).Model(&entities.LetterOutgoingDiscussion{}).Where("id = ?", e.ID).Updates(e).Error
}
func (r *LetterOutgoingDiscussionRepository) Delete(ctx context.Context, id uuid.UUID) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Where("id = ?", id).Delete(&entities.LetterOutgoingDiscussion{}).Error
}
type LetterOutgoingDiscussionAttachmentRepository struct{ db *gorm.DB }
func NewLetterOutgoingDiscussionAttachmentRepository(db *gorm.DB) *LetterOutgoingDiscussionAttachmentRepository {
return &LetterOutgoingDiscussionAttachmentRepository{db: db}
}
func (r *LetterOutgoingDiscussionAttachmentRepository) CreateBulk(ctx context.Context, list []entities.LetterOutgoingDiscussionAttachment) error {
db := DBFromContext(ctx, r.db)
if len(list) == 0 {
return nil
}
return db.WithContext(ctx).Create(&list).Error
}
type LetterOutgoingActivityLogRepository struct{ db *gorm.DB }
func NewLetterOutgoingActivityLogRepository(db *gorm.DB) *LetterOutgoingActivityLogRepository {
return &LetterOutgoingActivityLogRepository{db: db}
}
func (r *LetterOutgoingActivityLogRepository) Create(ctx context.Context, e *entities.LetterOutgoingActivityLog) error {
db := DBFromContext(ctx, r.db)
return db.WithContext(ctx).Create(e).Error
}
func (r *LetterOutgoingActivityLogRepository) ListByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingActivityLog, error) {
db := DBFromContext(ctx, r.db)
var list []entities.LetterOutgoingActivityLog
if err := db.WithContext(ctx).
Preload("ActorUser").
Preload("ActorDepartment").
Where("letter_id = ?", letterID).
Order("occurred_at DESC").
Find(&list).Error; err != nil {
return nil, err
}
return list, nil
}

View File

@ -9,6 +9,7 @@ type HealthHandler interface {
type UserHandler interface { type UserHandler interface {
ListUsers(c *gin.Context) ListUsers(c *gin.Context)
GetProfile(c *gin.Context) GetProfile(c *gin.Context)
GetUserProfile(c *gin.Context)
UpdateProfile(c *gin.Context) UpdateProfile(c *gin.Context)
ChangePassword(c *gin.Context) ChangePassword(c *gin.Context)
ListTitles(c *gin.Context) ListTitles(c *gin.Context)
@ -70,6 +71,43 @@ type LetterHandler interface {
UpdateDiscussion(c *gin.Context) UpdateDiscussion(c *gin.Context)
} }
type LetterOutgoingHandler interface {
CreateOutgoingLetter(c *gin.Context)
GetOutgoingLetter(c *gin.Context)
ListOutgoingLetters(c *gin.Context)
UpdateOutgoingLetter(c *gin.Context)
DeleteOutgoingLetter(c *gin.Context)
SubmitForApproval(c *gin.Context)
ApproveOutgoingLetter(c *gin.Context)
RejectOutgoingLetter(c *gin.Context)
SendOutgoingLetter(c *gin.Context)
ArchiveOutgoingLetter(c *gin.Context)
AddRecipients(c *gin.Context)
UpdateRecipient(c *gin.Context)
RemoveRecipient(c *gin.Context)
AddAttachments(c *gin.Context)
RemoveAttachment(c *gin.Context)
CreateDiscussion(c *gin.Context)
UpdateDiscussion(c *gin.Context)
DeleteDiscussion(c *gin.Context)
}
type AdminApprovalFlowHandler interface {
CreateApprovalFlow(c *gin.Context)
GetApprovalFlow(c *gin.Context)
GetApprovalFlowByDepartment(c *gin.Context)
UpdateApprovalFlow(c *gin.Context)
DeleteApprovalFlow(c *gin.Context)
ListApprovalFlows(c *gin.Context)
ActivateApprovalFlow(c *gin.Context)
DeactivateApprovalFlow(c *gin.Context)
CloneApprovalFlow(c *gin.Context)
}
type DispositionRouteHandler interface { type DispositionRouteHandler interface {
Create(c *gin.Context) Create(c *gin.Context)
Update(c *gin.Context) Update(c *gin.Context)

View File

@ -8,16 +8,18 @@ import (
) )
type Router struct { type Router struct {
config *config.Config config *config.Config
authHandler AuthHandler authHandler AuthHandler
healthHandler HealthHandler healthHandler HealthHandler
authMiddleware AuthMiddleware authMiddleware AuthMiddleware
userHandler UserHandler userHandler UserHandler
fileHandler FileHandler fileHandler FileHandler
rbacHandler RBACHandler rbacHandler RBACHandler
masterHandler MasterHandler masterHandler MasterHandler
letterHandler LetterHandler letterHandler LetterHandler
dispRouteHandler DispositionRouteHandler letterOutgoingHandler LetterOutgoingHandler
adminApprovalFlowHandler AdminApprovalFlowHandler
dispRouteHandler DispositionRouteHandler
} }
func NewRouter( func NewRouter(
@ -30,19 +32,23 @@ func NewRouter(
rbacHandler RBACHandler, rbacHandler RBACHandler,
masterHandler MasterHandler, masterHandler MasterHandler,
letterHandler LetterHandler, letterHandler LetterHandler,
letterOutgoingHandler LetterOutgoingHandler,
adminApprovalFlowHandler AdminApprovalFlowHandler,
dispRouteHandler DispositionRouteHandler, dispRouteHandler DispositionRouteHandler,
) *Router { ) *Router {
return &Router{ return &Router{
config: cfg, config: cfg,
authHandler: authHandler, authHandler: authHandler,
authMiddleware: authMiddleware, authMiddleware: authMiddleware,
healthHandler: healthHandler, healthHandler: healthHandler,
userHandler: userHandler, userHandler: userHandler,
fileHandler: fileHandler, fileHandler: fileHandler,
rbacHandler: rbacHandler, rbacHandler: rbacHandler,
masterHandler: masterHandler, masterHandler: masterHandler,
letterHandler: letterHandler, letterHandler: letterHandler,
dispRouteHandler: dispRouteHandler, letterOutgoingHandler: letterOutgoingHandler,
adminApprovalFlowHandler: adminApprovalFlowHandler,
dispRouteHandler: dispRouteHandler,
} }
} }
@ -79,6 +85,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
{ {
users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers) users.GET("", r.authMiddleware.RequirePermissions("user.read"), r.userHandler.ListUsers)
users.GET("/profile", r.userHandler.GetProfile) users.GET("/profile", r.userHandler.GetProfile)
users.GET("/:id/profile", r.userHandler.GetUserProfile)
users.PUT("/profile", r.userHandler.UpdateProfile) users.PUT("/profile", r.userHandler.UpdateProfile)
users.PUT(":id/password", r.userHandler.ChangePassword) users.PUT(":id/password", r.userHandler.ChangePassword)
users.GET("/titles", r.userHandler.ListTitles) users.GET("/titles", r.userHandler.ListTitles)
@ -139,6 +146,29 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
lettersch.PUT("/incoming/:id", r.letterHandler.UpdateIncomingLetter) lettersch.PUT("/incoming/:id", r.letterHandler.UpdateIncomingLetter)
lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter) lettersch.DELETE("/incoming/:id", r.letterHandler.DeleteIncomingLetter)
lettersch.POST("/outgoing", r.letterOutgoingHandler.CreateOutgoingLetter)
lettersch.GET("/outgoing/:id", r.letterOutgoingHandler.GetOutgoingLetter)
lettersch.GET("/outgoing", r.letterOutgoingHandler.ListOutgoingLetters)
lettersch.PUT("/outgoing/:id", r.letterOutgoingHandler.UpdateOutgoingLetter)
lettersch.DELETE("/outgoing/:id", r.letterOutgoingHandler.DeleteOutgoingLetter)
lettersch.POST("/outgoing/:id/submit", r.letterOutgoingHandler.SubmitForApproval)
lettersch.POST("/outgoing/:id/approve", r.letterOutgoingHandler.ApproveOutgoingLetter)
lettersch.POST("/outgoing/:id/reject", r.letterOutgoingHandler.RejectOutgoingLetter)
lettersch.POST("/outgoing/:id/send", r.letterOutgoingHandler.SendOutgoingLetter)
lettersch.POST("/outgoing/:id/archive", r.letterOutgoingHandler.ArchiveOutgoingLetter)
lettersch.POST("/outgoing/:id/recipients", r.letterOutgoingHandler.AddRecipients)
lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient)
lettersch.DELETE("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.RemoveRecipient)
lettersch.POST("/outgoing/:id/attachments", r.letterOutgoingHandler.AddAttachments)
lettersch.DELETE("/outgoing/:id/attachments/:attachment_id", r.letterOutgoingHandler.RemoveAttachment)
lettersch.POST("/outgoing/:id/discussions", r.letterOutgoingHandler.CreateDiscussion)
lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion)
lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion)
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions) lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter) lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
@ -155,5 +185,23 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
droutes.GET("department", r.dispRouteHandler.ListByFromDept) droutes.GET("department", r.dispRouteHandler.ListByFromDept)
droutes.PUT(":id/active", r.dispRouteHandler.SetActive) droutes.PUT(":id/active", r.dispRouteHandler.SetActive)
} }
admin := v1.Group("/admin")
admin.Use(r.authMiddleware.RequireAuth())
{
approvalFlows := admin.Group("/approval-flows")
approvalFlows.Use(r.authMiddleware.RequirePermissions("admin.approval_flow"))
{
approvalFlows.POST("", r.adminApprovalFlowHandler.CreateApprovalFlow)
approvalFlows.GET("", r.adminApprovalFlowHandler.ListApprovalFlows)
approvalFlows.GET("/:id", r.adminApprovalFlowHandler.GetApprovalFlow)
approvalFlows.GET("/department/:department_id", r.adminApprovalFlowHandler.GetApprovalFlowByDepartment)
approvalFlows.PUT("/:id", r.adminApprovalFlowHandler.UpdateApprovalFlow)
approvalFlows.DELETE("/:id", r.adminApprovalFlowHandler.DeleteApprovalFlow)
approvalFlows.POST("/:id/activate", r.adminApprovalFlowHandler.ActivateApprovalFlow)
approvalFlows.POST("/:id/deactivate", r.adminApprovalFlowHandler.DeactivateApprovalFlow)
approvalFlows.POST("/:id/clone", r.adminApprovalFlowHandler.CloneApprovalFlow)
}
}
} }
} }

View File

@ -0,0 +1,247 @@
package service
import (
"context"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ApprovalFlowService interface {
CreateApprovalFlow(ctx context.Context, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error)
GetApprovalFlow(ctx context.Context, id uuid.UUID) (*contract.ApprovalFlowResponse, error)
GetApprovalFlowByDepartment(ctx context.Context, departmentID uuid.UUID) (*contract.ApprovalFlowResponse, error)
UpdateApprovalFlow(ctx context.Context, id uuid.UUID, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error)
DeleteApprovalFlow(ctx context.Context, id uuid.UUID) error
ListApprovalFlows(ctx context.Context, req *contract.ListApprovalFlowsRequest) (*contract.ListApprovalFlowsResponse, error)
}
type ApprovalFlowServiceImpl struct {
db *gorm.DB
flowRepo *repository.ApprovalFlowRepository
stepRepo *repository.ApprovalFlowStepRepository
txManager *repository.TxManager
}
func NewApprovalFlowService(
db *gorm.DB,
flowRepo *repository.ApprovalFlowRepository,
stepRepo *repository.ApprovalFlowStepRepository,
txManager *repository.TxManager,
) *ApprovalFlowServiceImpl {
return &ApprovalFlowServiceImpl{
db: db,
flowRepo: flowRepo,
stepRepo: stepRepo,
txManager: txManager,
}
}
func (s *ApprovalFlowServiceImpl) CreateApprovalFlow(ctx context.Context, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) {
flow := &entities.ApprovalFlow{
DepartmentID: req.DepartmentID,
Name: req.Name,
Description: req.Description,
IsActive: req.IsActive,
}
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.flowRepo.Create(txCtx, flow); err != nil {
return err
}
if len(req.Steps) > 0 {
steps := make([]entities.ApprovalFlowStep, len(req.Steps))
for i, stepReq := range req.Steps {
if stepReq.ApproverRoleID == nil && stepReq.ApproverUserID == nil {
return gorm.ErrInvalidData
}
steps[i] = entities.ApprovalFlowStep{
FlowID: flow.ID,
StepOrder: stepReq.StepOrder,
ParallelGroup: stepReq.ParallelGroup,
ApproverRoleID: stepReq.ApproverRoleID,
ApproverUserID: stepReq.ApproverUserID,
Required: stepReq.Required,
}
}
if err := s.stepRepo.CreateBulk(txCtx, steps); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
result, err := s.flowRepo.Get(ctx, flow.ID)
if err != nil {
return nil, err
}
return transformApprovalFlowToResponse(result), nil
}
func (s *ApprovalFlowServiceImpl) GetApprovalFlow(ctx context.Context, id uuid.UUID) (*contract.ApprovalFlowResponse, error) {
flow, err := s.flowRepo.Get(ctx, id)
if err != nil {
return nil, err
}
return transformApprovalFlowToResponse(flow), nil
}
func (s *ApprovalFlowServiceImpl) GetApprovalFlowByDepartment(ctx context.Context, departmentID uuid.UUID) (*contract.ApprovalFlowResponse, error) {
flow, err := s.flowRepo.GetByDepartment(ctx, departmentID)
if err != nil {
return nil, err
}
return transformApprovalFlowToResponse(flow), nil
}
func (s *ApprovalFlowServiceImpl) UpdateApprovalFlow(ctx context.Context, id uuid.UUID, req *contract.ApprovalFlowRequest) (*contract.ApprovalFlowResponse, error) {
flow, err := s.flowRepo.Get(ctx, id)
if err != nil {
return nil, err
}
flow.DepartmentID = req.DepartmentID
flow.Name = req.Name
flow.Description = req.Description
flow.IsActive = req.IsActive
err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.flowRepo.Update(txCtx, flow); err != nil {
return err
}
if err := s.stepRepo.DeleteByFlow(txCtx, id); err != nil {
return err
}
if len(req.Steps) > 0 {
steps := make([]entities.ApprovalFlowStep, len(req.Steps))
for i, stepReq := range req.Steps {
if stepReq.ApproverRoleID == nil && stepReq.ApproverUserID == nil {
return gorm.ErrInvalidData
}
steps[i] = entities.ApprovalFlowStep{
FlowID: flow.ID,
StepOrder: stepReq.StepOrder,
ParallelGroup: stepReq.ParallelGroup,
ApproverRoleID: stepReq.ApproverRoleID,
ApproverUserID: stepReq.ApproverUserID,
Required: stepReq.Required,
}
}
if err := s.stepRepo.CreateBulk(txCtx, steps); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
result, err := s.flowRepo.Get(ctx, id)
if err != nil {
return nil, err
}
return transformApprovalFlowToResponse(result), nil
}
func (s *ApprovalFlowServiceImpl) DeleteApprovalFlow(ctx context.Context, id uuid.UUID) error {
return s.flowRepo.Delete(ctx, id)
}
func (s *ApprovalFlowServiceImpl) ListApprovalFlows(ctx context.Context, req *contract.ListApprovalFlowsRequest) (*contract.ListApprovalFlowsResponse, error) {
filter := repository.ListApprovalFlowsFilter{
DepartmentID: req.DepartmentID,
IsActive: req.IsActive,
}
flows, total, err := s.flowRepo.List(ctx, filter, req.Limit, req.Offset)
if err != nil {
return nil, err
}
items := make([]*contract.ApprovalFlowResponse, len(flows))
for i, flow := range flows {
items[i] = transformApprovalFlowToResponse(&flow)
}
return &contract.ListApprovalFlowsResponse{
Items: items,
Total: total,
}, nil
}
func transformApprovalFlowToResponse(flow *entities.ApprovalFlow) *contract.ApprovalFlowResponse {
resp := &contract.ApprovalFlowResponse{
ID: flow.ID,
DepartmentID: flow.DepartmentID,
Name: flow.Name,
Description: flow.Description,
IsActive: flow.IsActive,
CreatedAt: flow.CreatedAt,
UpdatedAt: flow.UpdatedAt,
}
if flow.Department != nil {
resp.Department = &contract.DepartmentResponse{
ID: flow.Department.ID,
Name: flow.Department.Name,
}
}
if len(flow.Steps) > 0 {
resp.Steps = make([]contract.ApprovalFlowStepResponse, len(flow.Steps))
for i, step := range flow.Steps {
stepResp := contract.ApprovalFlowStepResponse{
ID: step.ID,
StepOrder: step.StepOrder,
ParallelGroup: step.ParallelGroup,
ApproverRoleID: step.ApproverRoleID,
ApproverUserID: step.ApproverUserID,
Required: step.Required,
CreatedAt: step.CreatedAt,
UpdatedAt: step.UpdatedAt,
}
if step.ApproverRole != nil {
stepResp.ApproverRole = &contract.RoleResponse{
ID: step.ApproverRole.ID,
Name: step.ApproverRole.Name,
}
}
if step.ApproverUser != nil {
stepResp.ApproverUser = &contract.UserResponse{
ID: step.ApproverUser.ID,
Name: step.ApproverUser.Name,
Email: step.ApproverUser.Email,
}
}
resp.Steps[i] = stepResp
}
}
return resp
}

View File

@ -0,0 +1,836 @@
package service
import (
"context"
"time"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type LetterOutgoingService interface {
CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error)
ListOutgoingLetters(ctx context.Context, req *contract.ListOutgoingLettersRequest) (*contract.ListOutgoingLettersResponse, error)
UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error)
DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error
SubmitForApproval(ctx context.Context, letterID uuid.UUID) error
ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error
RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error
SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error
ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error
AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error
UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error
RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error
AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error
RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error
CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error)
UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
}
type LetterOutgoingServiceImpl struct {
db *gorm.DB
letterRepo *repository.LetterOutgoingRepository
attachmentRepo *repository.LetterOutgoingAttachmentRepository
recipientRepo *repository.LetterOutgoingRecipientRepository
discussionRepo *repository.LetterOutgoingDiscussionRepository
discussionAttachmentRepo *repository.LetterOutgoingDiscussionAttachmentRepository
activityLogRepo *repository.LetterOutgoingActivityLogRepository
approvalFlowRepo *repository.ApprovalFlowRepository
approvalRepo *repository.LetterOutgoingApprovalRepository
txManager *repository.TxManager
}
func NewLetterOutgoingService(
db *gorm.DB,
letterRepo *repository.LetterOutgoingRepository,
attachmentRepo *repository.LetterOutgoingAttachmentRepository,
recipientRepo *repository.LetterOutgoingRecipientRepository,
discussionRepo *repository.LetterOutgoingDiscussionRepository,
discussionAttachmentRepo *repository.LetterOutgoingDiscussionAttachmentRepository,
activityLogRepo *repository.LetterOutgoingActivityLogRepository,
approvalFlowRepo *repository.ApprovalFlowRepository,
approvalRepo *repository.LetterOutgoingApprovalRepository,
txManager *repository.TxManager,
) *LetterOutgoingServiceImpl {
return &LetterOutgoingServiceImpl{
db: db,
letterRepo: letterRepo,
attachmentRepo: attachmentRepo,
recipientRepo: recipientRepo,
discussionRepo: discussionRepo,
discussionAttachmentRepo: discussionAttachmentRepo,
activityLogRepo: activityLogRepo,
approvalFlowRepo: approvalFlowRepo,
approvalRepo: approvalRepo,
txManager: txManager,
}
}
func (s *LetterOutgoingServiceImpl) CreateOutgoingLetter(ctx context.Context, req *contract.CreateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) {
userID := getUserIDFromContext(ctx)
letter := &entities.LetterOutgoing{
Subject: req.Subject,
Description: req.Description,
PriorityID: req.PriorityID,
ReceiverInstitutionID: req.ReceiverInstitutionID,
IssueDate: req.IssueDate,
Status: entities.LetterOutgoingStatusDraft,
ApprovalFlowID: req.ApprovalFlowID,
CreatedBy: userID,
}
if req.ReferenceNumber != nil {
letter.ReferenceNumber = req.ReferenceNumber
}
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.letterRepo.Create(txCtx, letter); err != nil {
return err
}
if len(req.Recipients) > 0 {
recipients := make([]entities.LetterOutgoingRecipient, len(req.Recipients))
for i, r := range req.Recipients {
recipients[i] = entities.LetterOutgoingRecipient{
LetterID: letter.ID,
RecipientName: r.Name,
RecipientEmail: r.Email,
RecipientPosition: r.Position,
RecipientInstitution: r.Institution,
IsPrimary: r.IsPrimary,
}
}
if err := s.recipientRepo.CreateBulk(txCtx, recipients); err != nil {
return err
}
}
if len(req.Attachments) > 0 {
attachments := make([]entities.LetterOutgoingAttachment, len(req.Attachments))
for i, a := range req.Attachments {
attachments[i] = entities.LetterOutgoingAttachment{
LetterID: letter.ID,
FileURL: a.FileURL,
FileName: a.FileName,
FileType: a.FileType,
UploadedBy: &userID,
}
}
if err := s.attachmentRepo.CreateBulk(txCtx, attachments); err != nil {
return err
}
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letter.ID,
ActionType: entities.LetterOutgoingActionCreated,
ActorUserID: &userID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
result, err := s.letterRepo.Get(ctx, letter.ID)
if err != nil {
return nil, err
}
return transformLetterToResponse(result), nil
}
func (s *LetterOutgoingServiceImpl) GetOutgoingLetterByID(ctx context.Context, id uuid.UUID) (*contract.OutgoingLetterResponse, error) {
letter, err := s.letterRepo.Get(ctx, id)
if err != nil {
return nil, err
}
return transformLetterToResponse(letter), nil
}
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,
}
letters, total, err := s.letterRepo.List(ctx, filter, req.Limit, req.Offset)
if err != nil {
return nil, err
}
items := make([]*contract.OutgoingLetterResponse, len(letters))
for i, letter := range letters {
items[i] = transformLetterToResponse(&letter)
}
return &contract.ListOutgoingLettersResponse{
Items: items,
Total: total,
}, nil
}
func (s *LetterOutgoingServiceImpl) UpdateOutgoingLetter(ctx context.Context, id uuid.UUID, req *contract.UpdateOutgoingLetterRequest) (*contract.OutgoingLetterResponse, error) {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, id)
if err != nil {
return nil, err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return nil, gorm.ErrInvalidData
}
if req.Subject != nil {
letter.Subject = *req.Subject
}
if req.Description != nil {
letter.Description = req.Description
}
if req.PriorityID != nil {
letter.PriorityID = req.PriorityID
}
if req.ReceiverInstitutionID != nil {
letter.ReceiverInstitutionID = req.ReceiverInstitutionID
}
if req.IssueDate != nil {
letter.IssueDate = *req.IssueDate
}
if req.ReferenceNumber != nil {
letter.ReferenceNumber = req.ReferenceNumber
}
err = s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.letterRepo.Update(txCtx, letter); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letter.ID,
ActionType: entities.LetterOutgoingActionUpdated,
ActorUserID: &userID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
result, err := s.letterRepo.Get(ctx, id)
if err != nil {
return nil, err
}
return transformLetterToResponse(result), nil
}
func (s *LetterOutgoingServiceImpl) DeleteOutgoingLetter(ctx context.Context, id uuid.UUID) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, id)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.letterRepo.SoftDelete(txCtx, id); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letter.ID,
ActionType: entities.LetterOutgoingActionDeleted,
ActorUserID: &userID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) SubmitForApproval(ctx context.Context, letterID uuid.UUID) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
if letter.ApprovalFlowID == nil {
return gorm.ErrInvalidData
}
flow, err := s.approvalFlowRepo.Get(ctx, *letter.ApprovalFlowID)
if err != nil {
return err
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
approvals := make([]entities.LetterOutgoingApproval, len(flow.Steps))
for i, step := range flow.Steps {
approvals[i] = entities.LetterOutgoingApproval{
LetterID: letterID,
StepID: step.ID,
Status: entities.ApprovalStatusPending,
}
}
if err := s.approvalRepo.CreateBulk(txCtx, approvals); err != nil {
return err
}
if err := s.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusPendingApproval); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionSubmittedApproval,
ActorUserID: &userID,
FromStatus: ptr(string(entities.LetterOutgoingStatusDraft)),
ToStatus: ptr(string(entities.LetterOutgoingStatusPendingApproval)),
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) ApproveOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.ApproveLetterRequest) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusPendingApproval {
return gorm.ErrInvalidData
}
approvals, err := s.approvalRepo.ListByLetter(ctx, letterID)
if err != nil {
return err
}
var currentApproval *entities.LetterOutgoingApproval
for i := range approvals {
if approvals[i].Status == entities.ApprovalStatusPending {
step := approvals[i].Step
if (step.ApproverUserID != nil && *step.ApproverUserID == userID) ||
(step.ApproverRoleID != nil && userHasRole(ctx, *step.ApproverRoleID)) {
currentApproval = &approvals[i]
break
}
}
}
if currentApproval == nil {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
now := time.Now()
currentApproval.Status = entities.ApprovalStatusApproved
currentApproval.ApproverID = &userID
currentApproval.ActedAt = &now
currentApproval.Remarks = req.Remarks
if err := s.approvalRepo.Update(txCtx, currentApproval); err != nil {
return err
}
allApproved := true
for _, approval := range approvals {
if approval.ID != currentApproval.ID && approval.Status == entities.ApprovalStatusPending {
allApproved = false
break
}
}
if allApproved {
if err := s.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusApproved); err != nil {
return err
}
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionApproved,
ActorUserID: &userID,
TargetID: &currentApproval.ID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) RejectOutgoingLetter(ctx context.Context, letterID uuid.UUID, req *contract.RejectLetterRequest) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusPendingApproval {
return gorm.ErrInvalidData
}
approvals, err := s.approvalRepo.ListByLetter(ctx, letterID)
if err != nil {
return err
}
var currentApproval *entities.LetterOutgoingApproval
for i := range approvals {
if approvals[i].Status == entities.ApprovalStatusPending {
step := approvals[i].Step
if (step.ApproverUserID != nil && *step.ApproverUserID == userID) ||
(step.ApproverRoleID != nil && userHasRole(ctx, *step.ApproverRoleID)) {
currentApproval = &approvals[i]
break
}
}
}
if currentApproval == nil {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
now := time.Now()
currentApproval.Status = entities.ApprovalStatusRejected
currentApproval.ApproverID = &userID
currentApproval.ActedAt = &now
currentApproval.Remarks = &req.Reason
if err := s.approvalRepo.Update(txCtx, currentApproval); err != nil {
return err
}
if err := s.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusDraft); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionRejected,
ActorUserID: &userID,
TargetID: &currentApproval.ID,
FromStatus: ptr(string(entities.LetterOutgoingStatusPendingApproval)),
ToStatus: ptr(string(entities.LetterOutgoingStatusDraft)),
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) SendOutgoingLetter(ctx context.Context, letterID uuid.UUID) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusApproved {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusSent); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionSent,
ActorUserID: &userID,
FromStatus: ptr(string(entities.LetterOutgoingStatusApproved)),
ToStatus: ptr(string(entities.LetterOutgoingStatusSent)),
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) ArchiveOutgoingLetter(ctx context.Context, letterID uuid.UUID) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusSent {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.letterRepo.UpdateStatus(txCtx, letterID, entities.LetterOutgoingStatusArchived); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionArchived,
ActorUserID: &userID,
FromStatus: ptr(string(entities.LetterOutgoingStatusSent)),
ToStatus: ptr(string(entities.LetterOutgoingStatusArchived)),
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) AddRecipients(ctx context.Context, letterID uuid.UUID, req *contract.AddRecipientsRequest) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
recipients := make([]entities.LetterOutgoingRecipient, len(req.Recipients))
for i, r := range req.Recipients {
recipients[i] = entities.LetterOutgoingRecipient{
LetterID: letterID,
RecipientName: r.Name,
RecipientEmail: r.Email,
RecipientPosition: r.Position,
RecipientInstitution: r.Institution,
IsPrimary: r.IsPrimary,
}
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.recipientRepo.CreateBulk(txCtx, recipients); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionRecipientAdded,
ActorUserID: &userID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) UpdateRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID, req *contract.UpdateRecipientRequest) error {
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
recipient := &entities.LetterOutgoingRecipient{
ID: recipientID,
RecipientName: req.Name,
RecipientEmail: req.Email,
RecipientPosition: req.Position,
RecipientInstitution: req.Institution,
IsPrimary: req.IsPrimary,
}
return s.recipientRepo.Update(ctx, recipient)
}
func (s *LetterOutgoingServiceImpl) RemoveRecipient(ctx context.Context, letterID uuid.UUID, recipientID uuid.UUID) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.recipientRepo.Delete(txCtx, recipientID); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionRecipientRemoved,
ActorUserID: &userID,
TargetID: &recipientID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) AddAttachments(ctx context.Context, letterID uuid.UUID, req *contract.AddAttachmentsRequest) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
attachments := make([]entities.LetterOutgoingAttachment, len(req.Attachments))
for i, a := range req.Attachments {
attachments[i] = entities.LetterOutgoingAttachment{
LetterID: letterID,
FileURL: a.FileURL,
FileName: a.FileName,
FileType: a.FileType,
UploadedBy: &userID,
}
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.attachmentRepo.CreateBulk(txCtx, attachments); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionAttachmentAdded,
ActorUserID: &userID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) RemoveAttachment(ctx context.Context, letterID uuid.UUID, attachmentID uuid.UUID) error {
userID := getUserIDFromContext(ctx)
letter, err := s.letterRepo.Get(ctx, letterID)
if err != nil {
return err
}
if letter.Status != entities.LetterOutgoingStatusDraft {
return gorm.ErrInvalidData
}
return s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.attachmentRepo.Delete(txCtx, attachmentID); err != nil {
return err
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionAttachmentRemoved,
ActorUserID: &userID,
TargetID: &attachmentID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
}
func (s *LetterOutgoingServiceImpl) CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error) {
userID := getUserIDFromContext(ctx)
discussion := &entities.LetterOutgoingDiscussion{
LetterID: letterID,
ParentID: req.ParentID,
UserID: userID,
Message: req.Message,
}
if req.Mentions != nil {
discussion.Mentions = req.Mentions
}
err := s.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
if err := s.discussionRepo.Create(txCtx, discussion); err != nil {
return err
}
if len(req.Attachments) > 0 {
attachments := make([]entities.LetterOutgoingDiscussionAttachment, len(req.Attachments))
for i, a := range req.Attachments {
attachments[i] = entities.LetterOutgoingDiscussionAttachment{
DiscussionID: discussion.ID,
FileURL: a.FileURL,
FileName: a.FileName,
FileType: a.FileType,
UploadedBy: &userID,
}
}
if err := s.discussionAttachmentRepo.CreateBulk(txCtx, attachments); err != nil {
return err
}
}
activityLog := &entities.LetterOutgoingActivityLog{
LetterID: letterID,
ActionType: entities.LetterOutgoingActionDiscussionAdded,
ActorUserID: &userID,
TargetID: &discussion.ID,
}
if err := s.activityLogRepo.Create(txCtx, activityLog); err != nil {
return err
}
return nil
})
if err != nil {
return nil, err
}
result, err := s.discussionRepo.Get(ctx, discussion.ID)
if err != nil {
return nil, err
}
return transformDiscussionToResponse(result), nil
}
func (s *LetterOutgoingServiceImpl) UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error {
discussion, err := s.discussionRepo.Get(ctx, discussionID)
if err != nil {
return err
}
userID := getUserIDFromContext(ctx)
if discussion.UserID != userID {
return gorm.ErrInvalidData
}
discussion.Message = req.Message
if req.Mentions != nil {
discussion.Mentions = req.Mentions
}
return s.discussionRepo.Update(ctx, discussion)
}
func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error {
discussion, err := s.discussionRepo.Get(ctx, discussionID)
if err != nil {
return err
}
userID := getUserIDFromContext(ctx)
if discussion.UserID != userID {
return gorm.ErrInvalidData
}
return s.discussionRepo.Delete(ctx, discussionID)
}
func getUserIDFromContext(ctx context.Context) uuid.UUID {
return uuid.New()
}
func userHasRole(ctx context.Context, roleID uuid.UUID) bool {
return false
}
func ptr(s string) *string {
return &s
}
func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.OutgoingLetterResponse {
return &contract.OutgoingLetterResponse{
ID: letter.ID,
LetterNumber: letter.LetterNumber,
ReferenceNumber: letter.ReferenceNumber,
Subject: letter.Subject,
Description: letter.Description,
PriorityID: letter.PriorityID,
ReceiverInstitutionID: letter.ReceiverInstitutionID,
IssueDate: letter.IssueDate,
Status: string(letter.Status),
ApprovalFlowID: letter.ApprovalFlowID,
CreatedBy: letter.CreatedBy,
CreatedAt: letter.CreatedAt,
UpdatedAt: letter.UpdatedAt,
}
}
func transformDiscussionToResponse(discussion *entities.LetterOutgoingDiscussion) *contract.DiscussionResponse {
return &contract.DiscussionResponse{
ID: discussion.ID,
UserID: discussion.UserID,
Message: discussion.Message,
CreatedAt: discussion.CreatedAt,
UpdatedAt: discussion.UpdatedAt,
}
}

View File

@ -0,0 +1,24 @@
BEGIN;
-- Drop triggers first
DROP TRIGGER IF EXISTS trg_letter_outgoing_discussions_updated_at ON letter_outgoing_discussions;
DROP TRIGGER IF EXISTS trg_approval_flow_steps_updated_at ON approval_flow_steps;
DROP TRIGGER IF EXISTS trg_approval_flows_updated_at ON approval_flows;
DROP TRIGGER IF EXISTS trg_letters_outgoing_updated_at ON letters_outgoing;
-- Drop tables in reverse order (due to foreign key constraints)
DROP TABLE IF EXISTS letter_outgoing_activity_logs;
DROP TABLE IF EXISTS letter_outgoing_discussion_attachments;
DROP TABLE IF EXISTS letter_outgoing_discussions;
DROP TABLE IF EXISTS letter_outgoing_approvals;
DROP TABLE IF EXISTS letter_outgoing_attachments;
DROP TABLE IF EXISTS letter_outgoing_labels;
DROP TABLE IF EXISTS letter_outgoing_recipients;
DROP TABLE IF EXISTS letters_outgoing;
DROP TABLE IF EXISTS approval_flow_steps;
DROP TABLE IF EXISTS approval_flows;
-- Drop sequence
DROP SEQUENCE IF EXISTS letters_outgoing_seq;
COMMIT;

View File

@ -0,0 +1,199 @@
BEGIN;
-- =======================
-- SEQUENCE FOR LETTER NUMBER
-- =======================
CREATE SEQUENCE IF NOT EXISTS letters_outgoing_seq;
-- =======================
-- APPROVAL FLOWS
-- =======================
CREATE TABLE IF NOT EXISTS approval_flows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
department_id UUID NOT NULL REFERENCES departments(id) ON DELETE RESTRICT,
name TEXT NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_approval_flows_department ON approval_flows(department_id);
CREATE INDEX IF NOT EXISTS idx_approval_flows_active ON approval_flows(is_active);
CREATE TRIGGER trg_approval_flows_updated_at
BEFORE UPDATE ON approval_flows
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- APPROVAL FLOW STEPS
-- =======================
CREATE TABLE IF NOT EXISTS approval_flow_steps (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
flow_id UUID NOT NULL REFERENCES approval_flows(id) ON DELETE CASCADE,
step_order INT NOT NULL,
parallel_group INT DEFAULT 1,
approver_role_id UUID REFERENCES roles(id) ON DELETE SET NULL,
approver_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
required BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(flow_id, step_order, parallel_group, approver_role_id, approver_user_id),
CHECK ((approver_role_id IS NOT NULL) OR (approver_user_id IS NOT NULL))
);
CREATE INDEX IF NOT EXISTS idx_approval_flow_steps_flow ON approval_flow_steps(flow_id);
CREATE INDEX IF NOT EXISTS idx_approval_flow_steps_order ON approval_flow_steps(flow_id, step_order);
CREATE TRIGGER trg_approval_flow_steps_updated_at
BEFORE UPDATE ON approval_flow_steps
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- LETTERS OUTGOING
-- =======================
CREATE TABLE IF NOT EXISTS letters_outgoing (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_number TEXT NOT NULL UNIQUE DEFAULT ('OUT-' || lpad(nextval('letters_outgoing_seq')::text, 8, '0')),
reference_number TEXT,
subject TEXT NOT NULL,
description TEXT,
priority_id UUID REFERENCES priorities(id) ON DELETE SET NULL,
receiver_institution_id UUID REFERENCES institutions(id) ON DELETE SET NULL,
issue_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft','pending_approval','approved','sent','archived')),
approval_flow_id UUID REFERENCES approval_flows(id) ON DELETE SET NULL,
created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
deleted_at TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_status ON letters_outgoing(status);
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_issue_date ON letters_outgoing(issue_date);
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_approval_flow ON letters_outgoing(approval_flow_id);
CREATE TRIGGER trg_letters_outgoing_updated_at
BEFORE UPDATE ON letters_outgoing
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- LETTER OUTGOING RECIPIENTS
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_recipients (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE,
recipient_name TEXT NOT NULL,
recipient_email TEXT,
recipient_position TEXT,
recipient_institution TEXT,
is_primary BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_letter ON letter_outgoing_recipients(letter_id);
-- =======================
-- LETTER OUTGOING LABELS (M:N)
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_labels (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE,
label_id UUID NOT NULL REFERENCES labels(id) ON DELETE CASCADE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE (letter_id, label_id)
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_labels_letter ON letter_outgoing_labels(letter_id);
-- =======================
-- LETTER OUTGOING ATTACHMENTS
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE,
file_url TEXT NOT NULL,
file_name TEXT NOT NULL,
file_type TEXT NOT NULL,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_letter ON letter_outgoing_attachments(letter_id);
-- =======================
-- LETTER OUTGOING APPROVALS
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_approvals (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE,
step_id UUID NOT NULL REFERENCES approval_flow_steps(id) ON DELETE CASCADE,
approver_id UUID REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','approved','rejected')),
remarks TEXT,
acted_at TIMESTAMP WITHOUT TIME ZONE,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_letter ON letter_outgoing_approvals(letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_step ON letter_outgoing_approvals(step_id);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_approvals_status ON letter_outgoing_approvals(status);
-- =======================
-- LETTER OUTGOING DISCUSSIONS (Threaded)
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_discussions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE,
parent_id UUID REFERENCES letter_outgoing_discussions(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
message TEXT NOT NULL,
mentions JSONB,
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
edited_at TIMESTAMP WITHOUT TIME ZONE
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussions_letter ON letter_outgoing_discussions(letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussions_parent ON letter_outgoing_discussions(parent_id);
CREATE TRIGGER trg_letter_outgoing_discussions_updated_at
BEFORE UPDATE ON letter_outgoing_discussions
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- =======================
-- LETTER OUTGOING DISCUSSION ATTACHMENTS
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_discussion_attachments (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
discussion_id UUID NOT NULL REFERENCES letter_outgoing_discussions(id) ON DELETE CASCADE,
file_url TEXT NOT NULL,
file_name TEXT NOT NULL,
file_type TEXT NOT NULL,
uploaded_by UUID REFERENCES users(id) ON DELETE SET NULL,
uploaded_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussion_attachments_discussion ON letter_outgoing_discussion_attachments(discussion_id);
-- =======================
-- LETTER OUTGOING ACTIVITY LOGS (Immutable)
-- =======================
CREATE TABLE IF NOT EXISTS letter_outgoing_activity_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
letter_id UUID NOT NULL REFERENCES letters_outgoing(id) ON DELETE CASCADE,
action_type TEXT NOT NULL,
actor_user_id UUID REFERENCES users(id) ON DELETE SET NULL,
actor_department_id UUID REFERENCES departments(id) ON DELETE SET NULL,
target_type TEXT,
target_id UUID,
from_status TEXT,
to_status TEXT,
context JSONB,
occurred_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_activity_logs_letter ON letter_outgoing_activity_logs(letter_id);
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_activity_logs_action ON letter_outgoing_activity_logs(action_type);
COMMIT;