feat(macros): align attachment uploads
This commit is contained in:
@@ -555,24 +555,59 @@ func (s *ActionService) handleSendEmailTranscript(ctx context.Context, accountID
|
||||
// Reference: Chatwoot send_attachment action
|
||||
func (s *ActionService) handleSendAttachment(ctx context.Context, accountID, conversationID uint, action Action) error {
|
||||
attachmentURL, _ := action.ActionParams["attachment_url"].(string)
|
||||
if attachmentURL == "" {
|
||||
return fmt.Errorf("send_attachment action requires 'attachment_url' param")
|
||||
if attachmentURL != "" {
|
||||
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), model.DirectUpload{FileURL: attachmentURL, OriginalName: attachmentURL, FileType: "file"})
|
||||
}
|
||||
blobID := extractUintParam(action.ActionParams, "blob_id")
|
||||
if blobID == 0 {
|
||||
return fmt.Errorf("send_attachment action requires 'blob_id' param")
|
||||
}
|
||||
var upload model.DirectUpload
|
||||
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, blobID).First(&upload).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.createAttachmentMessage(ctx, accountID, conversationID, sourceID(action), upload)
|
||||
}
|
||||
|
||||
// Create a message with attachment content type
|
||||
senderID := uint(0) // Bot sender
|
||||
func (s *ActionService) createAttachmentMessage(ctx context.Context, accountID, conversationID, senderID uint, upload model.DirectUpload) error {
|
||||
var conversation model.Conversation
|
||||
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, conversationID).First(&conversation).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
senderType := "agent"
|
||||
if senderID == 0 {
|
||||
senderType = "bot"
|
||||
}
|
||||
msg := &model.Message{
|
||||
ConversationID: conversationID,
|
||||
AccountID: accountID,
|
||||
InboxID: conversation.InboxID,
|
||||
SenderID: &senderID,
|
||||
SenderType: "bot",
|
||||
Content: attachmentURL,
|
||||
SenderType: senderType,
|
||||
Content: "",
|
||||
ContentType: "file",
|
||||
MessageType: "outgoing",
|
||||
Private: false,
|
||||
Status: "sent",
|
||||
}
|
||||
|
||||
return s.db.DB().WithContext(ctx).Create(msg).Error
|
||||
return s.db.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(msg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
attachment := &model.Attachment{
|
||||
MessageID: msg.ID,
|
||||
AccountID: accountID,
|
||||
FileType: upload.FileType,
|
||||
FileURL: upload.FileURL,
|
||||
ThumbURL: upload.ThumbURL,
|
||||
FileSize: int(upload.FileSize),
|
||||
FileName: upload.OriginalName,
|
||||
}
|
||||
if attachment.FileType == "" {
|
||||
attachment.FileType = "file"
|
||||
}
|
||||
return tx.Create(attachment).Error
|
||||
})
|
||||
}
|
||||
|
||||
// handleAddPrivateNote adds a private note (internal comment) to the conversation.
|
||||
|
||||
@@ -39,6 +39,8 @@ func setupAutomationTestDB(t *testing.T) *gorm.DB {
|
||||
&model.ContactInbox{},
|
||||
&model.Conversation{},
|
||||
&model.Message{},
|
||||
&model.Attachment{},
|
||||
&model.DirectUpload{},
|
||||
&model.Team{},
|
||||
&model.TeamMember{},
|
||||
&model.SlaPolicy{},
|
||||
|
||||
@@ -2,7 +2,9 @@ package automation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gochat/gochat/internal/model"
|
||||
@@ -44,6 +46,7 @@ func (s *MacroService) GetByID(ctx context.Context, id uint) (*Macro, error) {
|
||||
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(¯o, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.hydrateMacroFiles(ctx, ¯o)
|
||||
return ¯o, nil
|
||||
}
|
||||
|
||||
@@ -56,6 +59,7 @@ func (s *MacroService) GetByIDForAccount(ctx context.Context, accountID, id uint
|
||||
First(¯o, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = s.hydrateMacroFiles(ctx, ¯o)
|
||||
return ¯o, nil
|
||||
}
|
||||
|
||||
@@ -73,6 +77,9 @@ func (s *MacroService) ListByAccount(ctx context.Context, accountID uint, userID
|
||||
if err := query.Preload("CreatedBy").Preload("UpdatedBy").Order("id ASC").Find(¯os).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for i := range macros {
|
||||
_ = s.hydrateMacroFiles(ctx, ¯os[i])
|
||||
}
|
||||
return macros, nil
|
||||
}
|
||||
|
||||
@@ -84,6 +91,9 @@ func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
|
||||
if macro.Actions == nil {
|
||||
macro.Actions = Actions{}
|
||||
}
|
||||
if err := s.normalizeMacroAttachmentActions(ctx, macro.AccountID, ¯o.Actions); err != nil {
|
||||
return err
|
||||
}
|
||||
macro.Active = true
|
||||
// Use Select to force all fields including zero-value bool Active=false.
|
||||
// Without Select, GORM skips zero-value fields and uses column defaults.
|
||||
@@ -94,7 +104,10 @@ func (s *MacroService) Create(ctx context.Context, macro *Macro) error {
|
||||
).Create(macro).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error
|
||||
if err := s.db.DB().WithContext(ctx).Preload("CreatedBy").Preload("UpdatedBy").First(macro, macro.ID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return s.hydrateMacroFiles(ctx, macro)
|
||||
}
|
||||
|
||||
// Update updates an existing macro.
|
||||
@@ -109,7 +122,12 @@ func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, mac
|
||||
return nil, err
|
||||
}
|
||||
existing.Name = macro.Name
|
||||
existing.Actions = macro.Actions
|
||||
if macro.Actions != nil {
|
||||
if err := s.normalizeMacroAttachmentActions(ctx, accountID, ¯o.Actions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
existing.Actions = macro.Actions
|
||||
}
|
||||
existing.Visibility = macro.Visibility
|
||||
existing.UpdatedByID = macro.UpdatedByID
|
||||
if err := s.Update(ctx, existing); err != nil {
|
||||
@@ -118,6 +136,126 @@ func (s *MacroService) UpdateForAccount(ctx context.Context, accountID uint, mac
|
||||
return s.GetByIDForAccount(ctx, accountID, existing.ID)
|
||||
}
|
||||
|
||||
func (s *MacroService) normalizeMacroAttachmentActions(ctx context.Context, accountID uint, actions *Actions) error {
|
||||
if actions == nil {
|
||||
return nil
|
||||
}
|
||||
for i := range *actions {
|
||||
action := &(*actions)[i]
|
||||
if action.ActionName != "send_attachment" {
|
||||
continue
|
||||
}
|
||||
if action.ActionParams == nil {
|
||||
return fmt.Errorf("invalid attachment")
|
||||
}
|
||||
blobID := firstMacroBlobID(action.ActionParams)
|
||||
upload, err := s.findMacroUpload(ctx, accountID, blobID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid attachment")
|
||||
}
|
||||
action.ActionParams["blob_id"] = upload.ID
|
||||
delete(action.ActionParams, "attachment_url")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func firstMacroBlobID(params map[string]interface{}) interface{} {
|
||||
if value := params["blob_id"]; value != nil {
|
||||
return value
|
||||
}
|
||||
return params["attachment_url"]
|
||||
}
|
||||
|
||||
func (s *MacroService) findMacroUpload(ctx context.Context, accountID uint, value interface{}) (*model.DirectUpload, error) {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
if parsed, err := strconv.ParseUint(v, 10, 64); err == nil {
|
||||
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
|
||||
}
|
||||
var upload model.DirectUpload
|
||||
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND upload_uuid = ?", accountID, v).First(&upload).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &upload, nil
|
||||
case float64:
|
||||
return s.findMacroUploadByID(ctx, accountID, uint(v))
|
||||
case int:
|
||||
return s.findMacroUploadByID(ctx, accountID, uint(v))
|
||||
case uint:
|
||||
return s.findMacroUploadByID(ctx, accountID, v)
|
||||
case json.Number:
|
||||
parsed, err := strconv.ParseUint(string(v), 10, 64)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.findMacroUploadByID(ctx, accountID, uint(parsed))
|
||||
default:
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
}
|
||||
|
||||
func (s *MacroService) findMacroUploadByID(ctx context.Context, accountID, id uint) (*model.DirectUpload, error) {
|
||||
var upload model.DirectUpload
|
||||
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id = ?", accountID, id).First(&upload).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &upload, nil
|
||||
}
|
||||
|
||||
func (s *MacroService) hydrateMacroFiles(ctx context.Context, macro *Macro) error {
|
||||
ids := macroAttachmentBlobIDs(macro.Actions)
|
||||
if len(ids) == 0 {
|
||||
macro.Files = nil
|
||||
return nil
|
||||
}
|
||||
var uploads []model.DirectUpload
|
||||
if err := s.db.DB().WithContext(ctx).Where("account_id = ? AND id IN ?", macro.AccountID, ids).Order("id ASC").Find(&uploads).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
files := make([]MacroFile, 0, len(uploads))
|
||||
for _, upload := range uploads {
|
||||
files = append(files, MacroFile{ID: upload.ID, MacroID: macro.ID, FileType: upload.MimeType, AccountID: upload.AccountID, FileURL: upload.FileURL, BlobID: upload.ID, Filename: upload.OriginalName})
|
||||
}
|
||||
macro.Files = files
|
||||
return nil
|
||||
}
|
||||
|
||||
func macroAttachmentBlobIDs(actions Actions) []uint {
|
||||
seen := map[uint]bool{}
|
||||
ids := []uint{}
|
||||
for _, action := range actions {
|
||||
if action.ActionName != "send_attachment" {
|
||||
continue
|
||||
}
|
||||
id := macroBlobIDAsUint(action.ActionParams["blob_id"])
|
||||
if id == 0 || seen[id] {
|
||||
continue
|
||||
}
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func macroBlobIDAsUint(value interface{}) uint {
|
||||
switch v := value.(type) {
|
||||
case uint:
|
||||
return v
|
||||
case int:
|
||||
return uint(v)
|
||||
case float64:
|
||||
return uint(v)
|
||||
case string:
|
||||
parsed, _ := strconv.ParseUint(v, 10, 64)
|
||||
return uint(parsed)
|
||||
case json.Number:
|
||||
parsed, _ := strconv.ParseUint(string(v), 10, 64)
|
||||
return uint(parsed)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
// Delete deletes a macro by ID.
|
||||
func (s *MacroService) Delete(ctx context.Context, id uint) error {
|
||||
return s.db.DB().WithContext(ctx).Delete(&Macro{}, id).Error
|
||||
|
||||
@@ -256,3 +256,44 @@ func TestMacroService_ExecuteForDisplayIDsQueuesDurableMacroJob(t *testing.T) {
|
||||
t.Fatalf("expected macro job to add label: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMacroService_ExecuteForDisplayIDsCreatesAttachmentMessages(t *testing.T) {
|
||||
dbProvider := setupAutomationTestDBProvider(t)
|
||||
db := dbProvider.DB()
|
||||
accountID, userID := seedTestAccount(db, t)
|
||||
inboxID := seedTestInbox(db, t, accountID)
|
||||
contactID := seedTestContact(db, t, accountID)
|
||||
displayID := uint(717)
|
||||
conversationID := seedTestConversationWithDetails(db, t, accountID, inboxID, contactID, "open", "low", "web", 0)
|
||||
if err := db.Model(&model.Conversation{}).Where("id = ?", conversationID).Update("display_id", displayID).Error; err != nil {
|
||||
t.Fatalf("set display id: %v", err)
|
||||
}
|
||||
upload := &model.DirectUpload{UploadUUID: "macro-exec-upload", AccountID: accountID, Status: model.DirectUploadStatusPending, Source: model.DirectUploadSourceAccount, OriginalName: "macro.pdf", FileType: "file", MimeType: "application/pdf", FileSize: 456, FileURL: "/uploads/account/1/macro.pdf"}
|
||||
if err := db.Create(upload).Error; err != nil {
|
||||
t.Fatalf("create upload: %v", err)
|
||||
}
|
||||
macro := &Macro{AccountID: accountID, Name: "file macro", Actions: Actions{{ActionName: "send_attachment", ActionParams: map[string]interface{}{"blob_id": "macro-exec-upload"}}}, Visibility: MacroVisibilityGlobal, CreatedByID: userID, UpdatedByID: userID}
|
||||
svc := NewMacroService(dbProvider)
|
||||
if err := svc.Create(context.Background(), macro); err != nil {
|
||||
t.Fatalf("create macro: %v", err)
|
||||
}
|
||||
|
||||
if err := svc.ExecuteForDisplayIDs(context.Background(), accountID, macro.ID, []uint{displayID}, userID); err != nil {
|
||||
t.Fatalf("execute macro: %v", err)
|
||||
}
|
||||
|
||||
var message model.Message
|
||||
if err := db.Where("conversation_id = ? AND content_type = ?", conversationID, "file").First(&message).Error; err != nil {
|
||||
t.Fatalf("expected file message: %v", err)
|
||||
}
|
||||
if message.SenderID == nil || *message.SenderID != userID {
|
||||
t.Fatalf("expected macro sender %d, got %#v", userID, message.SenderID)
|
||||
}
|
||||
var attachment model.Attachment
|
||||
if err := db.Where("message_id = ?", message.ID).First(&attachment).Error; err != nil {
|
||||
t.Fatalf("expected attachment: %v", err)
|
||||
}
|
||||
if attachment.FileURL != upload.FileURL || attachment.FileName != upload.OriginalName {
|
||||
t.Fatalf("unexpected attachment: %#v", attachment)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,10 +123,21 @@ type Macro struct {
|
||||
UpdatedByID uint `gorm:"index;not null" json:"updated_by_id"`
|
||||
CreatedBy *model.User `gorm:"foreignKey:CreatedByID" json:"created_by,omitempty"`
|
||||
UpdatedBy *model.User `gorm:"foreignKey:UpdatedByID" json:"updated_by,omitempty"`
|
||||
Files []MacroFile `gorm:"-" json:"files,omitempty"`
|
||||
}
|
||||
|
||||
func (Macro) TableName() string { return "macros" }
|
||||
|
||||
type MacroFile struct {
|
||||
ID uint `json:"id"`
|
||||
MacroID uint `json:"macro_id"`
|
||||
FileType string `json:"file_type"`
|
||||
AccountID uint `json:"account_id"`
|
||||
FileURL string `json:"file_url"`
|
||||
BlobID uint `json:"blob_id"`
|
||||
Filename string `json:"filename"`
|
||||
}
|
||||
|
||||
// ===========================
|
||||
// CsatSurveyResponse model
|
||||
// ===========================
|
||||
|
||||
@@ -105,6 +105,10 @@ func (h *MacroHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
if svcErr := h.svc.Create(c.Request.Context(), macro); svcErr != nil {
|
||||
if strings.Contains(svcErr.Error(), "invalid attachment") {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
@@ -154,6 +158,10 @@ func (h *MacroHandler) Update(c *gin.Context) {
|
||||
|
||||
updated, svcErr := h.svc.UpdateForAccount(c.Request.Context(), accountID, macro)
|
||||
if svcErr != nil {
|
||||
if strings.Contains(svcErr.Error(), "invalid attachment") {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid attachment"})
|
||||
return
|
||||
}
|
||||
handleServiceError(c, svcErr)
|
||||
return
|
||||
}
|
||||
@@ -312,9 +320,9 @@ type macroActionRequest struct {
|
||||
}
|
||||
|
||||
type macroRequest struct {
|
||||
Name string `json:"name"`
|
||||
Visibility interface{} `json:"visibility"`
|
||||
Actions []macroActionRequest `json:"actions"`
|
||||
Name string `json:"name"`
|
||||
Visibility interface{} `json:"visibility"`
|
||||
Actions *[]macroActionRequest `json:"actions"`
|
||||
}
|
||||
|
||||
func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
|
||||
@@ -322,13 +330,16 @@ func bindMacroRequest(c *gin.Context) (*automation.Macro, error) {
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions := make(automation.Actions, 0, len(req.Actions))
|
||||
for _, actionReq := range req.Actions {
|
||||
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
var actions automation.Actions
|
||||
if req.Actions != nil {
|
||||
actions = make(automation.Actions, 0, len(*req.Actions))
|
||||
for _, actionReq := range *req.Actions {
|
||||
params, err := normalizeAutomationActionParams(actionReq.ActionName, actionReq.ActionParams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
|
||||
}
|
||||
actions = append(actions, automation.Action{ActionName: actionReq.ActionName, ActionParams: params})
|
||||
}
|
||||
return &automation.Macro{
|
||||
Name: strings.TrimSpace(req.Name),
|
||||
@@ -392,6 +403,21 @@ func serializeMacro(macro *automation.Macro) gin.H {
|
||||
if macro.UpdatedBy != nil && macro.UpdatedBy.ID != 0 {
|
||||
item["updated_by"] = serializeMacroAgent(macro.UpdatedBy)
|
||||
}
|
||||
if len(macro.Files) > 0 {
|
||||
files := make([]gin.H, 0, len(macro.Files))
|
||||
for _, file := range macro.Files {
|
||||
files = append(files, gin.H{
|
||||
"id": file.ID,
|
||||
"macro_id": file.MacroID,
|
||||
"file_type": file.FileType,
|
||||
"account_id": file.AccountID,
|
||||
"file_url": file.FileURL,
|
||||
"blob_id": file.BlobID,
|
||||
"filename": file.Filename,
|
||||
})
|
||||
}
|
||||
item["files"] = files
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
|
||||
s.Require().NoError(err)
|
||||
s.Require().NoError(db.AutoMigrate(
|
||||
&model.Account{}, &model.User{}, &model.AccountUser{}, &model.Inbox{}, &model.Contact{}, &model.Conversation{}, &model.Message{},
|
||||
&model.Attachment{}, &model.DirectUpload{},
|
||||
&automation.Macro{}, &automation.MacroExecution{}, &automation.ConversationLabel{}, &automation.ConversationMute{},
|
||||
))
|
||||
s.db = db
|
||||
@@ -54,6 +55,62 @@ func (s *MacroHandlerTestSuite) SetupSuite() {
|
||||
s.db.Create(s.user)
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionAcceptsSignedUploadAndSerializesFiles() {
|
||||
upload := &model.DirectUpload{
|
||||
UploadUUID: "macro-upload-signed-id",
|
||||
AccountID: s.account.ID,
|
||||
Status: model.DirectUploadStatusPending,
|
||||
Source: model.DirectUploadSourceAccount,
|
||||
OriginalName: "avatar.png",
|
||||
FileType: "image",
|
||||
MimeType: "image/png",
|
||||
FileSize: 123,
|
||||
FileURL: "/uploads/account/1/avatar.png",
|
||||
ThumbURL: "/uploads/account/1/avatar.png",
|
||||
}
|
||||
s.Require().NoError(s.db.Create(upload).Error)
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
|
||||
c.Set("user_id", float64(s.user.ID))
|
||||
c.Next()
|
||||
}, s.handler.Create)
|
||||
body := []byte(`{"name":"send file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["macro-upload-signed-id"]}]}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusOK, w.Code)
|
||||
var resp map[string]interface{}
|
||||
s.Require().NoError(json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
payload := resp["payload"].(map[string]interface{})
|
||||
actions := payload["actions"].([]interface{})
|
||||
action := actions[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), []interface{}{float64(upload.ID)}, action["action_params"])
|
||||
files := payload["files"].([]interface{})
|
||||
s.Require().Len(files, 1)
|
||||
file := files[0].(map[string]interface{})
|
||||
assert.Equal(s.T(), float64(upload.ID), file["blob_id"])
|
||||
assert.Equal(s.T(), "avatar.png", file["filename"])
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TestCreate_AttachmentActionRejectsInvalidBlob() {
|
||||
r := gin.New()
|
||||
r.POST("/api/v1/accounts/:account_id/macros", func(c *gin.Context) {
|
||||
c.Set("user_id", float64(s.user.ID))
|
||||
c.Next()
|
||||
}, s.handler.Create)
|
||||
body := []byte(`{"name":"bad file","visibility":"global","actions":[{"action_name":"send_attachment","action_params":["missing-upload"]}]}`)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("POST", fmt.Sprintf("/api/v1/accounts/%d/macros", s.account.ID), bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(s.T(), http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func (s *MacroHandlerTestSuite) TearDownSuite() {
|
||||
if s.db != nil {
|
||||
sqlDB, _ := s.db.DB()
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package v1
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/gochat/gochat/internal/service"
|
||||
"github.com/gochat/gochat/pkg/response"
|
||||
@@ -29,21 +31,44 @@ func (h *UploadHandler) Upload(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.AbortWithStatusError(c, http.StatusBadRequest, response.ErrValidation, "file is required")
|
||||
return
|
||||
var result *service.UploadResponse
|
||||
var svcErr error
|
||||
if strings.Contains(c.GetHeader("Content-Type"), "application/json") {
|
||||
var req struct {
|
||||
ExternalURL string `json:"external_url"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.ExternalURL) == "" {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
|
||||
return
|
||||
}
|
||||
result, svcErr = h.svc.AccountUploadFromURL(c.Request.Context(), accountID, strings.TrimSpace(req.ExternalURL))
|
||||
} else {
|
||||
fileHeader, err := c.FormFile("attachment")
|
||||
if err != nil {
|
||||
fileHeader, err = c.FormFile("file")
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "missing input"})
|
||||
return
|
||||
}
|
||||
result, svcErr = h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
|
||||
FileHeader: fileHeader,
|
||||
})
|
||||
}
|
||||
|
||||
result, svcErr := h.svc.AccountUpload(c.Request.Context(), accountID, service.AccountUploadRequest{
|
||||
FileHeader: fileHeader,
|
||||
})
|
||||
if svcErr != nil {
|
||||
handleServiceError(c, svcErr)
|
||||
status := http.StatusUnprocessableEntity
|
||||
if errors.Is(svcErr, gorm.ErrRecordNotFound) {
|
||||
status = http.StatusNotFound
|
||||
}
|
||||
c.JSON(status, gin.H{"error": svcErr.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
response.OK(c, result)
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"file_url": result.FileURL,
|
||||
"blob_id": result.UploadUUID,
|
||||
"blob_key": result.UploadUUID,
|
||||
})
|
||||
}
|
||||
|
||||
// DirectUpload handles POST /api/v1/widget/direct_uploads — widget direct file upload.
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -55,9 +56,13 @@ func setupUploadHandlerRouter(h *UploadHandler) *gin.Engine {
|
||||
|
||||
// makeMultipartUploadBody creates a multipart form body with a file field.
|
||||
func makeMultipartUploadBody(filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
|
||||
return makeMultipartUploadBodyWithField("file", filename, content)
|
||||
}
|
||||
|
||||
func makeMultipartUploadBodyWithField(fieldName, filename string, content []byte) (body *bytes.Buffer, contentType string, err error) {
|
||||
body = &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", filename)
|
||||
part, err := writer.CreateFormFile(fieldName, filename)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
@@ -79,7 +84,60 @@ func TestUploadHandler_Upload_NoFile(t *testing.T) {
|
||||
// Set account_id in context (simulating middleware)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||
assert.Equal(t, http.StatusUnprocessableEntity, w.Code)
|
||||
}
|
||||
|
||||
func TestUploadHandler_Upload_ChatwootAttachmentFieldRawPayload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
|
||||
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
|
||||
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
|
||||
|
||||
body, contentType, err := makeMultipartUploadBodyWithField("attachment", "macro.png", []byte("fake png"))
|
||||
require.NoError(t, err)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
|
||||
req.Header.Set("Content-Type", contentType)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.NotContains(t, payload, "success")
|
||||
assert.NotEmpty(t, payload["file_url"])
|
||||
assert.NotEmpty(t, payload["blob_id"])
|
||||
assert.Equal(t, payload["blob_id"], payload["blob_key"])
|
||||
}
|
||||
|
||||
func TestUploadHandler_Upload_ChatwootExternalURLRawPayload(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&model.DirectUpload{}))
|
||||
uploadSvc := service.NewUploadService(repository.NewDirectUploadRepo(db), &config.Config{Storage: config.StorageConfig{LocalPath: tmpDir, MaxFileSize: 50 << 20}})
|
||||
router := setupUploadHandlerRouter(NewUploadHandler(uploadSvc))
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
_, _ = w.Write([]byte("external image"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
body := strings.NewReader(`{"external_url":"` + server.URL + `/image.png"}`)
|
||||
req, _ := http.NewRequest("POST", "/api/v1/accounts/1/upload", body)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &payload))
|
||||
assert.NotContains(t, payload, "success")
|
||||
assert.NotEmpty(t, payload["file_url"])
|
||||
assert.NotEmpty(t, payload["blob_id"])
|
||||
}
|
||||
|
||||
func TestUploadHandler_DirectUpload_NoFile(t *testing.T) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
@@ -9,6 +10,8 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -134,6 +137,48 @@ func (s *UploadService) AccountUpload(ctx context.Context, accountID uint, req A
|
||||
return s.processUpload(ctx, accountID, req.FileHeader, model.DirectUploadSourceAccount)
|
||||
}
|
||||
|
||||
func (s *UploadService) AccountUploadFromURL(ctx context.Context, accountID uint, externalURL string) (*UploadResponse, error) {
|
||||
if accountID == 0 {
|
||||
return nil, errors.New("account_id is required")
|
||||
}
|
||||
parsed, err := url.ParseRequestURI(externalURL)
|
||||
if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") {
|
||||
return nil, errors.New("invalid url")
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, externalURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch external url: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
|
||||
return nil, fmt.Errorf("failed to fetch external url: status %d", resp.StatusCode)
|
||||
}
|
||||
maxSize := int64(s.cfg.Storage.MaxFileSize)
|
||||
if maxSize <= 0 {
|
||||
maxSize = 50 << 20
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, maxSize+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) > maxSize {
|
||||
return nil, errors.New("file too large")
|
||||
}
|
||||
filename := filepath.Base(parsed.Path)
|
||||
if filename == "." || filename == "/" || filename == "" {
|
||||
filename = "upload"
|
||||
}
|
||||
contentType := resp.Header.Get("Content-Type")
|
||||
if idx := strings.Index(contentType, ";"); idx >= 0 {
|
||||
contentType = strings.TrimSpace(contentType[:idx])
|
||||
}
|
||||
return s.processUploadContent(ctx, accountID, model.DirectUploadSourceAccount, filename, contentType, int64(len(data)), bytes.NewReader(data))
|
||||
}
|
||||
|
||||
// --- Account Direct Upload (staged for message attachment) ---
|
||||
|
||||
// AccountDirectUpload handles a staged file upload from the dashboard (account-scoped).
|
||||
@@ -372,12 +417,20 @@ func (s *UploadService) createActiveStorageDirectUpload(ctx context.Context, acc
|
||||
}
|
||||
|
||||
func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileHeader *multipart.FileHeader, source model.DirectUploadSource) (*UploadResponse, error) {
|
||||
// Step 1: Validate file
|
||||
mimeType := fileHeader.Header.Get("Content-Type")
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
return s.processUploadContent(ctx, accountID, source, fileHeader.Filename, mimeType, fileHeader.Size, src)
|
||||
}
|
||||
|
||||
func (s *UploadService) processUploadContent(ctx context.Context, accountID uint, source model.DirectUploadSource, filename, mimeType string, size int64, reader io.Reader) (*UploadResponse, error) {
|
||||
if mimeType == "" || mimeType == "application/octet-stream" {
|
||||
// Fall back to filename-based detection when Content-Type is empty
|
||||
// or the generic default (browsers/multipart forms often send this).
|
||||
detected := detectUploadMIMEFromFilename(fileHeader.Filename)
|
||||
detected := detectUploadMIMEFromFilename(filename)
|
||||
if detected != "" && detected != "application/octet-stream" {
|
||||
mimeType = detected
|
||||
}
|
||||
@@ -396,13 +449,13 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
|
||||
if maxSize == 0 {
|
||||
maxSize = int64(s.cfg.Storage.MaxFileSize)
|
||||
}
|
||||
if fileHeader.Size > maxSize {
|
||||
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", fileHeader.Size, maxSize, fileCategory)
|
||||
if size > maxSize {
|
||||
return nil, fmt.Errorf("file size %d exceeds maximum %d for type %s", size, maxSize, fileCategory)
|
||||
}
|
||||
|
||||
// Step 2: Store file to disk
|
||||
ext := filepath.Ext(fileHeader.Filename)
|
||||
fileURL, thumbURL, err := s.saveFileToDisk(accountID, source, ext, fileHeader)
|
||||
ext := filepath.Ext(filename)
|
||||
fileURL, thumbURL, err := s.saveUploadReader(accountID, source, ext, mimeType, reader)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to save file: %w", err)
|
||||
}
|
||||
@@ -415,10 +468,10 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
|
||||
AccountID: accountID,
|
||||
Status: model.DirectUploadStatusPending,
|
||||
Source: source,
|
||||
OriginalName: fileHeader.Filename,
|
||||
OriginalName: filename,
|
||||
FileType: fileCategory,
|
||||
MimeType: mimeType,
|
||||
FileSize: fileHeader.Size,
|
||||
FileSize: size,
|
||||
FileURL: fileURL,
|
||||
ThumbURL: thumbURL,
|
||||
ExpiresAt: time.Now().Add(expiryDuration),
|
||||
@@ -431,7 +484,7 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
|
||||
}
|
||||
|
||||
applogger.L().Infof("Direct file upload: account=%d source=%s uuid=%s file=%s size=%d",
|
||||
accountID, source, uploadUUID, fileHeader.Filename, fileHeader.Size)
|
||||
accountID, source, uploadUUID, filename, size)
|
||||
|
||||
return &UploadResponse{
|
||||
UploadID: upload.ID,
|
||||
@@ -448,6 +501,15 @@ func (s *UploadService) processUpload(ctx context.Context, accountID uint, fileH
|
||||
}
|
||||
|
||||
func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUploadSource, ext string, fileHeader *multipart.FileHeader) (string, string, error) {
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
return s.saveUploadReader(accountID, source, ext, fileHeader.Header.Get("Content-Type"), src)
|
||||
}
|
||||
|
||||
func (s *UploadService) saveUploadReader(accountID uint, source model.DirectUploadSource, ext, mimeType string, reader io.Reader) (string, string, error) {
|
||||
localPath := s.cfg.Storage.LocalPath
|
||||
if localPath == "" {
|
||||
localPath = "./uploads"
|
||||
@@ -464,13 +526,6 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
|
||||
fileName := baseName + ext
|
||||
fullPath := filepath.Join(dirPath, fileName)
|
||||
|
||||
// Open uploaded file
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to open uploaded file: %w", err)
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
// Create destination file
|
||||
dst, err := os.Create(fullPath)
|
||||
if err != nil {
|
||||
@@ -479,7 +534,7 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
|
||||
defer dst.Close()
|
||||
|
||||
// Copy file content
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
if _, err := io.Copy(dst, reader); err != nil {
|
||||
os.Remove(fullPath) // Clean up on failure
|
||||
return "", "", fmt.Errorf("failed to copy file content: %w", err)
|
||||
}
|
||||
@@ -488,7 +543,6 @@ func (s *UploadService) saveFileToDisk(accountID uint, source model.DirectUpload
|
||||
thumbURL := ""
|
||||
|
||||
// For images, we reference the same path (thumbnail generation can be added later)
|
||||
mimeType := fileHeader.Header.Get("Content-Type")
|
||||
if strings.HasPrefix(mimeType, "image/") {
|
||||
thumbURL = fileURL // Placeholder: same as fileURL for now
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user