From f41daa63dac944192f939675b4ffc7051fd59f10 Mon Sep 17 00:00:00 2001 From: Aditya Siregar Date: Tue, 19 Aug 2025 00:31:04 +0700 Subject: [PATCH] Add letter and user id --- internal/app/app.go | 48 + internal/contract/letter_outgoing_contract.go | 213 +++++ internal/entities/approval_flow.go | 68 ++ internal/entities/letter_outgoing.go | 105 +++ .../entities/letter_outgoing_activity_log.go | 46 + .../handler/admin_approval_flow_handler.go | 344 +++++++ internal/handler/letter_outgoing_handler.go | 420 +++++++++ internal/handler/user_handler.go | 29 +- .../repository/approval_flow_repository.go | 229 +++++ .../repository/letter_outgoing_repository.go | 275 ++++++ internal/router/health_handler.go | 38 + internal/router/router.go | 88 +- internal/service/approval_flow_service.go | 247 ++++++ internal/service/letter_outgoing_service.go | 836 ++++++++++++++++++ .../000013_letters_outgoing_suite.down.sql | 24 + .../000013_letters_outgoing_suite.up.sql | 199 +++++ 16 files changed, 3188 insertions(+), 21 deletions(-) create mode 100644 internal/contract/letter_outgoing_contract.go create mode 100644 internal/entities/approval_flow.go create mode 100644 internal/entities/letter_outgoing.go create mode 100644 internal/entities/letter_outgoing_activity_log.go create mode 100644 internal/handler/admin_approval_flow_handler.go create mode 100644 internal/handler/letter_outgoing_handler.go create mode 100644 internal/repository/approval_flow_repository.go create mode 100644 internal/repository/letter_outgoing_repository.go create mode 100644 internal/service/approval_flow_service.go create mode 100644 internal/service/letter_outgoing_service.go create mode 100644 migrations/000013_letters_outgoing_suite.down.sql create mode 100644 migrations/000013_letters_outgoing_suite.up.sql diff --git a/internal/app/app.go b/internal/app/app.go index e8062b3..6ff1f5d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -46,6 +46,8 @@ func (a *App) Initialize(cfg *config.Config) error { rbacHandler := handler.NewRBACHandler(services.rbacService) masterHandler := handler.NewMasterHandler(services.masterService) letterHandler := handler.NewLetterHandler(services.letterService) + letterOutgoingHandler := handler.NewLetterOutgoingHandler(services.letterOutgoingService) + adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService) dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService) a.router = router.NewRouter( @@ -58,6 +60,8 @@ func (a *App) Initialize(cfg *config.Config) error { rbacHandler, masterHandler, letterHandler, + letterOutgoingHandler, + adminApprovalFlowHandler, dispositionRouteHandler, ) @@ -126,6 +130,15 @@ type repositories struct { recipientRepo *repository.LetterIncomingRecipientRepository departmentRepo *repository.DepartmentRepository 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 { @@ -151,6 +164,15 @@ func (a *App) initRepositories() *repositories { recipientRepo: repository.NewLetterIncomingRecipientRepository(a.db), departmentRepo: repository.NewDepartmentRepository(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 masterService *service.MasterServiceImpl letterService *service.LetterServiceImpl + letterOutgoingService *service.LetterOutgoingServiceImpl + approvalFlowService *service.ApprovalFlowServiceImpl dispositionRouteService *service.DispositionRouteServiceImpl } @@ -197,6 +221,28 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con letterSvc := service.NewLetterService(processors.letterProcessor) 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{ userService: userSvc, @@ -205,6 +251,8 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con rbacService: rbacSvc, masterService: masterSvc, letterService: letterSvc, + letterOutgoingService: letterOutgoingSvc, + approvalFlowService: approvalFlowSvc, dispositionRouteService: dispRouteSvc, } } diff --git a/internal/contract/letter_outgoing_contract.go b/internal/contract/letter_outgoing_contract.go new file mode 100644 index 0000000..b719ffe --- /dev/null +++ b/internal/contract/letter_outgoing_contract.go @@ -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"` +} \ No newline at end of file diff --git a/internal/entities/approval_flow.go b/internal/entities/approval_flow.go new file mode 100644 index 0000000..e563982 --- /dev/null +++ b/internal/entities/approval_flow.go @@ -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" } \ No newline at end of file diff --git a/internal/entities/letter_outgoing.go b/internal/entities/letter_outgoing.go new file mode 100644 index 0000000..f3c1e01 --- /dev/null +++ b/internal/entities/letter_outgoing.go @@ -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" +} \ No newline at end of file diff --git a/internal/entities/letter_outgoing_activity_log.go b/internal/entities/letter_outgoing_activity_log.go new file mode 100644 index 0000000..7fe4f71 --- /dev/null +++ b/internal/entities/letter_outgoing_activity_log.go @@ -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" +) \ No newline at end of file diff --git a/internal/handler/admin_approval_flow_handler.go b/internal/handler/admin_approval_flow_handler.go new file mode 100644 index 0000000..18a4058 --- /dev/null +++ b/internal/handler/admin_approval_flow_handler.go @@ -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)) +} \ No newline at end of file diff --git a/internal/handler/letter_outgoing_handler.go b/internal/handler/letter_outgoing_handler.go new file mode 100644 index 0000000..5d1f4ab --- /dev/null +++ b/internal/handler/letter_outgoing_handler.go @@ -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"}) +} \ No newline at end of file diff --git a/internal/handler/user_handler.go b/internal/handler/user_handler.go index e6e8815..15679fa 100644 --- a/internal/handler/user_handler.go +++ b/internal/handler/user_handler.go @@ -296,6 +296,33 @@ func (h *UserHandler) ListTitles(c *gin.Context) { 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) { search := c.Query("search") limitStr := c.DefaultQuery("limit", "50") @@ -307,7 +334,7 @@ func (h *UserHandler) GetActiveUsersForMention(c *gin.Context) { if limit > 100 { limit = 100 } - + var searchPtr *string if search != "" { searchPtr = &search diff --git a/internal/repository/approval_flow_repository.go b/internal/repository/approval_flow_repository.go new file mode 100644 index 0000000..43fee08 --- /dev/null +++ b/internal/repository/approval_flow_repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/repository/letter_outgoing_repository.go b/internal/repository/letter_outgoing_repository.go new file mode 100644 index 0000000..e2cc5eb --- /dev/null +++ b/internal/repository/letter_outgoing_repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 611f2a6..8445d92 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -9,6 +9,7 @@ type HealthHandler interface { type UserHandler interface { ListUsers(c *gin.Context) GetProfile(c *gin.Context) + GetUserProfile(c *gin.Context) UpdateProfile(c *gin.Context) ChangePassword(c *gin.Context) ListTitles(c *gin.Context) @@ -70,6 +71,43 @@ type LetterHandler interface { 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 { Create(c *gin.Context) Update(c *gin.Context) diff --git a/internal/router/router.go b/internal/router/router.go index be8b44b..3038d9e 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -8,16 +8,18 @@ import ( ) type Router struct { - config *config.Config - authHandler AuthHandler - healthHandler HealthHandler - authMiddleware AuthMiddleware - userHandler UserHandler - fileHandler FileHandler - rbacHandler RBACHandler - masterHandler MasterHandler - letterHandler LetterHandler - dispRouteHandler DispositionRouteHandler + config *config.Config + authHandler AuthHandler + healthHandler HealthHandler + authMiddleware AuthMiddleware + userHandler UserHandler + fileHandler FileHandler + rbacHandler RBACHandler + masterHandler MasterHandler + letterHandler LetterHandler + letterOutgoingHandler LetterOutgoingHandler + adminApprovalFlowHandler AdminApprovalFlowHandler + dispRouteHandler DispositionRouteHandler } func NewRouter( @@ -30,19 +32,23 @@ func NewRouter( rbacHandler RBACHandler, masterHandler MasterHandler, letterHandler LetterHandler, + letterOutgoingHandler LetterOutgoingHandler, + adminApprovalFlowHandler AdminApprovalFlowHandler, dispRouteHandler DispositionRouteHandler, ) *Router { return &Router{ - config: cfg, - authHandler: authHandler, - authMiddleware: authMiddleware, - healthHandler: healthHandler, - userHandler: userHandler, - fileHandler: fileHandler, - rbacHandler: rbacHandler, - masterHandler: masterHandler, - letterHandler: letterHandler, - dispRouteHandler: dispRouteHandler, + config: cfg, + authHandler: authHandler, + authMiddleware: authMiddleware, + healthHandler: healthHandler, + userHandler: userHandler, + fileHandler: fileHandler, + rbacHandler: rbacHandler, + masterHandler: masterHandler, + letterHandler: letterHandler, + 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("/profile", r.userHandler.GetProfile) + users.GET("/:id/profile", r.userHandler.GetUserProfile) users.PUT("/profile", r.userHandler.UpdateProfile) users.PUT(":id/password", r.userHandler.ChangePassword) 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.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.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.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) + } + } } } diff --git a/internal/service/approval_flow_service.go b/internal/service/approval_flow_service.go new file mode 100644 index 0000000..1377d09 --- /dev/null +++ b/internal/service/approval_flow_service.go @@ -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 +} \ No newline at end of file diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go new file mode 100644 index 0000000..9c26101 --- /dev/null +++ b/internal/service/letter_outgoing_service.go @@ -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: ¤tApproval.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: ¤tApproval.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, + } +} \ No newline at end of file diff --git a/migrations/000013_letters_outgoing_suite.down.sql b/migrations/000013_letters_outgoing_suite.down.sql new file mode 100644 index 0000000..f39276a --- /dev/null +++ b/migrations/000013_letters_outgoing_suite.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/000013_letters_outgoing_suite.up.sql b/migrations/000013_letters_outgoing_suite.up.sql new file mode 100644 index 0000000..b066c5a --- /dev/null +++ b/migrations/000013_letters_outgoing_suite.up.sql @@ -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; \ No newline at end of file