diff --git a/config/configs.go b/config/configs.go index 6a6c529..9063f7f 100644 --- a/config/configs.go +++ b/config/configs.go @@ -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"` +} diff --git a/infra/development.yaml b/infra/development.yaml index 62ed50b..5c4fc8d 100644 --- a/infra/development.yaml +++ b/infra/development.yaml @@ -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' \ No newline at end of file + log_level: 'debug' + +onlyoffice: + url: 'https://onlyoffice.apskel.org/' + token: '2DmKgd5PT3n1vH3f2v2iRZUqTVHj9GQx' \ No newline at end of file diff --git a/internal/app/app.go b/internal/app/app.go index d6f780c..0ab0e14 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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, } } diff --git a/internal/contract/letter_contract.go b/internal/contract/letter_contract.go index 66acb12..a1d1c68 100644 --- a/internal/contract/letter_contract.go +++ b/internal/contract/letter_contract.go @@ -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 { diff --git a/internal/contract/letter_outgoing_contract.go b/internal/contract/letter_outgoing_contract.go index d3d4f68..242ebbd 100644 --- a/internal/contract/letter_outgoing_contract.go +++ b/internal/contract/letter_outgoing_contract.go @@ -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"` +} diff --git a/internal/contract/onlyoffice_contract.go b/internal/contract/onlyoffice_contract.go new file mode 100644 index 0000000..3877609 --- /dev/null +++ b/internal/contract/onlyoffice_contract.go @@ -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"` +} diff --git a/internal/entities/approval_flow.go b/internal/entities/approval_flow.go index e563982..b51396f 100644 --- a/internal/entities/approval_flow.go +++ b/internal/entities/approval_flow.go @@ -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" } @@ -65,4 +63,4 @@ type LetterOutgoingApproval struct { Approver *User `gorm:"foreignKey:ApproverID" json:"approver,omitempty"` } -func (LetterOutgoingApproval) TableName() string { return "letter_outgoing_approvals" } \ No newline at end of file +func (LetterOutgoingApproval) TableName() string { return "letter_outgoing_approvals" } diff --git a/internal/entities/document_session.go b/internal/entities/document_session.go new file mode 100644 index 0000000..9691312 --- /dev/null +++ b/internal/entities/document_session.go @@ -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 +} \ No newline at end of file diff --git a/internal/entities/letter_outgoing.go b/internal/entities/letter_outgoing.go index f3c1e01..66f265a 100644 --- a/internal/entities/letter_outgoing.go +++ b/internal/entities/letter_outgoing.go @@ -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" } @@ -102,4 +106,4 @@ type LetterOutgoingDiscussionAttachment struct { func (LetterOutgoingDiscussionAttachment) TableName() string { return "letter_outgoing_discussion_attachments" -} \ No newline at end of file +} diff --git a/internal/handler/letter_handler.go b/internal/handler/letter_handler.go index 1e4827d..fbf5811 100644 --- a/internal/handler/letter_handler.go +++ b/internal/handler/letter_handler.go @@ -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 } diff --git a/internal/handler/letter_outgoing_handler.go b/internal/handler/letter_outgoing_handler.go index 3767fe7..dd498b3 100644 --- a/internal/handler/letter_outgoing_handler.go +++ b/internal/handler/letter_outgoing_handler.go @@ -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 { @@ -420,4 +424,41 @@ 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)) } \ No newline at end of file diff --git a/internal/handler/onlyoffice_handler.go b/internal/handler/onlyoffice_handler.go new file mode 100644 index 0000000..0490aed --- /dev/null +++ b/internal/handler/onlyoffice_handler.go @@ -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)) +} diff --git a/internal/processor/letter_outgoing_processor.go b/internal/processor/letter_outgoing_processor.go index d5bdbb0..84c290f 100644 --- a/internal/processor/letter_outgoing_processor.go +++ b/internal/processor/letter_outgoing_processor.go @@ -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 +} diff --git a/internal/processor/letter_processor.go b/internal/processor/letter_processor.go index cc291d4..8493659 100644 --- a/internal/processor/letter_processor.go +++ b/internal/processor/letter_processor.go @@ -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 { diff --git a/internal/processor/onlyoffice_processor.go b/internal/processor/onlyoffice_processor.go new file mode 100644 index 0000000..c41978f --- /dev/null +++ b/internal/processor/onlyoffice_processor.go @@ -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) +} \ No newline at end of file diff --git a/internal/repository/document_repository.go b/internal/repository/document_repository.go new file mode 100644 index 0000000..5e8301f --- /dev/null +++ b/internal/repository/document_repository.go @@ -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 +} \ No newline at end of file diff --git a/internal/repository/letter_outgoing_repository.go b/internal/repository/letter_outgoing_repository.go index 91c67a3..6309668 100644 --- a/internal/repository/letter_outgoing_repository.go +++ b/internal/repository/letter_outgoing_repository.go @@ -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 diff --git a/internal/repository/letter_repository.go b/internal/repository/letter_repository.go index c237351..165b907 100644 --- a/internal/repository/letter_repository.go +++ b/internal/repository/letter_repository.go @@ -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 +} diff --git a/internal/router/health_handler.go b/internal/router/health_handler.go index 34a2da8..5e0058f 100644 --- a/internal/router/health_handler.go +++ b/internal/router/health_handler.go @@ -31,7 +31,7 @@ type RBACHandler interface { UpdateRole(c *gin.Context) DeleteRole(c *gin.Context) ListRoles(c *gin.Context) - + // New methods GetPermissionsGrouped(c *gin.Context) CreateOrUpdateRole(c *gin.Context) @@ -84,23 +84,26 @@ type LetterOutgoingHandler interface { ListOutgoingLetters(c *gin.Context) UpdateOutgoingLetter(c *gin.Context) DeleteOutgoingLetter(c *gin.Context) - + SubmitForApproval(c *gin.Context) ApproveOutgoingLetter(c *gin.Context) RejectOutgoingLetter(c *gin.Context) SendOutgoingLetter(c *gin.Context) ArchiveOutgoingLetter(c *gin.Context) - + GetLetterApprovalInfo(c *gin.Context) + AddRecipients(c *gin.Context) UpdateRecipient(c *gin.Context) RemoveRecipient(c *gin.Context) - + AddAttachments(c *gin.Context) RemoveAttachment(c *gin.Context) - + CreateDiscussion(c *gin.Context) UpdateDiscussion(c *gin.Context) DeleteDiscussion(c *gin.Context) + + 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) +} diff --git a/internal/router/router.go b/internal/router/router.go index 9e14b3d..5b8241b 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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) @@ -178,6 +182,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) { lettersch.POST("/outgoing/:id/discussions", r.letterOutgoingHandler.CreateDiscussion) lettersch.PUT("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.UpdateDiscussion) lettersch.DELETE("/outgoing/discussions/:discussion_id", r.letterOutgoingHandler.DeleteDiscussion) + + // 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) + } + } } } diff --git a/internal/service/letter_outgoing_service.go b/internal/service/letter_outgoing_service.go index 90af0e3..ff6e820 100644 --- a/internal/service/letter_outgoing_service.go +++ b/internal/service/letter_outgoing_service.go @@ -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 } } diff --git a/internal/service/onlyoffice_service.go b/internal/service/onlyoffice_service.go new file mode 100644 index 0000000..3e46351 --- /dev/null +++ b/internal/service/onlyoffice_service.go @@ -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 +} diff --git a/migrations/000020_update_letter_outgoing_recipients.down.sql b/migrations/000020_update_letter_outgoing_recipients.down.sql new file mode 100644 index 0000000..431815e --- /dev/null +++ b/migrations/000020_update_letter_outgoing_recipients.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/000020_update_letter_outgoing_recipients.up.sql b/migrations/000020_update_letter_outgoing_recipients.up.sql new file mode 100644 index 0000000..4e10180 --- /dev/null +++ b/migrations/000020_update_letter_outgoing_recipients.up.sql @@ -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); \ No newline at end of file diff --git a/migrations/000021_update_letter_outgoing_recipients.down.sql b/migrations/000021_update_letter_outgoing_recipients.down.sql new file mode 100644 index 0000000..62532c7 --- /dev/null +++ b/migrations/000021_update_letter_outgoing_recipients.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/000021_update_letter_outgoing_recipients.up.sql b/migrations/000021_update_letter_outgoing_recipients.up.sql new file mode 100644 index 0000000..91f1f64 --- /dev/null +++ b/migrations/000021_update_letter_outgoing_recipients.up.sql @@ -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; \ No newline at end of file diff --git a/migrations/000022_create_document_sessions.down.sql b/migrations/000022_create_document_sessions.down.sql new file mode 100644 index 0000000..179cd2d --- /dev/null +++ b/migrations/000022_create_document_sessions.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/000022_create_document_sessions.up.sql b/migrations/000022_create_document_sessions.up.sql new file mode 100644 index 0000000..2a62216 --- /dev/null +++ b/migrations/000022_create_document_sessions.up.sql @@ -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(); \ No newline at end of file diff --git a/migrations/000023_add_performance_indexes.down.sql b/migrations/000023_add_performance_indexes.down.sql new file mode 100644 index 0000000..a50f5fb --- /dev/null +++ b/migrations/000023_add_performance_indexes.down.sql @@ -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; \ No newline at end of file diff --git a/migrations/000023_add_performance_indexes.up.sql b/migrations/000023_add_performance_indexes.up.sql new file mode 100644 index 0000000..5143a12 --- /dev/null +++ b/migrations/000023_add_performance_indexes.up.sql @@ -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); \ No newline at end of file diff --git a/migrations/000024_fix_document_metadata_constraints.down.sql b/migrations/000024_fix_document_metadata_constraints.down.sql new file mode 100644 index 0000000..42c010b --- /dev/null +++ b/migrations/000024_fix_document_metadata_constraints.down.sql @@ -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); \ No newline at end of file diff --git a/migrations/000024_fix_document_metadata_constraints.up.sql b/migrations/000024_fix_document_metadata_constraints.up.sql new file mode 100644 index 0000000..cd80d9d --- /dev/null +++ b/migrations/000024_fix_document_metadata_constraints.up.sql @@ -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); \ No newline at end of file diff --git a/server b/server index a8d527e..6593902 100755 Binary files a/server and b/server differ