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) }