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
+98 -22
View File
@@ -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
}
+11 -7
View File
@@ -182,18 +182,20 @@ func (p *LetterProcessorImpl) GetIncomingLetterByID(ctx context.Context, id uuid
func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *contract.ListIncomingLettersRequest) (*contract.ListIncomingLettersResponse, error) {
page, limit := req.Page, req.Limit
if page <= 0 {
page = 1
filter := repository.ListIncomingLettersFilter{
Status: req.Status,
Query: req.Query,
DepartmentID: req.DepartmentID,
}
if limit <= 0 {
limit = 10
}
filter := repository.ListIncomingLettersFilter{Status: req.Status, Query: req.Query}
list, total, err := p.letterRepo.List(ctx, filter, limit, (page-1)*limit)
if err != nil {
return nil, err
}
respList := make([]contract.IncomingLetterResponse, 0, len(list))
for _, e := range list {
atts, _ := p.attachRepo.ListByLetter(ctx, e.ID)
var pr *entities.Priority
@@ -202,12 +204,14 @@ func (p *LetterProcessorImpl) ListIncomingLetters(ctx context.Context, req *cont
pr = got
}
}
var inst *entities.Institution
if e.SenderInstitutionID != nil && p.institutionRepo != nil {
if got, err := p.institutionRepo.Get(ctx, *e.SenderInstitutionID); err == nil {
inst = got
}
}
resp := transformer.LetterEntityToContract(&e, atts, pr, inst)
respList = append(respList, *resp)
}
@@ -433,7 +437,7 @@ func (p *LetterProcessorImpl) CreateDiscussion(ctx context.Context, letterID uui
return err
}
if p.activity != nil {
action := "reference_numberdiscussion.created"
action := "discussion.created"
tgt := "discussion"
ctxMap := map[string]interface{}{"message": req.Message, "parent_id": req.ParentID}
if err := p.activity.Log(txCtx, letterID, action, &userID, nil, &tgt, &disc.ID, nil, nil, ctxMap); err != nil {
+463
View File
@@ -0,0 +1,463 @@
package processor
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"eslogad-be/internal/entities"
"eslogad-be/internal/repository"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type OnlyOfficeProcessor interface {
GetDocumentSessionByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error)
GetOrCreateDocumentSession(ctx context.Context, documentID, userID uuid.UUID) (*entities.DocumentSession, error)
UpdateDocumentSession(ctx context.Context, session *entities.DocumentSession) error
CreateDocumentVersion(ctx context.Context, version *entities.DocumentVersion) error
UpdateDocumentURL(ctx context.Context, documentID uuid.UUID, newURL string) error
LockDocument(ctx context.Context, documentID, userID uuid.UUID) error
UnlockDocument(ctx context.Context, documentID, userID uuid.UUID) error
LogDocumentError(ctx context.Context, documentID uuid.UUID, errorMsg string, details interface{}) error
GetDocumentDetails(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error)
}
type DocumentDetails struct {
DocumentID uuid.UUID
FileName string
FileType string
FileURL string
FileSize int64
DocumentType string
ReferenceID uuid.UUID
}
type OnlyOfficeProcessorImpl struct {
db *gorm.DB
sessionRepo *repository.DocumentSessionRepository
versionRepo *repository.DocumentVersionRepository
metadataRepo *repository.DocumentMetadataRepository
errorRepo *repository.DocumentErrorRepository
txManager *repository.TxManager
}
func NewOnlyOfficeProcessor(
db *gorm.DB,
sessionRepo *repository.DocumentSessionRepository,
versionRepo *repository.DocumentVersionRepository,
metadataRepo *repository.DocumentMetadataRepository,
errorRepo *repository.DocumentErrorRepository,
txManager *repository.TxManager,
) *OnlyOfficeProcessorImpl {
return &OnlyOfficeProcessorImpl{
db: db,
sessionRepo: sessionRepo,
versionRepo: versionRepo,
metadataRepo: metadataRepo,
errorRepo: errorRepo,
txManager: txManager,
}
}
// GetDocumentSessionByKey retrieves a document session by its OnlyOffice key
func (p *OnlyOfficeProcessorImpl) GetDocumentSessionByKey(ctx context.Context, documentKey string) (*entities.DocumentSession, error) {
return p.sessionRepo.GetByKey(ctx, documentKey)
}
// GetOrCreateDocumentSession gets an existing session or creates a new one
func (p *OnlyOfficeProcessorImpl) GetOrCreateDocumentSession(ctx context.Context, documentID, userID uuid.UUID) (*entities.DocumentSession, error) {
// Try to get existing active session
session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID)
if err == nil && session != nil {
// Return existing session if it's not closed
if session.Status != 4 { // Status 4 = closed
return session, nil
}
}
// Create new session
session = &entities.DocumentSession{
DocumentID: documentID,
UserID: userID,
Status: 0, // Initial status
Version: 1,
IsLocked: false,
}
// Generate unique document key with retry logic
for attempts := 0; attempts < 3; attempts++ {
session.DocumentKey = p.generateDocumentKey(documentID, userID)
err = p.sessionRepo.Create(ctx, session)
if err == nil {
return session, nil
}
// If it's not a duplicate key error, return the error
if !errors.Is(err, gorm.ErrDuplicatedKey) && !contains(err.Error(), "duplicate key") {
return nil, fmt.Errorf("failed to create document session: %w", err)
}
// Wait a bit before retrying
time.Sleep(time.Millisecond * 10)
}
return nil, fmt.Errorf("failed to create document session after retries: %w", err)
}
// UpdateDocumentSession updates an existing document session
func (p *OnlyOfficeProcessorImpl) UpdateDocumentSession(ctx context.Context, session *entities.DocumentSession) error {
return p.sessionRepo.Update(ctx, session)
}
// CreateDocumentVersion creates a new document version
func (p *OnlyOfficeProcessorImpl) CreateDocumentVersion(ctx context.Context, version *entities.DocumentVersion) error {
return p.txManager.WithTransaction(ctx, func(txCtx context.Context) error {
// Deactivate previous versions if this is the active one
if version.IsActive {
err := p.versionRepo.DeactivateAllVersions(txCtx, version.DocumentID)
if err != nil {
return fmt.Errorf("failed to deactivate previous versions: %w", err)
}
}
// Create new version
err := p.versionRepo.Create(txCtx, version)
if err != nil {
return fmt.Errorf("failed to create document version: %w", err)
}
return nil
})
}
// UpdateDocumentURL updates the document URL in the appropriate table
func (p *OnlyOfficeProcessorImpl) UpdateDocumentURL(ctx context.Context, documentID uuid.UUID, newURL string) error {
// Get document metadata to determine type
metadata, err := p.metadataRepo.GetByDocumentID(ctx, documentID)
if err != nil {
return fmt.Errorf("failed to get document metadata: %w", err)
}
// Update based on document type
switch metadata.DocumentType {
case "letter_attachment":
return p.updateLetterAttachmentURL(ctx, metadata.ReferenceID, newURL)
case "outgoing_attachment":
return p.updateOutgoingAttachmentURL(ctx, metadata.ReferenceID, newURL)
case "discussion_attachment":
return p.updateDiscussionAttachmentURL(ctx, metadata.ReferenceID, newURL)
default:
return fmt.Errorf("unknown document type: %s", metadata.DocumentType)
}
}
// LockDocument locks a document for editing
func (p *OnlyOfficeProcessorImpl) LockDocument(ctx context.Context, documentID, userID uuid.UUID) error {
session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID)
if err != nil {
return fmt.Errorf("failed to get document session: %w", err)
}
// Check if already locked by another user
if session.IsLocked && session.LockedBy != nil && *session.LockedBy != userID {
return errors.New("document is already locked by another user")
}
// Lock the document
session.IsLocked = true
session.LockedBy = &userID
now := time.Now()
session.LockedAt = &now
return p.sessionRepo.Update(ctx, session)
}
// UnlockDocument unlocks a document
func (p *OnlyOfficeProcessorImpl) UnlockDocument(ctx context.Context, documentID, userID uuid.UUID) error {
session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID)
if err != nil {
return fmt.Errorf("failed to get document session: %w", err)
}
// Check if locked by the requesting user
if session.IsLocked && session.LockedBy != nil && *session.LockedBy != userID {
return errors.New("document is locked by another user")
}
// Unlock the document
session.IsLocked = false
session.LockedBy = nil
session.LockedAt = nil
return p.sessionRepo.Update(ctx, session)
}
// LogDocumentError logs an error related to document operations
func (p *OnlyOfficeProcessorImpl) LogDocumentError(ctx context.Context, documentID uuid.UUID, errorMsg string, details interface{}) error {
detailsMap := make(map[string]interface{})
// Convert details to map if possible
if details != nil {
detailsJSON, _ := json.Marshal(details)
json.Unmarshal(detailsJSON, &detailsMap)
}
docError := &entities.DocumentError{
DocumentID: documentID,
ErrorType: "onlyoffice_callback",
ErrorMsg: errorMsg,
Details: detailsMap,
}
// Get current session if available
if session, err := p.sessionRepo.GetActiveByDocument(ctx, documentID); err == nil && session != nil {
docError.SessionID = &session.ID
}
return p.errorRepo.Create(ctx, docError)
}
// GetDocumentDetails retrieves document details based on type
func (p *OnlyOfficeProcessorImpl) GetDocumentDetails(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) {
// Get metadata
metadata, err := p.metadataRepo.GetByDocumentID(ctx, documentID)
if err != nil {
// If metadata doesn't exist, create it based on the document type
if errors.Is(err, gorm.ErrRecordNotFound) {
return p.createDocumentMetadata(ctx, documentID, documentType)
}
return nil, err
}
// Get the actual file URL from the appropriate table
fileURL, err := p.getDocumentURL(ctx, metadata)
if err != nil {
return nil, err
}
return &DocumentDetails{
DocumentID: metadata.DocumentID,
FileName: metadata.FileName,
FileType: metadata.FileType,
FileURL: fileURL,
FileSize: metadata.FileSize,
DocumentType: metadata.DocumentType,
ReferenceID: metadata.ReferenceID,
}, nil
}
// Helper methods
func (p *OnlyOfficeProcessorImpl) generateDocumentKey(documentID, userID uuid.UUID) string {
// Use nanoseconds for better precision
now := time.Now().UnixNano()
// Generate random bytes for additional uniqueness
randomBytes := make([]byte, 4)
rand.Read(randomBytes)
randomHex := hex.EncodeToString(randomBytes)
return fmt.Sprintf("%s_%s_%d_%s", documentID.String()[:8], userID.String()[:8], now, randomHex)
}
func (p *OnlyOfficeProcessorImpl) updateLetterAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error {
// Update letter attachment URL
return p.db.WithContext(ctx).
Table("letter_incoming_attachments").
Where("id = ?", attachmentID).
Update("file_url", newURL).Error
}
func (p *OnlyOfficeProcessorImpl) updateOutgoingAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error {
// Update outgoing letter attachment URL
return p.db.WithContext(ctx).
Table("letter_outgoing_attachments").
Where("id = ?", attachmentID).
Update("file_url", newURL).Error
}
func (p *OnlyOfficeProcessorImpl) updateDiscussionAttachmentURL(ctx context.Context, attachmentID uuid.UUID, newURL string) error {
// Update discussion attachment URL
return p.db.WithContext(ctx).
Table("letter_outgoing_discussion_attachments").
Where("id = ?", attachmentID).
Update("file_url", newURL).Error
}
func (p *OnlyOfficeProcessorImpl) createDocumentMetadata(ctx context.Context, documentID uuid.UUID, documentType string) (*DocumentDetails, error) {
var metadata entities.DocumentMetadata
var details DocumentDetails
// Fetch document information based on type
switch documentType {
case "letter_attachment":
var attachment struct {
ID uuid.UUID `gorm:"column:id"`
LetterID uuid.UUID `gorm:"column:letter_id"`
FileName string `gorm:"column:file_name"`
FileURL string `gorm:"column:file_url"`
FileSize int64 `gorm:"column:file_size"`
FileType string `gorm:"column:file_type"`
}
err := p.db.WithContext(ctx).
Table("letter_incoming_attachments").
Where("id = ?", documentID).
First(&attachment).Error
if err != nil {
return nil, fmt.Errorf("failed to get letter attachment: %w", err)
}
metadata = entities.DocumentMetadata{
DocumentID: documentID,
DocumentType: documentType,
ReferenceID: attachment.LetterID,
FileName: attachment.FileName,
FileType: attachment.FileType,
FileSize: attachment.FileSize,
}
details = DocumentDetails{
DocumentID: documentID,
FileName: attachment.FileName,
FileType: attachment.FileType,
FileURL: attachment.FileURL,
FileSize: attachment.FileSize,
DocumentType: documentType,
ReferenceID: attachment.LetterID,
}
case "outgoing_attachment":
var attachment struct {
ID uuid.UUID `gorm:"column:id"`
LetterID uuid.UUID `gorm:"column:letter_id"`
FileName string `gorm:"column:file_name"`
FileURL string `gorm:"column:file_url"`
FileSize int64 `gorm:"column:file_size"`
FileType string `gorm:"column:file_type"`
}
err := p.db.WithContext(ctx).
Table("letter_outgoing_attachments").
Where("id = ?", documentID).
First(&attachment).Error
if err != nil {
return nil, fmt.Errorf("failed to get outgoing letter attachment: %w", err)
}
metadata = entities.DocumentMetadata{
DocumentID: documentID,
DocumentType: documentType,
ReferenceID: attachment.LetterID,
FileName: attachment.FileName,
FileType: attachment.FileType,
FileSize: attachment.FileSize,
}
details = DocumentDetails{
DocumentID: documentID,
FileName: attachment.FileName,
FileType: attachment.FileType,
FileURL: attachment.FileURL,
FileSize: attachment.FileSize,
DocumentType: documentType,
ReferenceID: attachment.LetterID,
}
case "discussion_attachment":
var attachment struct {
ID uuid.UUID `gorm:"column:id"`
DiscussionID uuid.UUID `gorm:"column:discussion_id"`
FileName string `gorm:"column:file_name"`
FileURL string `gorm:"column:file_url"`
FileSize int64 `gorm:"column:file_size"`
FileType string `gorm:"column:file_type"`
}
err := p.db.WithContext(ctx).
Table("letter_outgoing_discussion_attachments").
Where("id = ?", documentID).
First(&attachment).Error
if err != nil {
return nil, fmt.Errorf("failed to get discussion attachment: %w", err)
}
metadata = entities.DocumentMetadata{
DocumentID: documentID,
DocumentType: documentType,
ReferenceID: attachment.DiscussionID,
FileName: attachment.FileName,
FileType: attachment.FileType,
FileSize: attachment.FileSize,
}
details = DocumentDetails{
DocumentID: documentID,
FileName: attachment.FileName,
FileType: attachment.FileType,
FileURL: attachment.FileURL,
FileSize: attachment.FileSize,
DocumentType: documentType,
ReferenceID: attachment.DiscussionID,
}
default:
return nil, fmt.Errorf("unknown document type: %s", documentType)
}
// Create metadata in database
err := p.metadataRepo.Create(ctx, &metadata)
if err != nil {
// If it already exists (race condition), try to get it
if strings.Contains(err.Error(), "duplicate key") {
existingMetadata, getErr := p.metadataRepo.GetByDocumentID(ctx, documentID)
if getErr == nil {
// Get the file URL
fileURL, urlErr := p.getDocumentURL(ctx, existingMetadata)
if urlErr == nil {
details.FileURL = fileURL
}
return &details, nil
}
}
return nil, fmt.Errorf("failed to create document metadata: %w", err)
}
return &details, nil
}
func (p *OnlyOfficeProcessorImpl) getDocumentURL(ctx context.Context, metadata *entities.DocumentMetadata) (string, error) {
var fileURL string
switch metadata.DocumentType {
case "letter_attachment":
err := p.db.WithContext(ctx).
Table("letter_incoming_attachments").
Where("id = ?", metadata.ReferenceID).
Select("file_url").
Scan(&fileURL).Error
return fileURL, err
case "outgoing_attachment":
err := p.db.WithContext(ctx).
Table("letter_outgoing_attachments").
Where("id = ?", metadata.ReferenceID).
Select("file_url").
Scan(&fileURL).Error
return fileURL, err
case "discussion_attachment":
err := p.db.WithContext(ctx).
Table("letter_outgoing_discussion_attachments").
Where("id = ?", metadata.ReferenceID).
Select("file_url").
Scan(&fileURL).Error
return fileURL, err
default:
return "", fmt.Errorf("unknown document type: %s", metadata.DocumentType)
}
}
// Helper function to check if string contains substring
func contains(s, substr string) bool {
return strings.Contains(s, substr)
}