dukcapil/internal/processor/onlyoffice_processor.go
2025-08-29 16:10:05 +07:00

463 lines
15 KiB
Go

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