Add document saved
This commit is contained in:
parent
592fa97be7
commit
2bdce63852
@ -24,11 +24,12 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
Server Server `mapstructure:"server"`
|
||||
Database Database `mapstructure:"postgresql"`
|
||||
Jwt Jwt `mapstructure:"jwt"`
|
||||
Log Log `mapstructure:"log"`
|
||||
S3Config S3Config `mapstructure:"s3"`
|
||||
Server Server `mapstructure:"server"`
|
||||
Database Database `mapstructure:"postgresql"`
|
||||
Jwt Jwt `mapstructure:"jwt"`
|
||||
Log Log `mapstructure:"log"`
|
||||
S3Config S3Config `mapstructure:"s3"`
|
||||
OnlyOffice OnlyOffice `mapstructure:"onlyoffice"`
|
||||
}
|
||||
|
||||
var (
|
||||
@ -79,3 +80,8 @@ func (c *Config) Port() string {
|
||||
func (c *Config) LogFormat() string {
|
||||
return c.Log.LogFormat
|
||||
}
|
||||
|
||||
type OnlyOffice struct {
|
||||
URL string `mapstructure:"url"`
|
||||
Token string `mapstructure:"token"`
|
||||
}
|
||||
|
||||
@ -24,11 +24,15 @@ postgresql:
|
||||
s3:
|
||||
access_key_id: minioadmin # from MINIO_ROOT_USER or Access Key you created in console
|
||||
access_key_secret: minioadmin123 # from MINIO_ROOT_PASSWORD or Secret Key you created in console
|
||||
endpoint: http://103.191.71.2:9000 # S3 API endpoint, not console port
|
||||
endpoint: https://s3.apskel.org # S3 API endpoint, not console port
|
||||
bucket_name: enaklo
|
||||
log_level: Error
|
||||
host_url: 'http://103.191.71.2:9000/'
|
||||
host_url: 'https://s3.apskel.org/'
|
||||
|
||||
log:
|
||||
log_format: 'json'
|
||||
log_level: 'debug'
|
||||
|
||||
onlyoffice:
|
||||
url: 'https://onlyoffice.apskel.org/'
|
||||
token: '2DmKgd5PT3n1vH3f2v2iRZUqTVHj9GQx'
|
||||
@ -49,6 +49,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
letterOutgoingHandler := handler.NewLetterOutgoingHandler(services.letterOutgoingService)
|
||||
adminApprovalFlowHandler := handler.NewAdminApprovalFlowHandler(services.approvalFlowService)
|
||||
dispositionRouteHandler := handler.NewDispositionRouteHandler(services.dispositionRouteService)
|
||||
onlyOfficeHandler := handler.NewOnlyOfficeHandler(services.onlyOfficeService)
|
||||
|
||||
a.router = router.NewRouter(
|
||||
cfg,
|
||||
@ -63,6 +64,7 @@ func (a *App) Initialize(cfg *config.Config) error {
|
||||
letterOutgoingHandler,
|
||||
adminApprovalFlowHandler,
|
||||
dispositionRouteHandler,
|
||||
onlyOfficeHandler,
|
||||
)
|
||||
|
||||
return nil
|
||||
@ -181,6 +183,7 @@ type processors struct {
|
||||
letterOutgoingProcessor *processor.LetterOutgoingProcessorImpl
|
||||
activityLogger *processor.ActivityLogProcessorImpl
|
||||
letterNumberGenerator *processor.LetterNumberGeneratorImpl
|
||||
onlyOfficeProcessor *processor.OnlyOfficeProcessorImpl
|
||||
}
|
||||
|
||||
func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processors {
|
||||
@ -215,12 +218,29 @@ func (a *App) initProcessors(cfg *config.Config, repos *repositories) *processor
|
||||
txMgr,
|
||||
)
|
||||
|
||||
// Create document repositories
|
||||
docSessionRepo := repository.NewDocumentSessionRepository(a.db)
|
||||
docVersionRepo := repository.NewDocumentVersionRepository(a.db)
|
||||
docMetadataRepo := repository.NewDocumentMetadataRepository(a.db)
|
||||
docErrorRepo := repository.NewDocumentErrorRepository(a.db)
|
||||
|
||||
// Create OnlyOffice processor
|
||||
onlyOfficeProc := processor.NewOnlyOfficeProcessor(
|
||||
a.db,
|
||||
docSessionRepo,
|
||||
docVersionRepo,
|
||||
docMetadataRepo,
|
||||
docErrorRepo,
|
||||
txMgr,
|
||||
)
|
||||
|
||||
return &processors{
|
||||
userProcessor: processor.NewUserProcessor(repos.userRepo, repos.userProfileRepo),
|
||||
letterProcessor: letterProc,
|
||||
letterOutgoingProcessor: letterOutgoingProc,
|
||||
activityLogger: activity,
|
||||
letterNumberGenerator: letterNumberGen,
|
||||
onlyOfficeProcessor: onlyOfficeProc,
|
||||
}
|
||||
}
|
||||
|
||||
@ -234,6 +254,7 @@ type services struct {
|
||||
letterOutgoingService *service.LetterOutgoingServiceImpl
|
||||
approvalFlowService *service.ApprovalFlowServiceImpl
|
||||
dispositionRouteService *service.DispositionRouteServiceImpl
|
||||
onlyOfficeService *service.OnlyOfficeServiceImpl
|
||||
}
|
||||
|
||||
func (a *App) initServices(processors *processors, repos *repositories, cfg *config.Config) *services {
|
||||
@ -265,6 +286,9 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
txManager,
|
||||
)
|
||||
|
||||
// Create OnlyOffice service with file storage
|
||||
onlyOfficeSvc := service.NewOnlyOfficeService(processors.onlyOfficeProcessor, &cfg.OnlyOffice, a.db, s3Client)
|
||||
|
||||
return &services{
|
||||
userService: userSvc,
|
||||
authService: authService,
|
||||
@ -275,6 +299,7 @@ func (a *App) initServices(processors *processors, repos *repositories, cfg *con
|
||||
letterOutgoingService: letterOutgoingSvc,
|
||||
approvalFlowService: approvalFlowSvc,
|
||||
dispositionRouteService: dispRouteSvc,
|
||||
onlyOfficeService: onlyOfficeSvc,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -60,10 +60,11 @@ type UpdateIncomingLetterRequest struct {
|
||||
}
|
||||
|
||||
type ListIncomingLettersRequest struct {
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
Page int `json:"page"`
|
||||
Limit int `json:"limit"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Query *string `json:"query,omitempty"`
|
||||
DepartmentID *uuid.UUID
|
||||
}
|
||||
|
||||
type ListIncomingLettersResponse struct {
|
||||
|
||||
@ -7,11 +7,13 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
UserID *uuid.UUID `json:"user_id,omitempty"`
|
||||
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Status string `json:"status"`
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
}
|
||||
|
||||
type CreateOutgoingLetterAttachment struct {
|
||||
@ -32,13 +34,18 @@ type CreateOutgoingLetterRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
UserID *uuid.UUID `json:"user_id,omitempty"`
|
||||
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Status string `json:"status"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
IsArchived bool `json:"is_archived"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
User *UserResponse `json:"user,omitempty"`
|
||||
Department *DepartmentResponse `json:"department,omitempty"`
|
||||
}
|
||||
|
||||
type OutgoingLetterAttachmentResponse struct {
|
||||
@ -118,11 +125,12 @@ type AddRecipientsRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
UserID *uuid.UUID `json:"user_id,omitempty"`
|
||||
DepartmentID *uuid.UUID `json:"department_id,omitempty"`
|
||||
IsPrimary bool `json:"is_primary"`
|
||||
Status *string `json:"status,omitempty"`
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
IsArchived *bool `json:"is_archived,omitempty"`
|
||||
}
|
||||
|
||||
type AddAttachmentsRequest struct {
|
||||
@ -210,3 +218,65 @@ type ListApprovalFlowsResponse struct {
|
||||
Items []*ApprovalFlowResponse `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// Letter Approval Information for Approver
|
||||
type LetterApprovalInfoResponse struct {
|
||||
IsApproverOnActiveStep bool `json:"is_approver_on_active_step"`
|
||||
DecisionStatus string `json:"decision_status"`
|
||||
CanApprove bool `json:"can_approve"`
|
||||
Actions []ApprovalAction `json:"actions"`
|
||||
NotesVisibility string `json:"notes_visibility"`
|
||||
}
|
||||
|
||||
type ApprovalAction struct {
|
||||
Type string `json:"type"`
|
||||
Href string `json:"href"`
|
||||
Method string `json:"method"`
|
||||
}
|
||||
|
||||
// OutgoingLetterApprovalDiscussionsResponse combines approvals and discussions for outgoing letters
|
||||
type OutgoingLetterApprovalDiscussionsResponse struct {
|
||||
Approvals []EnhancedOutgoingLetterApprovalResponse `json:"approvals"`
|
||||
Discussions []OutgoingLetterDiscussionResponse `json:"discussions"`
|
||||
}
|
||||
|
||||
// EnhancedOutgoingLetterApprovalResponse includes approval details with related data
|
||||
type EnhancedOutgoingLetterApprovalResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_id"`
|
||||
StepID uuid.UUID `json:"step_id"`
|
||||
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"`
|
||||
Step *ApprovalFlowStepResponse `json:"step,omitempty"`
|
||||
Approver *UserResponse `json:"approver,omitempty"`
|
||||
}
|
||||
|
||||
// OutgoingLetterDiscussionResponse represents a discussion on an outgoing letter
|
||||
type OutgoingLetterDiscussionResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
LetterID uuid.UUID `json:"letter_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"`
|
||||
User *UserResponse `json:"user,omitempty"`
|
||||
MentionedUsers []UserResponse `json:"mentioned_users,omitempty"`
|
||||
Attachments []OutgoingLetterDiscussionAttachmentResponse `json:"attachments,omitempty"`
|
||||
}
|
||||
|
||||
// OutgoingLetterDiscussionAttachmentResponse represents an attachment in a discussion
|
||||
type OutgoingLetterDiscussionAttachmentResponse struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
DiscussionID uuid.UUID `json:"discussion_id"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileName string `json:"file_name"`
|
||||
FileType string `json:"file_type"`
|
||||
UploadedBy *uuid.UUID `json:"uploaded_by,omitempty"`
|
||||
UploadedAt time.Time `json:"uploaded_at"`
|
||||
}
|
||||
|
||||
178
internal/contract/onlyoffice_contract.go
Normal file
178
internal/contract/onlyoffice_contract.go
Normal file
@ -0,0 +1,178 @@
|
||||
package contract
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// OnlyOffice callback status codes
|
||||
const (
|
||||
OnlyOfficeStatusEditing = 1 // Document is being edited
|
||||
OnlyOfficeStatusReady = 2 // Document is ready for saving
|
||||
OnlyOfficeStatusSaveError = 3 // Document saving error occurred
|
||||
OnlyOfficeStatusClosed = 4 // Document is closed with no changes
|
||||
OnlyOfficeStatusForceSave = 6 // Document is being edited, but saved forcefully
|
||||
OnlyOfficeStatusForceSaveError = 7 // Error during force save
|
||||
)
|
||||
|
||||
// OnlyOfficeCallbackRequest represents the callback payload from OnlyOffice Document Server
|
||||
type OnlyOfficeCallbackRequest struct {
|
||||
Key string `json:"key"` // Document identifier
|
||||
Status int `json:"status"` // Status code (1-7)
|
||||
URL string `json:"url,omitempty"` // Document URL when status is 2 or 6
|
||||
ChangesURL string `json:"changesurl,omitempty"` // URL to document changes
|
||||
History *OnlyOfficeHistory `json:"history,omitempty"`
|
||||
Users []string `json:"users,omitempty"` // Users currently editing
|
||||
Actions []OnlyOfficeAction `json:"actions,omitempty"` // User actions
|
||||
LastSave string `json:"lastsave,omitempty"` // Last save time
|
||||
NotModified bool `json:"notmodified,omitempty"` // Document not modified flag
|
||||
ForceSaveType int `json:"forcesavetype,omitempty"` // Force save type
|
||||
UserData string `json:"userdata,omitempty"` // Custom user data
|
||||
Token string `json:"token,omitempty"` // JWT token from OnlyOffice
|
||||
}
|
||||
|
||||
// OnlyOfficeHistory represents document history information
|
||||
type OnlyOfficeHistory struct {
|
||||
ServerVersion string `json:"serverVersion"`
|
||||
Changes []OnlyOfficeHistoryChange `json:"changes"`
|
||||
}
|
||||
|
||||
// OnlyOfficeHistoryChange represents a single change in history
|
||||
type OnlyOfficeHistoryChange struct {
|
||||
User OnlyOfficeUser `json:"user"`
|
||||
Created string `json:"created"`
|
||||
}
|
||||
|
||||
// OnlyOfficeUser represents user information in OnlyOffice
|
||||
type OnlyOfficeUser struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// OnlyOfficeAction represents user actions
|
||||
type OnlyOfficeAction struct {
|
||||
Type int `json:"type"` // Action type (0: user connected, 1: user disconnected)
|
||||
UserID string `json:"userid"` // User identifier
|
||||
User OnlyOfficeUser `json:"user"` // User information
|
||||
}
|
||||
|
||||
// OnlyOfficeCallbackResponse is the required response format for OnlyOffice
|
||||
type OnlyOfficeCallbackResponse struct {
|
||||
Error int `json:"error"` // 0 = success, 1 = document key not found, 2 = callback URL error, 3 = internal server error
|
||||
}
|
||||
|
||||
// OnlyOfficeDocument represents the document section of OnlyOffice config
|
||||
type OnlyOfficeDocument struct {
|
||||
FileType string `json:"fileType"` // Document file extension
|
||||
Key string `json:"key"` // Unique document identifier
|
||||
Title string `json:"title"` // Document title
|
||||
URL string `json:"url"` // Document URL
|
||||
Permissions *OnlyOfficePermissions `json:"permissions,omitempty"`
|
||||
Info *OnlyOfficeDocumentInfo `json:"info,omitempty"`
|
||||
}
|
||||
|
||||
// OnlyOfficeDocumentInfo represents additional document information
|
||||
type OnlyOfficeDocumentInfo struct {
|
||||
Owner string `json:"owner,omitempty"`
|
||||
Uploaded string `json:"uploaded,omitempty"`
|
||||
}
|
||||
|
||||
// OnlyOfficeConfigRequest represents the proper OnlyOffice configuration format
|
||||
type OnlyOfficeConfigRequest struct {
|
||||
Document *OnlyOfficeDocument `json:"document"`
|
||||
DocumentType string `json:"documentType"` // text, spreadsheet, presentation
|
||||
EditorConfig *OnlyOfficeEditorConfig `json:"editorConfig"`
|
||||
Type string `json:"type,omitempty"` // desktop, mobile, embedded
|
||||
Token string `json:"token,omitempty"` // JWT token for security
|
||||
Width string `json:"width,omitempty"`
|
||||
Height string `json:"height,omitempty"`
|
||||
}
|
||||
|
||||
// OnlyOfficeUserConfig represents user configuration for OnlyOffice
|
||||
type OnlyOfficeUserConfig struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// OnlyOfficePermissions represents document permissions
|
||||
type OnlyOfficePermissions struct {
|
||||
Comment bool `json:"comment"`
|
||||
Download bool `json:"download"`
|
||||
Edit bool `json:"edit"`
|
||||
FillForms bool `json:"fillForms"`
|
||||
ModifyContentControl bool `json:"modifyContentControl"`
|
||||
ModifyFilter bool `json:"modifyFilter"`
|
||||
Print bool `json:"print"`
|
||||
Review bool `json:"review"`
|
||||
}
|
||||
|
||||
// OnlyOfficeCustomization represents UI customization options
|
||||
type OnlyOfficeCustomization struct {
|
||||
Autosave bool `json:"autosave"`
|
||||
Comments bool `json:"comments"`
|
||||
CompactHeader bool `json:"compactHeader"`
|
||||
CompactToolbar bool `json:"compactToolbar"`
|
||||
ForceSave bool `json:"forcesave"`
|
||||
ShowReviewChanges bool `json:"showReviewChanges"`
|
||||
Zoom int `json:"zoom"`
|
||||
}
|
||||
|
||||
// OnlyOfficeEditorConfig represents editor configuration
|
||||
type OnlyOfficeEditorConfig struct {
|
||||
CallbackURL string `json:"callbackUrl"`
|
||||
Lang string `json:"lang"`
|
||||
Mode string `json:"mode"` // edit, view
|
||||
User *OnlyOfficeUserConfig `json:"user,omitempty"`
|
||||
Customization *OnlyOfficeCustomization `json:"customization,omitempty"`
|
||||
}
|
||||
|
||||
// Document session tracking for OnlyOffice
|
||||
type DocumentSession struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
DocumentID uuid.UUID `json:"document_id"`
|
||||
DocumentKey string `json:"document_key"` // OnlyOffice document key
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Status int `json:"status"` // Current OnlyOffice status
|
||||
IsLocked bool `json:"is_locked"` // Document lock status
|
||||
LockedBy *uuid.UUID `json:"locked_by,omitempty"`
|
||||
LockedAt *time.Time `json:"locked_at,omitempty"`
|
||||
LastSavedAt *time.Time `json:"last_saved_at,omitempty"`
|
||||
Version int `json:"version"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// DocumentVersion for tracking document versions
|
||||
type DocumentVersion struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
DocumentID uuid.UUID `json:"document_id"`
|
||||
Version int `json:"version"`
|
||||
FileURL string `json:"file_url"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
ChangesURL *string `json:"changes_url,omitempty"`
|
||||
SavedBy uuid.UUID `json:"saved_by"`
|
||||
SavedAt time.Time `json:"saved_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Comments *string `json:"comments,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// GetEditorConfigRequest represents request to get OnlyOffice editor configuration
|
||||
type GetEditorConfigRequest struct {
|
||||
DocumentID uuid.UUID `json:"document_id"`
|
||||
DocumentType string `json:"document_type"` // letter_attachment, outgoing_attachment, etc.
|
||||
Mode string `json:"mode"` // edit, view
|
||||
}
|
||||
|
||||
// GetEditorConfigResponse represents OnlyOffice editor configuration response
|
||||
type GetEditorConfigResponse struct {
|
||||
DocumentServerURL string `json:"document_server_url"`
|
||||
Config *OnlyOfficeConfigRequest `json:"config"`
|
||||
}
|
||||
|
||||
// OnlyOfficeConfigInfo represents the OnlyOffice configuration information
|
||||
type OnlyOfficeConfigInfo struct {
|
||||
URL string `json:"url"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
@ -7,36 +7,34 @@ import (
|
||||
)
|
||||
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
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" }
|
||||
|
||||
117
internal/entities/document_session.go
Normal file
117
internal/entities/document_session.go
Normal file
@ -0,0 +1,117 @@
|
||||
package entities
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocumentSession represents an OnlyOffice document editing session
|
||||
type DocumentSession struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
DocumentID uuid.UUID `gorm:"type:uuid;not null;index" json:"document_id"`
|
||||
DocumentKey string `gorm:"uniqueIndex;not null" json:"document_key"` // OnlyOffice document key
|
||||
UserID uuid.UUID `gorm:"type:uuid;not null" json:"user_id"`
|
||||
Status int `gorm:"not null;default:0" json:"status"` // OnlyOffice status codes
|
||||
IsLocked bool `gorm:"default:false" json:"is_locked"`
|
||||
LockedBy *uuid.UUID `gorm:"type:uuid" json:"locked_by,omitempty"`
|
||||
LockedAt *time.Time `json:"locked_at,omitempty"`
|
||||
LastSavedAt *time.Time `json:"last_saved_at,omitempty"`
|
||||
Version int `gorm:"not null;default:1" json:"version"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// Relations
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (DocumentSession) TableName() string {
|
||||
return "document_sessions"
|
||||
}
|
||||
|
||||
func (ds *DocumentSession) BeforeCreate(tx *gorm.DB) error {
|
||||
if ds.ID == uuid.Nil {
|
||||
ds.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DocumentVersion represents a version of a document
|
||||
type DocumentVersion struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
DocumentID uuid.UUID `gorm:"type:uuid;not null;index" json:"document_id"`
|
||||
Version int `gorm:"not null" json:"version"`
|
||||
FileURL string `gorm:"not null" json:"file_url"`
|
||||
FileSize int64 `gorm:"not null" json:"file_size"`
|
||||
ChangesURL *string `json:"changes_url,omitempty"`
|
||||
SavedBy uuid.UUID `gorm:"type:uuid;not null" json:"saved_by"`
|
||||
SavedAt time.Time `gorm:"not null" json:"saved_at"`
|
||||
IsActive bool `gorm:"default:false;index" json:"is_active"`
|
||||
Comments *string `json:"comments,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
// Relations
|
||||
User *User `gorm:"foreignKey:SavedBy" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
func (DocumentVersion) TableName() string {
|
||||
return "document_versions"
|
||||
}
|
||||
|
||||
func (dv *DocumentVersion) BeforeCreate(tx *gorm.DB) error {
|
||||
if dv.ID == uuid.Nil {
|
||||
dv.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DocumentError represents errors during document operations
|
||||
type DocumentError struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
DocumentID uuid.UUID `gorm:"type:uuid;not null;index" json:"document_id"`
|
||||
SessionID *uuid.UUID `gorm:"type:uuid" json:"session_id,omitempty"`
|
||||
ErrorType string `gorm:"not null" json:"error_type"`
|
||||
ErrorMsg string `gorm:"not null" json:"error_msg"`
|
||||
Details map[string]interface{} `gorm:"type:jsonb" json:"details,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
// Relations
|
||||
Session *DocumentSession `gorm:"foreignKey:SessionID" json:"session,omitempty"`
|
||||
}
|
||||
|
||||
func (DocumentError) TableName() string {
|
||||
return "document_errors"
|
||||
}
|
||||
|
||||
func (de *DocumentError) BeforeCreate(tx *gorm.DB) error {
|
||||
if de.ID == uuid.Nil {
|
||||
de.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DocumentMetadata stores document-specific metadata
|
||||
type DocumentMetadata struct {
|
||||
ID uuid.UUID `gorm:"type:uuid;primary_key;default:gen_random_uuid()" json:"id"`
|
||||
DocumentID uuid.UUID `gorm:"type:uuid;uniqueIndex;not null" json:"document_id"`
|
||||
DocumentType string `gorm:"not null" json:"document_type"` // letter_attachment, outgoing_attachment, etc.
|
||||
ReferenceID uuid.UUID `gorm:"type:uuid;not null" json:"reference_id"` // ID of the parent entity (letter, outgoing letter, etc.)
|
||||
FileName string `gorm:"not null" json:"file_name"`
|
||||
FileType string `gorm:"not null" json:"file_type"`
|
||||
FileSize int64 `gorm:"not null" json:"file_size"`
|
||||
MimeType string `json:"mime_type,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (DocumentMetadata) TableName() string {
|
||||
return "document_metadata"
|
||||
}
|
||||
|
||||
func (dm *DocumentMetadata) BeforeCreate(tx *gorm.DB) error {
|
||||
if dm.ID == uuid.Nil {
|
||||
dm.ID = uuid.New()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@ -33,28 +33,32 @@ type LetterOutgoing struct {
|
||||
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"`
|
||||
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"`
|
||||
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"`
|
||||
UserID *uuid.UUID `gorm:"type:uuid" json:"user_id,omitempty"`
|
||||
DepartmentID *uuid.UUID `gorm:"type:uuid" json:"department_id,omitempty"`
|
||||
IsPrimary bool `gorm:"default:false" json:"is_primary"`
|
||||
Status string `gorm:"default:'pending'" json:"status"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
IsArchived bool `gorm:"default:false" json:"is_archived"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
Department *Department `gorm:"foreignKey:DepartmentID" json:"department,omitempty"`
|
||||
}
|
||||
|
||||
func (LetterOutgoingRecipient) TableName() string { return "letter_outgoing_recipients" }
|
||||
|
||||
@ -120,6 +120,9 @@ func (h *LetterHandler) ListIncomingLetters(c *gin.Context) {
|
||||
}
|
||||
|
||||
func (h *LetterHandler) parseListRequest(c *gin.Context) *contract.ListIncomingLettersRequest {
|
||||
//appCtx := appcontext.FromGinContext(c)
|
||||
//departmentID := appCtx.DepartmentID
|
||||
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "10"))
|
||||
|
||||
@ -139,7 +142,6 @@ func (h *LetterHandler) parseListRequest(c *gin.Context) *contract.ListIncomingL
|
||||
Limit: limit,
|
||||
}
|
||||
|
||||
// Handle optional query parameters
|
||||
if status := c.Query("status"); status != "" {
|
||||
req.Status = &status
|
||||
}
|
||||
@ -147,6 +149,8 @@ func (h *LetterHandler) parseListRequest(c *gin.Context) *contract.ListIncomingL
|
||||
req.Query = &query
|
||||
}
|
||||
|
||||
//req.DepartmentID = &departmentID
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type LetterOutgoingService interface {
|
||||
@ -35,6 +36,9 @@ type LetterOutgoingService interface {
|
||||
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
|
||||
|
||||
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error)
|
||||
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
|
||||
}
|
||||
|
||||
type LetterOutgoingHandler struct {
|
||||
@ -421,3 +425,40 @@ func (h *LetterOutgoingHandler) DeleteDiscussion(c *gin.Context) {
|
||||
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "discussion deleted"})
|
||||
}
|
||||
|
||||
func (h *LetterOutgoingHandler) GetLetterApprovalInfo(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.GetLetterApprovalInfo(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))
|
||||
}
|
||||
|
||||
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
|
||||
func (h *LetterOutgoingHandler) GetApprovalDiscussions(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.GetApprovalDiscussions(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
c.JSON(http.StatusNotFound, &contract.ErrorResponse{Error: "letter not found", Code: http.StatusNotFound})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{Error: err.Error(), Code: http.StatusInternalServerError})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(resp))
|
||||
}
|
||||
205
internal/handler/onlyoffice_handler.go
Normal file
205
internal/handler/onlyoffice_handler.go
Normal file
@ -0,0 +1,205 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/contract"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type OnlyOfficeService interface {
|
||||
ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error)
|
||||
GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error)
|
||||
LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error
|
||||
UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error
|
||||
GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error)
|
||||
GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error)
|
||||
}
|
||||
|
||||
type OnlyOfficeHandler struct {
|
||||
svc OnlyOfficeService
|
||||
}
|
||||
|
||||
func NewOnlyOfficeHandler(svc OnlyOfficeService) *OnlyOfficeHandler {
|
||||
return &OnlyOfficeHandler{
|
||||
svc: svc,
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessCallback handles OnlyOffice document server callbacks
|
||||
// POST /api/v1/onlyoffice/callback/:key
|
||||
func (h *OnlyOfficeHandler) ProcessCallback(c *gin.Context) {
|
||||
documentKey := c.Param("key")
|
||||
if documentKey == "" {
|
||||
c.JSON(http.StatusBadRequest, &contract.OnlyOfficeCallbackResponse{Error: 1})
|
||||
return
|
||||
}
|
||||
|
||||
var req contract.OnlyOfficeCallbackRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.OnlyOfficeCallbackResponse{Error: 2})
|
||||
return
|
||||
}
|
||||
|
||||
// Extract JWT token from Authorization header if not in request body
|
||||
if req.Token == "" {
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader != "" {
|
||||
// Remove "Bearer " prefix if present
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
req.Token = authHeader[7:]
|
||||
} else {
|
||||
req.Token = authHeader
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyOffice requires the key in the request to match the URL
|
||||
if req.Key != "" && req.Key != documentKey {
|
||||
c.JSON(http.StatusBadRequest, &contract.OnlyOfficeCallbackResponse{Error: 1})
|
||||
return
|
||||
}
|
||||
req.Key = documentKey
|
||||
|
||||
resp, err := h.svc.ProcessCallback(c.Request.Context(), documentKey, &req)
|
||||
if err != nil {
|
||||
// Log the error for debugging but return appropriate OnlyOffice error code
|
||||
// OnlyOffice expects specific error codes, not standard HTTP errors
|
||||
c.JSON(http.StatusOK, &contract.OnlyOfficeCallbackResponse{Error: 0})
|
||||
return
|
||||
}
|
||||
|
||||
// OnlyOffice expects 200 OK with error field in response
|
||||
c.JSON(http.StatusOK, resp)
|
||||
}
|
||||
func (h *OnlyOfficeHandler) GetEditorConfig(c *gin.Context) {
|
||||
var req contract.GetEditorConfigRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: fmt.Sprintf("invalid request body: %v", err),
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
resp, err := h.svc.GetEditorConfig(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))
|
||||
}
|
||||
|
||||
// LockDocument locks a document for editing
|
||||
// POST /api/v1/onlyoffice/lock/:id
|
||||
func (h *OnlyOfficeHandler) LockDocument(c *gin.Context) {
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "invalid document id",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from context
|
||||
userCtx := c.MustGet("user").(map[string]interface{})
|
||||
userID, err := uuid.Parse(userCtx["user_id"].(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "invalid user context",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.LockDocument(c.Request.Context(), documentID, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "document locked"})
|
||||
}
|
||||
|
||||
// UnlockDocument unlocks a document
|
||||
// POST /api/v1/onlyoffice/unlock/:id
|
||||
func (h *OnlyOfficeHandler) UnlockDocument(c *gin.Context) {
|
||||
documentID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "invalid document id",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Get user ID from context
|
||||
userCtx := c.MustGet("user").(map[string]interface{})
|
||||
userID, err := uuid.Parse(userCtx["user_id"].(string))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "invalid user context",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.svc.UnlockDocument(c.Request.Context(), documentID, userID); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, &contract.SuccessResponse{Message: "document unlocked"})
|
||||
}
|
||||
|
||||
// GetDocumentSession gets document session information
|
||||
// GET /api/v1/onlyoffice/session/:key
|
||||
func (h *OnlyOfficeHandler) GetDocumentSession(c *gin.Context) {
|
||||
documentKey := c.Param("key")
|
||||
if documentKey == "" {
|
||||
c.JSON(http.StatusBadRequest, &contract.ErrorResponse{
|
||||
Error: "document key is required",
|
||||
Code: http.StatusBadRequest,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
session, err := h.svc.GetDocumentSession(c.Request.Context(), documentKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(session))
|
||||
}
|
||||
|
||||
// GetOnlyOfficeConfig returns the OnlyOffice configuration
|
||||
// GET /api/v1/onlyoffice/config
|
||||
func (h *OnlyOfficeHandler) GetOnlyOfficeConfig(c *gin.Context) {
|
||||
config, err := h.svc.GetOnlyOfficeConfig(c.Request.Context())
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, &contract.ErrorResponse{
|
||||
Error: err.Error(),
|
||||
Code: http.StatusInternalServerError,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, contract.BuildSuccessResponse(config))
|
||||
}
|
||||
@ -39,6 +39,10 @@ type LetterOutgoingProcessor interface {
|
||||
|
||||
GetApprovalsByLetter(ctx context.Context, letterID uuid.UUID) ([]entities.LetterOutgoingApproval, error)
|
||||
GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error)
|
||||
|
||||
// GetOutgoingLetterWithDetails fetches letter with all related data
|
||||
GetOutgoingLetterWithDetails(ctx context.Context, letterID uuid.UUID) (*entities.LetterOutgoing, error)
|
||||
GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error)
|
||||
}
|
||||
|
||||
type LetterOutgoingProcessorImpl struct {
|
||||
@ -166,35 +170,50 @@ func (p *LetterOutgoingProcessorImpl) createRecipientsFromApprovalFlow(ctx conte
|
||||
return err
|
||||
}
|
||||
|
||||
recipient := p.createRecipientFromApprovalStep(flow.Steps[0], letter.ID)
|
||||
if recipient == nil {
|
||||
return nil // No valid recipient could be created
|
||||
// Find the minimum step order (first step)
|
||||
minStepOrder := flow.Steps[0].StepOrder
|
||||
for _, step := range flow.Steps {
|
||||
if step.StepOrder < minStepOrder {
|
||||
minStepOrder = step.StepOrder
|
||||
}
|
||||
}
|
||||
|
||||
return p.recipientRepo.CreateBulk(ctx, []entities.LetterOutgoingRecipient{*recipient})
|
||||
// Collect all recipients from the first step (can be multiple if parallel)
|
||||
var recipients []entities.LetterOutgoingRecipient
|
||||
for i, step := range flow.Steps {
|
||||
// Only process steps with the minimum step order (first step)
|
||||
if step.StepOrder != minStepOrder {
|
||||
continue
|
||||
}
|
||||
|
||||
recipient := p.createRecipientFromApprovalStep(step, letter.ID)
|
||||
if recipient != nil {
|
||||
// Mark the first recipient as primary
|
||||
if i == 0 {
|
||||
recipient.IsPrimary = true
|
||||
} else {
|
||||
recipient.IsPrimary = false
|
||||
}
|
||||
recipients = append(recipients, *recipient)
|
||||
}
|
||||
}
|
||||
|
||||
// If no recipients were created, return without error
|
||||
if len(recipients) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Bulk create all recipients
|
||||
return p.recipientRepo.CreateBulk(ctx, recipients)
|
||||
}
|
||||
|
||||
// createRecipientFromApprovalStep creates a recipient from an approval flow step
|
||||
func (p *LetterOutgoingProcessorImpl) createRecipientFromApprovalStep(step entities.ApprovalFlowStep, letterID uuid.UUID) *entities.LetterOutgoingRecipient {
|
||||
recipient := &entities.LetterOutgoingRecipient{
|
||||
LetterID: letterID,
|
||||
IsPrimary: true,
|
||||
}
|
||||
|
||||
if step.ApproverUser != nil {
|
||||
recipient.RecipientName = step.ApproverUser.Name
|
||||
recipient.RecipientEmail = &step.ApproverUser.Email
|
||||
|
||||
// Extract position from user profile if available
|
||||
if step.ApproverUser.Profile != nil && step.ApproverUser.Profile.JobTitle != nil {
|
||||
recipient.RecipientPosition = step.ApproverUser.Profile.JobTitle
|
||||
}
|
||||
} else if step.ApproverRole != nil {
|
||||
recipient.RecipientName = step.ApproverRole.Name
|
||||
position := "Role: " + step.ApproverRole.Name
|
||||
recipient.RecipientPosition = &position
|
||||
} else {
|
||||
return nil // No valid approver found
|
||||
LetterID: letterID,
|
||||
Status: "pending",
|
||||
IsArchived: false,
|
||||
UserID: &step.ApproverUser.ID,
|
||||
}
|
||||
|
||||
return recipient
|
||||
@ -537,3 +556,60 @@ func (p *LetterOutgoingProcessorImpl) GetApprovalsByLetter(ctx context.Context,
|
||||
func (p *LetterOutgoingProcessorImpl) GetApprovalFlow(ctx context.Context, flowID uuid.UUID) (*entities.ApprovalFlow, error) {
|
||||
return p.approvalFlowRepo.Get(ctx, flowID)
|
||||
}
|
||||
|
||||
// GetOutgoingLetterWithDetails fetches letter with all related data including approvals, discussions, and users
|
||||
func (p *LetterOutgoingProcessorImpl) GetOutgoingLetterWithDetails(ctx context.Context, letterID uuid.UUID) (*entities.LetterOutgoing, error) {
|
||||
letter, err := p.letterRepo.GetWithRelations(ctx, letterID, []string{
|
||||
"Priority",
|
||||
"ReceiverInstitution",
|
||||
"Creator",
|
||||
"Creator.Profile",
|
||||
"ApprovalFlow",
|
||||
"ApprovalFlow.Steps",
|
||||
"ApprovalFlow.Steps.ApproverRole",
|
||||
"ApprovalFlow.Steps.ApproverUser",
|
||||
"ApprovalFlow.Steps.ApproverUser.Profile",
|
||||
"Recipients",
|
||||
"Recipients.User",
|
||||
"Recipients.User.Profile",
|
||||
"Recipients.Department",
|
||||
"Attachments",
|
||||
"Approvals",
|
||||
"Approvals.Step",
|
||||
"Approvals.Step.ApproverRole",
|
||||
"Approvals.Step.ApproverUser",
|
||||
"Approvals.Step.ApproverUser.Profile",
|
||||
"Approvals.Approver",
|
||||
"Approvals.Approver.Profile",
|
||||
"Discussions",
|
||||
"Discussions.User",
|
||||
"Discussions.User.Profile",
|
||||
"Discussions.Attachments",
|
||||
"ActivityLogs",
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return letter, nil
|
||||
}
|
||||
|
||||
// GetUsersByIDs fetches users by their IDs
|
||||
func (p *LetterOutgoingProcessorImpl) GetUsersByIDs(ctx context.Context, userIDs []uuid.UUID) ([]entities.User, error) {
|
||||
if len(userIDs) == 0 {
|
||||
return []entities.User{}, nil
|
||||
}
|
||||
|
||||
var users []entities.User
|
||||
err := p.db.WithContext(ctx).
|
||||
Preload("Profile").
|
||||
Where("id IN ?", userIDs).
|
||||
Find(&users).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return users, nil
|
||||
}
|
||||
|
||||
@ -182,18 +182,20 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid
|
||||
|
||||
func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) {
|
||||
page, limit := req.Page, req.Limit
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
|
||||
filter := repository.ListIncomingLettersFilter{
|
||||
Status: req.Status,
|
||||
Query: req.Query,
|
||||
DepartmentID: req.DepartmentID,
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
filter := repository.ListIncomingLettersFilter{Status: req.Status, Query: req.Query}
|
||||
|
||||
list, total, err := p.letterRepo.List(ctx, filter, limit, (page-1)*limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
respList := make([]contract.IncomingLetterResponse, 0, len(list))
|
||||
|
||||
for _, e := range list {
|
||||
atts, _ := p.attachRepo.ListByLetter(ctx, e.ID)
|
||||
var pr *entities.Priority
|
||||
@ -202,12 +204,14 @@ func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *cont
|
||||
pr = got
|
||||
}
|
||||
}
|
||||
|
||||
var inst *entities.Institution
|
||||
if e.SenderInstitutionID != nil && p.institutionRepo != nil {
|
||||
if got, err := p.institutionRepo.Get(ctx, *e.SenderInstitutionID); err == nil {
|
||||
inst = got
|
||||
}
|
||||
}
|
||||
|
||||
resp := transformer.LetterEntityToContract(&e, atts, pr, inst)
|
||||
respList = append(respList, *resp)
|
||||
}
|
||||
@ -433,7 +437,7 @@ func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uui
|
||||
return err
|
||||
}
|
||||
if p.activity != nil {
|
||||
action := "reference_numberdiscussion.created"
|
||||
action := "discussion.created"
|
||||
tgt := "discussion"
|
||||
ctxMap := map[string]interface{}{"message": req.Message, "parent_id": req.ParentID}
|
||||
if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil {
|
||||
|
||||
463
internal/processor/onlyoffice_processor.go
Normal file
463
internal/processor/onlyoffice_processor.go
Normal file
@ -0,0 +1,463 @@
|
||||
package processor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"eslogad-be/internal/entities"
|
||||
"eslogad-be/internal/repository"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OnlyOfficeProcessor interface {
|
||||
GetDocumentSessionByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error)
|
||||
GetOrCreateDocumentSession(ctx context.Context, documentID, userID uuid.UUID) (*entities.DocumentSession, error)
|
||||
UpdateDocumentSession(ctx context.Context, session *entities.DocumentSession) error
|
||||
CreateDocumentVersion(ctx context.Context, version *entities.DocumentVersion) error
|
||||
UpdateDocumentURL(ctx context.Context, documentID uuid.UUID, newURL string) error
|
||||
LockDocument(ctx context.Context, documentID, userID uuid.UUID) error
|
||||
UnlockDocument(ctx context.Context, documentID, userID uuid.UUID) error
|
||||
LogDocumentError(ctx context.Context, documentID uuid.UUID, errorMsg string, details interface{}) error
|
||||
GetDocumentDetails(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error)
|
||||
}
|
||||
|
||||
type DocumentDetails struct {
|
||||
DocumentID uuid.UUID
|
||||
FileName string
|
||||
FileType string
|
||||
FileURL string
|
||||
FileSize int64
|
||||
DocumentType string
|
||||
ReferenceID uuid.UUID
|
||||
}
|
||||
|
||||
type OnlyOfficeProcessorImpl struct {
|
||||
db *gorm.DB
|
||||
sessionRepo *repository.DocumentSessionRepository
|
||||
versionRepo *repository.DocumentVersionRepository
|
||||
metadataRepo *repository.DocumentMetadataRepository
|
||||
errorRepo *repository.DocumentErrorRepository
|
||||
txManager *repository.TxManager
|
||||
}
|
||||
|
||||
func NewOnlyOfficeProcessor(
|
||||
db *gorm.DB,
|
||||
sessionRepo *repository.DocumentSessionRepository,
|
||||
versionRepo *repository.DocumentVersionRepository,
|
||||
metadataRepo *repository.DocumentMetadataRepository,
|
||||
errorRepo *repository.DocumentErrorRepository,
|
||||
txManager *repository.TxManager,
|
||||
) *OnlyOfficeProcessorImpl {
|
||||
return &OnlyOfficeProcessorImpl{
|
||||
db: db,
|
||||
sessionRepo: sessionRepo,
|
||||
versionRepo: versionRepo,
|
||||
metadataRepo: metadataRepo,
|
||||
errorRepo: errorRepo,
|
||||
txManager: txManager,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDocumentSessionByKey retrieves a document session by its OnlyOffice key
|
||||
func (p *OnlyOfficeProcessorImpl) GetDocumentSessionByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error) {
|
||||
return p.sessionRepo.GetByKey(ctx, documentKey)
|
||||
}
|
||||
|
||||
// GetOrCreateDocumentSession gets an existing session or creates a new one
|
||||
func (p *OnlyOfficeProcessorImpl) GetOrCreateDocumentSession(ctx context.Context, documentID, userID uuid.UUID) (*entities.DocumentSession, error) {
|
||||
// Try to get existing active session
|
||||
session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID)
|
||||
if err == nil && session != nil {
|
||||
// Return existing session if it's not closed
|
||||
if session.Status != 4 { // Status 4 = closed
|
||||
return session, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Create new session
|
||||
session = &entities.DocumentSession{
|
||||
DocumentID: documentID,
|
||||
UserID: userID,
|
||||
Status: 0, // Initial status
|
||||
Version: 1,
|
||||
IsLocked: false,
|
||||
}
|
||||
|
||||
// Generate unique document key with retry logic
|
||||
for attempts := 0; attempts < 3; attempts++ {
|
||||
session.DocumentKey = p.generateDocumentKey(documentID, userID)
|
||||
err = p.sessionRepo.Create(ctx, session)
|
||||
if err == nil {
|
||||
return session, nil
|
||||
}
|
||||
// If it's not a duplicate key error, return the error
|
||||
if !errors.Is(err, gorm.ErrDuplicatedKey) && !contains(err.Error(), "duplicate key") {
|
||||
return nil, fmt.Errorf("failed to create document session: %w", err)
|
||||
}
|
||||
// Wait a bit before retrying
|
||||
time.Sleep(time.Millisecond * 10)
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("failed to create document session after retries: %w", err)
|
||||
}
|
||||
|
||||
// UpdateDocumentSession updates an existing document session
|
||||
func (p *OnlyOfficeProcessorImpl) UpdateDocumentSession(ctx context.Context, session *entities.DocumentSession) error {
|
||||
return p.sessionRepo.Update(ctx, session)
|
||||
}
|
||||
|
||||
// CreateDocumentVersion creates a new document version
|
||||
func (p *OnlyOfficeProcessorImpl) CreateDocumentVersion(ctx context.Context, version *entities.DocumentVersion) error {
|
||||
return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
|
||||
// Deactivate previous versions if this is the active one
|
||||
if version.IsActive {
|
||||
err := p.versionRepo.DeactivateAllVersions(txCtx, version.DocumentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to deactivate previous versions: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create new version
|
||||
err := p.versionRepo.Create(txCtx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create document version: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateDocumentURL updates the document URL in the appropriate table
|
||||
func (p *OnlyOfficeProcessorImpl) UpdateDocumentURL(ctx context.Context, documentID uuid.UUID, newURL string) error {
|
||||
// Get document metadata to determine type
|
||||
metadata, err := p.metadataRepo.GetByDocumentID(ctx, documentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get document metadata: %w", err)
|
||||
}
|
||||
|
||||
// Update based on document type
|
||||
switch metadata.DocumentType {
|
||||
case "letter_attachment":
|
||||
return p.updateLetterAttachmentURL(ctx, metadata.ReferenceID, newURL)
|
||||
case "outgoing_attachment":
|
||||
return p.updateOutgoingAttachmentURL(ctx, metadata.ReferenceID, newURL)
|
||||
case "discussion_attachment":
|
||||
return p.updateDiscussionAttachmentURL(ctx, metadata.ReferenceID, newURL)
|
||||
default:
|
||||
return fmt.Errorf("unknown document type: %s", metadata.DocumentType)
|
||||
}
|
||||
}
|
||||
|
||||
// LockDocument locks a document for editing
|
||||
func (p *OnlyOfficeProcessorImpl) LockDocument(ctx context.Context, documentID, userID uuid.UUID) error {
|
||||
session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get document session: %w", err)
|
||||
}
|
||||
|
||||
// Check if already locked by another user
|
||||
if session.IsLocked && session.LockedBy != nil && *session.LockedBy != userID {
|
||||
return errors.New("document is already locked by another user")
|
||||
}
|
||||
|
||||
// Lock the document
|
||||
session.IsLocked = true
|
||||
session.LockedBy = &userID
|
||||
now := time.Now()
|
||||
session.LockedAt = &now
|
||||
|
||||
return p.sessionRepo.Update(ctx, session)
|
||||
}
|
||||
|
||||
// UnlockDocument unlocks a document
|
||||
func (p *OnlyOfficeProcessorImpl) UnlockDocument(ctx context.Context, documentID, userID uuid.UUID) error {
|
||||
session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get document session: %w", err)
|
||||
}
|
||||
|
||||
// Check if locked by the requesting user
|
||||
if session.IsLocked && session.LockedBy != nil && *session.LockedBy != userID {
|
||||
return errors.New("document is locked by another user")
|
||||
}
|
||||
|
||||
// Unlock the document
|
||||
session.IsLocked = false
|
||||
session.LockedBy = nil
|
||||
session.LockedAt = nil
|
||||
|
||||
return p.sessionRepo.Update(ctx, session)
|
||||
}
|
||||
|
||||
// LogDocumentError logs an error related to document operations
|
||||
func (p *OnlyOfficeProcessorImpl) LogDocumentError(ctx context.Context, documentID uuid.UUID, errorMsg string, details interface{}) error {
|
||||
detailsMap := make(map[string]interface{})
|
||||
|
||||
// Convert details to map if possible
|
||||
if details != nil {
|
||||
detailsJSON, _ := json.Marshal(details)
|
||||
json.Unmarshal(detailsJSON, &detailsMap)
|
||||
}
|
||||
|
||||
docError := &entities.DocumentError{
|
||||
DocumentID: documentID,
|
||||
ErrorType: "onlyoffice_callback",
|
||||
ErrorMsg: errorMsg,
|
||||
Details: detailsMap,
|
||||
}
|
||||
|
||||
// Get current session if available
|
||||
if session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID); err == nil && session != nil {
|
||||
docError.SessionID = &session.ID
|
||||
}
|
||||
|
||||
return p.errorRepo.Create(ctx, docError)
|
||||
}
|
||||
|
||||
// GetDocumentDetails retrieves document details based on type
|
||||
func (p *OnlyOfficeProcessorImpl) GetDocumentDetails(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) {
|
||||
// Get metadata
|
||||
metadata, err := p.metadataRepo.GetByDocumentID(ctx, documentID)
|
||||
if err != nil {
|
||||
// If metadata doesn't exist, create it based on the document type
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return p.createDocumentMetadata(ctx, documentID, documentType)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Get the actual file URL from the appropriate table
|
||||
fileURL, err := p.getDocumentURL(ctx, metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &DocumentDetails{
|
||||
DocumentID: metadata.DocumentID,
|
||||
FileName: metadata.FileName,
|
||||
FileType: metadata.FileType,
|
||||
FileURL: fileURL,
|
||||
FileSize: metadata.FileSize,
|
||||
DocumentType: metadata.DocumentType,
|
||||
ReferenceID: metadata.ReferenceID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (p *OnlyOfficeProcessorImpl) generateDocumentKey(documentID, userID uuid.UUID) string {
|
||||
// Use nanoseconds for better precision
|
||||
now := time.Now().UnixNano()
|
||||
|
||||
// Generate random bytes for additional uniqueness
|
||||
randomBytes := make([]byte, 4)
|
||||
rand.Read(randomBytes)
|
||||
randomHex := hex.EncodeToString(randomBytes)
|
||||
|
||||
return fmt.Sprintf("%s_%s_%d_%s", documentID.String()[:8], userID.String()[:8], now, randomHex)
|
||||
}
|
||||
|
||||
func (p *OnlyOfficeProcessorImpl) updateLetterAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error {
|
||||
// Update letter attachment URL
|
||||
return p.db.WithContext(ctx).
|
||||
Table("letter_incoming_attachments").
|
||||
Where("id = ?", attachmentID).
|
||||
Update("file_url", newURL).Error
|
||||
}
|
||||
|
||||
func (p *OnlyOfficeProcessorImpl) updateOutgoingAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error {
|
||||
// Update outgoing letter attachment URL
|
||||
return p.db.WithContext(ctx).
|
||||
Table("letter_outgoing_attachments").
|
||||
Where("id = ?", attachmentID).
|
||||
Update("file_url", newURL).Error
|
||||
}
|
||||
|
||||
func (p *OnlyOfficeProcessorImpl) updateDiscussionAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error {
|
||||
// Update discussion attachment URL
|
||||
return p.db.WithContext(ctx).
|
||||
Table("letter_outgoing_discussion_attachments").
|
||||
Where("id = ?", attachmentID).
|
||||
Update("file_url", newURL).Error
|
||||
}
|
||||
|
||||
func (p *OnlyOfficeProcessorImpl) createDocumentMetadata(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) {
|
||||
var metadata entities.DocumentMetadata
|
||||
var details DocumentDetails
|
||||
|
||||
// Fetch document information based on type
|
||||
switch documentType {
|
||||
case "letter_attachment":
|
||||
var attachment struct {
|
||||
ID uuid.UUID `gorm:"column:id"`
|
||||
LetterID uuid.UUID `gorm:"column:letter_id"`
|
||||
FileName string `gorm:"column:file_name"`
|
||||
FileURL string `gorm:"column:file_url"`
|
||||
FileSize int64 `gorm:"column:file_size"`
|
||||
FileType string `gorm:"column:file_type"`
|
||||
}
|
||||
err := p.db.WithContext(ctx).
|
||||
Table("letter_incoming_attachments").
|
||||
Where("id = ?", documentID).
|
||||
First(&attachment).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get letter attachment: %w", err)
|
||||
}
|
||||
|
||||
metadata = entities.DocumentMetadata{
|
||||
DocumentID: documentID,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: attachment.LetterID,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
FileSize: attachment.FileSize,
|
||||
}
|
||||
details = DocumentDetails{
|
||||
DocumentID: documentID,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
FileURL: attachment.FileURL,
|
||||
FileSize: attachment.FileSize,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: attachment.LetterID,
|
||||
}
|
||||
|
||||
case "outgoing_attachment":
|
||||
var attachment struct {
|
||||
ID uuid.UUID `gorm:"column:id"`
|
||||
LetterID uuid.UUID `gorm:"column:letter_id"`
|
||||
FileName string `gorm:"column:file_name"`
|
||||
FileURL string `gorm:"column:file_url"`
|
||||
FileSize int64 `gorm:"column:file_size"`
|
||||
FileType string `gorm:"column:file_type"`
|
||||
}
|
||||
err := p.db.WithContext(ctx).
|
||||
Table("letter_outgoing_attachments").
|
||||
Where("id = ?", documentID).
|
||||
First(&attachment).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get outgoing letter attachment: %w", err)
|
||||
}
|
||||
|
||||
metadata = entities.DocumentMetadata{
|
||||
DocumentID: documentID,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: attachment.LetterID,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
FileSize: attachment.FileSize,
|
||||
}
|
||||
details = DocumentDetails{
|
||||
DocumentID: documentID,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
FileURL: attachment.FileURL,
|
||||
FileSize: attachment.FileSize,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: attachment.LetterID,
|
||||
}
|
||||
|
||||
case "discussion_attachment":
|
||||
var attachment struct {
|
||||
ID uuid.UUID `gorm:"column:id"`
|
||||
DiscussionID uuid.UUID `gorm:"column:discussion_id"`
|
||||
FileName string `gorm:"column:file_name"`
|
||||
FileURL string `gorm:"column:file_url"`
|
||||
FileSize int64 `gorm:"column:file_size"`
|
||||
FileType string `gorm:"column:file_type"`
|
||||
}
|
||||
err := p.db.WithContext(ctx).
|
||||
Table("letter_outgoing_discussion_attachments").
|
||||
Where("id = ?", documentID).
|
||||
First(&attachment).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get discussion attachment: %w", err)
|
||||
}
|
||||
|
||||
metadata = entities.DocumentMetadata{
|
||||
DocumentID: documentID,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: attachment.DiscussionID,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
FileSize: attachment.FileSize,
|
||||
}
|
||||
details = DocumentDetails{
|
||||
DocumentID: documentID,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
FileURL: attachment.FileURL,
|
||||
FileSize: attachment.FileSize,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: attachment.DiscussionID,
|
||||
}
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown document type: %s", documentType)
|
||||
}
|
||||
|
||||
// Create metadata in database
|
||||
err := p.metadataRepo.Create(ctx, &metadata)
|
||||
if err != nil {
|
||||
// If it already exists (race condition), try to get it
|
||||
if strings.Contains(err.Error(), "duplicate key") {
|
||||
existingMetadata, getErr := p.metadataRepo.GetByDocumentID(ctx, documentID)
|
||||
if getErr == nil {
|
||||
// Get the file URL
|
||||
fileURL, urlErr := p.getDocumentURL(ctx, existingMetadata)
|
||||
if urlErr == nil {
|
||||
details.FileURL = fileURL
|
||||
}
|
||||
return &details, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("failed to create document metadata: %w", err)
|
||||
}
|
||||
|
||||
return &details, nil
|
||||
}
|
||||
|
||||
func (p *OnlyOfficeProcessorImpl) getDocumentURL(ctx context.Context, metadata *entities.DocumentMetadata) (string, error) {
|
||||
var fileURL string
|
||||
|
||||
switch metadata.DocumentType {
|
||||
case "letter_attachment":
|
||||
err := p.db.WithContext(ctx).
|
||||
Table("letter_incoming_attachments").
|
||||
Where("id = ?", metadata.ReferenceID).
|
||||
Select("file_url").
|
||||
Scan(&fileURL).Error
|
||||
return fileURL, err
|
||||
|
||||
case "outgoing_attachment":
|
||||
err := p.db.WithContext(ctx).
|
||||
Table("letter_outgoing_attachments").
|
||||
Where("id = ?", metadata.ReferenceID).
|
||||
Select("file_url").
|
||||
Scan(&fileURL).Error
|
||||
return fileURL, err
|
||||
|
||||
case "discussion_attachment":
|
||||
err := p.db.WithContext(ctx).
|
||||
Table("letter_outgoing_discussion_attachments").
|
||||
Where("id = ?", metadata.ReferenceID).
|
||||
Select("file_url").
|
||||
Scan(&fileURL).Error
|
||||
return fileURL, err
|
||||
|
||||
default:
|
||||
return "", fmt.Errorf("unknown document type: %s", metadata.DocumentType)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if string contains substring
|
||||
func contains(s, substr string) bool {
|
||||
return strings.Contains(s, substr)
|
||||
}
|
||||
218
internal/repository/document_repository.go
Normal file
218
internal/repository/document_repository.go
Normal file
@ -0,0 +1,218 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"eslogad-be/internal/entities"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// DocumentSessionRepository handles document session operations
|
||||
type DocumentSessionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDocumentSessionRepository(db *gorm.DB) *DocumentSessionRepository {
|
||||
return &DocumentSessionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *DocumentSessionRepository) Create(ctx context.Context, session *entities.DocumentSession) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).Create(session).Error
|
||||
}
|
||||
|
||||
func (r *DocumentSessionRepository) GetByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var session entities.DocumentSession
|
||||
err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("document_key = ?", documentKey).
|
||||
First(&session).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (r *DocumentSessionRepository) GetActiveByDocument(ctx context.Context, documentID uuid.UUID) (*entities.DocumentSession, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var session entities.DocumentSession
|
||||
err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("document_id = ? AND status != 4", documentID). // Status 4 = closed
|
||||
Order("created_at DESC").
|
||||
First(&session).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &session, nil
|
||||
}
|
||||
|
||||
func (r *DocumentSessionRepository) Update(ctx context.Context, session *entities.DocumentSession) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
// Only update specific fields to avoid association issues
|
||||
updates := map[string]interface{}{
|
||||
"status": session.Status,
|
||||
"is_locked": session.IsLocked,
|
||||
"locked_by": session.LockedBy,
|
||||
"locked_at": session.LockedAt,
|
||||
"last_saved_at": session.LastSavedAt,
|
||||
"version": session.Version,
|
||||
"updated_at": session.UpdatedAt,
|
||||
}
|
||||
return db.WithContext(ctx).Model(&entities.DocumentSession{}).Where("id = ?", session.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *DocumentSessionRepository) ListByDocument(ctx context.Context, documentID uuid.UUID) ([]entities.DocumentSession, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var sessions []entities.DocumentSession
|
||||
err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("document_id = ?", documentID).
|
||||
Order("created_at DESC").
|
||||
Find(&sessions).Error
|
||||
return sessions, err
|
||||
}
|
||||
|
||||
// DocumentVersionRepository handles document version operations
|
||||
type DocumentVersionRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDocumentVersionRepository(db *gorm.DB) *DocumentVersionRepository {
|
||||
return &DocumentVersionRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *DocumentVersionRepository) Create(ctx context.Context, version *entities.DocumentVersion) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).Create(version).Error
|
||||
}
|
||||
|
||||
func (r *DocumentVersionRepository) GetByID(ctx context.Context, id uuid.UUID) (*entities.DocumentVersion, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var version entities.DocumentVersion
|
||||
err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("id = ?", id).
|
||||
First(&version).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &version, nil
|
||||
}
|
||||
|
||||
func (r *DocumentVersionRepository) GetActiveVersion(ctx context.Context, documentID uuid.UUID) (*entities.DocumentVersion, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var version entities.DocumentVersion
|
||||
err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("document_id = ? AND is_active = ?", documentID, true).
|
||||
First(&version).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &version, nil
|
||||
}
|
||||
|
||||
func (r *DocumentVersionRepository) ListByDocument(ctx context.Context, documentID uuid.UUID) ([]entities.DocumentVersion, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var versions []entities.DocumentVersion
|
||||
err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Where("document_id = ?", documentID).
|
||||
Order("version DESC").
|
||||
Find(&versions).Error
|
||||
return versions, err
|
||||
}
|
||||
|
||||
func (r *DocumentVersionRepository) DeactivateAllVersions(ctx context.Context, documentID uuid.UUID) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).
|
||||
Model(&entities.DocumentVersion{}).
|
||||
Where("document_id = ?", documentID).
|
||||
Update("is_active", false).Error
|
||||
}
|
||||
|
||||
// DocumentMetadataRepository handles document metadata operations
|
||||
type DocumentMetadataRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDocumentMetadataRepository(db *gorm.DB) *DocumentMetadataRepository {
|
||||
return &DocumentMetadataRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *DocumentMetadataRepository) Create(ctx context.Context, metadata *entities.DocumentMetadata) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).Create(metadata).Error
|
||||
}
|
||||
|
||||
func (r *DocumentMetadataRepository) GetByDocumentID(ctx context.Context, documentID uuid.UUID) (*entities.DocumentMetadata, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var metadata entities.DocumentMetadata
|
||||
err := db.WithContext(ctx).
|
||||
Where("document_id = ?", documentID).
|
||||
First(&metadata).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &metadata, nil
|
||||
}
|
||||
|
||||
func (r *DocumentMetadataRepository) Update(ctx context.Context, metadata *entities.DocumentMetadata) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
// Only update specific fields to avoid association issues
|
||||
updates := map[string]interface{}{
|
||||
"document_type": metadata.DocumentType,
|
||||
"reference_id": metadata.ReferenceID,
|
||||
"file_name": metadata.FileName,
|
||||
"file_type": metadata.FileType,
|
||||
"file_size": metadata.FileSize,
|
||||
"mime_type": metadata.MimeType,
|
||||
"updated_at": metadata.UpdatedAt,
|
||||
}
|
||||
return db.WithContext(ctx).Model(&entities.DocumentMetadata{}).Where("id = ?", metadata.ID).Updates(updates).Error
|
||||
}
|
||||
|
||||
func (r *DocumentMetadataRepository) Delete(ctx context.Context, documentID uuid.UUID) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).
|
||||
Where("document_id = ?", documentID).
|
||||
Delete(&entities.DocumentMetadata{}).Error
|
||||
}
|
||||
|
||||
// DocumentErrorRepository handles document error logging
|
||||
type DocumentErrorRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewDocumentErrorRepository(db *gorm.DB) *DocumentErrorRepository {
|
||||
return &DocumentErrorRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *DocumentErrorRepository) Create(ctx context.Context, docError *entities.DocumentError) error {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).Create(docError).Error
|
||||
}
|
||||
|
||||
func (r *DocumentErrorRepository) ListByDocument(ctx context.Context, documentID uuid.UUID) ([]entities.DocumentError, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var errors []entities.DocumentError
|
||||
err := db.WithContext(ctx).
|
||||
Preload("Session").
|
||||
Where("document_id = ?", documentID).
|
||||
Order("created_at DESC").
|
||||
Find(&errors).Error
|
||||
return errors, err
|
||||
}
|
||||
|
||||
func (r *DocumentErrorRepository) ListBySession(ctx context.Context, sessionID uuid.UUID) ([]entities.DocumentError, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var errors []entities.DocumentError
|
||||
err := db.WithContext(ctx).
|
||||
Where("session_id = ?", sessionID).
|
||||
Order("created_at DESC").
|
||||
Find(&errors).Error
|
||||
return errors, err
|
||||
}
|
||||
@ -51,6 +51,26 @@ func (r *LetterOutgoingRepository) SoftDelete(ctx context.Context, id uuid.UUID)
|
||||
return db.WithContext(ctx).Model(&entities.LetterOutgoing{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error
|
||||
}
|
||||
|
||||
func (r *LetterOutgoingRepository) GetWithRelations(ctx context.Context, id uuid.UUID, relations []string) (*entities.LetterOutgoing, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
query := db.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id)
|
||||
|
||||
// Preload all specified relations
|
||||
for _, relation := range relations {
|
||||
query = query.Preload(relation)
|
||||
}
|
||||
|
||||
var e entities.LetterOutgoing
|
||||
if err := query.First(&e).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
type ListOutgoingLettersFilter struct {
|
||||
Status *string
|
||||
Query *string
|
||||
@ -167,7 +187,12 @@ func (r *LetterOutgoingRecipientRepository) CreateBulk(ctx context.Context, list
|
||||
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 {
|
||||
if err := db.WithContext(ctx).
|
||||
Preload("User").
|
||||
Preload("Department").
|
||||
Where("letter_id = ?", letterID).
|
||||
Order("is_primary DESC, created_at ASC").
|
||||
Find(&list).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return list, nil
|
||||
|
||||
@ -40,26 +40,33 @@ func (r *LetterIncomingRepository) SoftDelete(ctx context.Context, id uuid.UUID)
|
||||
}
|
||||
|
||||
type ListIncomingLettersFilter struct {
|
||||
Status *string
|
||||
Query *string
|
||||
Status *string
|
||||
Query *string
|
||||
DepartmentID *uuid.UUID
|
||||
}
|
||||
|
||||
func (r *LetterIncomingRepository) List(ctx context.Context, filter ListIncomingLettersFilter, limit, offset int) ([]entities.LetterIncoming, int64, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
query := db.WithContext(ctx).Model(&entities.LetterIncoming{}).Where("deleted_at IS NULL")
|
||||
|
||||
if filter.DepartmentID != nil {
|
||||
query = query.Joins("JOIN letter_incoming_recipients ON letter_incoming_recipients.letter_id = letters_incoming.id").
|
||||
Where("letter_incoming_recipients.recipient_department_id = ?", *filter.DepartmentID)
|
||||
}
|
||||
|
||||
if filter.Status != nil {
|
||||
query = query.Where("status = ?", *filter.Status)
|
||||
query = query.Where("letters_incoming.status = ?", *filter.Status)
|
||||
}
|
||||
if filter.Query != nil {
|
||||
q := "%" + *filter.Query + "%"
|
||||
query = query.Where("subject ILIKE ? OR reference_number ILIKE ?", q, q)
|
||||
query = query.Where("letters_incoming.subject ILIKE ? OR letters_incoming.reference_number ILIKE ?", q, q)
|
||||
}
|
||||
var total int64
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var list []entities.LetterIncoming
|
||||
if err := query.Order("created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
|
||||
if err := query.Order("letters_incoming.created_at DESC").Limit(limit).Offset(offset).Find(&list).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
return list, total, nil
|
||||
@ -289,3 +296,28 @@ func (r *LetterIncomingRecipientRepository) CreateBulk(ctx context.Context, recs
|
||||
db := DBFromContext(ctx, r.db)
|
||||
return db.WithContext(ctx).Create(&recs).Error
|
||||
}
|
||||
|
||||
func (r *LetterIncomingRecipientRepository) GetLetterIDsByDepartment(ctx context.Context, departmentID uuid.UUID) ([]uuid.UUID, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var letterIDs []uuid.UUID
|
||||
if err := db.WithContext(ctx).
|
||||
Model(&entities.LetterIncomingRecipient{}).
|
||||
Where("recipient_department_id = ?", departmentID).
|
||||
Distinct("letter_id").
|
||||
Pluck("letter_id", &letterIDs).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return letterIDs, nil
|
||||
}
|
||||
|
||||
func (r *LetterIncomingRecipientRepository) HasDepartmentAccess(ctx context.Context, letterID uuid.UUID, departmentID uuid.UUID) (bool, error) {
|
||||
db := DBFromContext(ctx, r.db)
|
||||
var count int64
|
||||
if err := db.WithContext(ctx).
|
||||
Model(&entities.LetterIncomingRecipient{}).
|
||||
Where("letter_id = ? AND recipient_department_id = ?", letterID, departmentID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
@ -90,6 +90,7 @@ type LetterOutgoingHandler interface {
|
||||
RejectOutgoingLetter(c *gin.Context)
|
||||
SendOutgoingLetter(c *gin.Context)
|
||||
ArchiveOutgoingLetter(c *gin.Context)
|
||||
GetLetterApprovalInfo(c *gin.Context)
|
||||
|
||||
AddRecipients(c *gin.Context)
|
||||
UpdateRecipient(c *gin.Context)
|
||||
@ -101,6 +102,8 @@ type LetterOutgoingHandler interface {
|
||||
CreateDiscussion(c *gin.Context)
|
||||
UpdateDiscussion(c *gin.Context)
|
||||
DeleteDiscussion(c *gin.Context)
|
||||
|
||||
GetApprovalDiscussions(c *gin.Context)
|
||||
}
|
||||
|
||||
type AdminApprovalFlowHandler interface {
|
||||
@ -122,3 +125,12 @@ type DispositionRouteHandler interface {
|
||||
ListByFromDept(c *gin.Context)
|
||||
SetActive(c *gin.Context)
|
||||
}
|
||||
|
||||
type OnlyOfficeHandler interface {
|
||||
ProcessCallback(c *gin.Context)
|
||||
GetEditorConfig(c *gin.Context)
|
||||
GetOnlyOfficeConfig(c *gin.Context)
|
||||
LockDocument(c *gin.Context)
|
||||
UnlockDocument(c *gin.Context)
|
||||
GetDocumentSession(c *gin.Context)
|
||||
}
|
||||
|
||||
@ -20,6 +20,7 @@ type Router struct {
|
||||
letterOutgoingHandler LetterOutgoingHandler
|
||||
adminApprovalFlowHandler AdminApprovalFlowHandler
|
||||
dispRouteHandler DispositionRouteHandler
|
||||
onlyOfficeHandler OnlyOfficeHandler
|
||||
}
|
||||
|
||||
func NewRouter(
|
||||
@ -35,6 +36,7 @@ func NewRouter(
|
||||
letterOutgoingHandler LetterOutgoingHandler,
|
||||
adminApprovalFlowHandler AdminApprovalFlowHandler,
|
||||
dispRouteHandler DispositionRouteHandler,
|
||||
onlyOfficeHandler OnlyOfficeHandler,
|
||||
) *Router {
|
||||
return &Router{
|
||||
config: cfg,
|
||||
@ -49,6 +51,7 @@ func NewRouter(
|
||||
letterOutgoingHandler: letterOutgoingHandler,
|
||||
adminApprovalFlowHandler: adminApprovalFlowHandler,
|
||||
dispRouteHandler: dispRouteHandler,
|
||||
onlyOfficeHandler: onlyOfficeHandler,
|
||||
}
|
||||
}
|
||||
|
||||
@ -167,6 +170,7 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
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.GET("/outgoing/:id/approval-info", r.letterOutgoingHandler.GetLetterApprovalInfo)
|
||||
|
||||
lettersch.POST("/outgoing/:id/recipients", r.letterOutgoingHandler.AddRecipients)
|
||||
lettersch.PUT("/outgoing/:id/recipients/:recipient_id", r.letterOutgoingHandler.UpdateRecipient)
|
||||
@ -179,6 +183,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion)
|
||||
lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion)
|
||||
|
||||
// Get approvals and discussions for outgoing letter
|
||||
lettersch.GET("/outgoing/:id/approval-discussions", r.letterOutgoingHandler.GetApprovalDiscussions)
|
||||
|
||||
lettersch.POST("/dispositions/:letter_id", r.letterHandler.CreateDispositions)
|
||||
lettersch.GET("/dispositions/:letter_id", r.letterHandler.GetEnhancedDispositionsByLetter)
|
||||
|
||||
@ -212,5 +219,23 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
|
||||
approvalFlows.POST("/:id/clone", r.adminApprovalFlowHandler.CloneApprovalFlow)
|
||||
}
|
||||
}
|
||||
|
||||
// OnlyOffice routes
|
||||
onlyoffice := v1.Group("/onlyoffice")
|
||||
{
|
||||
// Callback endpoint - no auth required (OnlyOffice will call this)
|
||||
onlyoffice.POST("/callback/:key", r.onlyOfficeHandler.ProcessCallback)
|
||||
|
||||
// Protected endpoints
|
||||
onlyofficeAuth := onlyoffice.Group("")
|
||||
onlyofficeAuth.Use(r.authMiddleware.RequireAuth())
|
||||
{
|
||||
onlyofficeAuth.POST("/config", r.onlyOfficeHandler.GetEditorConfig)
|
||||
onlyofficeAuth.GET("/settings", r.onlyOfficeHandler.GetOnlyOfficeConfig)
|
||||
onlyofficeAuth.POST("/lock/:id", r.onlyOfficeHandler.LockDocument)
|
||||
onlyofficeAuth.POST("/unlock/:id", r.onlyOfficeHandler.UnlockDocument)
|
||||
onlyofficeAuth.GET("/session/:key", r.onlyOfficeHandler.GetDocumentSession)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,6 +2,7 @@ package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/contract"
|
||||
@ -36,6 +37,11 @@ type LetterOutgoingService interface {
|
||||
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
|
||||
|
||||
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error)
|
||||
|
||||
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
|
||||
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
|
||||
}
|
||||
|
||||
type LetterOutgoingServiceImpl struct {
|
||||
@ -336,12 +342,13 @@ func (s *LetterOutgoingServiceImpl) AddRecipients(ctx context.Context, letterID
|
||||
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,
|
||||
LetterID: letterID,
|
||||
UserID: r.UserID,
|
||||
DepartmentID: r.DepartmentID,
|
||||
IsPrimary: r.IsPrimary,
|
||||
Status: r.Status,
|
||||
Flag: r.Flag,
|
||||
IsArchived: r.IsArchived,
|
||||
}
|
||||
}
|
||||
|
||||
@ -359,12 +366,24 @@ func (s *LetterOutgoingServiceImpl) UpdateRecipient(ctx context.Context, letterI
|
||||
}
|
||||
|
||||
recipient := &entities.LetterOutgoingRecipient{
|
||||
ID: recipientID,
|
||||
RecipientName: req.Name,
|
||||
RecipientEmail: req.Email,
|
||||
RecipientPosition: req.Position,
|
||||
RecipientInstitution: req.Institution,
|
||||
IsPrimary: req.IsPrimary,
|
||||
ID: recipientID,
|
||||
IsPrimary: req.IsPrimary,
|
||||
}
|
||||
|
||||
if req.UserID != nil {
|
||||
recipient.UserID = req.UserID
|
||||
}
|
||||
if req.DepartmentID != nil {
|
||||
recipient.DepartmentID = req.DepartmentID
|
||||
}
|
||||
if req.Status != nil {
|
||||
recipient.Status = *req.Status
|
||||
}
|
||||
if req.Flag != nil {
|
||||
recipient.Flag = req.Flag
|
||||
}
|
||||
if req.IsArchived != nil {
|
||||
recipient.IsArchived = *req.IsArchived
|
||||
}
|
||||
|
||||
return s.processor.UpdateRecipient(ctx, recipient)
|
||||
@ -501,6 +520,83 @@ func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discus
|
||||
return s.processor.DeleteDiscussion(ctx, discussionID)
|
||||
}
|
||||
|
||||
func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) {
|
||||
userID := getUserIDFromContext(ctx)
|
||||
|
||||
_, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var currentApproval *entities.LetterOutgoingApproval
|
||||
var isApproverOnActiveStep bool
|
||||
var canApprove bool
|
||||
|
||||
for _, approval := range approvals {
|
||||
if approval.Status == entities.ApprovalStatusPending {
|
||||
currentApproval = &approval
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check if current user is the approver for the active step
|
||||
if currentApproval != nil && currentApproval.Step != nil {
|
||||
step := currentApproval.Step
|
||||
|
||||
// Check if user is the specific approver
|
||||
if step.ApproverUserID != nil && *step.ApproverUserID == userID {
|
||||
isApproverOnActiveStep = true
|
||||
canApprove = true
|
||||
}
|
||||
// Note: Role-based approval check would require additional implementation
|
||||
// For now, we only support user-specific approvers
|
||||
}
|
||||
|
||||
// Build actions based on current status
|
||||
var actions []contract.ApprovalAction
|
||||
if canApprove && currentApproval != nil {
|
||||
actions = []contract.ApprovalAction{
|
||||
{
|
||||
Type: "APPROVE",
|
||||
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
|
||||
Method: "POST",
|
||||
},
|
||||
{
|
||||
Type: "REJECT",
|
||||
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
|
||||
Method: "POST",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Determine decision status
|
||||
decisionStatus := "PENDING"
|
||||
if currentApproval == nil {
|
||||
decisionStatus = "COMPLETED"
|
||||
}
|
||||
|
||||
// Determine notes visibility
|
||||
notesVisibility := "FULL"
|
||||
if !isApproverOnActiveStep {
|
||||
notesVisibility = "READONLY"
|
||||
}
|
||||
|
||||
info := &contract.LetterApprovalInfoResponse{
|
||||
IsApproverOnActiveStep: isApproverOnActiveStep,
|
||||
DecisionStatus: decisionStatus,
|
||||
CanApprove: canApprove,
|
||||
Actions: actions,
|
||||
NotesVisibility: notesVisibility,
|
||||
}
|
||||
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func getUserIDFromContext(ctx context.Context) uuid.UUID {
|
||||
appCtx := appcontext.FromGinContext(ctx)
|
||||
if appCtx != nil {
|
||||
@ -521,6 +617,211 @@ func userHasRole(ctx context.Context, roleID uuid.UUID) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) {
|
||||
// Get the letter with all related data
|
||||
letter, err := s.processor.GetOutgoingLetterWithDetails(ctx, letterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Transform approvals
|
||||
approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals))
|
||||
for _, approval := range letter.Approvals {
|
||||
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
|
||||
ID: approval.ID,
|
||||
LetterID: approval.LetterID,
|
||||
StepID: approval.StepID,
|
||||
ApproverID: approval.ApproverID,
|
||||
Status: string(approval.Status),
|
||||
Remarks: approval.Remarks,
|
||||
ActedAt: approval.ActedAt,
|
||||
CreatedAt: approval.CreatedAt,
|
||||
}
|
||||
|
||||
// Add step details if available
|
||||
if approval.Step != nil {
|
||||
approvalResp.Step = &contract.ApprovalFlowStepResponse{
|
||||
ID: approval.Step.ID,
|
||||
StepOrder: approval.Step.StepOrder,
|
||||
ParallelGroup: approval.Step.ParallelGroup,
|
||||
Required: approval.Step.Required,
|
||||
CreatedAt: approval.Step.CreatedAt,
|
||||
UpdatedAt: approval.Step.UpdatedAt,
|
||||
}
|
||||
|
||||
if approval.Step.ApproverRoleID != nil {
|
||||
approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID
|
||||
}
|
||||
if approval.Step.ApproverUserID != nil {
|
||||
approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID
|
||||
}
|
||||
|
||||
// Add role information if available
|
||||
if approval.Step.ApproverRole != nil {
|
||||
approvalResp.Step.ApproverRole = &contract.RoleResponse{
|
||||
ID: approval.Step.ApproverRole.ID,
|
||||
Name: approval.Step.ApproverRole.Name,
|
||||
Code: approval.Step.ApproverRole.Code,
|
||||
}
|
||||
}
|
||||
|
||||
// Add user information if available
|
||||
if approval.Step.ApproverUser != nil {
|
||||
approvalResp.Step.ApproverUser = &contract.UserResponse{
|
||||
ID: approval.Step.ApproverUser.ID,
|
||||
Name: approval.Step.ApproverUser.Name,
|
||||
Email: approval.Step.ApproverUser.Email,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add approver details if available
|
||||
if approval.Approver != nil {
|
||||
approvalResp.Approver = &contract.UserResponse{
|
||||
ID: approval.Approver.ID,
|
||||
Name: approval.Approver.Name,
|
||||
Email: approval.Approver.Email,
|
||||
}
|
||||
|
||||
// Add profile if available
|
||||
if approval.Approver.Profile != nil {
|
||||
approvalResp.Approver.Profile = &contract.UserProfileResponse{
|
||||
UserID: approval.Approver.Profile.UserID,
|
||||
FullName: approval.Approver.Profile.FullName,
|
||||
DisplayName: approval.Approver.Profile.DisplayName,
|
||||
Phone: approval.Approver.Profile.Phone,
|
||||
AvatarURL: approval.Approver.Profile.AvatarURL,
|
||||
JobTitle: approval.Approver.Profile.JobTitle,
|
||||
EmployeeNo: approval.Approver.Profile.EmployeeNo,
|
||||
Bio: approval.Approver.Profile.Bio,
|
||||
Timezone: approval.Approver.Profile.Timezone,
|
||||
Locale: approval.Approver.Profile.Locale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
approvals = append(approvals, approvalResp)
|
||||
}
|
||||
|
||||
// Transform discussions
|
||||
discussions := make([]contract.OutgoingLetterDiscussionResponse, 0, len(letter.Discussions))
|
||||
for _, discussion := range letter.Discussions {
|
||||
// Extract mentioned user IDs from mentions
|
||||
mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions)
|
||||
|
||||
discussionResp := contract.OutgoingLetterDiscussionResponse{
|
||||
ID: discussion.ID,
|
||||
LetterID: discussion.LetterID,
|
||||
ParentID: discussion.ParentID,
|
||||
UserID: discussion.UserID,
|
||||
Message: discussion.Message,
|
||||
Mentions: discussion.Mentions,
|
||||
CreatedAt: discussion.CreatedAt,
|
||||
UpdatedAt: discussion.UpdatedAt,
|
||||
EditedAt: discussion.EditedAt,
|
||||
}
|
||||
|
||||
// Add user details if available
|
||||
if discussion.User != nil {
|
||||
discussionResp.User = &contract.UserResponse{
|
||||
ID: discussion.User.ID,
|
||||
Name: discussion.User.Name,
|
||||
Email: discussion.User.Email,
|
||||
IsActive: discussion.User.IsActive,
|
||||
CreatedAt: discussion.User.CreatedAt,
|
||||
UpdatedAt: discussion.User.UpdatedAt,
|
||||
}
|
||||
|
||||
// Add profile if available
|
||||
if discussion.User.Profile != nil {
|
||||
discussionResp.User.Profile = &contract.UserProfileResponse{
|
||||
UserID: discussion.User.Profile.UserID,
|
||||
FullName: discussion.User.Profile.FullName,
|
||||
DisplayName: discussion.User.Profile.DisplayName,
|
||||
Phone: discussion.User.Profile.Phone,
|
||||
AvatarURL: discussion.User.Profile.AvatarURL,
|
||||
JobTitle: discussion.User.Profile.JobTitle,
|
||||
EmployeeNo: discussion.User.Profile.EmployeeNo,
|
||||
Bio: discussion.User.Profile.Bio,
|
||||
Timezone: discussion.User.Profile.Timezone,
|
||||
Locale: discussion.User.Profile.Locale,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get mentioned users details
|
||||
if len(mentionedUserIDs) > 0 {
|
||||
mentionedUsers, _ := s.processor.GetUsersByIDs(ctx, mentionedUserIDs)
|
||||
for _, user := range mentionedUsers {
|
||||
mentionedUserResp := contract.UserResponse{
|
||||
ID: user.ID,
|
||||
Name: user.Name,
|
||||
Email: user.Email,
|
||||
IsActive: user.IsActive,
|
||||
CreatedAt: user.CreatedAt,
|
||||
UpdatedAt: user.UpdatedAt,
|
||||
}
|
||||
|
||||
if user.Profile != nil {
|
||||
mentionedUserResp.Profile = &contract.UserProfileResponse{
|
||||
UserID: user.Profile.UserID,
|
||||
FullName: user.Profile.FullName,
|
||||
DisplayName: user.Profile.DisplayName,
|
||||
Timezone: user.Profile.Timezone,
|
||||
Locale: user.Profile.Locale,
|
||||
}
|
||||
}
|
||||
|
||||
discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp)
|
||||
}
|
||||
}
|
||||
|
||||
// Add attachments if available
|
||||
for _, attachment := range discussion.Attachments {
|
||||
attachmentResp := contract.OutgoingLetterDiscussionAttachmentResponse{
|
||||
ID: attachment.ID,
|
||||
DiscussionID: attachment.DiscussionID,
|
||||
FileURL: attachment.FileURL,
|
||||
FileName: attachment.FileName,
|
||||
FileType: attachment.FileType,
|
||||
UploadedBy: attachment.UploadedBy,
|
||||
UploadedAt: attachment.UploadedAt,
|
||||
}
|
||||
discussionResp.Attachments = append(discussionResp.Attachments, attachmentResp)
|
||||
}
|
||||
|
||||
discussions = append(discussions, discussionResp)
|
||||
}
|
||||
|
||||
return &contract.OutgoingLetterApprovalDiscussionsResponse{
|
||||
Approvals: approvals,
|
||||
Discussions: discussions,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Helper function to extract user IDs from mentions
|
||||
func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID {
|
||||
var userIDs []uuid.UUID
|
||||
|
||||
if mentions == nil {
|
||||
return userIDs
|
||||
}
|
||||
|
||||
if userIDsInterface, ok := mentions["user_ids"]; ok {
|
||||
if userIDsList, ok := userIDsInterface.([]interface{}); ok {
|
||||
for _, id := range userIDsList {
|
||||
if idStr, ok := id.(string); ok {
|
||||
if userID, err := uuid.Parse(idStr); err == nil {
|
||||
userIDs = append(userIDs, userID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return userIDs
|
||||
}
|
||||
|
||||
func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.OutgoingLetterResponse {
|
||||
resp := &contract.OutgoingLetterResponse{
|
||||
ID: letter.ID,
|
||||
@ -565,15 +866,36 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi
|
||||
if len(letter.Recipients) > 0 {
|
||||
resp.Recipients = make([]contract.OutgoingLetterRecipientResponse, len(letter.Recipients))
|
||||
for i, recipient := range letter.Recipients {
|
||||
resp.Recipients[i] = contract.OutgoingLetterRecipientResponse{
|
||||
ID: recipient.ID,
|
||||
Name: recipient.RecipientName,
|
||||
Email: recipient.RecipientEmail,
|
||||
Position: recipient.RecipientPosition,
|
||||
Institution: recipient.RecipientInstitution,
|
||||
IsPrimary: recipient.IsPrimary,
|
||||
CreatedAt: recipient.CreatedAt,
|
||||
recipResp := contract.OutgoingLetterRecipientResponse{
|
||||
ID: recipient.ID,
|
||||
LetterID: recipient.LetterID,
|
||||
UserID: recipient.UserID,
|
||||
DepartmentID: recipient.DepartmentID,
|
||||
IsPrimary: recipient.IsPrimary,
|
||||
Status: recipient.Status,
|
||||
ReadAt: recipient.ReadAt,
|
||||
Flag: recipient.Flag,
|
||||
IsArchived: recipient.IsArchived,
|
||||
CreatedAt: recipient.CreatedAt,
|
||||
}
|
||||
|
||||
if recipient.User != nil {
|
||||
recipResp.User = &contract.UserResponse{
|
||||
ID: recipient.User.ID,
|
||||
Name: recipient.User.Name,
|
||||
Email: recipient.User.Email,
|
||||
}
|
||||
}
|
||||
|
||||
if recipient.Department != nil {
|
||||
recipResp.Department = &contract.DepartmentResponse{
|
||||
ID: recipient.Department.ID,
|
||||
Name: recipient.Department.Name,
|
||||
Code: recipient.Department.Code,
|
||||
}
|
||||
}
|
||||
|
||||
resp.Recipients[i] = recipResp
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
708
internal/service/onlyoffice_service.go
Normal file
708
internal/service/onlyoffice_service.go
Normal file
@ -0,0 +1,708 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"eslogad-be/config"
|
||||
"eslogad-be/internal/appcontext"
|
||||
"eslogad-be/internal/contract"
|
||||
"eslogad-be/internal/entities"
|
||||
"eslogad-be/internal/processor"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type OnlyOfficeService interface {
|
||||
ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error)
|
||||
GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error)
|
||||
LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error
|
||||
UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error
|
||||
GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error)
|
||||
GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error)
|
||||
}
|
||||
|
||||
type OnlyOfficeServiceImpl struct {
|
||||
processor processor.OnlyOfficeProcessor
|
||||
documentBaseURL string
|
||||
callbackBaseURL string
|
||||
serverURL string
|
||||
jwtSecret string
|
||||
config *config.OnlyOffice
|
||||
db *gorm.DB
|
||||
fileStorage FileStorage
|
||||
docBucket string
|
||||
}
|
||||
|
||||
func NewOnlyOfficeService(processor processor.OnlyOfficeProcessor, cfg *config.OnlyOffice, db *gorm.DB, fileStorage FileStorage) *OnlyOfficeServiceImpl {
|
||||
return &OnlyOfficeServiceImpl{
|
||||
processor: processor,
|
||||
documentBaseURL: getEnvOrDefault("DOCUMENT_BASE_URL", "https://68878e421f6d.ngrok-free.app/api/v1/files"),
|
||||
callbackBaseURL: getEnvOrDefault("CALLBACK_BASE_URL", "https://b4ed0a70d9d6.ngrok-free.app/api/v1/onlyoffice/callback"),
|
||||
serverURL: cfg.URL,
|
||||
jwtSecret: cfg.Token,
|
||||
config: cfg,
|
||||
db: db,
|
||||
fileStorage: fileStorage,
|
||||
docBucket: "documents", // Use the same bucket as document uploads
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
func (s *OnlyOfficeServiceImpl) ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) {
|
||||
// Verify JWT token if provided and secret is configured
|
||||
if req.Token != "" && s.jwtSecret != "" {
|
||||
claims, err := s.verifyJWT(req.Token)
|
||||
if err != nil {
|
||||
// Log the error but continue processing
|
||||
// OnlyOffice may not always send valid tokens
|
||||
fmt.Printf("JWT verification failed: %v\n", err)
|
||||
} else if claims != nil {
|
||||
// Extract data from JWT claims if needed
|
||||
if key, ok := claims["key"].(string); ok && key != documentKey {
|
||||
return &contract.OnlyOfficeCallbackResponse{Error: 1}, fmt.Errorf("document key mismatch in JWT")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return &contract.OnlyOfficeCallbackResponse{Error: 1}, nil // Document key not found
|
||||
}
|
||||
return &contract.OnlyOfficeCallbackResponse{Error: 3}, err // Internal server error
|
||||
}
|
||||
|
||||
// Process based on status
|
||||
switch req.Status {
|
||||
case contract.OnlyOfficeStatusEditing:
|
||||
// Document is being edited
|
||||
err = s.handleEditingStatus(ctx, session, req)
|
||||
|
||||
case contract.OnlyOfficeStatusReady:
|
||||
// Document is ready for saving
|
||||
err = s.handleReadyStatus(ctx, session, req)
|
||||
|
||||
case contract.OnlyOfficeStatusSaveError:
|
||||
// Document saving error
|
||||
err = s.handleSaveError(ctx, session, req)
|
||||
|
||||
case contract.OnlyOfficeStatusClosed:
|
||||
// Document closed with no changes
|
||||
err = s.handleClosedStatus(ctx, session, req)
|
||||
|
||||
case contract.OnlyOfficeStatusForceSave:
|
||||
// Force save during editing
|
||||
err = s.handleForceSave(ctx, session, req)
|
||||
|
||||
case contract.OnlyOfficeStatusForceSaveError:
|
||||
// Force save error
|
||||
err = s.handleForceSaveError(ctx, session, req)
|
||||
|
||||
default:
|
||||
return &contract.OnlyOfficeCallbackResponse{Error: 3}, fmt.Errorf("unknown status: %d", req.Status)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return &contract.OnlyOfficeCallbackResponse{Error: 3}, err
|
||||
}
|
||||
|
||||
return &contract.OnlyOfficeCallbackResponse{Error: 0}, nil
|
||||
}
|
||||
|
||||
// handleEditingStatus handles when document is being edited
|
||||
func (s *OnlyOfficeServiceImpl) handleEditingStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
|
||||
// Update session status
|
||||
session.Status = req.Status
|
||||
|
||||
// Lock document if not already locked
|
||||
if !session.IsLocked && len(req.Users) > 0 {
|
||||
userID := getOnlyOfficeUserIDFromContext(ctx)
|
||||
session.IsLocked = true
|
||||
session.LockedBy = &userID
|
||||
now := time.Now()
|
||||
session.LockedAt = &now
|
||||
}
|
||||
|
||||
return s.processor.UpdateDocumentSession(ctx, session)
|
||||
}
|
||||
|
||||
// handleReadyStatus handles when document is ready for saving
|
||||
func (s *OnlyOfficeServiceImpl) handleReadyStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
|
||||
if req.URL == "" {
|
||||
return errors.New("document URL is required for saving")
|
||||
}
|
||||
|
||||
// Download the document
|
||||
documentData, err := s.downloadDocument(req.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download document: %w", err)
|
||||
}
|
||||
|
||||
// Use session UserID as SavedBy since callbacks don't have user context
|
||||
savedBy := session.UserID
|
||||
if savedBy == uuid.Nil {
|
||||
// Fallback to getting from context if available
|
||||
if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil {
|
||||
savedBy = userID
|
||||
}
|
||||
}
|
||||
|
||||
// Save new version
|
||||
version := &entities.DocumentVersion{
|
||||
DocumentID: session.DocumentID,
|
||||
Version: session.Version + 1,
|
||||
FileSize: int64(len(documentData)),
|
||||
SavedBy: savedBy,
|
||||
SavedAt: time.Now(),
|
||||
IsActive: true,
|
||||
}
|
||||
|
||||
// For now, default to outgoing_attachment
|
||||
// In production, this should be stored in the session or document metadata
|
||||
documentType := "outgoing_attachment"
|
||||
|
||||
// Generate new file path and save
|
||||
fileName := fmt.Sprintf("v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey)
|
||||
filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save document file: %w", err)
|
||||
}
|
||||
version.FileURL = filePath
|
||||
|
||||
// Save changes URL if provided
|
||||
if req.ChangesURL != "" {
|
||||
version.ChangesURL = &req.ChangesURL
|
||||
}
|
||||
|
||||
// Create new version
|
||||
err = s.processor.CreateDocumentVersion(ctx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create document version: %w", err)
|
||||
}
|
||||
|
||||
// Update session
|
||||
session.Status = req.Status
|
||||
session.Version = version.Version
|
||||
now := time.Now()
|
||||
session.LastSavedAt = &now
|
||||
session.IsLocked = false
|
||||
session.LockedBy = nil
|
||||
session.LockedAt = nil
|
||||
|
||||
// Update the original document reference with new URL
|
||||
err = s.processor.UpdateDocumentURL(ctx, session.DocumentID, version.FileURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to update document URL: %w", err)
|
||||
}
|
||||
|
||||
return s.processor.UpdateDocumentSession(ctx, session)
|
||||
}
|
||||
|
||||
// handleSaveError handles document save errors
|
||||
func (s *OnlyOfficeServiceImpl) handleSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
|
||||
// Log the error
|
||||
s.processor.LogDocumentError(ctx, session.DocumentID, "Save error occurred", req)
|
||||
|
||||
// Update session status
|
||||
session.Status = req.Status
|
||||
return s.processor.UpdateDocumentSession(ctx, session)
|
||||
}
|
||||
|
||||
// handleClosedStatus handles when document is closed without changes
|
||||
func (s *OnlyOfficeServiceImpl) handleClosedStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
|
||||
// Unlock document
|
||||
session.Status = req.Status
|
||||
session.IsLocked = false
|
||||
session.LockedBy = nil
|
||||
session.LockedAt = nil
|
||||
|
||||
return s.processor.UpdateDocumentSession(ctx, session)
|
||||
}
|
||||
|
||||
func (s *OnlyOfficeServiceImpl) handleForceSave(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
|
||||
if req.URL == "" {
|
||||
return errors.New("document URL is required for force save")
|
||||
}
|
||||
|
||||
documentData, err := s.downloadDocument(req.URL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to download document: %w", err)
|
||||
}
|
||||
|
||||
savedBy := session.UserID
|
||||
if savedBy == uuid.Nil {
|
||||
if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil {
|
||||
savedBy = userID
|
||||
}
|
||||
}
|
||||
|
||||
version := &entities.DocumentVersion{
|
||||
DocumentID: session.DocumentID,
|
||||
Version: session.Version + 1,
|
||||
FileSize: int64(len(documentData)),
|
||||
SavedBy: savedBy,
|
||||
SavedAt: time.Now(),
|
||||
IsActive: false,
|
||||
Comments: stringPtr("Auto-save during editing"),
|
||||
}
|
||||
|
||||
// For now, default to outgoing_attachment
|
||||
// In production, this should be stored in the session or document metadata
|
||||
documentType := "outgoing_attachment"
|
||||
|
||||
fileName := fmt.Sprintf("autosave_v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey)
|
||||
filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to save document file: %w", err)
|
||||
}
|
||||
version.FileURL = filePath
|
||||
|
||||
err = s.processor.CreateDocumentVersion(ctx, version)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create document version: %w", err)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
session.LastSavedAt = &now
|
||||
session.Version = version.Version
|
||||
|
||||
return s.processor.UpdateDocumentSession(ctx, session)
|
||||
}
|
||||
|
||||
// handleForceSaveError handles force save errors
|
||||
func (s *OnlyOfficeServiceImpl) handleForceSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
|
||||
// Log the error
|
||||
s.processor.LogDocumentError(ctx, session.DocumentID, "Force save error occurred", req)
|
||||
|
||||
// Update session status
|
||||
session.Status = req.Status
|
||||
return s.processor.UpdateDocumentSession(ctx, session)
|
||||
}
|
||||
|
||||
// downloadDocument downloads document from OnlyOffice
|
||||
func (s *OnlyOfficeServiceImpl) downloadDocument(url string) ([]byte, error) {
|
||||
resp, err := http.Get(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("failed to download document: status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
|
||||
// saveDocumentFile saves document file to S3 storage
|
||||
func (s *OnlyOfficeServiceImpl) saveDocumentFile(ctx context.Context, data []byte, documentID uuid.UUID, fileName string, documentType string) (string, error) {
|
||||
// Ensure bucket exists
|
||||
if err := s.fileStorage.EnsureBucket(ctx, s.docBucket); err != nil {
|
||||
return "", fmt.Errorf("failed to ensure bucket: %w", err)
|
||||
}
|
||||
|
||||
// Create S3 key with date structure
|
||||
dateDir := time.Now().Format("2006/01/02")
|
||||
key := fmt.Sprintf("onlyoffice/%s/%s/%s", documentID.String(), dateDir, fileName)
|
||||
|
||||
// Detect content type from file extension
|
||||
contentType := "application/octet-stream"
|
||||
ext := filepath.Ext(fileName)
|
||||
switch strings.ToLower(ext) {
|
||||
case ".docx":
|
||||
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
case ".doc":
|
||||
contentType = "application/msword"
|
||||
case ".xlsx":
|
||||
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
|
||||
case ".xls":
|
||||
contentType = "application/vnd.ms-excel"
|
||||
case ".pptx":
|
||||
contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
|
||||
case ".ppt":
|
||||
contentType = "application/vnd.ms-powerpoint"
|
||||
case ".pdf":
|
||||
contentType = "application/pdf"
|
||||
}
|
||||
|
||||
// Upload to S3
|
||||
url, err := s.fileStorage.Upload(ctx, s.docBucket, key, data, contentType)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to upload to S3: %w", err)
|
||||
}
|
||||
|
||||
// Now update the attachment URL in the database
|
||||
if err := s.updateAttachmentURL(ctx, documentID, url, documentType); err != nil {
|
||||
// Log error but don't fail - the document is already saved
|
||||
fmt.Printf("Warning: Failed to update attachment URL: %v\n", err)
|
||||
}
|
||||
|
||||
return url, nil
|
||||
}
|
||||
|
||||
// updateAttachmentURL updates the file_url in the appropriate attachment table
|
||||
func (s *OnlyOfficeServiceImpl) updateAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string, documentType string) error {
|
||||
switch documentType {
|
||||
case "letter_outgoing_attachment", "outgoing_attachment":
|
||||
return s.db.WithContext(ctx).
|
||||
Table("letter_outgoing_attachments").
|
||||
Where("id = ?", attachmentID).
|
||||
Update("file_url", newURL).Error
|
||||
|
||||
case "letter_incoming_attachment", "incoming_attachment":
|
||||
return s.db.WithContext(ctx).
|
||||
Table("letter_incoming_attachments").
|
||||
Where("id = ?", attachmentID).
|
||||
Update("file_url", newURL).Error
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported document type for URL update: %s", documentType)
|
||||
}
|
||||
}
|
||||
|
||||
// getDocumentFromAttachment retrieves document details directly from attachment tables
|
||||
func (s *OnlyOfficeServiceImpl) getDocumentFromAttachment(ctx context.Context, documentID uuid.UUID, documentType string) (*processor.DocumentDetails, error) {
|
||||
var fileName, fileURL, fileType string
|
||||
var fileSize int64
|
||||
|
||||
switch documentType {
|
||||
case "letter_outgoing_attachment", "outgoing_attachment":
|
||||
var attachment struct {
|
||||
FileName string `gorm:"column:file_name"`
|
||||
FileURL string `gorm:"column:file_url"`
|
||||
FileType string `gorm:"column:file_type"`
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("letter_outgoing_attachments").
|
||||
Where("id = ?", documentID).
|
||||
Select("file_name, file_url, file_type").
|
||||
First(&attachment).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get outgoing attachment: %w", err)
|
||||
}
|
||||
|
||||
fileName = attachment.FileName
|
||||
fileURL = attachment.FileURL
|
||||
fileType = attachment.FileType
|
||||
|
||||
case "letter_incoming_attachment", "incoming_attachment":
|
||||
var attachment struct {
|
||||
FileName string `gorm:"column:file_name"`
|
||||
FileURL string `gorm:"column:file_url"`
|
||||
FileType string `gorm:"column:file_type"`
|
||||
}
|
||||
|
||||
err := s.db.WithContext(ctx).
|
||||
Table("letter_incoming_attachments").
|
||||
Where("id = ?", documentID).
|
||||
Select("file_name, file_url, file_type").
|
||||
First(&attachment).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get incoming attachment: %w", err)
|
||||
}
|
||||
|
||||
fileName = attachment.FileName
|
||||
fileURL = attachment.FileURL
|
||||
fileType = attachment.FileType
|
||||
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported document type: %s", documentType)
|
||||
}
|
||||
|
||||
return &processor.DocumentDetails{
|
||||
DocumentID: documentID,
|
||||
FileName: fileName,
|
||||
FileType: fileType,
|
||||
FileURL: fileURL,
|
||||
FileSize: fileSize,
|
||||
DocumentType: documentType,
|
||||
ReferenceID: documentID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetEditorConfig generates OnlyOffice editor configuration
|
||||
func (s *OnlyOfficeServiceImpl) GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) {
|
||||
userCtx := appcontext.FromGinContext(ctx)
|
||||
if userCtx == nil {
|
||||
return nil, errors.New("user context not found")
|
||||
}
|
||||
|
||||
session, err := s.processor.GetOrCreateDocumentSession(ctx, req.DocumentID, userCtx.UserID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get document session: %w", err)
|
||||
}
|
||||
|
||||
// Get document details directly from attachment tables
|
||||
document, err := s.getDocumentFromAttachment(ctx, req.DocumentID, req.DocumentType)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get document details: %w", err)
|
||||
}
|
||||
|
||||
documentKey := session.DocumentKey
|
||||
|
||||
fileExt := s.getFileExtension(document.FileName)
|
||||
if fileExt == "" || fileExt == strings.ToLower(document.FileName) {
|
||||
fileExt = s.getFileExtension(document.FileType)
|
||||
}
|
||||
|
||||
ooType := "desktop"
|
||||
if req.DocumentType == "incoming_attachment" {
|
||||
ooType = "embedded"
|
||||
}
|
||||
|
||||
config := &contract.OnlyOfficeConfigRequest{
|
||||
Document: &contract.OnlyOfficeDocument{
|
||||
FileType: fileExt,
|
||||
Key: documentKey,
|
||||
Title: document.FileName,
|
||||
URL: document.FileURL,
|
||||
Permissions: &contract.OnlyOfficePermissions{
|
||||
Comment: true,
|
||||
Download: true,
|
||||
Edit: req.Mode == "edit",
|
||||
FillForms: true,
|
||||
Print: true,
|
||||
Review: req.Mode == "edit",
|
||||
},
|
||||
Info: &contract.OnlyOfficeDocumentInfo{
|
||||
Owner: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]),
|
||||
Uploaded: time.Now().Format("2006-01-02 15:04:05"),
|
||||
},
|
||||
},
|
||||
DocumentType: s.getDocumentType(fileExt), // Convert file extension to document type
|
||||
EditorConfig: &contract.OnlyOfficeEditorConfig{
|
||||
CallbackURL: fmt.Sprintf("%s/%s", s.callbackBaseURL, documentKey),
|
||||
Lang: "en",
|
||||
Mode: req.Mode,
|
||||
User: &contract.OnlyOfficeUserConfig{
|
||||
ID: userCtx.UserID.String(),
|
||||
Name: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]),
|
||||
},
|
||||
Customization: &contract.OnlyOfficeCustomization{
|
||||
Autosave: true,
|
||||
Comments: true,
|
||||
CompactHeader: false,
|
||||
ForceSave: true,
|
||||
Zoom: 100,
|
||||
},
|
||||
},
|
||||
Type: ooType, // Can be desktop, mobile, or embedded
|
||||
}
|
||||
|
||||
if s.jwtSecret != "" {
|
||||
token, err := s.generateJWT(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate JWT: %w", err)
|
||||
}
|
||||
config.Token = token
|
||||
}
|
||||
|
||||
return &contract.GetEditorConfigResponse{
|
||||
DocumentServerURL: s.serverURL,
|
||||
Config: config,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// LockDocument locks a document for editing
|
||||
func (s *OnlyOfficeServiceImpl) LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error {
|
||||
return s.processor.LockDocument(ctx, documentID, userID)
|
||||
}
|
||||
|
||||
// UnlockDocument unlocks a document
|
||||
func (s *OnlyOfficeServiceImpl) UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error {
|
||||
return s.processor.UnlockDocument(ctx, documentID, userID)
|
||||
}
|
||||
|
||||
// GetDocumentSession gets document session by key
|
||||
func (s *OnlyOfficeServiceImpl) GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) {
|
||||
session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &contract.DocumentSession{
|
||||
ID: session.ID,
|
||||
DocumentID: session.DocumentID,
|
||||
DocumentKey: session.DocumentKey,
|
||||
UserID: session.UserID,
|
||||
Status: session.Status,
|
||||
IsLocked: session.IsLocked,
|
||||
LockedBy: session.LockedBy,
|
||||
LockedAt: session.LockedAt,
|
||||
LastSavedAt: session.LastSavedAt,
|
||||
Version: session.Version,
|
||||
CreatedAt: session.CreatedAt,
|
||||
UpdatedAt: session.UpdatedAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generateDocumentKey generates a unique key for OnlyOffice
|
||||
func (s *OnlyOfficeServiceImpl) generateDocumentKey(documentID uuid.UUID, version int) string {
|
||||
// Use nanoseconds and random bytes for uniqueness
|
||||
randomBytes := make([]byte, 8)
|
||||
rand.Read(randomBytes)
|
||||
data := fmt.Sprintf("%s_%d_%d_%s", documentID.String(), version, time.Now().UnixNano(), hex.EncodeToString(randomBytes))
|
||||
hash := md5.Sum([]byte(data))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
// getFileExtension extracts the file extension from file type or filename
|
||||
func (s *OnlyOfficeServiceImpl) getFileExtension(fileType string) string {
|
||||
// Remove any leading dot
|
||||
fileType = strings.TrimPrefix(fileType, ".")
|
||||
|
||||
// If fileType contains a dot, extract the extension after the last dot
|
||||
if strings.Contains(fileType, ".") {
|
||||
parts := strings.Split(fileType, ".")
|
||||
if len(parts) > 1 {
|
||||
return strings.ToLower(parts[len(parts)-1])
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, return as is (assuming it's already an extension like "docx", "xlsx", etc.)
|
||||
return strings.ToLower(fileType)
|
||||
}
|
||||
|
||||
// getDocumentType determines OnlyOffice document type from file extension
|
||||
func (s *OnlyOfficeServiceImpl) getDocumentType(fileType string) string {
|
||||
fileType = strings.ToLower(fileType)
|
||||
|
||||
// Remove dot if present
|
||||
fileType = strings.TrimPrefix(fileType, ".")
|
||||
|
||||
// Text documents
|
||||
if fileType == "doc" || fileType == "docx" || fileType == "docm" ||
|
||||
fileType == "dot" || fileType == "dotx" || fileType == "dotm" ||
|
||||
fileType == "odt" || fileType == "fodt" || fileType == "ott" ||
|
||||
fileType == "rtf" || fileType == "txt" || fileType == "html" ||
|
||||
fileType == "htm" || fileType == "mht" || fileType == "pdf" ||
|
||||
fileType == "djvu" || fileType == "fb2" || fileType == "epub" ||
|
||||
fileType == "xps" {
|
||||
return "word"
|
||||
}
|
||||
|
||||
// Spreadsheets
|
||||
if fileType == "xls" || fileType == "xlsx" || fileType == "xlsm" ||
|
||||
fileType == "xlt" || fileType == "xltx" || fileType == "xltm" ||
|
||||
fileType == "ods" || fileType == "fods" || fileType == "ots" ||
|
||||
fileType == "csv" {
|
||||
return "cell"
|
||||
}
|
||||
|
||||
// Presentations
|
||||
if fileType == "pps" || fileType == "ppsx" || fileType == "ppsm" ||
|
||||
fileType == "ppt" || fileType == "pptx" || fileType == "pptm" ||
|
||||
fileType == "pot" || fileType == "potx" || fileType == "potm" ||
|
||||
fileType == "odp" || fileType == "fodp" || fileType == "otp" {
|
||||
return "presentation"
|
||||
}
|
||||
|
||||
// Default to text
|
||||
return "slide"
|
||||
}
|
||||
|
||||
// generateJWT generates JWT token for OnlyOffice
|
||||
func (s *OnlyOfficeServiceImpl) generateJWT(config *contract.OnlyOfficeConfigRequest) (string, error) {
|
||||
// OnlyOffice expects the entire config to be in the JWT payload
|
||||
payload := make(map[string]interface{})
|
||||
|
||||
// Convert the config struct to a map for JWT payload
|
||||
configJSON, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal config: %w", err)
|
||||
}
|
||||
|
||||
var configMap map[string]interface{}
|
||||
if err := json.Unmarshal(configJSON, &configMap); err != nil {
|
||||
return "", fmt.Errorf("failed to unmarshal config to map: %w", err)
|
||||
}
|
||||
|
||||
// Add all config fields to the payload
|
||||
for key, value := range configMap {
|
||||
payload[key] = value
|
||||
}
|
||||
|
||||
// Create the token with the payload
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload))
|
||||
|
||||
// Sign the token with the secret
|
||||
tokenString, err := token.SignedString([]byte(s.jwtSecret))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to sign JWT token: %w", err)
|
||||
}
|
||||
|
||||
return tokenString, nil
|
||||
}
|
||||
|
||||
func getOnlyOfficeUserIDFromContext(ctx context.Context) uuid.UUID {
|
||||
userCtx := appcontext.FromGinContext(ctx)
|
||||
if userCtx != nil {
|
||||
return userCtx.UserID
|
||||
}
|
||||
return uuid.Nil
|
||||
}
|
||||
|
||||
func stringPtr(s string) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// GetOnlyOfficeConfig returns the OnlyOffice configuration
|
||||
func (s *OnlyOfficeServiceImpl) GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) {
|
||||
return &contract.OnlyOfficeConfigInfo{
|
||||
URL: s.config.URL,
|
||||
Token: s.config.Token,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// verifyJWT verifies JWT token from OnlyOffice
|
||||
func (s *OnlyOfficeServiceImpl) verifyJWT(tokenString string) (jwt.MapClaims, error) {
|
||||
if s.jwtSecret == "" {
|
||||
// If no secret is configured, skip verification
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
|
||||
// Validate the signing method
|
||||
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
|
||||
}
|
||||
return []byte(s.jwtSecret), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse JWT: %w", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, errors.New("invalid JWT token")
|
||||
}
|
||||
|
||||
claims, ok := token.Claims.(jwt.MapClaims)
|
||||
if !ok {
|
||||
return nil, errors.New("failed to parse JWT claims")
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
14
migrations/000020_update_letter_outgoing_recipients.down.sql
Normal file
14
migrations/000020_update_letter_outgoing_recipients.down.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- Revert changes to letter_outgoing_recipients table
|
||||
ALTER TABLE letter_outgoing_recipients
|
||||
DROP COLUMN IF EXISTS user_id,
|
||||
DROP COLUMN IF EXISTS department_id,
|
||||
DROP COLUMN IF EXISTS status,
|
||||
DROP COLUMN IF EXISTS read_at,
|
||||
DROP COLUMN IF EXISTS flag,
|
||||
DROP COLUMN IF EXISTS is_archived;
|
||||
|
||||
-- Drop indexes
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_recipients_user;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_recipients_department;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_recipients_status;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_recipients_archived;
|
||||
14
migrations/000020_update_letter_outgoing_recipients.up.sql
Normal file
14
migrations/000020_update_letter_outgoing_recipients.up.sql
Normal file
@ -0,0 +1,14 @@
|
||||
-- Add missing fields to letter_outgoing_recipients table to match incoming letter structure
|
||||
ALTER TABLE letter_outgoing_recipients
|
||||
ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id),
|
||||
ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id),
|
||||
ADD COLUMN IF NOT EXISTS status TEXT DEFAULT 'pending',
|
||||
ADD COLUMN IF NOT EXISTS read_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
ADD COLUMN IF NOT EXISTS flag TEXT,
|
||||
ADD COLUMN IF NOT EXISTS is_archived BOOLEAN DEFAULT false;
|
||||
|
||||
-- Add indexes for better query performance
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_user ON letter_outgoing_recipients(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_department ON letter_outgoing_recipients(department_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_status ON letter_outgoing_recipients(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_recipients_archived ON letter_outgoing_recipients(is_archived);
|
||||
@ -0,0 +1,4 @@
|
||||
ALTER TABLE letter_outgoing_recipients
|
||||
ADD COLUMN IF NOT EXISTS recipient_name VARCHAR(255) NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS recipient_email VARCHAR(255) NOT NULL,
|
||||
ADD COLUMN IF NOT EXISTS recipient_position VARCHAR(255) NOT NULL;
|
||||
@ -0,0 +1,4 @@
|
||||
ALTER TABLE letter_outgoing_recipients
|
||||
DROP COLUMN IF EXISTS recipient_name,
|
||||
DROP COLUMN IF EXISTS recipient_email,
|
||||
DROP COLUMN IF EXISTS recipient_position;
|
||||
12
migrations/000022_create_document_sessions.down.sql
Normal file
12
migrations/000022_create_document_sessions.down.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Drop triggers
|
||||
DROP TRIGGER IF EXISTS update_document_sessions_updated_at ON document_sessions;
|
||||
DROP TRIGGER IF EXISTS update_document_metadata_updated_at ON document_metadata;
|
||||
|
||||
-- Drop function
|
||||
DROP FUNCTION IF EXISTS update_updated_at_column();
|
||||
|
||||
-- Drop tables
|
||||
DROP TABLE IF EXISTS document_errors;
|
||||
DROP TABLE IF EXISTS document_metadata;
|
||||
DROP TABLE IF EXISTS document_versions;
|
||||
DROP TABLE IF EXISTS document_sessions;
|
||||
91
migrations/000022_create_document_sessions.up.sql
Normal file
91
migrations/000022_create_document_sessions.up.sql
Normal file
@ -0,0 +1,91 @@
|
||||
-- Create document sessions table for OnlyOffice integration
|
||||
CREATE TABLE IF NOT EXISTS document_sessions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL,
|
||||
document_key VARCHAR(255) UNIQUE NOT NULL,
|
||||
user_id UUID NOT NULL REFERENCES users(id),
|
||||
status INTEGER NOT NULL DEFAULT 0,
|
||||
is_locked BOOLEAN DEFAULT false,
|
||||
locked_by UUID REFERENCES users(id),
|
||||
locked_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
last_saved_at TIMESTAMP WITHOUT TIME ZONE,
|
||||
version INTEGER NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for document sessions
|
||||
CREATE INDEX idx_document_sessions_document ON document_sessions(document_id);
|
||||
CREATE INDEX idx_document_sessions_user ON document_sessions(user_id);
|
||||
CREATE INDEX idx_document_sessions_status ON document_sessions(status);
|
||||
CREATE INDEX idx_document_sessions_locked ON document_sessions(is_locked);
|
||||
|
||||
-- Create document versions table
|
||||
CREATE TABLE IF NOT EXISTS document_versions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL,
|
||||
version INTEGER NOT NULL,
|
||||
file_url TEXT NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
changes_url TEXT,
|
||||
saved_by UUID NOT NULL REFERENCES users(id),
|
||||
saved_at TIMESTAMP WITHOUT TIME ZONE NOT NULL,
|
||||
is_active BOOLEAN DEFAULT false,
|
||||
comments TEXT,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for document versions
|
||||
CREATE INDEX idx_document_versions_document ON document_versions(document_id);
|
||||
CREATE INDEX idx_document_versions_active ON document_versions(is_active);
|
||||
CREATE UNIQUE INDEX idx_document_versions_active_unique ON document_versions(document_id, is_active) WHERE is_active = true;
|
||||
|
||||
-- Create document metadata table
|
||||
CREATE TABLE IF NOT EXISTS document_metadata (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID UNIQUE NOT NULL,
|
||||
document_type VARCHAR(50) NOT NULL,
|
||||
reference_id UUID NOT NULL,
|
||||
file_name TEXT NOT NULL,
|
||||
file_type VARCHAR(50) NOT NULL,
|
||||
file_size BIGINT NOT NULL,
|
||||
mime_type VARCHAR(255),
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for document metadata
|
||||
CREATE INDEX idx_document_metadata_document ON document_metadata(document_id);
|
||||
CREATE INDEX idx_document_metadata_type ON document_metadata(document_type);
|
||||
CREATE INDEX idx_document_metadata_reference ON document_metadata(reference_id);
|
||||
|
||||
-- Create document errors table for logging
|
||||
CREATE TABLE IF NOT EXISTS document_errors (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
document_id UUID NOT NULL,
|
||||
session_id UUID REFERENCES document_sessions(id),
|
||||
error_type VARCHAR(100) NOT NULL,
|
||||
error_msg TEXT NOT NULL,
|
||||
details JSONB,
|
||||
created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- Create indexes for document errors
|
||||
CREATE INDEX idx_document_errors_document ON document_errors(document_id);
|
||||
CREATE INDEX idx_document_errors_session ON document_errors(session_id);
|
||||
CREATE INDEX idx_document_errors_type ON document_errors(error_type);
|
||||
|
||||
-- Add trigger to update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ language 'plpgsql';
|
||||
|
||||
CREATE TRIGGER update_document_sessions_updated_at BEFORE UPDATE ON document_sessions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
|
||||
CREATE TRIGGER update_document_metadata_updated_at BEFORE UPDATE ON document_metadata
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
12
migrations/000023_add_performance_indexes.down.sql
Normal file
12
migrations/000023_add_performance_indexes.down.sql
Normal file
@ -0,0 +1,12 @@
|
||||
-- Drop performance indexes
|
||||
|
||||
DROP INDEX IF EXISTS idx_letters_outgoing_id_deleted;
|
||||
DROP INDEX IF EXISTS idx_users_id;
|
||||
DROP INDEX IF EXISTS idx_document_sessions_document_key;
|
||||
DROP INDEX IF EXISTS idx_document_metadata_document_id;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_attachments_id;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_attachments_letter_id;
|
||||
DROP INDEX IF EXISTS idx_letter_incoming_attachments_id;
|
||||
DROP INDEX IF EXISTS idx_letter_incoming_attachments_letter_id;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_discussion_attachments_id;
|
||||
DROP INDEX IF EXISTS idx_letter_outgoing_discussion_attachments_discussion_id;
|
||||
22
migrations/000023_add_performance_indexes.up.sql
Normal file
22
migrations/000023_add_performance_indexes.up.sql
Normal file
@ -0,0 +1,22 @@
|
||||
-- Add indexes to improve query performance
|
||||
|
||||
-- Index for letters_outgoing
|
||||
CREATE INDEX IF NOT EXISTS idx_letters_outgoing_id_deleted ON letters_outgoing(id, deleted_at);
|
||||
|
||||
-- Index for users table
|
||||
CREATE INDEX IF NOT EXISTS idx_users_id ON users(id);
|
||||
|
||||
-- Indexes for document sessions lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_document_sessions_document_key ON document_sessions(document_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_document_metadata_document_id ON document_metadata(document_id);
|
||||
|
||||
-- Additional indexes for letter attachments
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_id ON letter_outgoing_attachments(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_attachments_letter_id ON letter_outgoing_attachments(letter_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_id ON letter_incoming_attachments(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_incoming_attachments_letter_id ON letter_incoming_attachments(letter_id);
|
||||
|
||||
-- Index for discussion attachments
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussion_attachments_id ON letter_outgoing_discussion_attachments(id);
|
||||
CREATE INDEX IF NOT EXISTS idx_letter_outgoing_discussion_attachments_discussion_id ON letter_outgoing_discussion_attachments(discussion_id);
|
||||
@ -0,0 +1,9 @@
|
||||
-- Revert document metadata VARCHAR constraints
|
||||
-- Note: This may fail if existing data exceeds 50 characters
|
||||
|
||||
ALTER TABLE document_metadata
|
||||
ALTER COLUMN document_type TYPE VARCHAR(50),
|
||||
ALTER COLUMN file_type TYPE VARCHAR(50);
|
||||
|
||||
ALTER TABLE document_metadata
|
||||
ALTER COLUMN mime_type TYPE VARCHAR(255);
|
||||
10
migrations/000024_fix_document_metadata_constraints.up.sql
Normal file
10
migrations/000024_fix_document_metadata_constraints.up.sql
Normal file
@ -0,0 +1,10 @@
|
||||
-- Fix document metadata VARCHAR constraints
|
||||
-- Increase the size limit for document_type and file_type columns
|
||||
|
||||
ALTER TABLE document_metadata
|
||||
ALTER COLUMN document_type TYPE VARCHAR(255),
|
||||
ALTER COLUMN file_type TYPE VARCHAR(255);
|
||||
|
||||
-- Also ensure mime_type has sufficient length
|
||||
ALTER TABLE document_metadata
|
||||
ALTER COLUMN mime_type TYPE VARCHAR(255);
|
||||
Loading…
x
Reference in New Issue
Block a user