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

View File

@ -29,6 +29,7 @@ type Config struct {
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"`
}

View File

@ -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'
onlyoffice:
url: 'https://onlyoffice.apskel.org/'
token: '2DmKgd5PT3n1vH3f2v2iRZUqTVHj9GQx'

View File

@ -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,
}
}

View File

@ -64,6 +64,7 @@ type ListIncomingLettersRequest struct {
Limit int `json:"limit"`
Status *string `json:"status,omitempty"`
Query *string `json:"query,omitempty"`
DepartmentID *uuid.UUID
}
type ListIncomingLettersResponse struct {

View File

@ -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"`
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 {
@ -33,12 +35,17 @@ 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"`
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"`
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"`
}

View File

@ -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"`
}

View File

@ -32,8 +32,6 @@ type ApprovalFlowStep struct {
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"`

View File

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

View File

@ -49,12 +49,16 @@ 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"`
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" }

View File

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

View File

@ -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 {
@ -421,3 +425,40 @@ 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))
}

View File

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

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

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 {

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

View File

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

View File

@ -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

View File

@ -42,24 +42,31 @@ func (r *LetterIncomingRepository) SoftDelete(ctx context.Context, id uuid.UUID)
type ListIncomingLettersFilter struct {
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
}

View File

@ -90,6 +90,7 @@ type LetterOutgoingHandler interface {
RejectOutgoingLetter(c *gin.Context)
SendOutgoingLetter(c *gin.Context)
ArchiveOutgoingLetter(c *gin.Context)
GetLetterApprovalInfo(c *gin.Context)
AddRecipients(c *gin.Context)
UpdateRecipient(c *gin.Context)
@ -101,6 +102,8 @@ type LetterOutgoingHandler interface {
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)
}

View File

@ -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)
@ -179,6 +183,9 @@ func (r *Router) addAppRoutes(rg *gin.Engine) {
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)
}
}
}
}

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 {
@ -337,11 +343,12 @@ func (s *LetterOutgoingServiceImpl) AddRecipients(ctx context.Context, letterID
for i, r := range req.Recipients {
recipients[i] = entities.LetterOutgoingRecipient{
LetterID: letterID,
RecipientName: r.Name,
RecipientEmail: r.Email,
RecipientPosition: r.Position,
RecipientInstitution: r.Institution,
UserID: r.UserID,
DepartmentID: r.DepartmentID,
IsPrimary: r.IsPrimary,
Status: r.Status,
Flag: r.Flag,
IsArchived: r.IsArchived,
}
}
@ -360,13 +367,25 @@ 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,
}
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{
recipResp := contract.OutgoingLetterRecipientResponse{
ID: recipient.ID,
Name: recipient.RecipientName,
Email: recipient.RecipientEmail,
Position: recipient.RecipientPosition,
Institution: recipient.RecipientInstitution,
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
}
}

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
}

View File

@ -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;

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -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();

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

BIN
server

Binary file not shown.