Add document saved

This commit is contained in:
Aditya Siregar
2025-08-29 16:10:05 +07:00
parent 592fa97be7
commit 2bdce63852
33 changed files with 2862 additions and 132 deletions
+342 -20
View File
@@ -2,6 +2,7 @@ package service
import (
"context"
"fmt"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
@@ -36,6 +37,11 @@ type LetterOutgoingService interface {
CreateDiscussion(ctx context.Context, letterID uuid.UUID, req *contract.CreateDiscussionRequest) (*contract.DiscussionResponse, error)
UpdateDiscussion(ctx context.Context, discussionID uuid.UUID, req *contract.UpdateDiscussionRequest) error
DeleteDiscussion(ctx context.Context, discussionID uuid.UUID) error
GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error)
// GetApprovalDiscussions returns both approvals and discussions for an outgoing letter
GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error)
}
type LetterOutgoingServiceImpl struct {
@@ -336,12 +342,13 @@ func (s *LetterOutgoingServiceImpl) AddRecipients(ctx context.Context, letterID
recipients := make([]entities.LetterOutgoingRecipient, len(req.Recipients))
for i, r := range req.Recipients {
recipients[i] = entities.LetterOutgoingRecipient{
LetterID: letterID,
RecipientName: r.Name,
RecipientEmail: r.Email,
RecipientPosition: r.Position,
RecipientInstitution: r.Institution,
IsPrimary: r.IsPrimary,
LetterID: letterID,
UserID: r.UserID,
DepartmentID: r.DepartmentID,
IsPrimary: r.IsPrimary,
Status: r.Status,
Flag: r.Flag,
IsArchived: r.IsArchived,
}
}
@@ -359,12 +366,24 @@ func (s *LetterOutgoingServiceImpl) UpdateRecipient(ctx context.Context, letterI
}
recipient := &entities.LetterOutgoingRecipient{
ID: recipientID,
RecipientName: req.Name,
RecipientEmail: req.Email,
RecipientPosition: req.Position,
RecipientInstitution: req.Institution,
IsPrimary: req.IsPrimary,
ID: recipientID,
IsPrimary: req.IsPrimary,
}
if req.UserID != nil {
recipient.UserID = req.UserID
}
if req.DepartmentID != nil {
recipient.DepartmentID = req.DepartmentID
}
if req.Status != nil {
recipient.Status = *req.Status
}
if req.Flag != nil {
recipient.Flag = req.Flag
}
if req.IsArchived != nil {
recipient.IsArchived = *req.IsArchived
}
return s.processor.UpdateRecipient(ctx, recipient)
@@ -501,6 +520,83 @@ func (s *LetterOutgoingServiceImpl) DeleteDiscussion(ctx context.Context, discus
return s.processor.DeleteDiscussion(ctx, discussionID)
}
func (s *LetterOutgoingServiceImpl) GetLetterApprovalInfo(ctx context.Context, letterID uuid.UUID) (*contract.LetterApprovalInfoResponse, error) {
userID := getUserIDFromContext(ctx)
_, err := s.processor.GetOutgoingLetterByID(ctx, letterID)
if err != nil {
return nil, err
}
approvals, err := s.processor.GetApprovalsByLetter(ctx, letterID)
if err != nil {
return nil, err
}
var currentApproval *entities.LetterOutgoingApproval
var isApproverOnActiveStep bool
var canApprove bool
for _, approval := range approvals {
if approval.Status == entities.ApprovalStatusPending {
currentApproval = &approval
break
}
}
// Check if current user is the approver for the active step
if currentApproval != nil && currentApproval.Step != nil {
step := currentApproval.Step
// Check if user is the specific approver
if step.ApproverUserID != nil && *step.ApproverUserID == userID {
isApproverOnActiveStep = true
canApprove = true
}
// Note: Role-based approval check would require additional implementation
// For now, we only support user-specific approvers
}
// Build actions based on current status
var actions []contract.ApprovalAction
if canApprove && currentApproval != nil {
actions = []contract.ApprovalAction{
{
Type: "APPROVE",
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
Method: "POST",
},
{
Type: "REJECT",
Href: fmt.Sprintf("/v1/letters/%s/approvals/%s/decision", letterID, currentApproval.ID),
Method: "POST",
},
}
}
// Determine decision status
decisionStatus := "PENDING"
if currentApproval == nil {
decisionStatus = "COMPLETED"
}
// Determine notes visibility
notesVisibility := "FULL"
if !isApproverOnActiveStep {
notesVisibility = "READONLY"
}
info := &contract.LetterApprovalInfoResponse{
IsApproverOnActiveStep: isApproverOnActiveStep,
DecisionStatus: decisionStatus,
CanApprove: canApprove,
Actions: actions,
NotesVisibility: notesVisibility,
}
return info, nil
}
func getUserIDFromContext(ctx context.Context) uuid.UUID {
appCtx := appcontext.FromGinContext(ctx)
if appCtx != nil {
@@ -521,6 +617,211 @@ func userHasRole(ctx context.Context, roleID uuid.UUID) bool {
return false
}
func (s *LetterOutgoingServiceImpl) GetApprovalDiscussions(ctx context.Context, letterID uuid.UUID) (*contract.OutgoingLetterApprovalDiscussionsResponse, error) {
// Get the letter with all related data
letter, err := s.processor.GetOutgoingLetterWithDetails(ctx, letterID)
if err != nil {
return nil, err
}
// Transform approvals
approvals := make([]contract.EnhancedOutgoingLetterApprovalResponse, 0, len(letter.Approvals))
for _, approval := range letter.Approvals {
approvalResp := contract.EnhancedOutgoingLetterApprovalResponse{
ID: approval.ID,
LetterID: approval.LetterID,
StepID: approval.StepID,
ApproverID: approval.ApproverID,
Status: string(approval.Status),
Remarks: approval.Remarks,
ActedAt: approval.ActedAt,
CreatedAt: approval.CreatedAt,
}
// Add step details if available
if approval.Step != nil {
approvalResp.Step = &contract.ApprovalFlowStepResponse{
ID: approval.Step.ID,
StepOrder: approval.Step.StepOrder,
ParallelGroup: approval.Step.ParallelGroup,
Required: approval.Step.Required,
CreatedAt: approval.Step.CreatedAt,
UpdatedAt: approval.Step.UpdatedAt,
}
if approval.Step.ApproverRoleID != nil {
approvalResp.Step.ApproverRoleID = approval.Step.ApproverRoleID
}
if approval.Step.ApproverUserID != nil {
approvalResp.Step.ApproverUserID = approval.Step.ApproverUserID
}
// Add role information if available
if approval.Step.ApproverRole != nil {
approvalResp.Step.ApproverRole = &contract.RoleResponse{
ID: approval.Step.ApproverRole.ID,
Name: approval.Step.ApproverRole.Name,
Code: approval.Step.ApproverRole.Code,
}
}
// Add user information if available
if approval.Step.ApproverUser != nil {
approvalResp.Step.ApproverUser = &contract.UserResponse{
ID: approval.Step.ApproverUser.ID,
Name: approval.Step.ApproverUser.Name,
Email: approval.Step.ApproverUser.Email,
}
}
}
// Add approver details if available
if approval.Approver != nil {
approvalResp.Approver = &contract.UserResponse{
ID: approval.Approver.ID,
Name: approval.Approver.Name,
Email: approval.Approver.Email,
}
// Add profile if available
if approval.Approver.Profile != nil {
approvalResp.Approver.Profile = &contract.UserProfileResponse{
UserID: approval.Approver.Profile.UserID,
FullName: approval.Approver.Profile.FullName,
DisplayName: approval.Approver.Profile.DisplayName,
Phone: approval.Approver.Profile.Phone,
AvatarURL: approval.Approver.Profile.AvatarURL,
JobTitle: approval.Approver.Profile.JobTitle,
EmployeeNo: approval.Approver.Profile.EmployeeNo,
Bio: approval.Approver.Profile.Bio,
Timezone: approval.Approver.Profile.Timezone,
Locale: approval.Approver.Profile.Locale,
}
}
}
approvals = append(approvals, approvalResp)
}
// Transform discussions
discussions := make([]contract.OutgoingLetterDiscussionResponse, 0, len(letter.Discussions))
for _, discussion := range letter.Discussions {
// Extract mentioned user IDs from mentions
mentionedUserIDs := extractMentionedUserIDs(discussion.Mentions)
discussionResp := contract.OutgoingLetterDiscussionResponse{
ID: discussion.ID,
LetterID: discussion.LetterID,
ParentID: discussion.ParentID,
UserID: discussion.UserID,
Message: discussion.Message,
Mentions: discussion.Mentions,
CreatedAt: discussion.CreatedAt,
UpdatedAt: discussion.UpdatedAt,
EditedAt: discussion.EditedAt,
}
// Add user details if available
if discussion.User != nil {
discussionResp.User = &contract.UserResponse{
ID: discussion.User.ID,
Name: discussion.User.Name,
Email: discussion.User.Email,
IsActive: discussion.User.IsActive,
CreatedAt: discussion.User.CreatedAt,
UpdatedAt: discussion.User.UpdatedAt,
}
// Add profile if available
if discussion.User.Profile != nil {
discussionResp.User.Profile = &contract.UserProfileResponse{
UserID: discussion.User.Profile.UserID,
FullName: discussion.User.Profile.FullName,
DisplayName: discussion.User.Profile.DisplayName,
Phone: discussion.User.Profile.Phone,
AvatarURL: discussion.User.Profile.AvatarURL,
JobTitle: discussion.User.Profile.JobTitle,
EmployeeNo: discussion.User.Profile.EmployeeNo,
Bio: discussion.User.Profile.Bio,
Timezone: discussion.User.Profile.Timezone,
Locale: discussion.User.Profile.Locale,
}
}
}
// Get mentioned users details
if len(mentionedUserIDs) > 0 {
mentionedUsers, _ := s.processor.GetUsersByIDs(ctx, mentionedUserIDs)
for _, user := range mentionedUsers {
mentionedUserResp := contract.UserResponse{
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsActive: user.IsActive,
CreatedAt: user.CreatedAt,
UpdatedAt: user.UpdatedAt,
}
if user.Profile != nil {
mentionedUserResp.Profile = &contract.UserProfileResponse{
UserID: user.Profile.UserID,
FullName: user.Profile.FullName,
DisplayName: user.Profile.DisplayName,
Timezone: user.Profile.Timezone,
Locale: user.Profile.Locale,
}
}
discussionResp.MentionedUsers = append(discussionResp.MentionedUsers, mentionedUserResp)
}
}
// Add attachments if available
for _, attachment := range discussion.Attachments {
attachmentResp := contract.OutgoingLetterDiscussionAttachmentResponse{
ID: attachment.ID,
DiscussionID: attachment.DiscussionID,
FileURL: attachment.FileURL,
FileName: attachment.FileName,
FileType: attachment.FileType,
UploadedBy: attachment.UploadedBy,
UploadedAt: attachment.UploadedAt,
}
discussionResp.Attachments = append(discussionResp.Attachments, attachmentResp)
}
discussions = append(discussions, discussionResp)
}
return &contract.OutgoingLetterApprovalDiscussionsResponse{
Approvals: approvals,
Discussions: discussions,
}, nil
}
// Helper function to extract user IDs from mentions
func extractMentionedUserIDs(mentions map[string]interface{}) []uuid.UUID {
var userIDs []uuid.UUID
if mentions == nil {
return userIDs
}
if userIDsInterface, ok := mentions["user_ids"]; ok {
if userIDsList, ok := userIDsInterface.([]interface{}); ok {
for _, id := range userIDsList {
if idStr, ok := id.(string); ok {
if userID, err := uuid.Parse(idStr); err == nil {
userIDs = append(userIDs, userID)
}
}
}
}
}
return userIDs
}
func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.OutgoingLetterResponse {
resp := &contract.OutgoingLetterResponse{
ID: letter.ID,
@@ -565,15 +866,36 @@ func transformLetterToResponse(letter *entities.LetterOutgoing) *contract.Outgoi
if len(letter.Recipients) > 0 {
resp.Recipients = make([]contract.OutgoingLetterRecipientResponse, len(letter.Recipients))
for i, recipient := range letter.Recipients {
resp.Recipients[i] = contract.OutgoingLetterRecipientResponse{
ID: recipient.ID,
Name: recipient.RecipientName,
Email: recipient.RecipientEmail,
Position: recipient.RecipientPosition,
Institution: recipient.RecipientInstitution,
IsPrimary: recipient.IsPrimary,
CreatedAt: recipient.CreatedAt,
recipResp := contract.OutgoingLetterRecipientResponse{
ID: recipient.ID,
LetterID: recipient.LetterID,
UserID: recipient.UserID,
DepartmentID: recipient.DepartmentID,
IsPrimary: recipient.IsPrimary,
Status: recipient.Status,
ReadAt: recipient.ReadAt,
Flag: recipient.Flag,
IsArchived: recipient.IsArchived,
CreatedAt: recipient.CreatedAt,
}
if recipient.User != nil {
recipResp.User = &contract.UserResponse{
ID: recipient.User.ID,
Name: recipient.User.Name,
Email: recipient.User.Email,
}
}
if recipient.Department != nil {
recipResp.Department = &contract.DepartmentResponse{
ID: recipient.Department.ID,
Name: recipient.Department.Name,
Code: recipient.Department.Code,
}
}
resp.Recipients[i] = recipResp
}
}
+708
View File
@@ -0,0 +1,708 @@
package service
import (
"context"
"crypto/md5"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"eslogad-be/config"
"eslogad-be/internal/appcontext"
"eslogad-be/internal/contract"
"eslogad-be/internal/entities"
"eslogad-be/internal/processor"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
"gorm.io/gorm"
)
type OnlyOfficeService interface {
ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error)
GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error)
LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error
UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error
GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error)
GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error)
}
type OnlyOfficeServiceImpl struct {
processor processor.OnlyOfficeProcessor
documentBaseURL string
callbackBaseURL string
serverURL string
jwtSecret string
config *config.OnlyOffice
db *gorm.DB
fileStorage FileStorage
docBucket string
}
func NewOnlyOfficeService(processor processor.OnlyOfficeProcessor, cfg *config.OnlyOffice, db *gorm.DB, fileStorage FileStorage) *OnlyOfficeServiceImpl {
return &OnlyOfficeServiceImpl{
processor: processor,
documentBaseURL: getEnvOrDefault("DOCUMENT_BASE_URL", "https://68878e421f6d.ngrok-free.app/api/v1/files"),
callbackBaseURL: getEnvOrDefault("CALLBACK_BASE_URL", "https://b4ed0a70d9d6.ngrok-free.app/api/v1/onlyoffice/callback"),
serverURL: cfg.URL,
jwtSecret: cfg.Token,
config: cfg,
db: db,
fileStorage: fileStorage,
docBucket: "documents", // Use the same bucket as document uploads
}
}
func getEnvOrDefault(key, defaultValue string) string {
if value := os.Getenv(key); value != "" {
return value
}
return defaultValue
}
func (s *OnlyOfficeServiceImpl) ProcessCallback(ctx context.Context, documentKey string, req *contract.OnlyOfficeCallbackRequest) (*contract.OnlyOfficeCallbackResponse, error) {
// Verify JWT token if provided and secret is configured
if req.Token != "" && s.jwtSecret != "" {
claims, err := s.verifyJWT(req.Token)
if err != nil {
// Log the error but continue processing
// OnlyOffice may not always send valid tokens
fmt.Printf("JWT verification failed: %v\n", err)
} else if claims != nil {
// Extract data from JWT claims if needed
if key, ok := claims["key"].(string); ok && key != documentKey {
return &contract.OnlyOfficeCallbackResponse{Error: 1}, fmt.Errorf("document key mismatch in JWT")
}
}
}
session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &contract.OnlyOfficeCallbackResponse{Error: 1}, nil // Document key not found
}
return &contract.OnlyOfficeCallbackResponse{Error: 3}, err // Internal server error
}
// Process based on status
switch req.Status {
case contract.OnlyOfficeStatusEditing:
// Document is being edited
err = s.handleEditingStatus(ctx, session, req)
case contract.OnlyOfficeStatusReady:
// Document is ready for saving
err = s.handleReadyStatus(ctx, session, req)
case contract.OnlyOfficeStatusSaveError:
// Document saving error
err = s.handleSaveError(ctx, session, req)
case contract.OnlyOfficeStatusClosed:
// Document closed with no changes
err = s.handleClosedStatus(ctx, session, req)
case contract.OnlyOfficeStatusForceSave:
// Force save during editing
err = s.handleForceSave(ctx, session, req)
case contract.OnlyOfficeStatusForceSaveError:
// Force save error
err = s.handleForceSaveError(ctx, session, req)
default:
return &contract.OnlyOfficeCallbackResponse{Error: 3}, fmt.Errorf("unknown status: %d", req.Status)
}
if err != nil {
return &contract.OnlyOfficeCallbackResponse{Error: 3}, err
}
return &contract.OnlyOfficeCallbackResponse{Error: 0}, nil
}
// handleEditingStatus handles when document is being edited
func (s *OnlyOfficeServiceImpl) handleEditingStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
// Update session status
session.Status = req.Status
// Lock document if not already locked
if !session.IsLocked && len(req.Users) > 0 {
userID := getOnlyOfficeUserIDFromContext(ctx)
session.IsLocked = true
session.LockedBy = &userID
now := time.Now()
session.LockedAt = &now
}
return s.processor.UpdateDocumentSession(ctx, session)
}
// handleReadyStatus handles when document is ready for saving
func (s *OnlyOfficeServiceImpl) handleReadyStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
if req.URL == "" {
return errors.New("document URL is required for saving")
}
// Download the document
documentData, err := s.downloadDocument(req.URL)
if err != nil {
return fmt.Errorf("failed to download document: %w", err)
}
// Use session UserID as SavedBy since callbacks don't have user context
savedBy := session.UserID
if savedBy == uuid.Nil {
// Fallback to getting from context if available
if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil {
savedBy = userID
}
}
// Save new version
version := &entities.DocumentVersion{
DocumentID: session.DocumentID,
Version: session.Version + 1,
FileSize: int64(len(documentData)),
SavedBy: savedBy,
SavedAt: time.Now(),
IsActive: true,
}
// For now, default to outgoing_attachment
// In production, this should be stored in the session or document metadata
documentType := "outgoing_attachment"
// Generate new file path and save
fileName := fmt.Sprintf("v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey)
filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType)
if err != nil {
return fmt.Errorf("failed to save document file: %w", err)
}
version.FileURL = filePath
// Save changes URL if provided
if req.ChangesURL != "" {
version.ChangesURL = &req.ChangesURL
}
// Create new version
err = s.processor.CreateDocumentVersion(ctx, version)
if err != nil {
return fmt.Errorf("failed to create document version: %w", err)
}
// Update session
session.Status = req.Status
session.Version = version.Version
now := time.Now()
session.LastSavedAt = &now
session.IsLocked = false
session.LockedBy = nil
session.LockedAt = nil
// Update the original document reference with new URL
err = s.processor.UpdateDocumentURL(ctx, session.DocumentID, version.FileURL)
if err != nil {
return fmt.Errorf("failed to update document URL: %w", err)
}
return s.processor.UpdateDocumentSession(ctx, session)
}
// handleSaveError handles document save errors
func (s *OnlyOfficeServiceImpl) handleSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
// Log the error
s.processor.LogDocumentError(ctx, session.DocumentID, "Save error occurred", req)
// Update session status
session.Status = req.Status
return s.processor.UpdateDocumentSession(ctx, session)
}
// handleClosedStatus handles when document is closed without changes
func (s *OnlyOfficeServiceImpl) handleClosedStatus(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
// Unlock document
session.Status = req.Status
session.IsLocked = false
session.LockedBy = nil
session.LockedAt = nil
return s.processor.UpdateDocumentSession(ctx, session)
}
func (s *OnlyOfficeServiceImpl) handleForceSave(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
if req.URL == "" {
return errors.New("document URL is required for force save")
}
documentData, err := s.downloadDocument(req.URL)
if err != nil {
return fmt.Errorf("failed to download document: %w", err)
}
savedBy := session.UserID
if savedBy == uuid.Nil {
if userID := getOnlyOfficeUserIDFromContext(ctx); userID != uuid.Nil {
savedBy = userID
}
}
version := &entities.DocumentVersion{
DocumentID: session.DocumentID,
Version: session.Version + 1,
FileSize: int64(len(documentData)),
SavedBy: savedBy,
SavedAt: time.Now(),
IsActive: false,
Comments: stringPtr("Auto-save during editing"),
}
// For now, default to outgoing_attachment
// In production, this should be stored in the session or document metadata
documentType := "outgoing_attachment"
fileName := fmt.Sprintf("autosave_v%d_%s_%s.docx", version.Version, time.Now().Format("20060102150405"), session.DocumentKey)
filePath, err := s.saveDocumentFile(ctx, documentData, session.DocumentID, fileName, documentType)
if err != nil {
return fmt.Errorf("failed to save document file: %w", err)
}
version.FileURL = filePath
err = s.processor.CreateDocumentVersion(ctx, version)
if err != nil {
return fmt.Errorf("failed to create document version: %w", err)
}
now := time.Now()
session.LastSavedAt = &now
session.Version = version.Version
return s.processor.UpdateDocumentSession(ctx, session)
}
// handleForceSaveError handles force save errors
func (s *OnlyOfficeServiceImpl) handleForceSaveError(ctx context.Context, session *entities.DocumentSession, req *contract.OnlyOfficeCallbackRequest) error {
// Log the error
s.processor.LogDocumentError(ctx, session.DocumentID, "Force save error occurred", req)
// Update session status
session.Status = req.Status
return s.processor.UpdateDocumentSession(ctx, session)
}
// downloadDocument downloads document from OnlyOffice
func (s *OnlyOfficeServiceImpl) downloadDocument(url string) ([]byte, error) {
resp, err := http.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to download document: status %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
// saveDocumentFile saves document file to S3 storage
func (s *OnlyOfficeServiceImpl) saveDocumentFile(ctx context.Context, data []byte, documentID uuid.UUID, fileName string, documentType string) (string, error) {
// Ensure bucket exists
if err := s.fileStorage.EnsureBucket(ctx, s.docBucket); err != nil {
return "", fmt.Errorf("failed to ensure bucket: %w", err)
}
// Create S3 key with date structure
dateDir := time.Now().Format("2006/01/02")
key := fmt.Sprintf("onlyoffice/%s/%s/%s", documentID.String(), dateDir, fileName)
// Detect content type from file extension
contentType := "application/octet-stream"
ext := filepath.Ext(fileName)
switch strings.ToLower(ext) {
case ".docx":
contentType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
case ".doc":
contentType = "application/msword"
case ".xlsx":
contentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
case ".xls":
contentType = "application/vnd.ms-excel"
case ".pptx":
contentType = "application/vnd.openxmlformats-officedocument.presentationml.presentation"
case ".ppt":
contentType = "application/vnd.ms-powerpoint"
case ".pdf":
contentType = "application/pdf"
}
// Upload to S3
url, err := s.fileStorage.Upload(ctx, s.docBucket, key, data, contentType)
if err != nil {
return "", fmt.Errorf("failed to upload to S3: %w", err)
}
// Now update the attachment URL in the database
if err := s.updateAttachmentURL(ctx, documentID, url, documentType); err != nil {
// Log error but don't fail - the document is already saved
fmt.Printf("Warning: Failed to update attachment URL: %v\n", err)
}
return url, nil
}
// updateAttachmentURL updates the file_url in the appropriate attachment table
func (s *OnlyOfficeServiceImpl) updateAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string, documentType string) error {
switch documentType {
case "letter_outgoing_attachment", "outgoing_attachment":
return s.db.WithContext(ctx).
Table("letter_outgoing_attachments").
Where("id = ?", attachmentID).
Update("file_url", newURL).Error
case "letter_incoming_attachment", "incoming_attachment":
return s.db.WithContext(ctx).
Table("letter_incoming_attachments").
Where("id = ?", attachmentID).
Update("file_url", newURL).Error
default:
return fmt.Errorf("unsupported document type for URL update: %s", documentType)
}
}
// getDocumentFromAttachment retrieves document details directly from attachment tables
func (s *OnlyOfficeServiceImpl) getDocumentFromAttachment(ctx context.Context, documentID uuid.UUID, documentType string) (*processor.DocumentDetails, error) {
var fileName, fileURL, fileType string
var fileSize int64
switch documentType {
case "letter_outgoing_attachment", "outgoing_attachment":
var attachment struct {
FileName string `gorm:"column:file_name"`
FileURL string `gorm:"column:file_url"`
FileType string `gorm:"column:file_type"`
}
err := s.db.WithContext(ctx).
Table("letter_outgoing_attachments").
Where("id = ?", documentID).
Select("file_name, file_url, file_type").
First(&attachment).Error
if err != nil {
return nil, fmt.Errorf("failed to get outgoing attachment: %w", err)
}
fileName = attachment.FileName
fileURL = attachment.FileURL
fileType = attachment.FileType
case "letter_incoming_attachment", "incoming_attachment":
var attachment struct {
FileName string `gorm:"column:file_name"`
FileURL string `gorm:"column:file_url"`
FileType string `gorm:"column:file_type"`
}
err := s.db.WithContext(ctx).
Table("letter_incoming_attachments").
Where("id = ?", documentID).
Select("file_name, file_url, file_type").
First(&attachment).Error
if err != nil {
return nil, fmt.Errorf("failed to get incoming attachment: %w", err)
}
fileName = attachment.FileName
fileURL = attachment.FileURL
fileType = attachment.FileType
default:
return nil, fmt.Errorf("unsupported document type: %s", documentType)
}
return &processor.DocumentDetails{
DocumentID: documentID,
FileName: fileName,
FileType: fileType,
FileURL: fileURL,
FileSize: fileSize,
DocumentType: documentType,
ReferenceID: documentID,
}, nil
}
// GetEditorConfig generates OnlyOffice editor configuration
func (s *OnlyOfficeServiceImpl) GetEditorConfig(ctx context.Context, req *contract.GetEditorConfigRequest) (*contract.GetEditorConfigResponse, error) {
userCtx := appcontext.FromGinContext(ctx)
if userCtx == nil {
return nil, errors.New("user context not found")
}
session, err := s.processor.GetOrCreateDocumentSession(ctx, req.DocumentID, userCtx.UserID)
if err != nil {
return nil, fmt.Errorf("failed to get document session: %w", err)
}
// Get document details directly from attachment tables
document, err := s.getDocumentFromAttachment(ctx, req.DocumentID, req.DocumentType)
if err != nil {
return nil, fmt.Errorf("failed to get document details: %w", err)
}
documentKey := session.DocumentKey
fileExt := s.getFileExtension(document.FileName)
if fileExt == "" || fileExt == strings.ToLower(document.FileName) {
fileExt = s.getFileExtension(document.FileType)
}
ooType := "desktop"
if req.DocumentType == "incoming_attachment" {
ooType = "embedded"
}
config := &contract.OnlyOfficeConfigRequest{
Document: &contract.OnlyOfficeDocument{
FileType: fileExt,
Key: documentKey,
Title: document.FileName,
URL: document.FileURL,
Permissions: &contract.OnlyOfficePermissions{
Comment: true,
Download: true,
Edit: req.Mode == "edit",
FillForms: true,
Print: true,
Review: req.Mode == "edit",
},
Info: &contract.OnlyOfficeDocumentInfo{
Owner: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]),
Uploaded: time.Now().Format("2006-01-02 15:04:05"),
},
},
DocumentType: s.getDocumentType(fileExt), // Convert file extension to document type
EditorConfig: &contract.OnlyOfficeEditorConfig{
CallbackURL: fmt.Sprintf("%s/%s", s.callbackBaseURL, documentKey),
Lang: "en",
Mode: req.Mode,
User: &contract.OnlyOfficeUserConfig{
ID: userCtx.UserID.String(),
Name: fmt.Sprintf("User-%s", userCtx.UserID.String()[:8]),
},
Customization: &contract.OnlyOfficeCustomization{
Autosave: true,
Comments: true,
CompactHeader: false,
ForceSave: true,
Zoom: 100,
},
},
Type: ooType, // Can be desktop, mobile, or embedded
}
if s.jwtSecret != "" {
token, err := s.generateJWT(config)
if err != nil {
return nil, fmt.Errorf("failed to generate JWT: %w", err)
}
config.Token = token
}
return &contract.GetEditorConfigResponse{
DocumentServerURL: s.serverURL,
Config: config,
}, nil
}
// LockDocument locks a document for editing
func (s *OnlyOfficeServiceImpl) LockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error {
return s.processor.LockDocument(ctx, documentID, userID)
}
// UnlockDocument unlocks a document
func (s *OnlyOfficeServiceImpl) UnlockDocument(ctx context.Context, documentID uuid.UUID, userID uuid.UUID) error {
return s.processor.UnlockDocument(ctx, documentID, userID)
}
// GetDocumentSession gets document session by key
func (s *OnlyOfficeServiceImpl) GetDocumentSession(ctx context.Context, documentKey string) (*contract.DocumentSession, error) {
session, err := s.processor.GetDocumentSessionByKey(ctx, documentKey)
if err != nil {
return nil, err
}
return &contract.DocumentSession{
ID: session.ID,
DocumentID: session.DocumentID,
DocumentKey: session.DocumentKey,
UserID: session.UserID,
Status: session.Status,
IsLocked: session.IsLocked,
LockedBy: session.LockedBy,
LockedAt: session.LockedAt,
LastSavedAt: session.LastSavedAt,
Version: session.Version,
CreatedAt: session.CreatedAt,
UpdatedAt: session.UpdatedAt,
}, nil
}
// generateDocumentKey generates a unique key for OnlyOffice
func (s *OnlyOfficeServiceImpl) generateDocumentKey(documentID uuid.UUID, version int) string {
// Use nanoseconds and random bytes for uniqueness
randomBytes := make([]byte, 8)
rand.Read(randomBytes)
data := fmt.Sprintf("%s_%d_%d_%s", documentID.String(), version, time.Now().UnixNano(), hex.EncodeToString(randomBytes))
hash := md5.Sum([]byte(data))
return hex.EncodeToString(hash[:])
}
// getFileExtension extracts the file extension from file type or filename
func (s *OnlyOfficeServiceImpl) getFileExtension(fileType string) string {
// Remove any leading dot
fileType = strings.TrimPrefix(fileType, ".")
// If fileType contains a dot, extract the extension after the last dot
if strings.Contains(fileType, ".") {
parts := strings.Split(fileType, ".")
if len(parts) > 1 {
return strings.ToLower(parts[len(parts)-1])
}
}
// Otherwise, return as is (assuming it's already an extension like "docx", "xlsx", etc.)
return strings.ToLower(fileType)
}
// getDocumentType determines OnlyOffice document type from file extension
func (s *OnlyOfficeServiceImpl) getDocumentType(fileType string) string {
fileType = strings.ToLower(fileType)
// Remove dot if present
fileType = strings.TrimPrefix(fileType, ".")
// Text documents
if fileType == "doc" || fileType == "docx" || fileType == "docm" ||
fileType == "dot" || fileType == "dotx" || fileType == "dotm" ||
fileType == "odt" || fileType == "fodt" || fileType == "ott" ||
fileType == "rtf" || fileType == "txt" || fileType == "html" ||
fileType == "htm" || fileType == "mht" || fileType == "pdf" ||
fileType == "djvu" || fileType == "fb2" || fileType == "epub" ||
fileType == "xps" {
return "word"
}
// Spreadsheets
if fileType == "xls" || fileType == "xlsx" || fileType == "xlsm" ||
fileType == "xlt" || fileType == "xltx" || fileType == "xltm" ||
fileType == "ods" || fileType == "fods" || fileType == "ots" ||
fileType == "csv" {
return "cell"
}
// Presentations
if fileType == "pps" || fileType == "ppsx" || fileType == "ppsm" ||
fileType == "ppt" || fileType == "pptx" || fileType == "pptm" ||
fileType == "pot" || fileType == "potx" || fileType == "potm" ||
fileType == "odp" || fileType == "fodp" || fileType == "otp" {
return "presentation"
}
// Default to text
return "slide"
}
// generateJWT generates JWT token for OnlyOffice
func (s *OnlyOfficeServiceImpl) generateJWT(config *contract.OnlyOfficeConfigRequest) (string, error) {
// OnlyOffice expects the entire config to be in the JWT payload
payload := make(map[string]interface{})
// Convert the config struct to a map for JWT payload
configJSON, err := json.Marshal(config)
if err != nil {
return "", fmt.Errorf("failed to marshal config: %w", err)
}
var configMap map[string]interface{}
if err := json.Unmarshal(configJSON, &configMap); err != nil {
return "", fmt.Errorf("failed to unmarshal config to map: %w", err)
}
// Add all config fields to the payload
for key, value := range configMap {
payload[key] = value
}
// Create the token with the payload
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims(payload))
// Sign the token with the secret
tokenString, err := token.SignedString([]byte(s.jwtSecret))
if err != nil {
return "", fmt.Errorf("failed to sign JWT token: %w", err)
}
return tokenString, nil
}
func getOnlyOfficeUserIDFromContext(ctx context.Context) uuid.UUID {
userCtx := appcontext.FromGinContext(ctx)
if userCtx != nil {
return userCtx.UserID
}
return uuid.Nil
}
func stringPtr(s string) *string {
return &s
}
// GetOnlyOfficeConfig returns the OnlyOffice configuration
func (s *OnlyOfficeServiceImpl) GetOnlyOfficeConfig(ctx context.Context) (*contract.OnlyOfficeConfigInfo, error) {
return &contract.OnlyOfficeConfigInfo{
URL: s.config.URL,
Token: s.config.Token,
}, nil
}
// verifyJWT verifies JWT token from OnlyOffice
func (s *OnlyOfficeServiceImpl) verifyJWT(tokenString string) (jwt.MapClaims, error) {
if s.jwtSecret == "" {
// If no secret is configured, skip verification
return nil, nil
}
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Validate the signing method
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(s.jwtSecret), nil
})
if err != nil {
return nil, fmt.Errorf("failed to parse JWT: %w", err)
}
if !token.Valid {
return nil, errors.New("invalid JWT token")
}
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, errors.New("failed to parse JWT claims")
}
return claims, nil
}